├── .gitignore ├── .nvmrc ├── README.md ├── examples ├── README.md ├── machine.js ├── machine.json └── openwhisk │ ├── action.js │ └── deploy.sh ├── init.sh ├── package.json └── src ├── graph.js ├── graph.test.js ├── index.js ├── index.test.js ├── pathutils.js ├── pathutils.test.js ├── schema ├── Branch.json ├── Catcher.json ├── Choice.json ├── ChoiceRule.json ├── Fail.json ├── NonEmptyString.json ├── Parallel.json ├── Pass.json ├── Path.json ├── PositiveInteger.json ├── README.md ├── Retrier.json ├── Schema.json ├── State.json ├── States.json ├── Succeed.json ├── Task.json ├── Timestamp.json ├── TopLevelChoiceRule.json ├── Wait.json ├── __tests__ │ └── pass.js └── index.js ├── states ├── __mocktask__.js ├── choice.test.js ├── choice │ ├── index.js │ ├── operators.js │ └── rule.js ├── factory.js ├── factory.test.js ├── fail.js ├── fail.test.js ├── mixins │ ├── catch.js │ ├── catch.test.js │ ├── errorutils.js │ ├── filter.js │ ├── filter.test.js │ ├── index.js │ ├── retry.js │ ├── retry.test.js │ ├── state.js │ ├── state.test.js │ ├── timeout.js │ └── timeout.test.js ├── parallel.js ├── parallel.test.js ├── pass.js ├── pass.test.js ├── succeed.js ├── succeed.test.js ├── task.js ├── task.test.js ├── wait.js └── wait.test.js ├── util.js └── util.test.js /.gitignore: -------------------------------------------------------------------------------- 1 | env.sh 2 | node_modules 3 | coverage 4 | .nyc_output 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v6.9.1 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # step 2 | 3 | An [Amazon State Language](https://states-language.net/spec.html) based state machine. 4 | 5 | It is probably worth pointing out that this design probably isn't terribly 6 | practical in reality. There are many ways to solve this problem but the current 7 | structure was chosen for ease of development, to better understand the problem 8 | space, and to get a feel for the Amazon State Language spec itself. One could 9 | imagine many different approaches, including: 10 | - **Separate the validation and execution steps:** Validation and deployment of a 11 | specification versus the runtime invocation are distinctly different as it 12 | pertains to the overall lifecycle of the state machine. One approach could be to 13 | validate both structure and data integrity, then place the valid specification 14 | in a data store. At runtime the state machine could reference that specification 15 | by name, retrieve it, and run the machine to completion. 16 | - **Generate the state machine implementation:** Perhaps a more efficient solution 17 | would be to generate and deploy the complete implementation (a FaaS Function) 18 | based on the provided spec. The platform could either change the implementation 19 | of how it would fulfill remote Tasks based on the FaaS provider, or the 20 | implementation could be provided by the author. (I like this one, personally.) 21 | 22 | See additional [notes below](#notes). 23 | 24 | 25 | ## Basic API 26 | ```js 27 | const json = { 28 | "StartAt": "Demo", 29 | "States": { 30 | "Demo": { 31 | "Type": "Pass", 32 | "Result": { 33 | "pass": { 34 | "a": "b" 35 | } 36 | }, 37 | "Next": "Done" 38 | }, 39 | "Done": { 40 | "Type": "Succeed" 41 | } 42 | } 43 | }; 44 | 45 | const input = { 46 | foo: { 47 | bar: true, 48 | }, 49 | }; 50 | 51 | const machine = Machine.create(json); 52 | const result = await machine.run(input); 53 | console.log(result); 54 | ``` 55 | 56 | ## Testing 57 | ```bash 58 | $ npm test 59 | ``` 60 | 61 | ##### Notes 62 | - The Node runtime version (in `.npmrc`) was explicitly chosen for OpenWhisk 63 | compatibility. The associated stability that comes with selecting one runtime 64 | is preferred for now (in the early stages of development) over flexbility 65 | across providers. 66 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # OpenWhisk Example 2 | 3 | 1. Ensure your environment is setup. 4 | ```bash 5 | $ . init.sh 6 | Found '.nvmrc' with version 7 | Now using node v6.9.1 (npm v3.10.8) 8 | ``` 9 | 10 | 2. Deploy the test action. 11 | ```bash 12 | $ ./examples/openwhisk/deploy.sh 13 | ok: updated action step_test_action 14 | ``` 15 | 16 | 3. Run the example. 17 | ```bash 18 | $ node examples/machine.js 19 | ``` 20 | -------------------------------------------------------------------------------- /examples/machine.js: -------------------------------------------------------------------------------- 1 | const json = require('./machine.json'); 2 | const Machine = require('../src/index'); 3 | 4 | 5 | const log = name => { 6 | const states = json.States; 7 | return ({ name: stateName = '', input, output }) => { 8 | const state = states[stateName] || { Type: '' }; 9 | console.log(new Date().toISOString(), `${state.Type}${name}`, JSON.stringify(input || output)); 10 | }; 11 | } 12 | 13 | const machine = Machine.create(json); 14 | machine.on('ExecutionStarted', log('ExecutionStarted')); 15 | machine.on('ExecutionCompleted', log('ExecutionCompleted')); 16 | machine.on('StateEntered', log('StateEntered')); 17 | machine.on('StateExited', log('StateExited')); 18 | machine.run({}).then(console.log, console.error); 19 | -------------------------------------------------------------------------------- /examples/machine.json: -------------------------------------------------------------------------------- 1 | { 2 | "StartAt": "One", 3 | "TimeoutSeconds": 1, 4 | "States": { 5 | "One": { 6 | "Type": "Wait", 7 | "Seconds": 1, 8 | "Next": "Two" 9 | }, 10 | "Two": { 11 | "Type": "Pass", 12 | "Result": { 13 | "abc": 123 14 | }, 15 | "Next": "Seven" 16 | }, 17 | "Seven": { 18 | "Type": "Task", 19 | "Resource": "step_test_action", 20 | "OutputPath": "$.response.result", 21 | "TimeoutSeconds": 1, 22 | "Retry": [ 23 | { 24 | "ErrorEquals": [ 25 | "States.Timeout" 26 | ], 27 | "MaxAttempts": 2 28 | } 29 | ], 30 | "Catch": [ 31 | { 32 | "ErrorEquals": [ 33 | "States.TaskFailed" 34 | ], 35 | "Next": "Four" 36 | }, 37 | { 38 | "ErrorEquals": [ 39 | "States.Timeout" 40 | ], 41 | "Next": "Four" 42 | }, 43 | { 44 | "ErrorEquals": [ 45 | "States.ALL" 46 | ], 47 | "Next": "Six" 48 | } 49 | ], 50 | "Next": "Three" 51 | }, 52 | "Three": { 53 | "Type": "Choice", 54 | "Choices": [ 55 | { 56 | "Or": [ 57 | { 58 | "Variable": "$.abc", 59 | "NumericEquals": 124 60 | }, 61 | { 62 | "Variable": "$.abc", 63 | "NumericEquals": 123 64 | } 65 | ], 66 | "Next": "Five" 67 | } 68 | ], 69 | "Default": "Six" 70 | }, 71 | "Four": { 72 | "Type": "Fail", 73 | "Error": "States.Error", 74 | "Cause": "A cause." 75 | }, 76 | "Five": { 77 | "Type": "Parallel", 78 | "Branches": [ 79 | { 80 | "StartAt": "FiveOne", 81 | "States": { 82 | "FiveOne": { 83 | "Type": "Wait", 84 | "Seconds": 1, 85 | "End": true 86 | } 87 | } 88 | }, 89 | { 90 | "StartAt": "FiveTwo", 91 | "States": { 92 | "FiveTwo": { 93 | "Type": "Wait", 94 | "Seconds": 2, 95 | "Next": "FiveFour" 96 | }, 97 | "FiveFour": { 98 | "Type": "Pass", 99 | "Result": 456, 100 | "ResultPath": "$.def", 101 | "End": true 102 | } 103 | } 104 | } 105 | ], 106 | "Catch": [], 107 | "Next": "Six" 108 | }, 109 | "Six": { 110 | "Type": "Pass", 111 | "Result": "bar", 112 | "ResultPath": "$.foo", 113 | "Next": "Eight" 114 | }, 115 | "Eight": { 116 | "Type": "Succeed" 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /examples/openwhisk/action.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | function main(params) { 4 | return Promise.resolve(params); 5 | } 6 | -------------------------------------------------------------------------------- /examples/openwhisk/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd "${0%/*}" 4 | wsk action update step_test_action action.js -i -u $__OW_API_KEY --apihost $__OW_API_HOST 5 | -------------------------------------------------------------------------------- /init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | FILE=env.sh 5 | CONTENTS=$(cat <<-EOM 6 | #!/bin/bash 7 | 8 | # Prepare the environment to mimick OpenWhisk runtime. 9 | export __OW_API_KEY={api_key} 10 | export __OW_API_HOST={api_host} 11 | EOM 12 | ) 13 | 14 | 15 | if [ ! -f $FILE ]; then 16 | echo "$CONTENTS" > $FILE 17 | echo 'To setup the environment for testing, set the appropriate values in env.sh.' 18 | exit 1 19 | fi 20 | 21 | 22 | # Initialize env variables. (Used by the openwhisk js client.) 23 | source $FILE 24 | 25 | # Ensure we're using the right Node runtime version. 26 | source ~/.nvm/nvm.sh 27 | nvm use 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "step", 3 | "version": "1.0.0", 4 | "description": "An Amazon States Language implementation in JavaScript.", 5 | "main": "src/index.js", 6 | "directories": { 7 | "src": "src", 8 | "examples": "examples" 9 | }, 10 | "scripts": { 11 | "test": "ava", 12 | "cover": "nyc ava" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/totherik/step.git" 17 | }, 18 | "author": "Erik Toth ", 19 | "license": "ISC", 20 | "bugs": { 21 | "url": "https://github.com/totherik/step/issues" 22 | }, 23 | "homepage": "https://github.com/totherik/step#readme", 24 | "devDependencies": { 25 | "ava": "^0.18.2", 26 | "nyc": "^10.2.0", 27 | "openwhisk": "^3.4.0" 28 | }, 29 | "dependencies": { 30 | "jsonpath": "^0.2.11", 31 | "jsonschema": "^1.1.1" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/graph.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | class Graph { 4 | 5 | constructor() { 6 | // Optimize for look-ups. Delete will be slower. 7 | this.adjacencies = new Map(/* vertex, Map(edge, vertex)*/); 8 | } 9 | 10 | getNeighbors(vertex) { 11 | const { adjacencies } = this; 12 | return adjacencies.get(vertex) || new Map(); 13 | } 14 | 15 | getVertexAt(vertexA, edgeA) { 16 | const { adjacencies } = this; 17 | if (adjacencies.has(vertexA)) { 18 | const edges = adjacencies.get(vertexA); 19 | return edges.get(edgeA); 20 | } 21 | return undefined; 22 | } 23 | 24 | addVertex(vA) { 25 | const { adjacencies } = this; 26 | if (!adjacencies.has(vA)) { 27 | adjacencies.set(vA, new Map()); 28 | } 29 | } 30 | 31 | removeVertex(vertexA) { 32 | const { adjacencies } = this; 33 | 34 | if (!adjacencies.has(vertexA)) { 35 | return false; 36 | } 37 | 38 | for (let [ _ , edges ] of adjacencies.entries()) { 39 | for (let [ edge, vertex ] of edges.entries()) { 40 | if (vertex === vertexA) { 41 | edges.delete(edge); 42 | } 43 | } 44 | } 45 | 46 | return adjacencies.delete(vertexA); 47 | } 48 | 49 | addEdge(vertexA, vertexB, edgeValue) { 50 | const { adjacencies } = this; 51 | 52 | if (!adjacencies.has(vertexA)) { 53 | this.addVertex(vertexA); 54 | } 55 | 56 | if (!adjacencies.has(vertexB)) { 57 | this.addVertex(vertexB); 58 | } 59 | 60 | const edgesA = adjacencies.get(vertexA); 61 | 62 | // Delete existing edges between the two vertices. May 63 | // not want to do this and allow multiple edges with multiple 64 | // values, but for now only allow one. 65 | for (const [ edge, vertex ] of edgesA) { 66 | if (vertex === vertexB) { 67 | edgesA.delete(edge); 68 | break; 69 | } 70 | } 71 | 72 | edgesA.set(edgeValue, vertexB); 73 | } 74 | 75 | removeEdge(vertexA, vertexB) { 76 | const { adjacencies } = this; 77 | 78 | if (adjacencies.has(vertexA)) { 79 | const edgesA = adjacencies.get(vertexA); 80 | for (const [ edge, vertex ] of edgesA) { 81 | if (vertex === vertexB) { 82 | return edgesA.delete(edge); 83 | } 84 | } 85 | } 86 | 87 | return false; 88 | } 89 | 90 | } 91 | 92 | 93 | module.exports = Graph; 94 | -------------------------------------------------------------------------------- /src/graph.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const Graph = require('./graph'); 3 | 4 | 5 | test('Add vertex', t => { 6 | 7 | const vertex = {}; 8 | 9 | const g = new Graph(); 10 | g.addVertex(vertex); 11 | t.true(g.adjacencies.has(vertex)); 12 | 13 | g.addVertex(vertex); 14 | t.is(g.adjacencies.size, 1); 15 | 16 | }); 17 | 18 | 19 | test('Get vertex', t => { 20 | 21 | const vertexA = {}; 22 | const vertexB = {}; 23 | const vertexC = {}; 24 | const edge = 'a'; 25 | 26 | const g = new Graph(); 27 | g.addEdge(vertexA, vertexB, edge); 28 | t.is(g.getVertexAt(vertexA, edge), vertexB); 29 | t.is(g.getVertexAt(vertexC, edge), undefined); 30 | 31 | }); 32 | 33 | 34 | test('Remove vertex', t => { 35 | 36 | const vertexA = {}; 37 | const vertexB = {}; 38 | const vertexC = {}; 39 | const vertexD = {}; 40 | 41 | const g = new Graph(); 42 | g.addEdge(vertexA, vertexC); 43 | g.addVertex(vertexB); 44 | t.true(g.adjacencies.has(vertexA)); 45 | 46 | t.true(g.removeVertex(vertexB)); 47 | t.true(g.removeVertex(vertexA)); 48 | t.true(g.removeVertex(vertexC)); 49 | t.false(g.removeVertex(vertexD)); 50 | t.false(g.adjacencies.has(vertexA)); 51 | 52 | }); 53 | 54 | 55 | test('Remove vertex with edges', t => { 56 | 57 | const vertexA = {}; 58 | const vertexB = {}; 59 | const edge = 'a'; 60 | 61 | const g = new Graph(); 62 | g.addEdge(vertexA, vertexB, edge); 63 | t.true(g.adjacencies.has(vertexA)); 64 | t.true(g.adjacencies.has(vertexB)); 65 | 66 | g.removeVertex(vertexB); 67 | t.false(g.adjacencies.has(vertexB)); 68 | 69 | const neighbors = g.getNeighbors(vertexA); 70 | t.false(neighbors.has(edge)); 71 | 72 | }); 73 | 74 | 75 | test('Add new edge', t => { 76 | 77 | const vertexA = {}; 78 | const vertexB = {}; 79 | const vertexC = {}; 80 | const edgeA = 'a'; 81 | const edgeB = 'b'; 82 | 83 | const g = new Graph(); 84 | g.addEdge(vertexA, vertexB, edgeA); 85 | g.addEdge(vertexA, vertexC, edgeB); 86 | t.true(g.adjacencies.has(vertexA)); 87 | t.true(g.adjacencies.has(vertexB)); 88 | 89 | const edgesA = g.adjacencies.get(vertexA); 90 | const edgesB = g.adjacencies.get(vertexB); 91 | 92 | t.true(edgesA.has(edgeA)); 93 | t.is(edgesA.get(edgeA), vertexB); 94 | t.false(edgesB.has(edgeA)); 95 | 96 | }); 97 | 98 | 99 | test('Update existing edge', t => { 100 | 101 | const vertexA = {}; 102 | const vertexB = {}; 103 | const edgeA = 'a'; 104 | const edgeB = 'b'; 105 | 106 | const g = new Graph(); 107 | g.addEdge(vertexA, vertexB, edgeA); 108 | g.addEdge(vertexA, vertexB, edgeB); 109 | 110 | t.true(g.adjacencies.has(vertexA)); 111 | t.true(g.adjacencies.has(vertexB)); 112 | 113 | const edgesA = g.adjacencies.get(vertexA); 114 | const edgesB = g.adjacencies.get(vertexB); 115 | 116 | t.false(edgesA.has(edgeA)); 117 | t.true(edgesA.has(edgeB)); 118 | t.is(edgesA.get(edgeB), vertexB); 119 | 120 | }); 121 | 122 | 123 | test('Remove edge', t => { 124 | 125 | const vertexA = {}; 126 | const vertexB = {}; 127 | const vertexC = {}; 128 | const vertexD = {}; 129 | const edgeA = 'a'; 130 | const edgeB = 'b'; 131 | 132 | const g = new Graph(); 133 | g.addEdge(vertexA, vertexB, edgeA); 134 | g.addEdge(vertexA, vertexC, edgeB); 135 | 136 | t.true(g.adjacencies.has(vertexA)); 137 | t.true(g.adjacencies.has(vertexB)); 138 | 139 | const edges = g.adjacencies.get(vertexA); 140 | t.true(edges.has(edgeA)); 141 | 142 | t.false(g.removeEdge(vertexA, vertexD)); 143 | t.true(g.removeEdge(vertexA, vertexB)); 144 | t.true(g.removeEdge(vertexA, vertexC)); 145 | t.false(g.removeEdge(vertexD, vertexA)); 146 | t.false(edges.has(edgeA)); 147 | 148 | }); 149 | 150 | 151 | test('Neighbors', t => { 152 | 153 | const vertexA = {}; 154 | const vertexB = {}; 155 | const edge = 'a'; 156 | 157 | const g = new Graph(); 158 | g.addEdge(vertexA, vertexB, edge); 159 | 160 | const neighbors = g.getNeighbors(vertexA); 161 | t.true(neighbors.has(edge)); 162 | t.is(neighbors.get(edge), vertexB); 163 | 164 | const missing = g.getNeighbors({}); 165 | t.is(missing.size, 0); 166 | 167 | }); 168 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const Graph = require('./graph'); 2 | const Schema = require('./schema'); 3 | const { async, clone } = require('./util'); 4 | const Factory = require('./states/factory'); 5 | const { EventEmitter } = require('events'); 6 | 7 | 8 | class Machine extends EventEmitter { 9 | 10 | static create(json) { 11 | Schema.validate(json); 12 | return new Machine(json); 13 | } 14 | 15 | constructor({ StartAt, States }) { 16 | super(); 17 | this.graph = new Graph(); 18 | this.states = States; 19 | this.startAt = this.build(StartAt); 20 | } 21 | 22 | build(name) { 23 | const { graph, states } = this; 24 | 25 | const fromState = clone(states[name]); 26 | fromState.Name = name; 27 | graph.addVertex(fromState); 28 | 29 | const addEdge = Next => { 30 | const toState = this.build(Next); 31 | graph.addEdge(fromState, toState, Next); 32 | }; 33 | 34 | if (fromState.Next) { 35 | addEdge(fromState.Next); 36 | } 37 | 38 | if (fromState.Default) { 39 | addEdge(fromState.Default); 40 | } 41 | 42 | if (Array.isArray(fromState.Catch)) { 43 | fromState.Catch.forEach(({ Next }) => addEdge(Next)); 44 | } 45 | 46 | if (Array.isArray(fromState.Choices)) { 47 | fromState.Choices.forEach(({ Next }) => addEdge(Next)); 48 | } 49 | 50 | // Branches are handled internally to the Parallel State 51 | // because they don't define state transitions. 52 | 53 | return fromState; 54 | } 55 | 56 | run(input) { 57 | const { graph, startAt } = this; 58 | const run = this._runner(); 59 | 60 | this.emit('ExecutionStarted', { 61 | input, 62 | }); 63 | 64 | const resolve = output => { 65 | this.emit('ExecutionCompleted', { 66 | output, 67 | }); 68 | 69 | return output; 70 | }; 71 | 72 | const reject = output => Promise.reject(resolve(output)); 73 | 74 | return run(graph, startAt, input).then(resolve, reject); 75 | } 76 | 77 | _runner() { 78 | return async(function *(graph, startAt, input) { 79 | let currentState = startAt; 80 | let result = input; 81 | 82 | while (currentState) { 83 | const { Name: name, Type: type } = currentState; 84 | 85 | this.emit('StateEntered', { 86 | name, 87 | input: result, 88 | }); 89 | 90 | 91 | let output, next; 92 | try { 93 | 94 | // Only build states that are executed in this 95 | // particular invocation of the machine. 96 | const state = Factory.create(currentState, Machine); 97 | ({ output, next } = yield state.run(result)); 98 | 99 | const nextState = graph.getVertexAt(currentState, next); 100 | currentState = nextState; 101 | result = clone(output); 102 | 103 | } catch (error) { 104 | 105 | if (error instanceof Error) { 106 | error = { 107 | Error: error.message, 108 | Cause: error.stack, 109 | }; 110 | } 111 | 112 | output = error; 113 | next = undefined; 114 | throw error; 115 | 116 | } finally { 117 | 118 | this.emit('StateExited', { 119 | name, 120 | output, 121 | }); 122 | 123 | } 124 | } 125 | 126 | return result; 127 | }, this); 128 | } 129 | 130 | 131 | } 132 | 133 | 134 | module.exports = Machine; 135 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const Machine = require('./index'); 3 | 4 | 5 | test('Graph', t => { 6 | 7 | const json = { 8 | StartAt: 'One', 9 | TimeoutSeconds: 1, 10 | States: { 11 | One: { 12 | Type: 'Wait', 13 | Seconds: 1, 14 | Next: 'Two', 15 | }, 16 | Two: { 17 | Type: 'Pass', 18 | Result: { 19 | 'abc': 123, 20 | }, 21 | ResultPath: '$.Result', 22 | Next: 'Seven', 23 | }, 24 | Seven: { 25 | Type: 'Task', 26 | Resource: '__mockresource__', 27 | Next: 'Three', 28 | Catch: [ 29 | { 30 | ErrorEquals: [ 'States.ALL' ], 31 | Next: 'Four', 32 | }, 33 | ], 34 | }, 35 | Three: { 36 | Type: 'Choice', 37 | Choices: [ 38 | { 39 | Or: [ 40 | { 41 | Variable: '$.abc', 42 | NumericEquals: 124, 43 | }, 44 | { 45 | Variable: '$.abc', 46 | NumericEquals: 123, 47 | } 48 | ], 49 | Next: 'Five' 50 | } 51 | 52 | ], 53 | Default: 'Four' 54 | }, 55 | Five: { 56 | Type: 'Parallel', 57 | Branches: [ 58 | { 59 | StartAt: 'FiveOne', 60 | States: { 61 | FiveOne: { 62 | Type: 'Wait', 63 | Seconds: 1, 64 | End: true 65 | }, 66 | } 67 | }, 68 | { 69 | StartAt: 'FiveTwo', 70 | States: { 71 | FiveTwo: { 72 | Type: 'Pass', 73 | Result: { 74 | def: 456 75 | }, 76 | End: true 77 | }, 78 | } 79 | } 80 | ], 81 | Next: 'Eight', 82 | }, 83 | Four: { 84 | Type: 'Fail', 85 | Error: 'States.Error', 86 | Cause: 'A cause.', 87 | }, 88 | Eight: { 89 | Type: 'Succeed', 90 | }, 91 | }, 92 | }; 93 | 94 | const start = Date.now(); 95 | 96 | const input = { SleepSeconds: [ /*2, 2, 2, 2, 2*/ ] }; 97 | const machine = Machine.create(json); 98 | return machine.run(input).then((output) => { 99 | // Should take ~2 seconds based on the Wait states. 100 | const duration = Date.now() - start; 101 | t.true(duration > 2000); 102 | t.true(duration < 2200); 103 | 104 | t.true(Array.isArray(output)); 105 | t.is(output.length, 2); 106 | t.deepEqual(output[0], { abc: 123 }); 107 | t.deepEqual(output[1], { def: 456 }); 108 | }); 109 | 110 | }); 111 | 112 | 113 | test('Uncaught Error handling', t => { 114 | 115 | const json = { 116 | StartAt: 'One', 117 | States: { 118 | One: { 119 | Type: 'Task', 120 | Resource: 'not_found', 121 | End: true, 122 | }, 123 | }, 124 | }; 125 | 126 | const machine = Machine.create(json); 127 | return t.throws(machine.run({})).then(error => { 128 | const { Error, Cause } = error; 129 | t.is(typeof Error, 'string'); 130 | t.is(typeof Cause, 'string'); 131 | t.truthy(Error.length); 132 | t.truthy(Cause.length); 133 | }); 134 | 135 | }); 136 | 137 | 138 | test('Fail Error handling', t => { 139 | 140 | const json = { 141 | StartAt: 'One', 142 | States: { 143 | One: { 144 | Type: 'Fail', 145 | Error: 'States.NotOk', 146 | Cause: 'not ok.', 147 | }, 148 | }, 149 | }; 150 | 151 | const machine = Machine.create(json); 152 | return t.throws(machine.run({})).then(error => { 153 | const { Error, Cause } = error; 154 | t.is(Error, json.States.One.Error); 155 | t.is(Cause, json.States.One.Cause); 156 | }); 157 | 158 | }); 159 | 160 | 161 | test('Post execution error handling', t => { 162 | // Adding this integration test for a specific error handling use-case 163 | // involving ResultPath (since ResultPaths are evaluated after a give state 164 | // is run) 165 | 166 | const json = { 167 | StartAt: 'One', 168 | States: { 169 | One: { 170 | Type: 'Pass', 171 | Result: 10, 172 | ResultPath: '$.foo.length', 173 | End: true, 174 | }, 175 | }, 176 | }; 177 | 178 | const input = { 179 | foo: 'foo', 180 | }; 181 | 182 | const machine = Machine.create(json); 183 | return t.throws(machine.run(input)).then(error => { 184 | const { Error, Cause } = error; 185 | t.is(Error, 'States.ResultPathMatchFailure'); 186 | t.is(typeof Cause, 'string'); 187 | t.truthy(Cause.length); 188 | }); 189 | 190 | }); 191 | 192 | test('Timeout Error handling', t => { 193 | 194 | const json = { 195 | StartAt: 'One', 196 | States: { 197 | One: { 198 | Type: 'Task', 199 | Resource: '__mockresource__', 200 | TimeoutSeconds: 1, 201 | End: true, 202 | }, 203 | }, 204 | }; 205 | 206 | const machine = Machine.create(json); 207 | return t.throws(machine.run({ SleepSeconds: [ 2 ] })).then(error => { 208 | const { Error, Cause } = error; 209 | t.is(Error, 'States.Timeout'); 210 | t.is(Cause, 'State \'One\' exceeded the configured timeout of 1 seconds.'); 211 | }); 212 | 213 | }); 214 | -------------------------------------------------------------------------------- /src/pathutils.js: -------------------------------------------------------------------------------- 1 | const JSONPath = require('jsonpath'); 2 | 3 | 4 | const INDEFINITE_TYPES = new Map([ 5 | [ 'script_expression', true ], 6 | [ 'filter_expression', true ], 7 | [ 'union', true ], 8 | [ 'slice', true ], 9 | [ 'wildcard', true ], 10 | ]); 11 | 12 | 13 | const INDEFINITE_SCOPES = new Map([ 14 | [ 'descendant', true ], 15 | ]); 16 | 17 | 18 | const CACHE = new Map([ 19 | [ '$', true ] 20 | ]); 21 | 22 | 23 | function isDefinitePath(path) { 24 | if (CACHE.has(path)) { 25 | return CACHE.get(path); 26 | } 27 | 28 | const parsed = JSONPath.parse(path); 29 | const result = !parsed.find(({ expression: { type }, scope }) => { 30 | // http://goessner.net/articles/JsonPath/ 31 | // https://github.com/jayway/JsonPath#what-is-returned-when 32 | return INDEFINITE_TYPES.has(type) || INDEFINITE_SCOPES.has(scope); 33 | }); 34 | 35 | CACHE.set(path, result); 36 | return result; 37 | } 38 | 39 | 40 | function query(object, path) { 41 | if (path === '$') { 42 | return object; 43 | } 44 | 45 | if (isDefinitePath(path)) { 46 | return JSONPath.value(object, path); 47 | } 48 | 49 | return JSONPath.query(object, path); 50 | } 51 | 52 | 53 | function parse(path) { 54 | return JSONPath.parse(path); 55 | } 56 | 57 | 58 | /** 59 | * Centralized dependency on JSONPath b/c I'm not super happy with it and am 60 | * entertaining finding a replacement. 61 | */ 62 | module.exports = { 63 | isDefinitePath, 64 | query, 65 | parse, 66 | }; 67 | -------------------------------------------------------------------------------- /src/pathutils.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const PathUtils = require('./pathutils'); 3 | 4 | 5 | test('PathUtils.isDefinitePath', t => { 6 | 7 | const definite = [ 8 | '$', 9 | '$.store.book[0].author', 10 | '$["store"]["book"][3]["author"]', 11 | ]; 12 | 13 | const indefinite = [ 14 | '$.store.book[*].author', 15 | '$..author', 16 | '$.store.*', 17 | '$.store..price', 18 | '$..book[2]', 19 | '$..book[(@.length-1)]', 20 | '$..book[-1:]', 21 | '$..book[0,1]', 22 | '$..book[:2]', 23 | '$..book[?(@.isbn)]', 24 | '$..book[?(@.price<10)]', 25 | '$..book[?(@.price==8.95)]', 26 | '$..book[?(@.price<30 && @.category=="fiction")]', 27 | '$..*', 28 | '$.store.book[?(@.price < 10)]', 29 | '$.a[0,1]', 30 | ]; 31 | 32 | for (let path of definite) { 33 | t.true(PathUtils.isDefinitePath(path)); 34 | } 35 | 36 | for (let path of indefinite) { 37 | t.false(PathUtils.isDefinitePath(path)); 38 | } 39 | 40 | }); 41 | -------------------------------------------------------------------------------- /src/schema/Branch.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "array", 3 | "items": { 4 | "required": [ "StartAt", "States" ], 5 | "properties": { 6 | "StartAt": { 7 | "$ref": "/NonEmptyString" 8 | }, 9 | "States": { 10 | "$ref": "/States" 11 | } 12 | }, 13 | "additionalProperties": false 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/schema/Catcher.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "required": [ "ErrorEquals", "Next" ], 4 | "properties": { 5 | "ErrorEquals": { 6 | "type": "array", 7 | "items": { 8 | "$ref": "/NonEmptyString" 9 | }, 10 | "minLength": 1 11 | }, 12 | "Next": { 13 | "$ref": "/NonEmptyString" 14 | }, 15 | "ResultPath": { 16 | "$ref": "/NonEmptyString" 17 | } 18 | }, 19 | "additionalProperties": false 20 | } 21 | -------------------------------------------------------------------------------- /src/schema/Choice.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "required": [ "Choices" ], 4 | "properties": { 5 | "Type": { 6 | "enum": [ "Choice" ] 7 | }, 8 | "InputPath": { 9 | "$ref": "/Path" 10 | }, 11 | "OutputPath": { 12 | "$ref": "/Path" 13 | }, 14 | "Choices": { 15 | "type": "array", 16 | "items": { 17 | "$ref": "/TopLevelChoiceRule" 18 | } 19 | }, 20 | "Default": { 21 | "$ref": "/NonEmptyString" 22 | } 23 | }, 24 | "additionalProperties": false 25 | } 26 | -------------------------------------------------------------------------------- /src/schema/ChoiceRule.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "oneOf": [ 4 | { 5 | "required": [ "StringEquals" ] 6 | }, 7 | { 8 | "required": [ "StringLessThan" ] 9 | }, 10 | { 11 | "required": [ "StringGreaterThan" ] 12 | }, 13 | { 14 | "required": [ "StringLessThanEquals" ] 15 | }, 16 | { 17 | "required": [ "StringGreaterThanEquals" ] 18 | }, 19 | { 20 | "required": [ "NumericEquals" ] 21 | }, 22 | { 23 | "required": [ "NumericLessThan" ] 24 | }, 25 | { 26 | "required": [ "NumericGreaterThan" ] 27 | }, 28 | { 29 | "required": [ "NumericLessThanEquals" ] 30 | }, 31 | { 32 | "required": [ "NumericGreaterThanEquals" ] 33 | }, 34 | { 35 | "required": [ "BooleanEquals" ] 36 | }, 37 | { 38 | "required": [ "TimestampEquals" ] 39 | }, 40 | { 41 | "required": [ "TimestampLessThan" ] 42 | }, 43 | { 44 | "required": [ "TimestampGreaterThan" ] 45 | }, 46 | { 47 | "required": [ "TimestampLessThanEquals" ] 48 | }, 49 | { 50 | "required": [ "TimestampGreaterThanEquals" ] 51 | }, 52 | { 53 | "required": [ "And" ] 54 | }, 55 | { 56 | "required": [ "Or" ] 57 | }, 58 | { 59 | "required": [ "Not" ] 60 | } 61 | ], 62 | "properties": { 63 | "StringEquals": { 64 | "$ref": "/NonEmptyString" 65 | }, 66 | "StringLessThan": { 67 | "$ref": "/NonEmptyString" 68 | }, 69 | "StringGreaterThan": { 70 | "$ref": "/NonEmptyString" 71 | }, 72 | "StringLessThanEquals": { 73 | "$ref": "/NonEmptyString" 74 | }, 75 | "StringGreaterThanEquals": { 76 | "$ref": "/NonEmptyString" 77 | }, 78 | "NumericEquals": { 79 | "type": "number" 80 | }, 81 | "NumericLessThan": { 82 | "type": "number" 83 | }, 84 | "NumericGreaterThan": { 85 | "type": "number" 86 | }, 87 | "NumericLessThanEquals": { 88 | "type": "number" 89 | }, 90 | "NumericGreaterThanEquals": { 91 | "type": "number" 92 | }, 93 | "BooleanEquals": { 94 | "type": "boolean" 95 | }, 96 | "TimestampEquals": { 97 | "$ref": "/Timestamp" 98 | }, 99 | "TimestampLessThan": { 100 | "$ref": "/Timestamp" 101 | }, 102 | "TimestampGreaterThan": { 103 | "$ref": "/Timestamp" 104 | }, 105 | "TimestampLessThanEquals": { 106 | "$ref": "/Timestamp" 107 | }, 108 | "TimestampGreaterThanEquals": { 109 | "$ref": "/Timestamp" 110 | }, 111 | "And": { 112 | "type": "array", 113 | "items": { 114 | "$ref": "/ChoiceRule" 115 | } 116 | }, 117 | "Or": { 118 | "type": "array", 119 | "items": { 120 | "$ref": "/ChoiceRule" 121 | } 122 | }, 123 | "Not": { 124 | "$ref": "/ChoiceRule" 125 | }, 126 | "Variable": { 127 | "$ref": "/NonEmptyString" 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/schema/Fail.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "required": [ "Error", "Cause" ], 4 | "properties": { 5 | "Type": { 6 | "enum": [ "Fail" ] 7 | }, 8 | "Error": { 9 | "type": "string" 10 | }, 11 | "Cause": { 12 | "type": "string" 13 | } 14 | }, 15 | "additionalProperties": false 16 | } 17 | -------------------------------------------------------------------------------- /src/schema/NonEmptyString.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "string", 3 | "minLength": 1 4 | } 5 | -------------------------------------------------------------------------------- /src/schema/Parallel.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "required": [ "Branches" ], 4 | "oneOf": [ 5 | { 6 | "required": [ "Next" ] 7 | }, 8 | { 9 | "required": [ "End" ] 10 | } 11 | ], 12 | "properties": { 13 | "Type": { 14 | "enum": [ "Parallel" ] 15 | }, 16 | "Branches": { 17 | "$ref": "/Branch" 18 | }, 19 | "InputPath": { 20 | "$ref": "/Path" 21 | }, 22 | "OutputPath": { 23 | "$ref": "/Path" 24 | }, 25 | "ResultPath": { 26 | "$ref": "/Path" 27 | }, 28 | "Next": { 29 | "$ref": "/NonEmptyString" 30 | }, 31 | "End": { 32 | "enum": [ true ] 33 | }, 34 | "Retry": { 35 | "type": "Array", 36 | "items": { 37 | "$ref": "/Retrier" 38 | } 39 | }, 40 | "Catch": { 41 | "type": "Array", 42 | "items": { 43 | "$ref": "/Catcher" 44 | } 45 | } 46 | }, 47 | "additionalProperties": false 48 | } 49 | -------------------------------------------------------------------------------- /src/schema/Pass.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "oneOf": [ 4 | { 5 | "required": [ "Next" ] 6 | }, 7 | { 8 | "required": [ "End" ] 9 | } 10 | ], 11 | "properties": { 12 | "Type": { 13 | "enum": [ "Pass" ] 14 | }, 15 | "Result": { 16 | "type": "any" 17 | }, 18 | "InputPath": { 19 | "$ref": "/Path" 20 | }, 21 | "OutputPath": { 22 | "$ref": "/Path" 23 | }, 24 | "ResultPath": { 25 | "$ref": "/Path" 26 | }, 27 | "Next": { 28 | "$ref": "/NonEmptyString" 29 | }, 30 | "End": { 31 | "enum": [ true ] 32 | } 33 | }, 34 | "additionalProperties": false 35 | } 36 | -------------------------------------------------------------------------------- /src/schema/Path.json: -------------------------------------------------------------------------------- 1 | { 2 | "oneOf": [ 3 | { 4 | "$ref": "/NonEmptyString" 5 | }, 6 | { 7 | "type": "null" 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /src/schema/PositiveInteger.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "integer", 3 | "minimum": 0, 4 | "exclusiveMinimum": true 5 | } 6 | -------------------------------------------------------------------------------- /src/schema/README.md: -------------------------------------------------------------------------------- 1 | # Amazon States Language JSONSchema 2 | This is a JSON Schema that validates JSON-based state machine descriptions 3 | according to the [Amazon States Language](https://states-language.net/spec.html). 4 | This does _not_ completely fulfill the specification as to behavior, only the 5 | rules that describe a valid state machine. 6 | 7 | The top level type is [Schema.json](./Schema.json). 8 | 9 | (This could possibly be its own npm module at some point.) 10 | 11 | 12 | NOTE: While it's fairly well-organized (I think), a caveat here is that error 13 | reporting on individual Task types is somewhat lacking as the validation just 14 | reports it can't find a matching type for the provided input. This should 15 | probably be improved. 16 | -------------------------------------------------------------------------------- /src/schema/Retrier.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "required": [ "ErrorEquals" ], 4 | "properties": { 5 | "ErrorEquals": { 6 | "type": "array", 7 | "items": { 8 | "$ref": "/NonEmptyString" 9 | }, 10 | "minLength": 1 11 | }, 12 | "IntervalSeconds": { 13 | "$ref": "/PositiveInteger" 14 | }, 15 | "MaxAttempts": { 16 | "type": "integer", 17 | "minimum": 0 18 | }, 19 | "BackoffRate": { 20 | "type": "number", 21 | "minimum": 1.0 22 | } 23 | }, 24 | "additionalProperties": false 25 | } 26 | -------------------------------------------------------------------------------- /src/schema/Schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "type": "object", 4 | "required": [ "States", "StartAt" ], 5 | "properties": { 6 | "States": { 7 | "$ref": "/States" 8 | }, 9 | "StartAt": { 10 | "$ref": "/NonEmptyString" 11 | }, 12 | "Comment": { 13 | "type": "string" 14 | }, 15 | "Version": { 16 | "type": "string" 17 | }, 18 | "TimeoutSeconds": { 19 | "type": "integer" 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/schema/State.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "required": [ "Type" ], 4 | "properties": { 5 | "Type": { 6 | "$ref": "/NonEmptyString" 7 | }, 8 | "Comment": { 9 | "type": "string" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/schema/States.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "patternProperties": { 4 | "^.*$": { 5 | "allOf": [ 6 | { 7 | "$ref": "/State" 8 | }, 9 | { 10 | "oneOf": [ 11 | { 12 | "$ref": "/Pass" 13 | }, 14 | { 15 | "$ref": "/Task" 16 | }, 17 | { 18 | "$ref": "/Choice" 19 | }, 20 | { 21 | "$ref": "/Wait" 22 | }, 23 | { 24 | "$ref": "/Parallel" 25 | }, 26 | { 27 | "$ref": "/Succeed" 28 | }, 29 | { 30 | "$ref": "/Fail" 31 | } 32 | ] 33 | } 34 | ] 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/schema/Succeed.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "Type": { 5 | "enum": [ "Succeed" ] 6 | }, 7 | "InputPath": { 8 | "$ref": "/Path" 9 | }, 10 | "OutputPath": { 11 | "$ref": "/Path" 12 | } 13 | }, 14 | "additionalProperties": false 15 | } 16 | -------------------------------------------------------------------------------- /src/schema/Task.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "required": [ "Resource" ], 4 | "oneOf": [ 5 | { 6 | "required": [ "Next" ] 7 | }, 8 | { 9 | "required": [ "End" ] 10 | } 11 | ], 12 | "properties": { 13 | "Type": { 14 | "enum": [ "Task" ] 15 | }, 16 | "InputPath": { 17 | "$ref": "/Path" 18 | }, 19 | "OutputPath": { 20 | "$ref": "/Path" 21 | }, 22 | "ResultPath": { 23 | "$ref": "/Path" 24 | }, 25 | "Next": { 26 | "$ref": "/NonEmptyString" 27 | }, 28 | "End": { 29 | "enum": [ true ] 30 | }, 31 | "Retry": { 32 | "type": "Array", 33 | "items": { 34 | "$ref": "/Retrier" 35 | } 36 | }, 37 | "Catch": { 38 | "type": "Array", 39 | "items": { 40 | "$ref": "/Catcher" 41 | } 42 | }, 43 | "Resource": { 44 | "$ref": "/NonEmptyString" 45 | }, 46 | "TimeoutSeconds": { 47 | "$ref": "/PositiveInteger" 48 | }, 49 | "HeartbeatSeconds": { 50 | "$ref": "/PositiveInteger" 51 | } 52 | }, 53 | "additionalProperties": false 54 | } 55 | -------------------------------------------------------------------------------- /src/schema/Timestamp.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "string" 3 | } 4 | -------------------------------------------------------------------------------- /src/schema/TopLevelChoiceRule.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "allOf": [ 4 | { 5 | "required": [ "Next" ], 6 | "properties": { 7 | "Next": { 8 | "$ref": "/NonEmptyString" 9 | } 10 | } 11 | }, 12 | { 13 | "$ref": "/ChoiceRule" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/schema/Wait.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "allOf": [{ 4 | "oneOf": [ 5 | { 6 | "required": [ "Seconds" ] 7 | }, 8 | { 9 | "required": [ "SecondsPath" ] 10 | }, 11 | { 12 | "required": [ "Timestamp" ] 13 | }, 14 | { 15 | "required": [ "TimestampPath" ] 16 | } 17 | ] 18 | }, 19 | { 20 | "oneOf": [ 21 | { 22 | "required": [ "Next" ] 23 | }, 24 | { 25 | "required": [ "End" ] 26 | } 27 | ] 28 | } 29 | ], 30 | "properties": { 31 | "Type": { 32 | "enum": [ "Wait" ] 33 | }, 34 | "InputPath": { 35 | "$ref": "/Path" 36 | }, 37 | "OutputPath": { 38 | "$ref": "/Path" 39 | }, 40 | "Seconds": { 41 | "$ref": "/PositiveInteger" 42 | }, 43 | "SecondsPath": { 44 | "$ref": "/NonEmptyString" 45 | }, 46 | "Timestamp": { 47 | "$ref": "/Timestamp" 48 | }, 49 | "TimestampPath": { 50 | "type": "string" 51 | }, 52 | "Next": { 53 | "$ref": "/NonEmptyString" 54 | }, 55 | "End": { 56 | "enum": [ true ] 57 | } 58 | }, 59 | "additionalProperties": false 60 | } 61 | -------------------------------------------------------------------------------- /src/schema/__tests__/pass.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const Schema = require('../'); 3 | 4 | 5 | test('Invalid Schema', t => { 6 | 7 | const invalid = { 8 | StartAt: '', 9 | States: null 10 | }; 11 | 12 | t.throws(() => { 13 | Schema.validate(invalid); 14 | }); 15 | 16 | }); 17 | 18 | 19 | test('Pass Type', t => { 20 | 21 | const pass = { 22 | StartAt: "PassTest", 23 | States: { 24 | PassTest: { 25 | Type: "Pass", 26 | InputPath: "$", 27 | ResultPath: "$.foo", 28 | OutputPath: "$.foo.pass", 29 | Result: { 30 | pass: { 31 | a: "b" 32 | } 33 | }, 34 | Next: "Done" 35 | }, 36 | Done: { 37 | Type: "Succeed" 38 | } 39 | } 40 | }; 41 | 42 | Schema.validate(pass); 43 | 44 | }); 45 | -------------------------------------------------------------------------------- /src/schema/index.js: -------------------------------------------------------------------------------- 1 | const { Validator } = require('jsonschema'); 2 | 3 | 4 | function resolveReferences(validator) { 5 | const { unresolvedRefs: refs } = validator; 6 | const name = refs.shift(); 7 | if (name) { 8 | const schema = require(`.${name}`); 9 | validator.addSchema(schema, name); 10 | resolveReferences(validator); 11 | } 12 | } 13 | 14 | function init(schema, name) { 15 | const validator = new Validator(); 16 | validator.addSchema(schema, name); 17 | resolveReferences(validator); 18 | 19 | return function validate(obj) { 20 | const { errors } = validator.validate(obj, schema); 21 | if (errors.length) { 22 | // Blah! https://github.com/tdegrunt/jsonschema/pull/174 23 | // Should probably find a new JSON Schema validation library. 24 | const [{ message, schema, property, instance, name, argument, stack }] = errors; 25 | const error = new Error(message); 26 | error.schema = schema; 27 | error.property = property; 28 | error.instance = instance; 29 | error.name = name; 30 | error.argument = argument; 31 | error.stack = stack; 32 | throw error; 33 | } 34 | }; 35 | } 36 | 37 | 38 | // Build the Schema at initialization-time. 39 | const name = '/Schema'; 40 | const schema = { $ref: name }; 41 | const validate = init(schema, name); 42 | 43 | module.exports = { 44 | validate, 45 | }; 46 | -------------------------------------------------------------------------------- /src/states/__mocktask__.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | function mock(input) { 4 | const { SleepSeconds = [], Result } = input; 5 | return new Promise(resolve => { 6 | const time = (SleepSeconds.shift() || 0) * 1000; 7 | setTimeout(resolve, time, Result); 8 | }); 9 | } 10 | 11 | 12 | module.exports = mock; 13 | -------------------------------------------------------------------------------- /src/states/choice.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const Choice = require('./choice'); 3 | 4 | 5 | test('Default', t => { 6 | 7 | const spec = { 8 | Choices: [ 9 | { 10 | Variable: '$', 11 | StringEquals: 'bar', 12 | Next: 'StringEquals', 13 | } 14 | ], 15 | Default: 'Default', 16 | }; 17 | 18 | const input = 'baz'; 19 | 20 | const choice = new Choice(spec); 21 | return choice.run(input).then(({ output, next }) => { 22 | t.deepEqual(input, output); 23 | t.is(next, spec.Default); 24 | }); 25 | 26 | }); 27 | 28 | 29 | test('No Choices', t => { 30 | 31 | const spec = { 32 | Default: 'Default', 33 | }; 34 | 35 | const input = 'baz'; 36 | 37 | const choice = new Choice(spec); 38 | return choice.run(input).then(({ output, next }) => { 39 | t.deepEqual(input, output); 40 | t.is(next, spec.Default); 41 | }); 42 | 43 | }); 44 | 45 | 46 | test('No Variable', t => { 47 | 48 | const spec = { 49 | Choices: [ 50 | { 51 | // Variable: '$', // Use default 52 | StringEquals: 'bar', 53 | Next: 'StringEquals', 54 | } 55 | ], 56 | Default: 'Default', 57 | }; 58 | 59 | const input = 'bar'; 60 | 61 | const choice = new Choice(spec); 62 | return choice.run(input).then(({ output, next }) => { 63 | t.deepEqual(input, output); 64 | t.is(next, spec.Choices[0].Next); 65 | }); 66 | 67 | }); 68 | 69 | 70 | test('String', t => { 71 | 72 | const operators = [ 73 | { operator: 'StringEquals', value: 'bar' }, 74 | { operator: 'StringLessThan', value: 'cat' }, 75 | { operator: 'StringGreaterThan', value: 'ant' }, 76 | { operator: 'StringLessThanEquals', value: 'bat' }, 77 | { operator: 'StringGreaterThanEquals', value: 'ban' }, 78 | ]; 79 | 80 | const tests = operators.map(({ operator, value }) => { 81 | const spec = { 82 | Choices: [ 83 | { 84 | Variable: '$', 85 | [operator]: value, 86 | Next: operator, 87 | } 88 | ], 89 | Default: 'Default', 90 | }; 91 | 92 | const input = 'bar'; 93 | 94 | const choice = new Choice(spec); 95 | return choice.run(input).then(({ output, next }) => { 96 | t.deepEqual(input, output); 97 | t.is(next, spec.Choices[0].Next); 98 | }); 99 | }); 100 | 101 | return Promise.all(tests); 102 | 103 | }); 104 | 105 | 106 | test('Numeric', t => { 107 | 108 | const operators = [ 109 | { operator: 'NumericEquals', value: 42 }, 110 | { operator: 'NumericLessThan', value: 43 }, 111 | { operator: 'NumericGreaterThan', value: 41 }, 112 | { operator: 'NumericLessThanEquals', value: 42 }, 113 | { operator: 'NumericGreaterThanEquals', value: 41 }, 114 | ]; 115 | 116 | const tests = operators.map(({ operator, value }) => { 117 | const spec = { 118 | Choices: [ 119 | { 120 | Variable: '$', 121 | [operator]: value, 122 | Next: operator, 123 | } 124 | ], 125 | Default: 'Default', 126 | }; 127 | 128 | const input = 42; 129 | 130 | const choice = new Choice(spec); 131 | return choice.run(input).then(({ output, next }) => { 132 | t.deepEqual(input, output); 133 | t.is(next, spec.Choices[0].Next); 134 | }); 135 | }); 136 | 137 | return Promise.all(tests); 138 | 139 | }); 140 | 141 | 142 | test('Boolean', t => { 143 | 144 | const operators = [ 145 | { operator: 'BooleanEquals', value: true }, 146 | ]; 147 | 148 | const tests = operators.map(({ operator, value }) => { 149 | const spec = { 150 | Choices: [ 151 | { 152 | Variable: '$', 153 | [operator]: value, 154 | Next: operator, 155 | } 156 | ], 157 | Default: 'Default', 158 | }; 159 | 160 | const input = true; 161 | 162 | const choice = new Choice(spec); 163 | return choice.run(input).then(({ output, next }) => { 164 | t.deepEqual(input, output); 165 | t.is(next, spec.Choices[0].Next); 166 | }); 167 | }); 168 | 169 | return Promise.all(tests); 170 | 171 | }); 172 | 173 | 174 | test('Timestamp', t => { 175 | 176 | const now = Date.now(); 177 | 178 | const operators = [ 179 | { operator: 'TimestampEquals', value: new Date(now).toISOString() }, 180 | { operator: 'TimestampLessThan', value: new Date(now + 1000).toISOString() }, 181 | { operator: 'TimestampGreaterThan', value: new Date(now - 1000).toISOString() }, 182 | { operator: 'TimestampLessThanEquals', value: new Date(now + 1000).toISOString() }, 183 | { operator: 'TimestampGreaterThanEquals', value: new Date(now).toISOString() }, 184 | ]; 185 | 186 | const tests = operators.map(({ operator, value }) => { 187 | const spec = { 188 | Choices: [ 189 | { 190 | Variable: '$', 191 | [operator]: value, 192 | Next: operator, 193 | } 194 | ], 195 | Default: 'Default', 196 | }; 197 | 198 | const input = new Date(now).toISOString(); 199 | 200 | const choice = new Choice(spec); 201 | return choice.run(input).then(({ output, next }) => { 202 | t.deepEqual(input, output); 203 | t.is(next, spec.Choices[0].Next); 204 | }); 205 | }); 206 | 207 | return Promise.all(tests); 208 | 209 | }); 210 | 211 | 212 | test('No operator', t => { 213 | 214 | const spec = { 215 | Choices: [ 216 | { 217 | // And: [], 218 | Next: 'Next' 219 | } 220 | ], 221 | Default: 'Default', 222 | }; 223 | 224 | const input = 50; 225 | 226 | const choice = new Choice(spec); 227 | return choice.run(input).then(({ output, next}) => { 228 | t.deepEqual(input, output); 229 | t.is(next, spec.Default); 230 | }); 231 | 232 | }); 233 | 234 | 235 | test('And', t => { 236 | 237 | const spec = { 238 | Choices: [ 239 | { 240 | And: [ 241 | { 242 | Variable: '$', 243 | NumericGreaterThan: 0, 244 | }, 245 | { 246 | Variable: '$', 247 | NumericLessThan: 100, 248 | } 249 | ], 250 | Next: 'Next' 251 | } 252 | ], 253 | Default: 'Default', 254 | }; 255 | 256 | const input = 50; 257 | 258 | const choice = new Choice(spec); 259 | return choice.run(input).then(({ output, next}) => { 260 | t.deepEqual(input, output); 261 | t.is(next, spec.Choices[0].Next); 262 | }); 263 | 264 | }); 265 | 266 | 267 | test('Undefined And', t => { 268 | 269 | const spec = { 270 | Choices: [ 271 | { 272 | And: undefined, 273 | Next: 'Next' 274 | } 275 | ], 276 | Default: 'Default', 277 | }; 278 | 279 | const input = 50; 280 | 281 | const choice = new Choice(spec); 282 | return choice.run(input).then(({ output, next}) => { 283 | t.deepEqual(input, output); 284 | t.is(next, spec.Choices[0].Next); 285 | }); 286 | 287 | }); 288 | 289 | 290 | test('Or', t => { 291 | 292 | const spec = { 293 | Choices: [ 294 | { 295 | Or: [ 296 | { 297 | Variable: '$', 298 | NumericGreaterThan: 100, 299 | }, 300 | { 301 | Variable: '$', 302 | NumericLessThan: 99, 303 | } 304 | ], 305 | Next: 'Next' 306 | } 307 | ], 308 | Default: 'Default', 309 | }; 310 | 311 | const input = 50; 312 | 313 | const choice = new Choice(spec); 314 | return choice.run(input).then(({ output, next}) => { 315 | t.deepEqual(input, output); 316 | t.is(next, spec.Choices[0].Next); 317 | }); 318 | 319 | }); 320 | 321 | 322 | test('Undefined Or', t => { 323 | 324 | const spec = { 325 | Choices: [ 326 | { 327 | Or: undefined, 328 | Next: 'Next' 329 | } 330 | ], 331 | Default: 'Default', 332 | }; 333 | 334 | const input = 50; 335 | 336 | const choice = new Choice(spec); 337 | return choice.run(input).then(({ output, next}) => { 338 | t.deepEqual(input, output); 339 | t.is(next, spec.Default); 340 | }); 341 | 342 | }); 343 | 344 | 345 | test('Not', t => { 346 | 347 | const spec = { 348 | Choices: [ 349 | { 350 | Not: { 351 | Variable: '$', 352 | NumericGreaterThan: 50, 353 | }, 354 | Next: 'Next' 355 | } 356 | ], 357 | Default: 'Default', 358 | }; 359 | 360 | const input = 50; 361 | 362 | const choice = new Choice(spec); 363 | return choice.run(input).then(({ output, next}) => { 364 | t.deepEqual(input, output); 365 | t.is(next, spec.Choices[0].Next); 366 | }); 367 | 368 | }); 369 | -------------------------------------------------------------------------------- /src/states/choice/index.js: -------------------------------------------------------------------------------- 1 | const Rule = require('./rule'); 2 | const { mixin, Filter } = require('../mixins'); 3 | 4 | 5 | class Choice extends mixin(Filter) { 6 | 7 | constructor(spec) { 8 | super(spec); 9 | this.default = spec.Default; 10 | 11 | const choices = spec.Choices || []; 12 | this.choices = choices.map(choice => { 13 | const rule = Rule.create(choice); 14 | const destination = choice.Next; 15 | return [ rule, destination ]; 16 | }); 17 | } 18 | 19 | _run(input) { 20 | let next = this.default; 21 | 22 | for (const [ rule, destination ] of this.choices) { 23 | if (rule(input)) { 24 | next = destination; 25 | break; 26 | } 27 | } 28 | 29 | return Promise.resolve({ output: input, next }); 30 | } 31 | 32 | } 33 | 34 | 35 | module.exports = Choice; 36 | -------------------------------------------------------------------------------- /src/states/choice/operators.js: -------------------------------------------------------------------------------- 1 | const PathUtils = require('../../pathutils'); 2 | 3 | 4 | /** 5 | * Operators 6 | */ 7 | 8 | function eq(a, b) { 9 | return a === b; 10 | } 11 | 12 | function gt(a, b) { 13 | return a > b; 14 | } 15 | 16 | function gte(a, b) { 17 | return a >= b; 18 | } 19 | 20 | function lt(a, b) { 21 | return a < b; 22 | } 23 | 24 | function lte(a, b) { 25 | return a <= b; 26 | } 27 | 28 | 29 | /** 30 | * Type Validators 31 | */ 32 | function str(value) { 33 | return typeof value === 'string'; 34 | } 35 | 36 | function num(value) { 37 | return typeof value === 'number'; 38 | } 39 | 40 | function bool(value) { 41 | return typeof value === 'boolean'; 42 | } 43 | 44 | 45 | /** 46 | * Timestamp type converter decorator. 47 | */ 48 | function ts(fn) { 49 | return function (...args) { 50 | return fn(...args.map(arg => new Date(arg).getTime())); 51 | }; 52 | } 53 | 54 | 55 | /** 56 | * Rule Builder 57 | */ 58 | function build(name, type, test) { 59 | return function rule({ Variable = '$', [name]: expected }) { 60 | return function exec(input) { 61 | const actual = PathUtils.query(input, Variable); 62 | return type(expected) && type(actual) && test(actual, expected); 63 | }; 64 | }; 65 | } 66 | 67 | 68 | module.exports = [ 69 | 70 | [ 'StringEquals', str, eq ], 71 | [ 'StringLessThan', str, lt ], 72 | [ 'StringGreaterThan', str, gt ], 73 | [ 'StringLessThanEquals', str, lte ], 74 | [ 'StringGreaterThanEquals', str, gte ], 75 | [ 'NumericEquals', num, eq ], 76 | [ 'NumericLessThan', num, lt ], 77 | [ 'NumericGreaterThan', num, gt ], 78 | [ 'NumericLessThanEquals', num, lte ], 79 | [ 'NumericGreaterThanEquals', num, gte ], 80 | [ 'BooleanEquals', bool, eq ], 81 | [ 'TimestampEquals', str, eq ], 82 | [ 'TimestampLessThan', str, ts(lt) ], 83 | [ 'TimestampGreaterThan', str, ts(gt) ], 84 | [ 'TimestampLessThanEquals', str, ts(lte) ], 85 | [ 'TimestampGreaterThanEquals', str, ts(gte) ], 86 | 87 | ].map(([ name, ...args ]) => [ name, build(name, ...args) ]); 88 | -------------------------------------------------------------------------------- /src/states/choice/rule.js: -------------------------------------------------------------------------------- 1 | const operators = require('./operators'); 2 | 3 | 4 | const ChoiceOperators = new Map([ 5 | [ 6 | 'And', 7 | function ({ And = [] }) { 8 | const rules = And.map(ChoiceRule.create); 9 | return function And(input) { 10 | return rules.every(rule => rule(input)); 11 | }; 12 | }, 13 | ], 14 | [ 15 | 'Or', 16 | function ({ Or = [] }) { 17 | const rules = Or.map(ChoiceRule.create); 18 | return function Or(input) { 19 | return rules.some(rule => rule(input)); 20 | }; 21 | } 22 | ], 23 | [ 24 | 'Not', 25 | function ({ Not }) { 26 | const rule = ChoiceRule.create(Not); 27 | return function Not(input) { 28 | return !rule(input); 29 | }; 30 | } 31 | ], 32 | ...operators, 33 | ]); 34 | 35 | 36 | class ChoiceRule { 37 | 38 | static create(spec) { 39 | let impl = () => false; 40 | 41 | for (const [ operator, fn ] of ChoiceOperators.entries()) { 42 | if (spec.hasOwnProperty(operator)) { 43 | impl = fn(spec); 44 | break; 45 | } 46 | } 47 | 48 | return impl; 49 | } 50 | 51 | } 52 | 53 | 54 | module.exports = ChoiceRule; 55 | -------------------------------------------------------------------------------- /src/states/factory.js: -------------------------------------------------------------------------------- 1 | const Pass = require('./pass'); 2 | const Task = require('./task'); 3 | const Wait = require('./wait'); 4 | const Fail = require('./fail'); 5 | const Choice = require('./choice'); 6 | const Succeed = require('./succeed'); 7 | const Parallel = require('./parallel'); 8 | 9 | 10 | const StateTypes = new Map([ 11 | [ 'Pass', Pass ], 12 | [ 'Task', Task ], 13 | [ 'Wait', Wait ], 14 | [ 'Fail', Fail ], 15 | [ 'Choice', Choice ], 16 | [ 'Succeed', Succeed ], 17 | [ 'Parallel', Parallel ], 18 | ]); 19 | 20 | 21 | const cache = new Map(); 22 | 23 | 24 | class Factory { 25 | 26 | static create(spec, Machine) { 27 | if (cache.has(spec)) { 28 | return cache.get(spec); 29 | } 30 | 31 | 32 | // Pass Machine to States that need to build sub-state machines 33 | // (really just Parallel). This is done to avoid a circular dependency 34 | // between Machine <-> Parallel. 35 | // TODO: Look for a better way to avoid circular dependencies. 36 | const State = StateTypes.get(spec.Type); 37 | const state = new State(spec, Machine); 38 | cache.set(spec, state); 39 | return state; 40 | } 41 | 42 | } 43 | 44 | module.exports = Factory; 45 | -------------------------------------------------------------------------------- /src/states/factory.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const Factory = require('./factory'); 3 | 4 | 5 | test('create', t => { 6 | 7 | const spec = { 8 | Type: 'Succeed' 9 | }; 10 | 11 | const state = Factory.create(spec); 12 | t.is(typeof state, 'object'); 13 | t.is(state.constructor.name, spec.Type); 14 | 15 | }); 16 | 17 | 18 | test('cache', t => { 19 | 20 | const spec = { 21 | Type: 'Succeed' 22 | }; 23 | 24 | const state = Factory.create(spec); 25 | t.is(typeof state, 'object'); 26 | 27 | const state2 = Factory.create(spec); 28 | t.is(typeof state2, 'object'); 29 | 30 | t.true(state === state2); 31 | 32 | }); 33 | -------------------------------------------------------------------------------- /src/states/fail.js: -------------------------------------------------------------------------------- 1 | const { mixin } = require('./mixins'); 2 | 3 | 4 | class Fail extends mixin(/* State */) { 5 | 6 | constructor(spec) { 7 | super(spec); 8 | this.error = spec.Error; 9 | this.cause = spec.Cause; 10 | } 11 | 12 | _run(input) { 13 | // TODO: Do we always use the configured Error and Cause, or pass 14 | // through error source details? 15 | const { error, cause } = this; 16 | const result = Object.assign({ Error: error, Cause: cause }, input); 17 | return Promise.reject(result); 18 | } 19 | 20 | } 21 | 22 | 23 | module.exports = Fail; 24 | -------------------------------------------------------------------------------- /src/states/fail.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const Fail = require('./fail'); 3 | 4 | 5 | test('succeed', t => { 6 | 7 | const spec = { Error: 'error', Cause: 'cause' }; 8 | const input = { Error: 'original error', Cause: 'original cause' }; 9 | 10 | const fail = new Fail(spec); 11 | return t.throws(fail.run(input)).then(error => { 12 | t.deepEqual(error, input); 13 | }); 14 | 15 | }); 16 | -------------------------------------------------------------------------------- /src/states/mixins/catch.js: -------------------------------------------------------------------------------- 1 | const { match, toErrorOutput } = require('./errorutils'); 2 | 3 | 4 | function Catch(Base) { 5 | 6 | return class Catch extends Base { 7 | 8 | constructor(spec) { 9 | super(spec); 10 | this.catchers = spec.Catch || []; 11 | this.resultPath = spec.ResultPath; 12 | } 13 | 14 | run(input) { 15 | return super.run(input).catch(error => this.catch(error)); 16 | } 17 | 18 | catch(error) { 19 | 20 | // TODO: The Catch type must support ResultPath. 21 | let output = toErrorOutput(error); 22 | const catcher = match(this.catchers, output.Error); 23 | if (catcher) { 24 | return { 25 | output, 26 | next: catcher.Next, 27 | }; 28 | } 29 | 30 | // WARN: Wrapped was possibly a type that cannot me mapped to Error/Cause. 31 | // Reject with the original error. 32 | return Promise.reject(error); 33 | } 34 | 35 | }; 36 | 37 | } 38 | 39 | 40 | module.exports = Catch; 41 | -------------------------------------------------------------------------------- /src/states/mixins/catch.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const CatchMixin = require('./catch'); 3 | 4 | 5 | const Catch = CatchMixin(class Base { 6 | run(input) { 7 | const { Errors = [], Result } = input; 8 | return new Promise((resolve, reject) => { 9 | const error = Errors.shift(); 10 | 11 | if (error instanceof Error) { 12 | reject(error); 13 | return; 14 | } 15 | 16 | if (error) { 17 | reject({ Error: error }); 18 | return; 19 | } 20 | 21 | resolve({ output: Result }); 22 | }); 23 | } 24 | }); 25 | 26 | 27 | test('Catch structured error', t => { 28 | 29 | const spec = { 30 | Retry: [ 31 | { 32 | ErrorEquals: [ 'States.Timeout' ], 33 | }, 34 | ], 35 | Catch: [ 36 | { 37 | ErrorEquals: [ 'States.ALL' ], 38 | Next: 'Catch' 39 | } 40 | ] 41 | }; 42 | 43 | const input = { 44 | Errors: [ 'States.ALL' ], 45 | Result: { 46 | foo: 'bar', 47 | }, 48 | }; 49 | 50 | const retry = new Catch(spec); 51 | return retry.run(input).then(({ output, next }) => { 52 | t.is(output.Error, 'States.ALL'); 53 | t.is(next, spec.Catch[0].Next); 54 | }); 55 | 56 | }); 57 | 58 | 59 | test('Catch thrown error', t => { 60 | 61 | const spec = { 62 | Retry: [ 63 | { 64 | ErrorEquals: [ 'States.Timeout' ], 65 | }, 66 | ], 67 | Catch: [ 68 | { 69 | ErrorEquals: [ 'States.ALL' ], 70 | Next: 'Catch' 71 | } 72 | ] 73 | }; 74 | 75 | const input = { 76 | Errors: [ new Error('States.ALL') ], 77 | Result: { 78 | foo: 'bar', 79 | }, 80 | }; 81 | 82 | const retry = new Catch(spec); 83 | return retry.run(input).then(({ output, next }) => { 84 | t.is(output.Error, 'States.ALL'); 85 | t.is(next, spec.Catch[0].Next); 86 | }); 87 | 88 | }); 89 | -------------------------------------------------------------------------------- /src/states/mixins/errorutils.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | function lookup(errorCode) { 4 | return function find(rule, index, rules) { 5 | const { ErrorEquals } = rule; 6 | 7 | if (ErrorEquals.includes(errorCode)) { 8 | return true; 9 | } 10 | 11 | /** 12 | * 'The reserved name “States.ALL” appearing in a Retrier/Catcher's 13 | * “ErrorEquals” field is a wild-card and matches any Error Name. 14 | * Such a value MUST appear alone in the “ErrorEquals” array and MUST 15 | * appear in the last Retrier/Catcher in the “Catch”/"Retry" array.' 16 | * 17 | * TODO: See if this rule can be enforced during validation. 18 | */ 19 | if (index === rules.length - 1 && ErrorEquals.length === 1 && ErrorEquals[0] === 'States.ALL') { 20 | return true; 21 | } 22 | 23 | return false; 24 | }; 25 | }; 26 | 27 | 28 | module.exports = { 29 | 30 | match(rules, errorCode, fallback) { 31 | const fn = lookup(errorCode); 32 | return rules.find(fn) || fallback; 33 | }, 34 | 35 | toErrorOutput(error) { 36 | if (error instanceof Error) { 37 | return { 38 | Error: error.message, 39 | Cause: error.stack, 40 | }; 41 | } 42 | 43 | if (typeof error === 'string') { 44 | return wrap(new Error(error)); 45 | } 46 | 47 | return error; 48 | } 49 | 50 | }; 51 | -------------------------------------------------------------------------------- /src/states/mixins/filter.js: -------------------------------------------------------------------------------- 1 | const PathUtils = require('../../pathutils'); 2 | 3 | 4 | function Filter(Base) { 5 | 6 | return class Filter extends Base { 7 | 8 | constructor(spec) { 9 | super(spec); 10 | this.inputPath = spec.InputPath; 11 | this.resultPath = spec.ResultPath; 12 | this.outputPath = spec.OutputPath; 13 | } 14 | 15 | run(data) { 16 | const input = this.filterInput(data); 17 | 18 | const resolved = (result) => { 19 | let output; 20 | output = this.filterResult(input, result.output); 21 | output = this.filterOutput(output); 22 | return Object.assign(result, { output }); 23 | }; 24 | 25 | return super.run(input).then(resolved); 26 | } 27 | 28 | filterInput(input) { 29 | const { inputPath = '$' } = this; 30 | /** 31 | * Per: https://states-language.net/spec.html#filters 32 | * 33 | * If the value of InputPath is null, that means that the raw input is 34 | * discarded, and the effective input for the state is an empty JSON 35 | * object, {}. 36 | */ 37 | if (inputPath === null) { 38 | return {}; 39 | } 40 | 41 | return PathUtils.query(input, inputPath); 42 | } 43 | 44 | filterResult(input, result) { 45 | const { resultPath } = this; 46 | 47 | // No mapping or merging of data necessary. 48 | if (resultPath === undefined) { 49 | return result; 50 | } 51 | 52 | /** 53 | * Per: https://states-language.net/spec.html#filters 54 | * 55 | * "If the value of of ResultPath is null, that means that the 56 | * state’s own raw output is discarded and its raw input becomes 57 | * its result." 58 | */ 59 | if (resultPath === null) { 60 | return input; 61 | } 62 | 63 | /** 64 | * The root path, '$', is basically a noop since we just return 65 | * result by default anyway, so short-circuit. 66 | */ 67 | if (resultPath === '$') { 68 | return result; 69 | } 70 | 71 | /** 72 | * Per: https://states-language.net/spec.html#filters 73 | * 74 | * "The ResultPath field’s value is a Reference Path that specifies 75 | * where to place the result, relative to the raw input. If the input 76 | * has a field which matches the ResultPath value, then in the output, 77 | * that field is discarded and overwritten by the state output. 78 | * Otherwise, a new field is created in the state output." 79 | * 80 | * Using the parser to ensure the provided path is valid. Then 81 | * operate on the input object directly. After this operation 82 | * completes the structure of `input` has changed. 83 | */ 84 | const parsed = PathUtils.parse(resultPath) 85 | parsed.reduce((target, path, index, paths) => { 86 | const { expression: { type, value }, operation, scope } = path; 87 | 88 | if (type == 'root') { 89 | // Keep on truckin' 90 | return target; 91 | } 92 | 93 | /** 94 | * Per: https://states-language.net/spec.html#filters 95 | * https://states-language.net/spec.html#path 96 | * 97 | * "The value of “ResultPath” MUST be a Reference Path, which 98 | * specifies the combination with or replacement of the state’s 99 | * result with its raw input." 100 | */ 101 | if (type === 'identifier' && operation === 'member' && scope === 'child') { 102 | if (index === paths.length - 1) { 103 | // Base case. End of the road. 104 | return target[value] = result; 105 | } 106 | 107 | /** 108 | * @see Runtime Errors - https://states-language.net/spec.html 109 | * 110 | * "Suppose a state’s input is the string 'foo', and its 111 | * 'ResultPath' field has the value '$.x'. Then ResultPath 112 | * cannot apply and the Interpreter fails the machine with 113 | * Error Name of 'States.OutputMatchFailure'." 114 | * 115 | * If I'm reading this correctly, due to the constraints 116 | * of Reference Paths, if the ResultPath can't resolve to 117 | * a single property on an object (non-primitive) type, 118 | * it must fail. 119 | * 120 | * This code takes the most literal interpretation and 121 | * only allows object type operations, excluding Array 122 | * indicies, etc. This constraint can be relaxed in the 123 | * future, if necessary. 124 | */ 125 | let child = target[value]; 126 | if (child !== undefined && (child === null || typeof child !== 'object' || Array.isArray(child))) { 127 | // const error = new Error(`Unable to match ResultPath "${resultPath}".`); 128 | // error.name = 'States.ResultPathMatchFailure'; 129 | const error = new Error('States.ResultPathMatchFailure'); 130 | throw error; 131 | } 132 | 133 | if (child === undefined) { 134 | child = target[value] = {}; 135 | } 136 | 137 | return target = child; 138 | } 139 | 140 | // const error = new Error(`Invalid ResultPath "${resultPath}". ResultPath must be a Reference Path (https://states-language.net/spec.html#path).`); 141 | // error.name = 'States.ResultPathMatchFailure'; 142 | const error = new Error('States.ResultPathMatchFailure'); 143 | throw error; 144 | 145 | }, input); 146 | 147 | return input; 148 | } 149 | 150 | filterOutput(output) { 151 | const { outputPath = '$' } = this; 152 | 153 | /** 154 | * Per: https://states-language.net/spec.html#filters 155 | * 156 | * If the value of OutputPath is null, that means the input and result 157 | * are discarded, and the effective output from the state is an empty 158 | * JSON object, {}. 159 | */ 160 | if (outputPath === null) { 161 | return {}; 162 | } 163 | 164 | return PathUtils.query(output, outputPath); 165 | } 166 | 167 | }; 168 | 169 | } 170 | 171 | 172 | module.exports = Filter; 173 | -------------------------------------------------------------------------------- /src/states/mixins/filter.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const FilterMixin = require('./filter'); 3 | 4 | 5 | const Filter = FilterMixin(class Base { 6 | run(input) { 7 | 8 | if (input.error) { 9 | return Promise.reject(new Error(input.error)); 10 | } 11 | 12 | if (input.result) { 13 | input = input.result; 14 | } 15 | 16 | return Promise.resolve({ output: input }) 17 | } 18 | }); 19 | 20 | 21 | test('Paths are undefined', t => { 22 | 23 | const spec = {}; 24 | const input = { 25 | foo: 'bar', 26 | }; 27 | 28 | const filter = new Filter(spec); 29 | return filter.run(input).then(({ output }) => { 30 | t.deepEqual(output, input); 31 | }); 32 | 33 | }); 34 | 35 | 36 | test('InputPath is "$"', t => { 37 | 38 | const spec = { 39 | // Should be a noop. 40 | InputPath: '$', 41 | }; 42 | 43 | const input = { 44 | foo: 'bar', 45 | }; 46 | 47 | const filter = new Filter(spec); 48 | return filter.run(input).then(({ output }) => { 49 | t.deepEqual(output, input); 50 | }); 51 | 52 | }); 53 | 54 | 55 | test('InputPath is null', t => { 56 | 57 | const spec = { 58 | // Should return new object. 59 | InputPath: null, 60 | }; 61 | 62 | const input = { 63 | foo: 'bar', 64 | }; 65 | 66 | const filter = new Filter(spec); 67 | return filter.run(input).then(({ output }) => { 68 | t.is(typeof output, 'object'); 69 | t.notDeepEqual(output, input); 70 | }); 71 | 72 | }); 73 | 74 | 75 | test('InputPath is definite', t => { 76 | 77 | const spec = { 78 | // Should return new object. 79 | InputPath: '$.foo', 80 | }; 81 | 82 | const input = { 83 | foo: 'bar', 84 | }; 85 | 86 | const filter = new Filter(spec); 87 | return filter.run(input).then(({ output }) => { 88 | t.is(output, 'bar'); 89 | }); 90 | 91 | }); 92 | 93 | 94 | test('InputPath is indefinite', t => { 95 | 96 | const spec = { 97 | // Should return new object. 98 | InputPath: '$..foo', 99 | }; 100 | 101 | const input = { 102 | foo: 'bar', 103 | bar: { 104 | foo: 'baz' 105 | } 106 | }; 107 | 108 | const filter = new Filter(spec); 109 | return filter.run(input).then(({ output }) => { 110 | t.true(Array.isArray(output)); 111 | t.is(output[0], input.foo); 112 | t.is(output[1], input.bar.foo); 113 | }); 114 | 115 | }); 116 | 117 | 118 | test('ResultPath is "$"', t => { 119 | 120 | const spec = { 121 | // Should be a noop. 122 | ResultPath: '$', 123 | }; 124 | 125 | const input = { 126 | foo: 'bar', 127 | }; 128 | 129 | const filter = new Filter(spec); 130 | return filter.run(input).then(({ output }) => { 131 | t.deepEqual(output, input); 132 | }); 133 | 134 | }); 135 | 136 | 137 | test('ResultPath is null', t => { 138 | 139 | const spec = { 140 | // Should return input. 141 | ResultPath: null, 142 | }; 143 | 144 | const input = { 145 | foo: 'bar', 146 | }; 147 | 148 | const filter = new Filter(spec); 149 | return filter.run(input).then(({ output }) => { 150 | t.deepEqual(output, input); 151 | }); 152 | 153 | }); 154 | 155 | 156 | test('ResultPath is set to a non-Reference Path', t => { 157 | 158 | const spec = { 159 | // Should return input. 160 | ResultPath: '$..foo', 161 | }; 162 | 163 | const input = { 164 | foo: 'bar', 165 | }; 166 | 167 | const filter = new Filter(spec); 168 | return t.throws(filter.run(input)).then(error => { 169 | t.is(error.message, 'States.ResultPathMatchFailure'); 170 | }); 171 | 172 | }); 173 | 174 | 175 | test('ResultPath is set to existing property.', t => { 176 | 177 | const spec = { 178 | // Should return new object. 179 | ResultPath: '$.foo', 180 | }; 181 | 182 | const input = { 183 | result: { 184 | foo: 'bar' 185 | }, 186 | foo: 'baz' 187 | }; 188 | 189 | const filter = new Filter(spec); 190 | return filter.run(input).then(({ output }) => { 191 | t.deepEqual(output.foo, input.result); 192 | }); 193 | 194 | }); 195 | 196 | 197 | test('ResultPath is set to non-existent property.', t => { 198 | 199 | const spec = { 200 | // Should return new object. 201 | ResultPath: '$.foo', 202 | }; 203 | 204 | const input = { 205 | result: { 206 | foo: 'bar', 207 | }, 208 | }; 209 | 210 | const filter = new Filter(spec); 211 | return filter.run(input).then(({ output }) => { 212 | t.deepEqual(output.foo, input.result); 213 | }); 214 | 215 | }); 216 | 217 | 218 | test('ResultPath is set to non-existent nested property.', t => { 219 | 220 | const spec = { 221 | // Should return new object. 222 | ResultPath: '$.foo.bar.baz', 223 | }; 224 | 225 | const input = { 226 | result: { 227 | foo: 'bar', 228 | }, 229 | }; 230 | 231 | const filter = new Filter(spec); 232 | return filter.run(input).then(({ output }) => { 233 | t.deepEqual(output.foo.bar.baz, input.result); 234 | }); 235 | 236 | }); 237 | 238 | 239 | test('ResultPath is set to nested property.', t => { 240 | 241 | const spec = { 242 | // Should return new object. 243 | ResultPath: '$.a.b.c', 244 | }; 245 | 246 | const input = { 247 | result: { 248 | foo: 'bar', 249 | }, 250 | a: { 251 | b: { 252 | c: 'foo', 253 | }, 254 | }, 255 | }; 256 | 257 | const filter = new Filter(spec); 258 | return filter.run(input).then(({ output }) => { 259 | t.deepEqual(output.a.b.c, input.result); 260 | }); 261 | 262 | }); 263 | 264 | 265 | test('ResultPath is non-traversable property.', t => { 266 | 267 | const spec = { 268 | // Should return new object. 269 | ResultPath: '$.a.length', 270 | }; 271 | 272 | const input = { 273 | result: { 274 | foo: 'bar' 275 | }, 276 | a: [] 277 | }; 278 | 279 | const filter = new Filter(spec); 280 | return t.throws(filter.run(input)).then(error => { 281 | t.is(error.message, 'States.ResultPathMatchFailure'); 282 | }); 283 | 284 | }); 285 | 286 | 287 | test('OutputPath is null', t => { 288 | 289 | const spec = { 290 | // Should return new object. 291 | OutputPath: null, 292 | }; 293 | 294 | const input = { 295 | foo: 'bar', 296 | }; 297 | 298 | const filter = new Filter(spec); 299 | return filter.run(input).then(({ output }) => { 300 | t.is(typeof output, 'object'); 301 | t.notDeepEqual(output, input); 302 | }); 303 | 304 | }); 305 | 306 | 307 | test('OutputPath is "$"', t => { 308 | 309 | const spec = { 310 | // Should return new object. 311 | OutputPath: '$', 312 | }; 313 | 314 | const input = { 315 | foo: 'bar', 316 | }; 317 | 318 | const filter = new Filter(spec); 319 | return filter.run(input).then(({ output }) => { 320 | t.deepEqual(output, input); 321 | }); 322 | 323 | }); 324 | 325 | 326 | test('InputPath is definite', t => { 327 | 328 | const spec = { 329 | // Should return new object. 330 | OutputPath: '$.foo', 331 | }; 332 | 333 | const input = { 334 | foo: 'bar', 335 | }; 336 | 337 | const filter = new Filter(spec); 338 | return filter.run(input).then(({ output }) => { 339 | t.is(output, 'bar'); 340 | }); 341 | 342 | }); 343 | 344 | 345 | test('OutputPath is indefinite', t => { 346 | 347 | const spec = { 348 | // Should return new object. 349 | OutputPath: '$..foo', 350 | }; 351 | 352 | const input = { 353 | foo: 'bar', 354 | bar: { 355 | foo: 'baz' 356 | } 357 | }; 358 | 359 | const filter = new Filter(spec); 360 | return filter.run(input).then(({ output }) => { 361 | t.true(Array.isArray(output)); 362 | t.is(output[0], input.foo); 363 | t.is(output[1], input.bar.foo); 364 | }); 365 | 366 | }); 367 | -------------------------------------------------------------------------------- /src/states/mixins/index.js: -------------------------------------------------------------------------------- 1 | const State = require('./state'); 2 | const Retry = require('./retry'); 3 | const Catch = require('./catch'); 4 | const Filter = require('./filter'); 5 | const Timeout = require('./timeout'); 6 | 7 | 8 | function mixin(...factories) { 9 | factories.unshift(State); 10 | return factories.reduce((cls, factory) => factory(cls), class Base {}); 11 | } 12 | 13 | 14 | module.exports = { 15 | mixin, 16 | Retry, 17 | Catch, 18 | Filter, 19 | Timeout, 20 | }; 21 | -------------------------------------------------------------------------------- /src/states/mixins/retry.js: -------------------------------------------------------------------------------- 1 | const { match } = require('./errorutils'); 2 | 3 | 4 | function Retry(Base) { 5 | 6 | return class Retry extends Base { 7 | 8 | constructor(spec) { 9 | super(spec); 10 | this.retriers = spec.Retry || []; 11 | } 12 | 13 | run(input) { 14 | const task = () => super.run(input); 15 | return this.retry(task); 16 | } 17 | 18 | retry(task) { 19 | const counts = new WeakMap(); 20 | 21 | const retry = error => { 22 | const retrier = match(this.retriers, error.Error, { MaxAttempts: 0 }); 23 | const { MaxAttempts = 3, IntervalSeconds = 1, BackoffRate = 2.0 } = retrier; 24 | 25 | const attempts = counts.get(retrier) || 0; 26 | const seconds = IntervalSeconds + (attempts * BackoffRate); 27 | 28 | if (attempts >= MaxAttempts) { 29 | return Promise.reject(error); 30 | } 31 | 32 | counts.set(retrier, attempts + 1); 33 | return wait(run, seconds * 1000); 34 | }; 35 | 36 | const wait = (fn, duration) => { 37 | return new Promise(resolve => { 38 | setTimeout(resolve, duration); 39 | }).then(fn); 40 | }; 41 | 42 | const run = () => { 43 | return task().catch(retry); 44 | }; 45 | 46 | return run(); 47 | } 48 | 49 | }; 50 | 51 | } 52 | 53 | 54 | module.exports = Retry; 55 | -------------------------------------------------------------------------------- /src/states/mixins/retry.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const RetryMixin = require('./retry'); 3 | 4 | 5 | const Retry = RetryMixin(class Base { 6 | run(input) { 7 | const { Errors = [], Result } = input; 8 | return new Promise((resolve, reject) => { 9 | const error = Errors.shift(); 10 | if (error) { 11 | reject({ Error: error }); 12 | return; 13 | } 14 | resolve({ output: Result }); 15 | }); 16 | } 17 | }); 18 | 19 | 20 | test('Retry, exhaust max', t => { 21 | 22 | const spec = { 23 | Retry: [ 24 | { 25 | ErrorEquals: [ 'States.Timeout' ], 26 | MaxAttempts: 1, 27 | } 28 | ], 29 | }; 30 | 31 | const input = { 32 | Errors: [ 'States.Timeout', 'States.Timeout', 'States.Timeout' ], 33 | Result: { 34 | foo: 'bar', 35 | }, 36 | }; 37 | 38 | const retry = new Retry(spec); 39 | return t.throws(retry.run(input)).then((output) => { 40 | const { Error } = output; 41 | t.is(Error, 'States.Timeout'); 42 | }); 43 | 44 | }); 45 | 46 | 47 | test('Retry, no wildcard', t => { 48 | 49 | const spec = { 50 | Retry: [{ 51 | ErrorEquals: [ 'States.Timeout' ], 52 | MaxAttempts: 3, 53 | }], 54 | }; 55 | 56 | const input = { 57 | Errors: [ 'States.Timeout', 'States.Timeout' ], 58 | Result: { 59 | foo: 'bar', 60 | }, 61 | }; 62 | 63 | const retry = new Retry(spec); 64 | return retry.run(input).then(({ output }) => { 65 | t.is(output, input.Result); 66 | }); 67 | 68 | }); 69 | 70 | 71 | test('Retry, with wildcard', t => { 72 | 73 | const spec = { 74 | Retry: [ 75 | { 76 | ErrorEquals: [ 'States.Timeout' ], 77 | MaxAttempts: 1, 78 | }, 79 | { 80 | ErrorEquals: [ 'States.ALL' ], 81 | MaxAttempts: 2, 82 | }, 83 | ], 84 | }; 85 | 86 | const input = { 87 | Errors: [ 'States.Uncaught', 'States.Uncaught' ], 88 | Result: { 89 | foo: 'bar', 90 | }, 91 | }; 92 | 93 | const retry = new Retry(spec); 94 | return retry.run(input).then(({ output }) => { 95 | t.is(output, input.Result); 96 | }); 97 | 98 | }); 99 | 100 | 101 | test('Retry, with wildcard and exhaust max', t => { 102 | 103 | const spec = { 104 | Retry: [ 105 | { 106 | ErrorEquals: [ 'States.Timeout' ], 107 | MaxAttempts: 1, 108 | }, 109 | { 110 | ErrorEquals: [ 'States.ALL' ], 111 | MaxAttempts: 1, 112 | }, 113 | ], 114 | }; 115 | 116 | const input = { 117 | Errors: [ 'States.Uncaught', 'States.Uncaught', 'States.Uncaught' ], 118 | Result: { 119 | foo: 'bar', 120 | }, 121 | }; 122 | 123 | const retry = new Retry(spec); 124 | return t.throws(retry.run(input)).then(({ Error }) => { 125 | t.is(Error, 'States.Uncaught'); 126 | }); 127 | 128 | }); 129 | 130 | 131 | test('Retry, unmatched', t => { 132 | 133 | const spec = { 134 | Retry: [ 135 | { 136 | ErrorEquals: [ 'States.Timeout' ], 137 | MaxAttempts: 1, 138 | }, 139 | ], 140 | }; 141 | 142 | const input = { 143 | Errors: [ 'States.Uncaught' ], 144 | Result: { 145 | foo: 'bar', 146 | }, 147 | }; 148 | 149 | const retry = new Retry(spec); 150 | return t.throws(retry.run(input)).then(({ Error }) => { 151 | t.is(Error, 'States.Uncaught'); 152 | }); 153 | 154 | }); 155 | 156 | 157 | test('Retry, no max attempts', t => { 158 | 159 | const spec = { 160 | Retry: [ 161 | { 162 | ErrorEquals: [ 'States.Timeout' ], 163 | }, 164 | ], 165 | }; 166 | 167 | const input = { 168 | Errors: [ 'States.Timeout' ], 169 | Result: { 170 | foo: 'bar', 171 | }, 172 | }; 173 | 174 | const retry = new Retry(spec); 175 | return retry.run(input).then(({ output }) => { 176 | t.is(output, input.Result); 177 | }); 178 | 179 | }); 180 | -------------------------------------------------------------------------------- /src/states/mixins/state.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | function State(Base) { 4 | 5 | return class State extends Base { 6 | 7 | constructor(spec) { 8 | super(spec); 9 | this.name = spec.Name; 10 | this.type = spec.Type; 11 | this.comment = spec.Comment; 12 | this.next = spec.Next; 13 | } 14 | 15 | run(data) { 16 | const defaults = { next: this.next }; 17 | const merge = result => Object.assign(defaults, result); 18 | return this._run(data).then(merge); 19 | } 20 | 21 | _run(input) { 22 | return Promise.reject({ Error: 'Not implemented.' }); 23 | } 24 | 25 | }; 26 | 27 | } 28 | 29 | 30 | module.exports = State; 31 | -------------------------------------------------------------------------------- /src/states/mixins/state.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const StateMixin = require('./state'); 3 | 4 | 5 | const State = StateMixin(class {}); 6 | 7 | test('Unimplemented', t => { 8 | 9 | const spec = {}; 10 | const input = {}; 11 | 12 | const state = new State(spec); 13 | return t.throws(state.run(input)).then(error => { 14 | t.is(typeof error, 'object'); 15 | t.is(error.Error, 'Not implemented.'); 16 | }); 17 | 18 | }); 19 | -------------------------------------------------------------------------------- /src/states/mixins/timeout.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | function Timeout(Base) { 4 | 5 | return class Timeout extends Base { 6 | 7 | constructor(spec) { 8 | super(spec); 9 | this.timeoutSeconds = spec.TimeoutSeconds; 10 | } 11 | 12 | run(data) { 13 | const { timeoutSeconds } = this; 14 | const promise = super.run(data); 15 | 16 | if (isNaN(timeoutSeconds)) { 17 | return promise; 18 | } 19 | 20 | return new Promise((resolve, reject) => { 21 | 22 | // TODO: Rationalize error formatting. In this case 23 | // the error is returned in lieu our base class as the 24 | // task has not yet been run to completion. This could 25 | // probably be cleaned up and centralized. 26 | // @see ./filter.js 27 | // @see ./state.js 28 | const result = { 29 | Error: 'States.Timeout', 30 | Cause: `State '${this.name}' exceeded the configured timeout of ${timeoutSeconds} seconds.` 31 | }; 32 | 33 | // Once a promise is settled, additional calls to resolve/reject are a noop. 34 | const timer = setTimeout(reject, timeoutSeconds * 1000, result); 35 | 36 | promise 37 | .then(result => { 38 | clearTimeout(timer); 39 | resolve(result); 40 | }) 41 | .catch(error => { 42 | clearTimeout(timer); 43 | reject(error); 44 | }); 45 | }); 46 | 47 | } 48 | 49 | } 50 | } 51 | 52 | 53 | module.exports = Timeout; 54 | -------------------------------------------------------------------------------- /src/states/mixins/timeout.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const TimeoutMixin = require('./timeout'); 3 | 4 | 5 | const Timeout = TimeoutMixin(class Base { 6 | run(input) { 7 | const { SleepSeconds = [], Error, Result } = input; 8 | 9 | if (Error) { 10 | return Promise.reject(Error); 11 | } 12 | 13 | return new Promise(resolve => { 14 | setTimeout(resolve, (SleepSeconds.shift() || 0) * 1000, Result); 15 | }); 16 | } 17 | }); 18 | 19 | 20 | test('Completes', t => { 21 | 22 | const spec = { 23 | TimeoutSeconds: 1, 24 | }; 25 | 26 | const input = { 27 | SleepSeconds: [ 0 ], 28 | Result: 'foo', 29 | }; 30 | 31 | const timeout = new Timeout(spec) 32 | return timeout.run(input).then(result => { 33 | t.is(result, input.Result); 34 | }); 35 | 36 | }); 37 | 38 | 39 | test('Timeout triggered', t => { 40 | 41 | const spec = { 42 | TimeoutSeconds: 1, 43 | }; 44 | 45 | const input = { 46 | SleepSeconds: [ 2 ], 47 | }; 48 | 49 | const timeout = new Timeout(spec) 50 | return t.throws(timeout.run(input)).then(error => { 51 | // In this case we get a normalized error as this is triggered in 52 | // lieu of any work done by State to format errors. 53 | const { Error, Cause } = error; 54 | t.is(Error, 'States.Timeout'); 55 | t.is(Cause, 'State \'undefined\' exceeded the configured timeout of 1 seconds.'); 56 | }); 57 | 58 | }); 59 | 60 | 61 | test('Error', t => { 62 | 63 | const spec = { 64 | TimeoutSeconds: 1, 65 | }; 66 | 67 | const input = { 68 | Error: new Error('Broken') 69 | }; 70 | 71 | const timeout = new Timeout(spec); 72 | return t.throws(timeout.run(input)).then(error => { 73 | // In this case we get an error because we haven't mixed in State 74 | // to do any normalization of errors returned by `run`. 75 | t.true(error instanceof Error) 76 | }); 77 | 78 | }); 79 | -------------------------------------------------------------------------------- /src/states/parallel.js: -------------------------------------------------------------------------------- 1 | const { mixin, Retry, Catch, Filter } = require('./mixins'); 2 | 3 | 4 | class Parallel extends mixin(Filter, Retry, Catch) { 5 | 6 | constructor(spec, Machine) { 7 | super(spec); 8 | const { Branches = [] } = spec; 9 | this.branches = Branches.map(Machine.create); 10 | } 11 | 12 | _run(input) { 13 | const tasks = this.branches.map(machine => machine.run(input)); 14 | return Promise.all(tasks).then(output => ({ output })); 15 | } 16 | 17 | } 18 | 19 | 20 | module.exports = Parallel; 21 | -------------------------------------------------------------------------------- /src/states/parallel.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const Parallel = require('./parallel'); 3 | 4 | 5 | const Machine = { 6 | create(spec) { 7 | return { 8 | run({ Result }) { 9 | return Promise.resolve({ output: Result }); 10 | }, 11 | }; 12 | }, 13 | }; 14 | 15 | 16 | test('parallel', t => { 17 | 18 | const spec = { 19 | // These are faux specs used by the mock Machine. 20 | Branches: [ 21 | { 22 | Type: 'Pass', 23 | Result: 'foo', 24 | }, 25 | { 26 | Type: 'Pass', 27 | Result: 'bar', 28 | }, 29 | { 30 | Type: 'Pass', 31 | Result: 'baz', 32 | } 33 | ], 34 | Next: 'next', 35 | }; 36 | 37 | const input = {}; 38 | 39 | const parallel = new Parallel(spec, Machine); 40 | parallel.run(input).then(({ output, next }) => { 41 | t.true(Array.isArray(output)); 42 | t.is(next, spec.Next); 43 | 44 | const results = spec.Branches.map(({ Result }) => Result); 45 | for (const result of results) { 46 | t.true(output.includes(result)); 47 | } 48 | }); 49 | 50 | }); 51 | 52 | 53 | test('no branches', t => { 54 | 55 | const spec = { 56 | Next: 'next', 57 | }; 58 | 59 | const input = {}; 60 | 61 | const parallel = new Parallel(spec, Machine); 62 | parallel.run(input).then(({ output, next }) => { 63 | t.true(Array.isArray(output)); 64 | t.is(output.length, 0); 65 | t.is(next, spec.Next); 66 | }); 67 | 68 | }); 69 | -------------------------------------------------------------------------------- /src/states/pass.js: -------------------------------------------------------------------------------- 1 | const { mixin, Filter } = require('./mixins'); 2 | 3 | 4 | class Pass extends mixin(Filter) { 5 | 6 | constructor(spec) { 7 | super(spec); 8 | this.result = spec.Result; 9 | } 10 | 11 | _run(input) { 12 | const { result = input } = this; 13 | return Promise.resolve({ output: result }); 14 | } 15 | 16 | } 17 | 18 | 19 | module.exports = Pass; 20 | -------------------------------------------------------------------------------- /src/states/pass.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const Pass = require('./pass'); 3 | 4 | 5 | test('defaults', t => { 6 | 7 | const spec = { Next: 'next' }; 8 | const input = { foo: 'bar' }; 9 | 10 | const pass = new Pass(spec); 11 | return pass.run(input).then(({ output, next }) => { 12 | t.deepEqual(output, input); 13 | t.is(next, spec.Next); 14 | }); 15 | 16 | }); 17 | 18 | 19 | test('result', t => { 20 | 21 | const spec = { Result: 'result', Next: 'next' }; 22 | const input = { foo: 'bar' }; 23 | 24 | const pass = new Pass(spec); 25 | return pass.run(input).then(({ output, next }) => { 26 | t.deepEqual(output, spec.Result); 27 | t.is(next, spec.Next); 28 | }); 29 | 30 | }); 31 | -------------------------------------------------------------------------------- /src/states/succeed.js: -------------------------------------------------------------------------------- 1 | const { mixin, Filter } = require('./mixins'); 2 | 3 | 4 | class Succeed extends mixin(Filter) { 5 | 6 | constructor(spec) { 7 | super(spec); 8 | } 9 | 10 | _run(input) { 11 | return Promise.resolve({ output: input }); 12 | } 13 | 14 | } 15 | 16 | 17 | module.exports = Succeed; 18 | -------------------------------------------------------------------------------- /src/states/succeed.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const Succeed = require('./succeed'); 3 | 4 | 5 | test('succeed', t => { 6 | 7 | const spec = {}; 8 | const input = { foo: 'bar' }; 9 | 10 | const succeed = new Succeed(spec); 11 | return succeed.run(input).then(({ output, next }) => { 12 | t.deepEqual(output, input); 13 | t.is(next, undefined); 14 | }); 15 | 16 | }); 17 | -------------------------------------------------------------------------------- /src/states/task.js: -------------------------------------------------------------------------------- 1 | const mock = require('./__mocktask__'); 2 | const openwhisk = require('openwhisk'); 3 | const { mixin, Timeout, Retry, Catch, Filter } = require('./mixins'); 4 | 5 | 6 | class Task extends mixin(Timeout, Filter, Retry, Catch) { 7 | 8 | constructor(spec) { 9 | super(spec); 10 | this.resource = spec.Resource; 11 | } 12 | 13 | _run(input) { 14 | const { resource } = this; 15 | 16 | if (resource === '__mockresource__') { 17 | return mock(input).then(output => ({ output })); 18 | } 19 | 20 | if (process.env['__OW_API_KEY'] && process.env['__OW_API_HOST'] && typeof openwhisk === 'function') { 21 | const options = { 22 | name: resource, 23 | params: input, 24 | blocking: true, 25 | }; 26 | 27 | const resolve = output => ({ output }); 28 | const reject = ({ error }) => { 29 | const output = { 30 | Error: 'States.TaskFailed', 31 | Cause: JSON.stringify(error), 32 | }; 33 | return Promise.reject(output); 34 | }; 35 | 36 | const { actions } = openwhisk({ ignore_certs: true /* for testing */ }); 37 | return actions.invoke(options).then(resolve, reject); 38 | } 39 | 40 | return Promise.reject(new Error(`No task implementation provided to execute resource ${resource}.`)); 41 | } 42 | 43 | } 44 | 45 | 46 | module.exports = Task; 47 | -------------------------------------------------------------------------------- /src/states/task.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const Task = require('./task'); 3 | 4 | 5 | test('Mock Task', t => { 6 | 7 | const spec = { 8 | Resource: '__mockresource__', 9 | Next: 'next', 10 | }; 11 | 12 | const input = { Result: 'result' }; 13 | 14 | const task = new Task(spec); 15 | return task.run(input).then(({ output, next }) => { 16 | t.deepEqual(output, input.Result); 17 | t.is(next, spec.Next); 18 | }); 19 | 20 | }); 21 | 22 | 23 | test('OpenWhisk Task', t => { 24 | 25 | const spec = { 26 | Resource: 'step_test_action', 27 | ResultPath: '$.response.result', 28 | Next: 'next', 29 | }; 30 | 31 | const input = { foo: 'bar' }; 32 | 33 | const task = new Task(spec); 34 | return task.run(input).then(({ output, next }) => { 35 | t.deepEqual(output, input); 36 | t.is(next, spec.Next); 37 | }); 38 | 39 | }); 40 | 41 | 42 | test('No Task Impl', t => { 43 | 44 | const key = process.env['__OW_API_KEY']; 45 | process.env['__OW_API_KEY'] = ''; 46 | 47 | const spec = { 48 | Resource: 'not_found', 49 | Next: 'next', 50 | }; 51 | 52 | const input = { foo: 'bar' }; 53 | 54 | const task = new Task(spec); 55 | return t.throws(task.run(input)).then(error => { 56 | t.is(error.message, 'No task implementation provided to execute resource not_found.'); 57 | process.env['__OW_API_KEY'] = key; 58 | }); 59 | 60 | }); 61 | -------------------------------------------------------------------------------- /src/states/wait.js: -------------------------------------------------------------------------------- 1 | const PathUtils = require('../pathutils'); 2 | const { mixin, Filter } = require('./mixins'); 3 | 4 | 5 | class Wait extends mixin(Filter) { 6 | 7 | constructor(spec) { 8 | super(spec); 9 | this.seconds = spec.Seconds; 10 | this.secondsPath = spec.SecondsPath; 11 | this.timestamp = spec.Timestamp; 12 | this.timestampPath = spec.TimestampPath; 13 | } 14 | 15 | _run(input) { 16 | // Since there can only ever be one of these set, this code could 17 | // probably stand to be tightened up a bit. 18 | let { seconds, secondsPath, timestamp, timestampPath } = this; 19 | let milliseconds = 0; 20 | 21 | if (typeof timestampPath === 'string') { 22 | timestamp = PathUtils.query(input, timestampPath); 23 | } 24 | 25 | if (typeof timestamp === 'string') { 26 | const then = new Date(timestamp); 27 | milliseconds = then - Date.now(); 28 | } 29 | 30 | if (typeof secondsPath === 'string') { 31 | seconds = PathUtils.query(input, secondsPath); 32 | } 33 | 34 | if (typeof seconds === 'number') { 35 | milliseconds = seconds * 1000; 36 | } 37 | 38 | return new Promise(resolve => { 39 | setTimeout(resolve, milliseconds, { output: input }); 40 | }); 41 | } 42 | 43 | } 44 | 45 | 46 | module.exports = Wait; 47 | -------------------------------------------------------------------------------- /src/states/wait.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const Wait = require('./wait'); 3 | 4 | 5 | test('timestampPath', t => { 6 | 7 | const then = Date.now() + 1000; 8 | const spec = { TimestampPath: '$.ts', Next: 'Next' }; 9 | const input = { ts: new Date(then).toISOString() }; 10 | 11 | const start = Date.now(); 12 | const w = new Wait(spec); 13 | return w.run(input).then(({ output, next }) => { 14 | const duration = Date.now() - start; 15 | t.true(duration > 800 && duration < 1200); 16 | t.deepEqual(output, input); 17 | t.is(next, spec.Next); 18 | }); 19 | 20 | }); 21 | 22 | 23 | test('timestamp', t => { 24 | 25 | const then = Date.now() + 1000; 26 | const spec = { Timestamp: new Date(then).toISOString(), Next: 'Next' }; 27 | const input = {}; 28 | 29 | const start = Date.now(); 30 | const w = new Wait(spec); 31 | return w.run(input).then(({ output, next }) => { 32 | const duration = Date.now() - start; 33 | t.true(duration > 800 && duration < 1200); 34 | t.deepEqual(output, input); 35 | t.is(next, spec.Next); 36 | }); 37 | 38 | }); 39 | 40 | 41 | test('secondsPath', t => { 42 | 43 | const spec = { SecondsPath: '$.seconds', Next: 'Next' }; 44 | const input = { seconds: 1 }; 45 | 46 | const start = Date.now(); 47 | const w = new Wait(spec); 48 | return w.run(input).then(({ output, next }) => { 49 | const duration = Date.now() - start; 50 | t.true(duration > 800 && duration < 1200); 51 | t.deepEqual(output, input); 52 | t.is(next, spec.Next); 53 | }); 54 | 55 | }); 56 | 57 | 58 | test('seconds', t => { 59 | 60 | const spec = { Seconds: 1, Next: 'Next' }; 61 | const input = {}; 62 | 63 | const start = Date.now(); 64 | const w = new Wait(spec); 65 | return w.run(input).then(({ output, next }) => { 66 | const duration = Date.now() - start; 67 | t.true(duration > 800 && duration < 1200); 68 | t.deepEqual(output, input); 69 | t.is(next, spec.Next); 70 | }); 71 | 72 | }); 73 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | // Inspired by: 4 | // https://gist.github.com/jakearchibald/31b89cba627924972ad6 5 | // https://www.promisejs.org/generators/ 6 | 7 | function proceed(iterable, verb, arg) { 8 | const { value, done } = iterable[verb](arg); 9 | 10 | if (done) { 11 | return value; 12 | } 13 | 14 | return Promise.resolve(value) 15 | .then(output => proceed(iterable, 'next', output)) 16 | .catch(error => proceed(iterable, 'throw', error)); 17 | }; 18 | 19 | 20 | function async(generator, context) { 21 | return (...args) => { 22 | try { 23 | const iterable = generator.call(context, ...args); 24 | return proceed(iterable, 'next'); 25 | } catch (error) { 26 | return Promise.reject(error); 27 | } 28 | } 29 | } 30 | 31 | 32 | function clone(obj) { 33 | // Lolololol. 34 | return JSON.parse(JSON.stringify(obj)); 35 | } 36 | 37 | 38 | module.exports = { 39 | async, 40 | clone, 41 | }; 42 | -------------------------------------------------------------------------------- /src/util.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const util = require('./util'); 3 | 4 | 5 | 6 | test('async success', t => { 7 | 8 | let results = []; 9 | 10 | const run = util.async(function *() { 11 | while (results.length < 2) { 12 | const result = yield 'ok'; 13 | results.push(result); 14 | } 15 | return results; 16 | }); 17 | 18 | return run().then(result => { 19 | t.true(Array.isArray(result)); 20 | t.is(result.length, 2); 21 | }); 22 | 23 | }); 24 | 25 | 26 | test('async error', t => { 27 | 28 | let results = []; 29 | 30 | const run = util.async(function *() { 31 | while (results.length < 2) { 32 | const result = yield 'ok'; 33 | results.push(result); 34 | } 35 | 36 | throw new Error('not ok'); 37 | }); 38 | 39 | return t.throws(run()).then(error => { 40 | t.is(error.message, 'not ok'); 41 | }); 42 | 43 | }); 44 | 45 | test('async immediate error', t => { 46 | 47 | let results = []; 48 | 49 | const run = util.async(function *() { 50 | throw new Error('not ok'); 51 | }); 52 | 53 | return t.throws(run()).then(error => { 54 | t.is(error.message, 'not ok'); 55 | }); 56 | 57 | }); 58 | --------------------------------------------------------------------------------