├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── doc └── api.md ├── examples ├── async-sagas.js ├── async.js └── todo.js ├── package.json ├── rollup.config.js ├── src ├── errors.js ├── index.js ├── middleware.js ├── parse.js ├── reducer.js ├── schema-observer.js ├── types.js └── util.js └── test ├── actions-test.js ├── index.js ├── middleware-test.js ├── observer-test.js ├── parse-test.js ├── reducer-test.js └── types-test.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | 9 | # Runtime data 10 | pids 11 | *.pid 12 | *.seed 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # node-waf configuration 27 | .lock-wscript 28 | 29 | # Compiled binary addons (http://nodejs.org/api/addons.html) 30 | build/Release 31 | 32 | # Dependency directories 33 | node_modules 34 | jspm_packages 35 | 36 | # Optional npm cache directory 37 | .npm 38 | 39 | # Optional REPL history 40 | .node_repl_history 41 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | test 3 | examples 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6.1" 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Justin Falcone 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redux Action Schema 2 | Better action management for Redux 3 | 4 | [![build status](https://img.shields.io/travis/modernserf/redux-action-schema/master.svg?style=flat-square)](https://travis-ci.org/modernserf/redux-action-schema) 5 | [![npm version](https://img.shields.io/npm/v/redux-action-schema.svg?style=flat-square)](https://www.npmjs.com/package/redux-action-schema) 6 | 7 | Redux Action Schema is a library for managing actions in Redux apps. It is a replacement for the constants file, providing stronger type guarantees while reducing boilerplate. 8 | 9 | # Documentation 10 | - [Guide](#guide) 11 | - [API Reference](https://github.com/modernserf/redux-action-schema/blob/master/doc/api.md) 12 | 13 | # Examples 14 | - [Basic todo list](https://github.com/modernserf/redux-action-schema/blob/master/examples/todo.js) 15 | - [Async action creators with redux-thunk](https://github.com/modernserf/redux-action-schema/blob/master/examples/async.js) 16 | - [Usage with redux-saga](https://github.com/modernserf/redux-action-schema/blob/master/examples/async-saga.js) 17 | 18 | # Guide 19 | 20 | ``` 21 | npm install --save redux-action-schema 22 | ``` 23 | 24 | ## Creating a schema 25 | 26 | In larger Redux projects, action types are frequently collected in a constants file. From the Redux docs: 27 | 28 | > For larger projects, there are some benefits to defining action types as constants: 29 | > - It helps keep the naming consistent because all action types are gathered in a single place. 30 | - Sometimes you want to see all existing actions before working on a new feature. It may be that the action you need was already added by somebody on the team, but you didn’t know. 31 | - The list of action types that were added, removed, and changed in a Pull Request helps everyone on the team keep track of scope and implementation of new features. 32 | - If you make a typo when importing an action constant, you will get `undefined`. Redux will immediately throw when dispatching such an action, and you’ll find the mistake sooner. 33 | 34 | But the way this is frequently implemented is primitive and repetitive: 35 | ``` 36 | export const ADD_TODO = 'ADD_TODO' 37 | export const EDIT_TODO = 'EDIT_TODO' 38 | export const COMPLETE_TODO = 'COMPLETE_TODO' 39 | export const DELETE_TODO = 'DELETE_TODO' 40 | export const COMPLETE_ALL = 'COMPLETE_ALL' 41 | export const CLEAR_COMPLETED = 'CLEAR_COMPLETED' 42 | export const SET_VISIBILITY = 'SET_VISIBILITY' 43 | 44 | export const SHOW_ALL = 'show_all' 45 | export const SHOW_COMPLETED = 'show_completed' 46 | export const SHOW_ACTIVE = 'show_active' 47 | ``` 48 | 49 | This gets the job done, but its ugly and repetitive. Furthermore it doesn't provide any information about the _data_ in the action, only the type. Redux Action Schema enables compact action definitions with runtime type checks: 50 | 51 | ``` 52 | const showStates = ["all", "completed", "active"] 53 | 54 | const schema = createSchema([ 55 | // match actions with named parameters 56 | // e.g. { type: "addTodo", payload: { id: 123, text: "here's a todo" } } 57 | ["addTodo", "here is a docstring", 58 | ["id", "named params can have docstrings too", types.Number], 59 | ["text", types.String]], 60 | ["editTodo", 61 | ["id", types.Number], 62 | ["text", types.String]], 63 | 64 | // match actions with single values 65 | // e.g. { type: "completeTodo", payload: 123 } 66 | ["completeTodo", types.Number], 67 | ["deleteTodo", types.Number], 68 | 69 | // match actions with no data 70 | // e.g. { type: "completeAll" } 71 | ["completeAll"], 72 | ["clearCompleted"], 73 | 74 | // match actions with enumerated values 75 | // e.g. { type: "setVisibility", payload: "completed" } 76 | ["setVisibility", types.OneOf(showStates)], 77 | ]) 78 | ``` 79 | 80 | This provides all of the benefits of using constants, but with additional benefits: 81 | 82 | - **Consistent naming**: All action types are gathered in the same place. Additionally, the _argument names_ are gathered in the same place, so that those will be consistently named as well. 83 | - **Track changes in pull requests**: You can see actions added, removed and changed at a glance in a pull request. Additionally, you can see changes in the action payloads. 84 | - **Handle typos**: If you make a typo when using one of the created actions, e.g. `schema.actions.compleatTodo`, you will get `undefined`. Additionally, you will get errors if you: 85 | + use an undefined action creator, e.g. `schema.actionCreators.compleatTodo()` 86 | + use an unknown action in `createReducer`, e.g. `schema.createReducer({compleatTodo: (state) => state })` 87 | + dispatch an unknown action when using the validation middleware 88 | 89 | ## Generating a schema in an existing app 90 | 91 | Redux Action Schema also includes a middleware that can **automatically generate** a schema for an existing app. Add the schema observer middleware: 92 | ``` 93 | import { createSchemaObserver } from "redux-action-schema" 94 | import { createStore, applyMiddleware } from "redux" 95 | 96 | /* ... */ 97 | 98 | // attached to window so its accessible from inside console 99 | window.schemaObserver = createSchemaObserver() 100 | 101 | const store = createStore(reducer, applyMiddleware(window.schemaObserver)) 102 | ``` 103 | 104 | Run the app (manually or with a test runner). Then, from the console: 105 | 106 | 107 | 108 | ``` 109 | > window.schemaObserver.schemaDefinitionString() 110 | < "createSchema([ 111 | ["foo"], 112 | ["bar", types.Number], 113 | ["baz", types.String.optional], 114 | ["quux", types.OneOfType.optional(types.Number, types.String)], 115 | ["xyzzy", 116 | ["a", types.Number], 117 | ["b", types.String]] 118 | ])" 119 | ``` 120 | 121 | You can copy the output of `schemaDefinitionString` from the console into your code and get a head start on 122 | 123 | ## Generated actions 124 | 125 | Protect against typos and automatically handle namespaces with generated actions: 126 | 127 | ``` 128 | schema.actions.addTodo // => "addTodo" 129 | schema.actions.adTodo // => undefined 130 | 131 | // actions can be namespaced: 132 | const fooSchema = createSchema([...], { namespace: "foo" }) 133 | schema.actions.addTodo // => "foo_addTodo" 134 | ``` 135 | 136 | ## Action creators 137 | 138 | An action creator is generated for each action in the schema: 139 | 140 | ``` 141 | const { editTodo, completeTodo } = schema.actionCreators 142 | editTodo({ id: 10, text: "write docs" }) 143 | // => { type: "editTodo", payload: { id: 10, text: "write docs" } } 144 | 145 | completeTodo(20) // => { type: "completeTodo", payload: 20 } 146 | 147 | editTodo.byPosition(10, "write GOOD docs") 148 | // => { type: "editTodo", payload: { id: 10, text: "write GOOD docs" } } 149 | ``` 150 | 151 | ## Reducers 152 | 153 | The schema can be used to create and validate simple reducers a la redux-action's [handleActions](https://github.com/acdlite/redux-actions#handleactionsreducermap-defaultstate): 154 | 155 | ``` 156 | const todoReducer = schema.createReducer({ 157 | addTodo: (state, { id, text }) => 158 | state.concat([{ id, text, completed: false }]) 159 | completeTodo: (state, id) => 160 | state.map((todo) => todo.id === id 161 | ? { ...todo, completed: !todo.completed } 162 | : todo) 163 | }, []) 164 | ``` 165 | 166 | Unlike `handleActions`, createReducer verifies that a reducer's handled actions are in the schema: 167 | 168 | ``` 169 | schema.createReducer({ 170 | nope: (state, payload) => state 171 | }, initState) 172 | 173 | // => Uncaught Error: "unknown action: nope" 174 | ``` 175 | 176 | ## Middleware 177 | 178 | Finally, the schema generates a redux middleware for checking that dispatched actions are in the schema: 179 | 180 | ``` 181 | const store = createStore( 182 | reducer, 183 | applyMiddleware(schema.createMiddleware({ 184 | onError: (action) => console.error(action) 185 | }))) 186 | 187 | store.dispatch({ type: "idunno" }) // error 188 | store.dispatch({ type: "completeTodo", payload: "notAnID" }) // error 189 | ``` 190 | 191 | You may choose to use all these features at once, or mix and match -- you don't need to use the action creators or createReducer to benefit from the middleware, nor vice versa. 192 | -------------------------------------------------------------------------------- /doc/api.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | - [types](#types) 4 | - [createSchema](#makeschema) 5 | - [schema methods](#schema) 6 | + [actions](#actions) 7 | + [actionCreators](#actioncreators) 8 | + [createReducer](#createreducer) 9 | + [createMiddleware](#createmiddleware) 10 | - [createSchemaObserver](#createschemaobserver) 11 | 12 | ``` 13 | import { types, createSchema, createSchemaObserver } from "redux-action-schema" 14 | ``` 15 | 16 | # types 17 | 18 | Types are used for schema definitions and action validation. 19 | 20 | Any function that takes a value & returns a boolean can be used as a type, but we've provided a few common ones: 21 | 22 | ``` 23 | types.Object // matches any non-Array Object 24 | types.Number // matches numbers 25 | types.String // matches strings 26 | types.Array // matches Arrays 27 | types.Boolean // matches bools 28 | types.Any // matches any non-null 29 | types.OneOf([values...]) // matches members of array 30 | types.ArrayOf(typeFn) // matches arrays where each element matches typeFn 31 | types.OneOfType(typeA, typeB, ...) // matches any of its arguments 32 | ``` 33 | 34 | Each of the types also has an `optional` variant that also matches `null` and `undefined`: 35 | 36 | ``` 37 | types.Object.optional // matches { foo: bar }, null, undefined 38 | types.ArrayOf.optional(types.Number) // matches [1,2,3], null, undefined 39 | types.ArrayOf(types.Number.optional) // matches [1, null, 3] 40 | ``` 41 | 42 | # createSchema 43 | 44 | ``` 45 | createSchema(actions, [options]) 46 | ``` 47 | 48 | ### Arguments 49 | 50 | - `actions` _(Array)_: A list of action definitions to validate against. See [next section](#action-definitions) for more on this. 51 | - `[options]` _(Object)_: Additional options for building the schema: 52 | + `namespace` (_String_ | `(String) => String`): rewrite action type strings handled by this schema. 53 | * The function form takes an action name and returns a modified action name: `schema = createSchema(["foo"], { namespace: (type) => type.toUpperCase() })` will use `schema.actions.foo` but expect actions like `{ type: "FOO" }`. 54 | * The string form prepends that string with an underscore to the original action name: `schema = createSchema(["foo"], { namespace: "ns"})` will use `schema.actions.foo` but expect actions like `{ type: "ns_foo" }`. 55 | 56 | ### Returns 57 | 58 | ({ [actions](#actions), [actionCreators](#actioncreators), [createReducer](#createreducer), [createMiddleware](#createmiddleware) }) = : a collection of objects and functions for working with and validating actions. 59 | 60 | 61 | ## Action definitions 62 | 63 | ``` 64 | // no parameters 65 | [typeName] 66 | [typeName, docstring] 67 | 68 | // single parameter 69 | [typeName, typeFn] 70 | [typeName, docstring, typeFn] 71 | 72 | // named parameters 73 | [typeName, docstring, 74 | [paramName, typeFn], 75 | [paramName, docstring, typeFn], 76 | ... 77 | ] 78 | ``` 79 | 80 | # Schema methods 81 | 82 | ## actions 83 | 84 | ``` 85 | schema.actions. 86 | ``` 87 | 88 | An object mapping action names to their string representations. By default, these are identical, e.g. `actions.foo === "foo"`; however, if the schema is created with a namespace parameter, that will be prepended to the action name, e.g. given `{ namespace: "ns" }` then `actions.foo === "ns_foo"`. 89 | 90 | ## actionCreators 91 | 92 | An object mapping action names to functions that create actions of that type. 93 | 94 | ``` 95 | schema.actionCreators.foo(value) => { type: "foo", payload: value } 96 | ``` 97 | 98 | ## createReducer 99 | 100 | ``` 101 | schema.createReducer(reducerMap, [initialState]) 102 | ``` 103 | 104 | ### Arguments 105 | 106 | - `reducerMap` _(Object)_ : map of action types to reducer functions, with signature `(state, payload, action) => nextState` 107 | - `[initState]` _(Any)_ : initial state provided to reducer functions. 108 | 109 | ### Returns 110 | 111 | (`(state = initState, action) => nextState`): a reducer function that handles all of the actions in the reducer map. 112 | 113 | ### Throws 114 | 115 | `createReducer` throws if a key in the reducer map does not correspond to an action type, or if a value in the reducer map is not a function. 116 | 117 | ## createMiddleware 118 | 119 | ``` 120 | schema.createMiddleware([options]) 121 | ``` 122 | 123 | ### Arguments 124 | 125 | - `[options]` 126 | + `ignorePayloads` _(Boolean)_: Don't type check payloads, only check type names. You may want to use this for performance reasons. **Default value:** `false`. 127 | + `onError` (`(action) => ()`): Callback called when an action doesn't validate. **Default value:** `console.error("unknown action:", action)` 128 | + `ignoreActions` _(Array)_: List of actions not in schema that shouldn't cause validation errors, e.g. those used by third-party middleware. **Default value:** `["EFFECT_TRIGGERED", "EFFECT_RESOLVED", "@@router/UPDATE_LOCATION"]`, which are used by popular libraries redux-saga and react-redux-router. 129 | 130 | ### Returns 131 | 132 | (`middleware`): a Redux middleware. 133 | 134 | # createSchemaObserver 135 | 136 | ``` 137 | createSchemaObserver() 138 | ``` 139 | 140 | ### Returns 141 | 142 | (`observerMiddleware`): a redux middleware, with additional methods for schema generation. 143 | 144 | ## schemaDefinitionString 145 | 146 | ``` 147 | observerMiddleware.schemaDefinitionString() 148 | ``` 149 | 150 | ### Returns 151 | 152 | _(String)_: source code for a schema definition. 153 | -------------------------------------------------------------------------------- /examples/async-sagas.js: -------------------------------------------------------------------------------- 1 | const { types, createSchema } = require("redux-action-schema") 2 | const { createStore, combineReducers, applyMiddleware } = require("redux") 3 | const { default: createSagaMiddleware, takeLatest } = require("redux-saga") 4 | const { call, select, put, fork } = require("redux-saga/effects") 5 | 6 | const api = { 7 | load: () => window.fetch("/todos").then((res) => res.json()), 8 | save: (data) => window.fetch("/todos", { 9 | method: "post", 10 | body: JSON.stringify(data), 11 | }), 12 | } 13 | 14 | const schema = createSchema([ 15 | ["addTodo", 16 | ["id", types.Number], 17 | ["text", types.String]], 18 | ["toggleTodo", types.Number], 19 | ["saveTodos"], 20 | ["loadTodos"], 21 | ["loadTodosResolved", types.Array]]) 22 | 23 | // sagas 24 | const loadSaga = takeLatest(schema.actions.loadTodos, function * () { 25 | const data = yield call(api.load) 26 | yield put(schema.actionCreators.loadTodosResolved(data)) 27 | }) 28 | 29 | const saveSaga = takeLatest(schema.actions.saveTodos, function * () { 30 | const { todos } = yield select() 31 | yield call(api.save, todos) 32 | }) 33 | 34 | function * rootSaga () { 35 | yield fork(loadSaga) 36 | yield fork(saveSaga) 37 | } 38 | 39 | // reducers 40 | const merge = (a, b) => Object.assign({}, a, b) 41 | 42 | const update = (state, id, updateFn) => 43 | state.map((todo) => todo.id === id 44 | ? updateFn(todo) 45 | : todo) 46 | 47 | const todoReducer = schema.createReducer({ 48 | addTodo: (state, { id, text }) => 49 | state.concat([{ id, text, completed: false }]), 50 | toggleTodo: (state, id) => update(state, id, 51 | (todo) => merge(todo, { completed: !todo.completed })), 52 | loadTodosResolved: (_, todos) => todos, 53 | }, []) 54 | 55 | const visibilityReducer = schema.createReducer({ 56 | set_visibility: (state, option) => option, 57 | }, "all") 58 | 59 | const mainReducer = combineReducers({ 60 | todos: todoReducer, 61 | visibility: visibilityReducer, 62 | }) 63 | 64 | // store 65 | const sagaMiddleware = createSagaMiddleware() 66 | 67 | const store = createStore( 68 | mainReducer, 69 | applyMiddleware(sagaMiddleware, schema.createMiddleware())) 70 | 71 | sagaMiddleware.run(rootSaga) 72 | 73 | module.exports = { store, schema } 74 | -------------------------------------------------------------------------------- /examples/async.js: -------------------------------------------------------------------------------- 1 | const { types, createSchema } = require("redux-action-schema") 2 | const { createStore, combineReducers, applyMiddleware } = require("redux") 3 | const thunk = require("redux-thunk").default 4 | 5 | const api = { 6 | load: () => window.fetch("/todos").then((res) => res.json()), 7 | save: (data) => window.fetch("/todos", { 8 | method: "post", 9 | body: JSON.stringify(data), 10 | }), 11 | } 12 | 13 | const schema = createSchema([ 14 | ["addTodo", 15 | ["id", types.Number], 16 | ["text", types.String]], 17 | ["toggleTodo", types.Number], 18 | ["loadTodos", types.Array]]) 19 | 20 | // save the original synchronous action creator for loadTodos 21 | const { loadTodos } = schema.actionCreators 22 | 23 | // replace it in the actionCreators object with a thunk-producing action creator 24 | schema.actionCreators.loadTodos = () => (dispatch) => { 25 | api.load().then((todos) => { 26 | // call the _original_ action creator to create an action 27 | dispatch(loadTodos(todos)) 28 | }) 29 | } 30 | 31 | // note -- there is no corresponding "saveTodos" action 32 | schema.actionCreators.saveTodos = () => (dispatch, getState) => { 33 | api.save(getState().todos) 34 | } 35 | 36 | const merge = (a, b) => Object.assign({}, a, b) 37 | 38 | const update = (state, id, updateFn) => 39 | state.map((todo) => todo.id === id 40 | ? updateFn(todo) 41 | : todo) 42 | 43 | const todoReducer = schema.createReducer({ 44 | addTodo: (state, { id, text }) => 45 | state.concat([{ id, text, completed: false }]), 46 | toggleTodo: (state, id) => update(state, id, 47 | (todo) => merge(todo, { completed: !todo.completed })), 48 | loadTodos: (_, todos) => todos, 49 | }, []) 50 | 51 | const visibilityReducer = schema.createReducer({ 52 | set_visibility: (state, option) => option, 53 | }, "all") 54 | 55 | const mainReducer = combineReducers({ 56 | todos: todoReducer, 57 | visibility: visibilityReducer, 58 | }) 59 | 60 | const store = createStore( 61 | mainReducer, 62 | applyMiddleware(thunk, schema.createMiddleware())) 63 | 64 | module.exports = { store, schema } 65 | -------------------------------------------------------------------------------- /examples/todo.js: -------------------------------------------------------------------------------- 1 | import { types, createSchema } from "redux-action-schema" 2 | import { createStore, combineReducers, applyMiddleware } from "redux" 3 | 4 | const merge = (a, b) => Object.assign({}, a, b) 5 | 6 | // schema 7 | 8 | const show = ["all", "active", "completed"] 9 | 10 | const todoID = (value) => typeof value === "number" && value > 0 11 | 12 | const schema = createSchema([ 13 | ["addTodo", "here is a docstring", 14 | ["id", todoID], 15 | ["text", types.String]], 16 | ["editTodo", 17 | ["id", "params can have docstrings too", todoID], 18 | ["text", types.String]], 19 | ["toggleTodo", todoID], 20 | ["deleteTodo", todoID], 21 | ["completeAll"], 22 | ["clearCompleted"], 23 | ["setVisibility", types.OneOf(show)]]) 24 | 25 | // actions 26 | 27 | let id = 0 28 | export const actionCreators = merge(schema.actionCreators, { 29 | addTodo: (text) => { 30 | id += 1 31 | return schema.actionCreators.addTodo({ id, text }) 32 | }, 33 | }) 34 | 35 | // reducers 36 | 37 | const update = (state, id, updateFn) => 38 | state.map((todo) => todo.id === id 39 | ? updateFn(todo) 40 | : todo) 41 | 42 | const todoReducer = schema.createReducer({ 43 | addTodo: (state, { id, text }) => 44 | state.concat([{ id, text, completed: false }]), 45 | editTodo: (state, { id, text }) => update(state, id, 46 | (todo) => merge(todo, { text })), 47 | toggleTodo: (state, id) => update(state, id, 48 | (todo) => merge(todo, { completed: !todo.completed })), 49 | deleteTodo: (state, id) => 50 | state.filter((todo) => todo.id !== id), 51 | completeAll: (state) => 52 | state.map((todo) => merge(todo, { completed: true })), 53 | clearCompleted: (state) => 54 | state.filter((todo) => !todo.completed), 55 | }, []) 56 | 57 | const visibilityReducer = schema.createReducer({ 58 | set_visibility: (state, option) => option, 59 | }, "all") 60 | 61 | const mainReducer = combineReducers({ 62 | todos: todoReducer, 63 | visibility: visibilityReducer, 64 | }) 65 | 66 | export const visibleTodos = ({ todos, visibility }) => ({ 67 | all: () => todos, 68 | active: () => todos.filter((t) => !t.completed), 69 | completed: () => todos.filter((t) => t.completed), 70 | }[visibility]()) 71 | 72 | // store 73 | 74 | export const store = createStore( 75 | mainReducer, 76 | applyMiddleware(schema.createMiddleware())) 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-action-schema", 3 | "version": "0.7.3", 4 | "description": "better action management for redux", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "lint": "node_modules/.bin/eslint --ignore-pattern dist/ .", 8 | "tape": "npm run build && node test/index.js | node_modules/.bin/faucet", 9 | "test": "npm run lint && npm run tape", 10 | "build": "mkdir -p dist && node_modules/.bin/rollup -c", 11 | "prepublish": "npm run build", 12 | "deploy": "npm test && npm publish" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/modernserf/redux-action-schema.git" 17 | }, 18 | "keywords": [ 19 | "redux" 20 | ], 21 | "author": "Justin Falcone ", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/modernserf/redux-action-schema/issues" 25 | }, 26 | "homepage": "https://github.com/modernserf/redux-action-schema#readme", 27 | "devDependencies": { 28 | "buble": "^0.11.5", 29 | "eslint": "^2.13.0", 30 | "eslint-config-standard": "^5.3.1", 31 | "eslint-plugin-promise": "^1.3.2", 32 | "eslint-plugin-standard": "^1.3.2", 33 | "faucet": "0.0.1", 34 | "redux": "^3.5.2", 35 | "redux-saga": "^0.10.5", 36 | "redux-thunk": "^2.1.0", 37 | "rollup": "^0.32.0", 38 | "rollup-plugin-buble": "^0.11.0", 39 | "tape": "^4.5.1" 40 | }, 41 | "eslintConfig": { 42 | "extends": "standard", 43 | "rules": { 44 | "quotes": [ 45 | 2, 46 | "double", 47 | { 48 | "avoidEscape": true, 49 | "allowTemplateLiterals": true 50 | } 51 | ], 52 | "comma-dangle": [ 53 | 2, 54 | "always-multiline" 55 | ], 56 | "indent": [ 57 | 2, 58 | 4 59 | ] 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import buble from "rollup-plugin-buble" 2 | 3 | export default { 4 | entry: "src/index.js", 5 | dest: "dist/index.js", 6 | format: "cjs", 7 | plugins: [ buble() ], 8 | } 9 | -------------------------------------------------------------------------------- /src/errors.js: -------------------------------------------------------------------------------- 1 | export function duplicateActionError (type) { 2 | return new Error(`Multiple actions defined with type "${type}"`) 3 | } 4 | 5 | export function unknownActionError (type) { 6 | return new Error(`Unknown action type "${type}"`) 7 | } 8 | 9 | export function reducerHandlerError (type) { 10 | return new Error(`Handler for type "${type}" is not a function`) 11 | } 12 | 13 | export function namespaceError (type) { 14 | return new Error("Namespace must be a function or a string") 15 | } 16 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { types } from "./types" 2 | export { createSchemaObserver } from "./schema-observer" 3 | 4 | import { duplicateActionError, namespaceError } from "./errors" 5 | import { testArgs } from "./types" 6 | import { middlewareHelper } from "./middleware" 7 | import { reducerHelper } from "./reducer" 8 | import { parseAction } from "./parse" 9 | import { mergeIgnoreUndefined } from "./util" 10 | 11 | const defaultParams = { 12 | format: (type, payload) => ({ type, payload }), 13 | unformat: (action) => action, 14 | namespace: (t) => t, 15 | } 16 | 17 | export function createSchema (schema, params = {}) { 18 | const { format, unformat, namespace } = mergeIgnoreUndefined(defaultParams, params) 19 | const parsed = schema.map(parseAction) 20 | 21 | const nsFunc = (() => { 22 | if (typeof namespace === "function") { 23 | return namespace 24 | } else if (typeof namespace === "string") { 25 | return (t) => `${namespace}_${t}` 26 | } 27 | throw namespaceError 28 | })() 29 | 30 | const schemaMap = parsed.reduce((obj, action) => { 31 | obj[action.type] = action 32 | return obj 33 | }, {}) 34 | 35 | const values = {} 36 | 37 | // action type -> namespaced action type 38 | const actions = parsed.reduce((obj, { type }) => { 39 | obj[type] = nsFunc(type) 40 | if (values[obj[type]]) { 41 | throw duplicateActionError(type) 42 | } 43 | values[obj[type]] = true 44 | return obj 45 | }, {}) 46 | 47 | // action type -> payload => namespaced action 48 | const actionCreators = parsed.reduce((obj, { type, args }) => { 49 | const nType = actions[type] 50 | const ac = (payload) => format(nType, payload) 51 | ac.byPosition = function (a, b, c) { 52 | const [arg0, arg1, arg2] = args 53 | 54 | if (!args.length) { return format(nType) } 55 | if (arg0.wholePayload) { return format(nType, a) } 56 | 57 | const payload = {} 58 | payload[arg0.id] = a 59 | if (arg1) { payload[arg1.id] = b } 60 | if (arg2) { payload[arg2.id] = c } 61 | return format(nType, payload) 62 | } 63 | obj[type] = ac 64 | return obj 65 | }, {}) 66 | 67 | // namespaced action type -> test 68 | const tests = parsed.reduce((obj, { type, args }) => { 69 | const nType = actions[type] 70 | if (!args.length) { 71 | obj[nType] = (payload) => payload === undefined 72 | } else if (args.length === 1 && args[0].wholePayload) { 73 | obj[nType] = args[0].test 74 | } else { 75 | obj[nType] = testArgs(args) 76 | } 77 | return obj 78 | }, {}) 79 | 80 | const test = (action) => { 81 | const { type, payload } = unformat(action) 82 | return tests[type] && tests[type](payload) 83 | } 84 | 85 | return { 86 | schema: schemaMap, 87 | createMiddleware: middlewareHelper(tests, unformat), 88 | createReducer: reducerHelper(actions, unformat), 89 | actions, test, actionCreators, 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/middleware.js: -------------------------------------------------------------------------------- 1 | import { mergeIgnoreUndefined } from "./util" 2 | 3 | const defaultMiddlewareOptions = { 4 | ignorePayloads: false, 5 | onError: console.error.bind(console, "unknown action:"), 6 | ignoreActions: ["EFFECT_TRIGGERED", "EFFECT_RESOLVED", "@@router/UPDATE_LOCATION"], 7 | } 8 | 9 | // TODO: separate onError for unknown actions & bad props 10 | 11 | export const middlewareHelper = (tests, unformat) => (options = {}) => { 12 | const { ignoreActions, ignorePayloads, onError } = mergeIgnoreUndefined(defaultMiddlewareOptions, options) 13 | const ignoreMap = ignoreActions.reduce((obj, key) => { obj[key] = true; return obj }, {}) 14 | 15 | const test = (action) => { 16 | if (typeof action !== "object") { return } 17 | 18 | const { type, payload } = unformat(action) 19 | if (ignoreMap[type]) { return } 20 | if (tests[type] && ignorePayloads) { return } 21 | if (tests[type] && tests[type](payload)) { return } 22 | onError(action) 23 | } 24 | 25 | return () => (next) => (action) => { 26 | test(action) 27 | return next(action) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/parse.js: -------------------------------------------------------------------------------- 1 | const uncons = (array) => [array[0], array.slice(1)] 2 | 3 | export function parseAction (action) { 4 | const [type, docAndArgs] = uncons(action) 5 | if (!docAndArgs.length) { return { type, args: [], doc: "" } } 6 | 7 | if (typeof docAndArgs[0] === "string") { 8 | const [doc, args] = uncons(docAndArgs) 9 | return { type, doc, args: args.map(parseArg) } 10 | } 11 | return { type, doc: "", args: docAndArgs.map(parseArg) } 12 | } 13 | 14 | function parseArg (arg, i) { 15 | if (typeof arg === "function" && i === 0) { 16 | return { test: arg, doc: "", wholePayload: true } 17 | } 18 | if (arg.length === 3) { 19 | const [id, doc, test] = arg 20 | return { id, doc, test } 21 | } 22 | const [id, test] = arg 23 | return { id, doc: "", test } 24 | } 25 | -------------------------------------------------------------------------------- /src/reducer.js: -------------------------------------------------------------------------------- 1 | import { unknownActionError, reducerHandlerError } from "./errors" 2 | // TODO: throw custom functions 3 | 4 | export const reducerHelper = (actions, unformat) => (obj, initState) => { 5 | const nObj = {} 6 | for (const key in obj) { 7 | const nKey = actions[key] 8 | if (!nKey) { throw unknownActionError(key) } 9 | const fn = obj[key] 10 | if (typeof fn !== "function") { throw reducerHandlerError(key) } 11 | nObj[nKey] = obj[key] 12 | } 13 | 14 | return (state = initState, action) => { 15 | const { type, payload } = unformat(action) 16 | return nObj[type] 17 | ? nObj[type](state, payload, action) 18 | : state 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/schema-observer.js: -------------------------------------------------------------------------------- 1 | import { types, testArgs } from "./types" 2 | 3 | const nullType = (val) => val === undefined || val === null 4 | 5 | // not perfect, but probably sufficient 6 | const pojo = (obj) => obj && obj.constructor === Object 7 | 8 | const basicTypes = [ 9 | nullType, types.String, types.Number, types.Boolean, types.Array, types.Object, 10 | ] 11 | 12 | export function createSchemaObserver () { 13 | const receivedActions = [] 14 | 15 | const middleware = function () { 16 | return (next) => (action) => { 17 | receivedActions.push(action) 18 | next(action) 19 | } 20 | } 21 | 22 | middleware.receivedActions = () => { 23 | return receivedActions.reduce((map, action) => { 24 | if (!action || !action.type) { return map } 25 | 26 | const { type, payload } = action 27 | if (map[type]) { 28 | map[type].push(payload) 29 | } else { 30 | map[type] = [payload] 31 | } 32 | return map 33 | }, {}) 34 | } 35 | 36 | middleware.receivedTypes = () => { 37 | const actions = middleware.receivedActions() 38 | 39 | const receivedTypes = {} 40 | for (const key in actions) { 41 | receivedTypes[key] = findType(actions[key]) 42 | } 43 | return receivedTypes 44 | } 45 | 46 | middleware.schemaDefinitionString = () => { 47 | return defString(middleware.receivedTypes()) 48 | } 49 | 50 | return middleware 51 | } 52 | 53 | function findType (payloads) { 54 | const typeList = payloads.reduce((t, payload) => { 55 | t = t || [guess(payload)] 56 | 57 | // match existing type 58 | for (let i = 0; i < t.length; i++) { 59 | if (t[i].test(payload)) { return t } 60 | } 61 | 62 | // add new type 63 | t.push(guess(payload)) 64 | return t 65 | }, []) 66 | 67 | if (typeList.length === 1 && typeList[0].test === nullType) { return [] } 68 | 69 | if (typeList.every((t) => t.args)) { 70 | return fitTypesPerArg(typeList) 71 | } 72 | 73 | return [Object.assign(fitTypes(typeList), { wholePayload: true })] 74 | } 75 | 76 | function guess (payload, depth = 0) { 77 | if (!depth && pojo(payload)) { 78 | const args = Object.keys(payload) 79 | .map((key) => ({ id: key, test: guess(payload[key], 1).test })) 80 | return { args, test: testArgs(args) } 81 | } 82 | 83 | for (let i = 0; i < basicTypes.length; i++) { 84 | if (basicTypes[i](payload)) { return { test: basicTypes[i] } } 85 | } 86 | 87 | // idk, functions maybe? 88 | return { test: types.Any } 89 | } 90 | 91 | function fitTypesPerArg (typeList) { 92 | if (typeList.length === 1) { return typeList[0].args } 93 | 94 | // list list key args -> map key list args 95 | const argMap = typeList.reduce((map, { args }) => { 96 | args.forEach((arg) => { 97 | const { id, test } = arg 98 | if (map[id]) { 99 | // check for dupe test 100 | for (let j = 0; j < map[id].length; j++) { 101 | const { test: matchTest } = map[id][j] 102 | if (test === matchTest) { return } 103 | } 104 | // add new test 105 | map[id].push(arg) 106 | } else { 107 | // init tests 108 | map[id] = [arg] 109 | } 110 | }) 111 | 112 | return map 113 | }, {}) 114 | 115 | const possibleKeys = Object.keys(argMap) 116 | const everNull = typeList.reduce((en, { args }) => { 117 | const hasArg = args.reduce((m, v) => { 118 | m[v.id] = v 119 | return m 120 | }, {}) 121 | 122 | return possibleKeys.reduce((en_, key) => { 123 | if (!hasArg[key]) { en_[key] = true } 124 | return en_ 125 | }, en) 126 | }, {}) 127 | 128 | for (const key in everNull) { 129 | argMap[key].push({ id: key, test: nullType }) 130 | } 131 | 132 | // map key list args -> list key test 133 | const argDest = [] 134 | for (const key in argMap) { 135 | const param = fitTypes(argMap[key]) 136 | argDest.push(Object.assign(param, { id: key })) 137 | } 138 | return argDest 139 | } 140 | 141 | function fitTypes (typeList) { 142 | if (typeList.length === 1) { return typeList[0] } 143 | 144 | const optional = typeList.some((t) => t.test === nullType) 145 | 146 | const hasAnyType = typeList.some((t) => t.test === types.Any) 147 | if (hasAnyType) { 148 | return optional 149 | ? { test: types.Any.optional } 150 | : { test: types.Any } 151 | } 152 | 153 | const valueTypes = typeList 154 | .map((t) => t.args ? types.Object : t.test) 155 | .filter((t) => t !== nullType) 156 | const singleValue = valueTypes.length === 1 157 | 158 | if (optional) { 159 | return singleValue 160 | ? { test: valueTypes[0].optional } 161 | : { 162 | test: types.OneOfType.optional.apply(null, valueTypes), 163 | subTypes: valueTypes, 164 | optional: true, 165 | } 166 | } 167 | 168 | return singleValue 169 | ? { test: valueTypes[0] } 170 | : { 171 | test: types.OneOfType.apply(null, valueTypes), 172 | subTypes: valueTypes, 173 | } 174 | } 175 | 176 | const tab = " " 177 | 178 | function defString (actionMap) { 179 | const actions = Object.keys(actionMap) 180 | .map((type) => ({ type, args: actionMap[type] })) 181 | 182 | return ` 183 | createSchema([ 184 | ${actions.map(printAction).join(`,\n${tab}`)} 185 | ])` 186 | } 187 | 188 | function printAction (action) { 189 | return `["${action.type}"${printArgs(action.args)}]` 190 | } 191 | 192 | function printArgs (args) { 193 | if (!args.length) { return "" } 194 | if (args.length === 1 && args[0].wholePayload) { 195 | return `, ${printFnBlock(args[0])}` 196 | } 197 | return [""].concat(args.map(printNamedArg)).join(`,\n${tab}${tab}`) 198 | } 199 | 200 | const printFn = (fn) => `types.${fn._typeName}` 201 | 202 | function printFnBlock (argTest) { 203 | if (argTest.subTypes) { 204 | const subTypesStr = argTest.subTypes.map(printFn).join(", ") 205 | const optStr = argTest.optional ? ".optional" : "" 206 | return `types.OneOfType${optStr}(${subTypesStr})` 207 | } 208 | return printFn(argTest.test) 209 | } 210 | 211 | function printNamedArg (arg) { 212 | return `["${arg.id}", ${printFnBlock(arg)}]` 213 | } 214 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | export const types = { 2 | Object: (val) => 3 | !!val && !Array.isArray(val) && typeof val === "object", 4 | Number: (val) => typeof val === "number", 5 | String: (val) => typeof val === "string", 6 | Boolean: (val) => val === true || val === false, 7 | Array: Array.isArray, 8 | Any: (val) => val !== undefined && val !== null, 9 | } 10 | 11 | // TODO: OneOfType takes array instead of args? 12 | // TODO: OneOf takes object and matches values 13 | // TODO: types.Flag === types.Boolean.optional 14 | 15 | const optional = (fn) => (val) => 16 | val === undefined || val === null || fn(val) 17 | 18 | for (const key in types) { 19 | types[key]._typeName = key 20 | types[key].optional = optional(types[key]) 21 | types[key].optional._typeName = `${key}.optional` 22 | } 23 | 24 | const tParams = { 25 | OneOf: (opts) => (val) => opts.indexOf(val) !== -1, 26 | ArrayOf: (typeFn) => (val) => 27 | Array.isArray(val) && !!val.every(typeFn), 28 | OneOfType: function (...args) { 29 | return (val) => args.some((test) => test(val)) 30 | }, 31 | } 32 | 33 | const compose = (a, b) => function (...args) { 34 | return a(b.apply(this, args)) 35 | } 36 | 37 | for (const key in tParams) { 38 | types[key] = tParams[key] 39 | types[key].optional = compose(optional, tParams[key]) 40 | } 41 | 42 | export const testArgs = (args) => (payload) => 43 | payload && 44 | typeof payload === "object" && 45 | args.every(({ id, test }) => test(payload[id])) && 46 | Object.keys(payload).length === args.length 47 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | export const mergeIgnoreUndefined = (a, b) => { 2 | const merged = Object.assign({}, a) 3 | for (const key in b) { 4 | if (b[key] !== undefined) { 5 | merged[key] = b[key] 6 | } 7 | } 8 | return merged 9 | } 10 | -------------------------------------------------------------------------------- /test/actions-test.js: -------------------------------------------------------------------------------- 1 | const test = require("tape") 2 | const { createSchema, types } = require("../dist/index.js") 3 | 4 | test("makes action map", (t) => { 5 | const { actions } = createSchema([ 6 | ["foo"], 7 | ["bar"], 8 | ["baz"], 9 | ]) 10 | 11 | t.deepEquals(actions, { foo: "foo", bar: "bar", baz: "baz" }) 12 | t.end() 13 | }) 14 | 15 | test("uses default namespace if namespace undefined", (t) => { 16 | const { actions } = createSchema([ 17 | ["foo"], 18 | ["bar"], 19 | ["baz"], 20 | ], { namespace: undefined }) 21 | 22 | t.deepEquals(actions, { foo: "foo", bar: "bar", baz: "baz" }) 23 | t.end() 24 | }) 25 | 26 | test("makes action map with namespace string", (t) => { 27 | const { actions } = createSchema([ 28 | ["foo"], 29 | ["bar"], 30 | ["baz"], 31 | ], { namespace: "ns" }) 32 | 33 | t.deepEquals(actions, { 34 | foo: "ns_foo", bar: "ns_bar", baz: "ns_baz", 35 | }) 36 | t.end() 37 | }) 38 | 39 | test("makes action map with namespace func", (t) => { 40 | const { actions } = createSchema([ 41 | ["foo"], 42 | ["bar"], 43 | ["baz"], 44 | ], { namespace: (t) => `ns_${t}` }) 45 | 46 | t.deepEquals(actions, { 47 | foo: "ns_foo", bar: "ns_bar", baz: "ns_baz", 48 | }) 49 | t.end() 50 | }) 51 | 52 | test("throws on invalid namespace parameter", (t) => { 53 | t.throws(() => { 54 | createSchema([ 55 | ["foo"], 56 | ["bar"], 57 | ["baz"], 58 | ], { namespace: { foo: "bar" } }) 59 | }) 60 | t.end() 61 | }) 62 | 63 | test("throws on namespace collision", (t) => { 64 | t.throws(() => { 65 | createSchema([ 66 | ["foo"], 67 | ["bar"], 68 | ["baz"], 69 | ], { namespace: (t) => "same" }) 70 | }) 71 | t.end() 72 | }) 73 | 74 | test("makes action creators", (t) => { 75 | const { actionCreators } = createSchema([ 76 | ["foo"], 77 | ["bar", types.String], 78 | ["baz", ["a", types.Number], ["b", types.Number]], 79 | ]) 80 | t.deepEquals(actionCreators.foo(), 81 | { type: "foo", payload: undefined }) 82 | t.deepEquals(actionCreators.bar("value"), 83 | { type: "bar", payload: "value" }) 84 | t.deepEquals(actionCreators.baz({ a: 1, b: 2 }), 85 | { type: "baz", payload: { a: 1, b: 2 } }) 86 | t.end() 87 | }) 88 | 89 | test("makes positional argument action creators", (t) => { 90 | const { actionCreators: ac } = createSchema([ 91 | ["foo"], 92 | ["bar", types.String], 93 | ["baz", ["a", types.Number], ["b", types.Number]], 94 | ]) 95 | t.deepEquals(ac.foo.byPosition("any", "value"), 96 | { type: "foo", payload: undefined }) 97 | t.deepEquals(ac.bar.byPosition("value", "another"), 98 | { type: "bar", payload: "value" }) 99 | t.deepEquals(ac.baz.byPosition(0, 2, 3), 100 | { type: "baz", payload: { a: 0, b: 2 } }) 101 | t.end() 102 | }) 103 | 104 | test("makes action creators with alternate format", (t) => { 105 | const format = (type, payload) => payload === undefined 106 | ? [type] 107 | : [type, payload] 108 | const unformat = ([type, payload]) => ({ type, payload }) 109 | const { actionCreators: ac } = createSchema([ 110 | ["foo"], 111 | ["bar", types.String], 112 | ["baz", ["a", types.Number], ["b", types.Number]], 113 | ], { format, unformat }) 114 | 115 | t.deepEquals(ac.foo(), ["foo"]) 116 | t.deepEquals(ac.bar("value"), ["bar", "value"]) 117 | t.deepEquals(ac.baz({ a: 1, b: 2 }), ["baz", {a: 1, b: 2}]) 118 | t.deepEquals(ac.baz.byPosition(0, 2), ["baz", {a: 0, b: 2}]) 119 | t.end() 120 | }) 121 | 122 | test("makes namespaced action creators", (t) => { 123 | const { actionCreators: ac } = createSchema([ 124 | ["foo"], 125 | ["bar", types.String], 126 | ["baz", ["a", types.Number], ["b", types.Number]], 127 | ], { namespace: "ns" }) 128 | 129 | t.deepEquals(ac.foo(), 130 | { type: "ns_foo", payload: undefined }) 131 | t.deepEquals(ac.bar("value"), 132 | { type: "ns_bar", payload: "value" }) 133 | t.deepEquals(ac.baz({ a: 1, b: 2 }), 134 | { type: "ns_baz", payload: { a: 1, b: 2 } }) 135 | t.end() 136 | }) 137 | 138 | test("tests actions for validity", (t) => { 139 | const { test: testAction } = createSchema([ 140 | ["foo"], 141 | ["bar", types.String], 142 | ["baz", ["a", types.Number], ["b", types.Number]], 143 | ]) 144 | 145 | t.false(testAction({ type: "unknown" })) 146 | 147 | t.true(testAction({ type: "foo" })) 148 | t.true(testAction({ type: "foo", meta: "something" })) 149 | t.false(testAction({ type: "foo", payload: {} })) 150 | 151 | t.true(testAction({ type: "bar", payload: "value" })) 152 | t.false(testAction({ type: "bar" })) 153 | t.false(testAction({ type: "bar", payload: { a: 3 } })) 154 | 155 | t.true(testAction({ type: "baz", payload: { a: 1, b: 2 } })) 156 | t.false(testAction({ type: "baz" })) 157 | t.false(testAction({ type: "baz", payload: { a: 1, b: 2, c: 3 } })) 158 | t.false(testAction({ type: "baz", payload: { b: 2 } })) 159 | t.false(testAction({ type: "baz", payload: "value" })) 160 | t.end() 161 | }) 162 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | require("./parse-test.js") 2 | require("./actions-test.js") 3 | require("./reducer-test.js") 4 | require("./middleware-test.js") 5 | require("./types-test.js") 6 | require("./observer-test.js") 7 | -------------------------------------------------------------------------------- /test/middleware-test.js: -------------------------------------------------------------------------------- 1 | const test = require("tape") 2 | const { createSchema, types } = require("../dist/index.js") 3 | const { createStore, applyMiddleware } = require("redux") 4 | const thunk = require("redux-thunk").default 5 | 6 | test("create middleware", (t) => { 7 | const { createReducer, createMiddleware } = createSchema([ 8 | ["foo"], 9 | ["bar", types.String], 10 | ]) 11 | 12 | const initState = { count: 0, message: "hello" } 13 | 14 | const reducer = createReducer({ 15 | foo: (state) => state, 16 | bar: (state) => state, 17 | }, initState) 18 | 19 | const middleware = createMiddleware({ 20 | onError: () => { throw new Error("unknown action") }, 21 | }) 22 | 23 | const store = createStore(reducer, applyMiddleware(middleware)) 24 | 25 | t.doesNotThrow(() => { 26 | store.dispatch({ type: "foo" }) 27 | }) 28 | t.doesNotThrow(() => { 29 | store.dispatch({ type: "EFFECT_TRIGGERED" }) 30 | }) 31 | t.doesNotThrow(() => { 32 | store.dispatch({ type: "bar", payload: "world", meta: { a: 1 } }) 33 | }) 34 | t.throws(() => { 35 | store.dispatch({ type: "bar", payload: { a: "bad argument" } }) 36 | }) 37 | t.throws(() => { 38 | store.dispatch({ type: "foo", payload: "arg" }) 39 | }) 40 | t.throws(() => { 41 | store.dispatch({ type: "quux" }) 42 | }) 43 | t.end() 44 | }) 45 | 46 | test("create middleware with unchecked payloads", (t) => { 47 | const { createReducer, createMiddleware } = createSchema([ 48 | ["foo"], 49 | ["bar", types.String], 50 | ]) 51 | 52 | const initState = { count: 0, message: "hello" } 53 | 54 | const reducer = createReducer({ 55 | foo: (state) => state, 56 | bar: (state) => state, 57 | }, initState) 58 | 59 | const middleware = createMiddleware({ 60 | ignorePayloads: true, 61 | onError: () => { throw new Error("unknown action") }, 62 | }) 63 | 64 | const store = createStore(reducer, applyMiddleware(middleware)) 65 | 66 | t.doesNotThrow(() => { 67 | store.dispatch({ type: "foo" }) 68 | }) 69 | t.doesNotThrow(() => { 70 | store.dispatch({ type: "EFFECT_TRIGGERED" }) 71 | }) 72 | t.doesNotThrow(() => { 73 | store.dispatch({ type: "bar", payload: "world", meta: { a: 1 } }) 74 | }) 75 | t.doesNotThrow(() => { 76 | store.dispatch({ type: "bar", payload: { a: "bad argument" } }) 77 | }) 78 | t.throws(() => { 79 | store.dispatch({ type: "quux" }) 80 | }) 81 | t.end() 82 | }) 83 | 84 | test("create middleware with ignored actions", (t) => { 85 | const { createReducer, createMiddleware } = createSchema([ 86 | ["foo"], 87 | ["bar", types.String], 88 | ]) 89 | 90 | const initState = { count: 0, message: "hello" } 91 | 92 | const reducer = createReducer({ 93 | foo: (state) => state, 94 | bar: (state) => state, 95 | }, initState) 96 | 97 | const middleware = createMiddleware({ 98 | ignoreActions: ["baz", "quux"], 99 | onError: () => { throw new Error("unknown action") }, 100 | }) 101 | 102 | const store = createStore(reducer, applyMiddleware(middleware)) 103 | 104 | t.doesNotThrow(() => { 105 | store.dispatch({ type: "foo" }) 106 | }) 107 | t.throws(() => { 108 | store.dispatch({ type: "EFFECT_TRIGGERED" }) 109 | }) 110 | t.doesNotThrow(() => { 111 | store.dispatch({ type: "baz" }) 112 | }) 113 | t.end() 114 | }) 115 | 116 | test("doesn't interfere with redux-thunk", (t) => { 117 | const { createReducer, createMiddleware } = createSchema([ 118 | ["foo"], 119 | ["bar", types.String], 120 | ]) 121 | 122 | const initState = { count: 0, message: "hello" } 123 | 124 | const reducer = createReducer({ 125 | foo: (state) => state, 126 | bar: (state) => state, 127 | }, initState) 128 | 129 | const middleware = createMiddleware({ 130 | onError: () => { throw new Error("unknown action") }, 131 | }) 132 | 133 | const asyncAction = (dispatch) => { 134 | dispatch({ type: "foo" }) 135 | } 136 | 137 | const store = createStore( 138 | reducer, 139 | applyMiddleware(middleware, thunk)) 140 | 141 | t.doesNotThrow(() => { 142 | store.dispatch(asyncAction) 143 | }) 144 | t.end() 145 | }) 146 | -------------------------------------------------------------------------------- /test/observer-test.js: -------------------------------------------------------------------------------- 1 | const test = require("tape") 2 | const { createSchemaObserver, types } = require("../dist/index.js") 3 | const { createStore, applyMiddleware } = require("redux") 4 | 5 | test("creates a log of received actions, partitioned by type", (t) => { 6 | const o = createSchemaObserver() 7 | 8 | const state = {} 9 | const reducer = () => state 10 | const store = createStore(reducer, applyMiddleware(o)) 11 | 12 | ;[ 13 | { type: "foo" }, 14 | { type: "bar" }, 15 | { type: "foo", payload: 1 }, 16 | { type: "foo", payload: 3 }, 17 | { type: "foo", payload: 100 }, 18 | { type: "foo", payload: 1 }, 19 | { type: "bar", payload: { a: "string", b: "another" } }, 20 | ].forEach((action) => store.dispatch(action)) 21 | 22 | t.deepEquals(o.receivedActions(), { 23 | foo: [undefined, 1, 3, 100, 1], 24 | bar: [undefined, { a: "string", b: "another" }], 25 | }) 26 | t.end() 27 | }) 28 | 29 | test("creates a log of received 1-arg types", (t) => { 30 | const o = createSchemaObserver() 31 | 32 | const state = {} 33 | const reducer = () => state 34 | const store = createStore(reducer, applyMiddleware(o)) 35 | 36 | ;[ 37 | { type: "foo", payload: 1 }, 38 | { type: "foo", payload: 3 }, 39 | { type: "bar", payload: "a string" }, 40 | ].forEach((action) => store.dispatch(action)) 41 | 42 | t.deepEquals(o.receivedTypes(), { 43 | foo: [{ wholePayload: true, test: types.Number }], 44 | bar: [{ wholePayload: true, test: types.String }], 45 | }) 46 | t.end() 47 | }) 48 | 49 | test("handles actions with no args (ie. always null)", (t) => { 50 | const o = createSchemaObserver() 51 | 52 | const state = {} 53 | const reducer = () => state 54 | const store = createStore(reducer, applyMiddleware(o)) 55 | 56 | ;[ 57 | { type: "foo" }, 58 | { type: "foo" }, 59 | ].forEach((action) => store.dispatch(action)) 60 | 61 | t.deepEquals(o.receivedTypes(), { 62 | foo: [], 63 | }) 64 | t.end() 65 | }) 66 | 67 | test("handles nullable types", (t) => { 68 | const o = createSchemaObserver() 69 | 70 | const state = {} 71 | const reducer = () => state 72 | const store = createStore(reducer, applyMiddleware(o)) 73 | 74 | ;[ 75 | { type: "foo" }, 76 | { type: "foo", payload: 3 }, 77 | { type: "foo" }, 78 | ].forEach((action) => store.dispatch(action)) 79 | 80 | t.deepEquals(o.receivedTypes(), { 81 | foo: [{ wholePayload: true, test: types.Number.optional }], 82 | }) 83 | t.end() 84 | }) 85 | 86 | test("handles union types", (t) => { 87 | const o = createSchemaObserver() 88 | 89 | const state = {} 90 | const reducer = () => state 91 | const store = createStore(reducer, applyMiddleware(o)) 92 | 93 | ;[ 94 | { type: "foo", payload: 3 }, 95 | { type: "foo", payload: "string" }, 96 | { type: "bar", payload: 3 }, 97 | { type: "bar", payload: "string" }, 98 | { type: "bar" }, 99 | ].forEach((action) => store.dispatch(action)) 100 | 101 | const { foo, bar } = o.receivedTypes() 102 | const fooType = foo[0].test 103 | const barType = bar[0].test 104 | 105 | t.true(fooType(10)) 106 | t.true(fooType("a string")) 107 | t.false(fooType(null)) 108 | t.deepEquals(foo[0].subTypes, [types.Number, types.String]) 109 | 110 | t.true(barType(10)) 111 | t.true(barType("a string")) 112 | t.true(barType(null)) 113 | t.deepEquals(bar[0].subTypes, [types.Number, types.String]) 114 | 115 | t.end() 116 | }) 117 | 118 | test("handles Any type (for function)", (t) => { 119 | const o = createSchemaObserver() 120 | 121 | const state = {} 122 | const reducer = () => state 123 | const store = createStore(reducer, applyMiddleware(o)) 124 | 125 | ;[ 126 | { type: "foo", payload: "a string" }, 127 | { type: "foo", payload: () => {} }, 128 | { type: "bar" }, 129 | { type: "bar", payload: 123 }, 130 | { type: "bar", payload: () => {} }, 131 | ].forEach((action) => store.dispatch(action)) 132 | 133 | t.deepEquals(o.receivedTypes(), { 134 | foo: [{ wholePayload: true, test: types.Any }], 135 | bar: [{ wholePayload: true, test: types.Any.optional }], 136 | }) 137 | t.end() 138 | }) 139 | 140 | test("handles objects with 1 named arg", (t) => { 141 | const o = createSchemaObserver() 142 | 143 | const state = {} 144 | const reducer = () => state 145 | const store = createStore(reducer, applyMiddleware(o)) 146 | 147 | ;[ 148 | { type: "bar", payload: { a: "string" } }, 149 | { type: "bar", payload: { a: "another" } }, 150 | ].forEach((action) => store.dispatch(action)) 151 | 152 | t.deepEquals(o.receivedTypes(), { 153 | bar: [{ id: "a", test: types.String }], 154 | }) 155 | t.end() 156 | }) 157 | 158 | test("handles objects by arg", (t) => { 159 | const o = createSchemaObserver() 160 | 161 | const state = {} 162 | const reducer = () => state 163 | const store = createStore(reducer, applyMiddleware(o)) 164 | 165 | ;[ 166 | { type: "bar", payload: { a: "string", b: "another" } }, 167 | { type: "bar", payload: { a: "another", b: "string" } }, 168 | ].forEach((action) => store.dispatch(action)) 169 | 170 | t.deepEquals(o.receivedTypes(), { 171 | bar: [{ id: "a", test: types.String }, { id: "b", test: types.String }], 172 | }) 173 | t.end() 174 | }) 175 | 176 | test("handles objects by arg with optionals", (t) => { 177 | const o = createSchemaObserver() 178 | 179 | const state = {} 180 | const reducer = () => state 181 | const store = createStore(reducer, applyMiddleware(o)) 182 | 183 | ;[ 184 | { type: "bar", payload: { a: "string", b: "another" } }, 185 | { type: "bar", payload: { a: "another" } }, 186 | ].forEach((action) => store.dispatch(action)) 187 | 188 | t.deepEquals(o.receivedTypes(), { 189 | bar: [{ id: "a", test: types.String }, { id: "b", test: types.String.optional }], 190 | }) 191 | t.end() 192 | }) 193 | 194 | test("handles objects by arg with unions", (t) => { 195 | const o = createSchemaObserver() 196 | 197 | const state = {} 198 | const reducer = () => state 199 | const store = createStore(reducer, applyMiddleware(o)) 200 | 201 | ;[ 202 | { type: "bar", payload: { a: "string", b: "another" } }, 203 | { type: "bar", payload: { a: "another", b: 23 } }, 204 | ].forEach((action) => store.dispatch(action)) 205 | 206 | const { bar } = o.receivedTypes() 207 | const [a, b] = bar 208 | t.equals(bar.length, 2) 209 | t.equals(a.id, "a") 210 | t.equals(b.id, "b") 211 | t.equals(a.test, types.String) 212 | t.deepEquals(b.subTypes, [types.String, types.Number]) 213 | t.end() 214 | }) 215 | 216 | test("matches non-plain objects as types.Object", (t) => { 217 | const o = createSchemaObserver() 218 | 219 | const state = {} 220 | const reducer = () => state 221 | const store = createStore(reducer, applyMiddleware(o)) 222 | 223 | ;[ 224 | { type: "bar", payload: new Date() }, 225 | { type: "bar", payload: new Date() }, 226 | ].forEach((action) => store.dispatch(action)) 227 | 228 | t.deepEquals(o.receivedTypes(), { 229 | bar: [{ wholePayload: true, test: types.Object }], 230 | }) 231 | t.end() 232 | }) 233 | 234 | test("matches union of {a, b} & 123 as OneOf(Object, Number)", (t) => { 235 | const o = createSchemaObserver() 236 | 237 | const state = {} 238 | const reducer = () => state 239 | const store = createStore(reducer, applyMiddleware(o)) 240 | 241 | ;[ 242 | { type: "bar", payload: { a: "string", b: "another" } }, 243 | { type: "bar", payload: 123 }, 244 | ].forEach((action) => store.dispatch(action)) 245 | 246 | const bar = o.receivedTypes().bar[0] 247 | t.true(bar.wholePayload) 248 | t.deepEquals(bar.subTypes, [types.Object, types.Number]) 249 | t.end() 250 | }) 251 | 252 | test("print schema", (t) => { 253 | const o = createSchemaObserver() 254 | 255 | const state = {} 256 | const reducer = () => state 257 | const store = createStore(reducer, applyMiddleware(o)) 258 | 259 | ;[ 260 | { type: "foo" }, 261 | { type: "bar", payload: 123 }, 262 | { type: "baz" }, 263 | { type: "baz", payload: "str" }, 264 | { type: "quux", payload: 123 }, 265 | { type: "quux" }, 266 | { type: "quux", payload: "str" }, 267 | { type: "xyzzy", payload: { a: 123, b: "foo" } }, 268 | ].forEach((action) => store.dispatch(action)) 269 | 270 | t.equals(o.schemaDefinitionString(), ` 271 | createSchema([ 272 | ["foo"], 273 | ["bar", types.Number], 274 | ["baz", types.String.optional], 275 | ["quux", types.OneOfType.optional(types.Number, types.String)], 276 | ["xyzzy", 277 | ["a", types.Number], 278 | ["b", types.String]] 279 | ])`) 280 | 281 | t.end() 282 | }) 283 | -------------------------------------------------------------------------------- /test/parse-test.js: -------------------------------------------------------------------------------- 1 | const test = require("tape") 2 | const { createSchema, types } = require("../dist/index.js") 3 | 4 | test("parses a valid schema", (t) => { 5 | try { 6 | const { schema } = createSchema([ 7 | ["foo", "has no arguments"], 8 | ["bar"], // no docstring 9 | ["baz", "has single argument", types.String], 10 | ["quux", /* no docstring */ types.Number], 11 | ["plugh", "has single named argument", 12 | ["a", "argument a", types.String]], 13 | ["xyzzy", // no docstring 14 | ["a", "argument a", types.String], 15 | ["b", /* no docstring */ types.Number]], 16 | ]) 17 | 18 | t.deepEquals(schema.foo, { 19 | type: "foo", doc: "has no arguments", args: [], 20 | }) 21 | t.deepEquals(schema.bar, { 22 | type: "bar", doc: "", args: [], 23 | }) 24 | t.deepEquals(schema.baz, { 25 | type: "baz", doc: "has single argument", 26 | args: [{ test: types.String, doc: "", wholePayload: true }], 27 | }) 28 | t.deepEquals(schema.quux, { 29 | type: "quux", doc: "", 30 | args: [{ test: types.Number, doc: "", wholePayload: true }], 31 | }) 32 | t.deepEquals(schema.plugh, { 33 | type: "plugh", doc: "has single named argument", 34 | args: [{ id: "a", doc: "argument a", test: types.String }], 35 | }) 36 | t.deepEquals(schema.xyzzy, { 37 | type: "xyzzy", doc: "", 38 | args: [ 39 | { id: "a", doc: "argument a", test: types.String }, 40 | { id: "b", doc: "", test: types.Number }, 41 | ], 42 | }) 43 | } catch (e) { 44 | t.fail("valid schema generation threw an error") 45 | } finally { 46 | t.end() 47 | } 48 | }) 49 | 50 | test.skip("throws on invalid schema") 51 | -------------------------------------------------------------------------------- /test/reducer-test.js: -------------------------------------------------------------------------------- 1 | const test = require("tape") 2 | const { createSchema, types } = require("../dist/index.js") 3 | 4 | const merge = (a, b) => Object.assign({}, a, b) 5 | 6 | test("create reducer", (t) => { 7 | const { createReducer } = createSchema([ 8 | ["foo"], 9 | ["bar", types.String], 10 | ["baz", ["a", types.Number], ["b", types.Number]], 11 | ]) 12 | 13 | const initState = { count: 0, message: "hello" } 14 | 15 | const reducer = createReducer({ 16 | foo: (state) => merge(state, { count: state.count + 1 }), 17 | bar: (state, message) => merge(state, { message }), 18 | baz: (state, { a, b }, { meta }) => 19 | merge(state, { count: a + b, message: meta || state.message }), 20 | }, initState) 21 | 22 | t.equal(initState, reducer(undefined, { type: "@@INIT" })) 23 | t.equal(initState, reducer(initState, { type: "@@INIT" })) 24 | t.deepEquals({ count: 1, message: "hello" }, 25 | reducer(initState, { type: "foo" })) 26 | t.deepEquals({ count: 0, message: "world" }, 27 | reducer(initState, { type: "bar", payload: "world" })) 28 | t.deepEquals({ count: 3, message: "hello" }, 29 | reducer(initState, { type: "baz", payload: { a: 1, b: 2 } })) 30 | t.deepEquals({ count: 3, message: "world" }, 31 | reducer(initState, 32 | { type: "baz", payload: { a: 1, b: 2 }, meta: "world" })) 33 | t.end() 34 | }) 35 | 36 | test("throws when reducer created with unknown action", (t) => { 37 | const { createReducer } = createSchema([ 38 | ["foo"], 39 | ]) 40 | 41 | t.throws(() => { 42 | createReducer({ 43 | foo: (state) => state, 44 | quux: (state) => state, 45 | }) 46 | }) 47 | t.end() 48 | }) 49 | 50 | test("throws if reducer created with non-function", (t) => { 51 | const { createReducer } = createSchema([ 52 | ["foo"], 53 | ["bar", types.String], 54 | ]) 55 | 56 | t.throws(() => { 57 | createReducer({ 58 | foo: (state) => state, 59 | bar: "a string", 60 | }) 61 | }) 62 | t.end() 63 | }) 64 | 65 | test("create namespaced reducer", (t) => { 66 | const { createReducer } = createSchema([ 67 | ["foo"], 68 | ["bar", types.String], 69 | ], { namespace: "ns" }) 70 | 71 | const initState = { count: 0, message: "hello" } 72 | 73 | const reducer = createReducer({ 74 | foo: (state) => merge(state, { count: state.count + 1 }), 75 | bar: (state, message) => merge(state, { message }), 76 | }, initState) 77 | 78 | t.equals(initState, reducer(initState, { type: "foo" })) 79 | t.deepEquals({ count: 1, message: "hello" }, 80 | reducer(initState, { type: "ns_foo" })) 81 | t.deepEquals({ count: 0, message: "world" }, 82 | reducer(initState, { type: "ns_bar", payload: "world" })) 83 | t.end() 84 | }) 85 | -------------------------------------------------------------------------------- /test/types-test.js: -------------------------------------------------------------------------------- 1 | const test = require("tape") 2 | const { types } = require("../dist/index.js") 3 | 4 | test("type checking", (t) => { 5 | const vals = [ 6 | null, undefined, false, "foo", 0, { a: 1, b: "b" }, [1, 2, 3], 7 | ] 8 | 9 | t.deepEquals(vals.map(types.Object), 10 | [false, false, false, false, false, true, false]) 11 | t.deepEquals(vals.map(types.Number), 12 | [false, false, false, false, true, false, false]) 13 | t.deepEquals(vals.map(types.String), 14 | [false, false, false, true, false, false, false]) 15 | t.deepEquals(vals.map(types.Boolean), 16 | [false, false, true, false, false, false, false]) 17 | t.deepEquals(vals.map(types.Array), 18 | [false, false, false, false, false, false, true]) 19 | t.deepEquals(vals.map(types.Any), 20 | [false, false, true, true, true, true, true]) 21 | t.end() 22 | }) 23 | 24 | test("optional type checking", (t) => { 25 | const vals = [ 26 | null, undefined, false, "", 0, { a: 1, b: "b" }, 27 | ] 28 | 29 | t.deepEquals(vals.map(types.Object.optional), 30 | [true, true, false, false, false, true]) 31 | t.deepEquals(vals.map(types.Number.optional), 32 | [true, true, false, false, true, false]) 33 | t.deepEquals(vals.map(types.String.optional), 34 | [true, true, false, true, false, false]) 35 | t.deepEquals(vals.map(types.Any.optional), 36 | [true, true, true, true, true, true]) 37 | t.end() 38 | }) 39 | 40 | test("enum type checking", (t) => { 41 | const e = ["foo", "bar", "baz"] 42 | 43 | t.true(types.OneOf(e)("foo")) 44 | t.false(types.OneOf(e)("quux")) 45 | t.false(types.OneOf(e)(null)) 46 | t.true(types.OneOf.optional(e)(null)) 47 | t.false(types.OneOf.optional(e)("quux")) 48 | t.end() 49 | }) 50 | 51 | test("one of type", (t) => { 52 | const vals = [ 53 | null, undefined, false, "", 0, { a: 1, b: "b" }, 54 | ] 55 | 56 | t.deepEquals(vals.map(types.OneOfType(types.Number, types.String)), 57 | [false, false, false, true, true, false]) 58 | t.deepEquals(vals.map( 59 | types.OneOfType.optional(types.Number, types.String)), 60 | [true, true, false, true, true, false]) 61 | t.end() 62 | }) 63 | 64 | test("array of type", (t) => { 65 | t.true(types.ArrayOf(types.Number)([1, 2, 3])) 66 | t.false(types.ArrayOf(types.Number)(["foo", "bar", "baz"])) 67 | t.false(types.ArrayOf(types.Number)([1, 2, "foo", 4])) 68 | t.false(types.ArrayOf(types.Number)([1, 2, null, 4])) 69 | t.false(types.ArrayOf.optional(types.Number)([1, 2, null, 4])) 70 | t.true(types.ArrayOf.optional(types.Number)(null)) 71 | t.false(types.ArrayOf.optional(types.Number)(0)) 72 | t.true(types.ArrayOf(types.Number.optional)([1, 2, null, 4])) 73 | t.end() 74 | }) 75 | --------------------------------------------------------------------------------