├── .npmignore ├── .editorconfig ├── test ├── .eslintrc ├── util │ ├── setup-lifecycle.util.js │ ├── test-route.util.js │ └── build-action-and-send-request.util.js ├── double-wrap.test.js ├── url-wildcard-suffix.test.js └── sanity.test.js ├── .travis.yml ├── .gitignore ├── package.json ├── appveyor.yml ├── .eslintrc ├── lib ├── private │ ├── get-output-example.js │ └── normalize-responses.js └── machine-as-action.js ├── .jshintrc └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | .git 2 | ./.gitignore 3 | ./.jshintrc 4 | ./.editorconfig 5 | ./.travis.yml 6 | ./appveyor.yml 7 | ./example 8 | ./examples 9 | ./test 10 | ./tests 11 | ./.github 12 | 13 | node_modules 14 | npm-debug.log 15 | .node_history 16 | *.swo 17 | *.swp 18 | *.swn 19 | *.swm 20 | *.seed 21 | *.log 22 | *.out 23 | *.pid 24 | lib-cov 25 | .DS_STORE 26 | *# 27 | *\# 28 | .\#* 29 | *~ 30 | .idea 31 | .netbeans 32 | nbproject 33 | .tmp 34 | dump.rdb 35 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # ╔═╗╔╦╗╦╔╦╗╔═╗╦═╗┌─┐┌─┐┌┐┌┌─┐┬┌─┐ 2 | # ║╣ ║║║ ║ ║ ║╠╦╝│ │ ││││├┤ ││ ┬ 3 | # o╚═╝═╩╝╩ ╩ ╚═╝╩╚═└─┘└─┘┘└┘└ ┴└─┘ 4 | # 5 | # This file (`.editorconfig`) exists to help maintain consistent formatting 6 | # throughout this package, the Sails framework, and the Node-Machine project. 7 | # 8 | # To review what each of these options mean, see: 9 | # http://editorconfig.org/ 10 | root = true 11 | 12 | [*] 13 | indent_style = space 14 | indent_size = 2 15 | end_of_line = lf 16 | charset = utf-8 17 | trim_trailing_whitespace = true 18 | insert_final_newline = true 19 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | // ╔═╗╔═╗╦ ╦╔╗╔╔╦╗┬─┐┌─┐ ┌─┐┬ ┬┌─┐┬─┐┬─┐┬┌┬┐┌─┐ 3 | // ║╣ ╚═╗║ ║║║║ ║ ├┬┘│ │ │└┐┌┘├┤ ├┬┘├┬┘│ ││├┤ 4 | // o╚═╝╚═╝╩═╝╩╝╚╝ ╩ ┴└─└─┘ └─┘ └┘ └─┘┴└─┴└─┴─┴┘└─┘ 5 | // ┌─ ┌─┐┌─┐┬─┐ ┌─┐┬ ┬┌┬┐┌─┐┌┬┐┌─┐┌┬┐┌─┐┌┬┐ ┌┬┐┌─┐┌─┐┌┬┐┌─┐ ─┐ 6 | // │ ├┤ │ │├┬┘ ├─┤│ │ │ │ ││││├─┤ │ ├┤ ││ │ ├┤ └─┐ │ └─┐ │ 7 | // └─ └ └─┘┴└─ ┴ ┴└─┘ ┴ └─┘┴ ┴┴ ┴ ┴ └─┘─┴┘ ┴ └─┘└─┘ ┴ └─┘ ─┘ 8 | // > An .eslintrc configuration override for use with the tests in this directory. 9 | // 10 | // (See .eslintrc in the root directory of this package for more info.) 11 | 12 | "extends": [ 13 | "../.eslintrc" 14 | ], 15 | 16 | "env": { 17 | "mocha": true 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # # # # # # # # # # # # # # # # # # # # # # # # # # # # # 2 | # ╔╦╗╦═╗╔═╗╦ ╦╦╔═╗ ┬ ┬┌┬┐┬ # 3 | # ║ ╠╦╝╠═╣╚╗╔╝║╚═╗ └┬┘││││ # 4 | # o ╩ ╩╚═╩ ╩ ╚╝ ╩╚═╝o ┴ ┴ ┴┴─┘ # 5 | # # 6 | # This file configures Travis CI. # 7 | # (i.e. how we run the tests... mainly) # 8 | # # 9 | # https://docs.travis-ci.com/user/customizing-the-build # 10 | # # # # # # # # # # # # # # # # # # # # # # # # # # # # # 11 | 12 | language: node_js 13 | 14 | node_js: 15 | - "4" 16 | - "6" 17 | - "8" 18 | - "node" 19 | 20 | branches: 21 | only: 22 | - master 23 | 24 | notifications: 25 | email: 26 | - ci@sailsjs.com 27 | -------------------------------------------------------------------------------- /test/util/setup-lifecycle.util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Module dependencies 3 | */ 4 | 5 | var Sails = require('sails').Sails; 6 | 7 | 8 | /** 9 | * before and after lifecycle callbacks for mocha 10 | * @return {SailsApp} 11 | */ 12 | 13 | module.exports = function setupLifecycle() { 14 | 15 | var app = Sails(); 16 | before(function(done) { 17 | app.load({ 18 | hooks: { 19 | grunt: false 20 | }, 21 | log: { 22 | level: 'warn' 23 | }, 24 | globals: false 25 | }, function(err) { 26 | if (err) { 27 | return done(err); 28 | } else { 29 | return done(); 30 | } 31 | }); 32 | }); 33 | 34 | after(function(done) { 35 | app.lower(function(err) { 36 | if (err) { 37 | return done(err); 38 | } else { 39 | return done(); 40 | } 41 | }); 42 | }); 43 | 44 | return app; 45 | 46 | }; 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ┌─┐┬┌┬┐╦╔═╗╔╗╔╔═╗╦═╗╔═╗ 2 | # │ ┬│ │ ║║ ╦║║║║ ║╠╦╝║╣ 3 | # o└─┘┴ ┴ ╩╚═╝╝╚╝╚═╝╩╚═╚═╝ 4 | # 5 | # This file (`.gitignore`) exists to signify to `git` that certain files 6 | # and/or directories should be ignored for the purposes of version control. 7 | # 8 | # This is primarily useful for excluding temporary files of all sorts; stuff 9 | # generated by IDEs, build scripts, automated tests, package managers, or even 10 | # end-users (e.g. file uploads). `.gitignore` files like this also do a nice job 11 | # at keeping sensitive credentials and personal data out of version control systems. 12 | # 13 | 14 | ############################ 15 | # sails / node.js / npm 16 | ############################ 17 | node_modules 18 | .tmp 19 | npm-debug.log 20 | .waterline 21 | .node_history 22 | package-lock.json 23 | 24 | ############################ 25 | # editor & OS files 26 | ############################ 27 | *.swo 28 | *.swp 29 | *.swn 30 | *.swm 31 | *.seed 32 | *.log 33 | *.out 34 | *.pid 35 | lib-cov 36 | .DS_STORE 37 | *# 38 | *\# 39 | .\#* 40 | *~ 41 | .idea 42 | .netbeans 43 | nbproject 44 | 45 | ############################ 46 | # misc 47 | ############################ 48 | dump.rdb 49 | -------------------------------------------------------------------------------- /test/double-wrap.test.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var asAction = require('../'); 3 | var testRoute = require('./util/test-route.util'); 4 | 5 | 6 | 7 | testRoute('double-wrapping should fail when passing in an already-converted-machine-which-is-now-an-action as `machine`', { 8 | 9 | machine: asAction({ 10 | inputs: {}, 11 | exits: {}, 12 | fn: function (inputs, exits) { 13 | return exits.success(); 14 | } 15 | }) 16 | 17 | }, function (err, resp, body, done){ 18 | if (err) { 19 | assert.equal(err.code, 'E_DOUBLE_WRAP'); 20 | return done(); 21 | } 22 | return done(new Error('Double-wrapped machine-as-action should have failed!')); 23 | }); 24 | 25 | 26 | 27 | 28 | 29 | 30 | testRoute('double-wrapping should fail when passing in an already-converted-machine-which-is-now-an-action at the top level', asAction({ 31 | inputs: {}, 32 | exits: {}, 33 | fn: function (inputs, exits) { 34 | return exits.success(); 35 | } 36 | }), function (err, resp, body, done){ 37 | if (err) { 38 | assert.equal(err.code, 'E_DOUBLE_WRAP'); 39 | return done(); 40 | } 41 | return done(new Error('Double-wrapped machine-as-action should have failed!')); 42 | }); 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "machine-as-action", 3 | "version": "10.3.1", 4 | "description": "Run a machine from an HTTP or WebSocket request.", 5 | "scripts": { 6 | "test": "npm run lint && npm run custom-tests && echo 'Done.'", 7 | "lint": "node ./node_modules/eslint/bin/eslint . --max-warnings=0 && echo '✔ Your code looks good.'", 8 | "custom-tests": "echo \"Running tests...\" && echo && node ./node_modules/mocha/bin/mocha -t 8000 && echo" 9 | }, 10 | "main": "lib/machine-as-action.js", 11 | "keywords": [ 12 | "machine", 13 | "action", 14 | "actions2", 15 | "controller", 16 | "sails.js", 17 | "sails", 18 | "blueprint", 19 | "request", 20 | "websocket", 21 | "http" 22 | ], 23 | "author": "Mike McNeil", 24 | "license": "MIT", 25 | "dependencies": { 26 | "flaverr": "^1.5.1", 27 | "@sailshq/lodash": "^3.10.2", 28 | "machine": "^15.2.2", 29 | "rttc": "^10.0.0-4", 30 | "streamifier": "0.1.1" 31 | }, 32 | "devDependencies": { 33 | "eslint": "3.5.0", 34 | "mocha": "3.0.2", 35 | "sails": "^1.0.0-0" 36 | }, 37 | "repository": { 38 | "type": "git", 39 | "url": "git@github.com:treelinehq/machine-as-action.git" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/url-wildcard-suffix.test.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var testRoute = require('./util/test-route.util'); 3 | 4 | 5 | 6 | testRoute('with urlWildcardSuffix option', { 7 | 8 | 9 | //------(these are just for the test utilities)------------// 10 | _testOpts: { 11 | routeAddress: 'GET /*', 12 | method: 'GET', 13 | path: '/foo/bar', 14 | }, 15 | //-----------------------------------------------------// 16 | 17 | 18 | urlWildcardSuffix: 'star', 19 | 20 | 21 | inputs: { 22 | 23 | star: { 24 | description: 'The wildcard string, the final segment of the incoming request\'s URL.', 25 | extendedDescription: 'This is the actual, runtime value of the "wildcard variable" ("*") expected by this route\'s URL pattern: `<>`. Note that, unlike with URL _pattern variables_ (e.g. ":foo"), URL wildcard variables can contain slashes ("/").', 26 | example: 'some-string/like-this/which%20might%20contain/slashes', 27 | required: true 28 | } 29 | 30 | }, 31 | 32 | 33 | exits: { 34 | 35 | success: { 36 | outputExample: '/blah/blah/blah' 37 | } 38 | 39 | }, 40 | 41 | 42 | fn: function(inputs, exits) { 43 | return exits.success(inputs.star); 44 | } 45 | 46 | 47 | }, function(err, resp, body, done) { 48 | if (err) { 49 | return done(err); 50 | } 51 | 52 | try { 53 | 54 | assert.equal(body, 'foo/bar'); 55 | 56 | } catch (e) { 57 | return done(e); 58 | } 59 | 60 | return done(); 61 | 62 | }); 63 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | # # # # # # # # # # # # # # # # # # # # # # # # # # 2 | # ╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╦╔═╗╦═╗ ┬ ┬┌┬┐┬ # 3 | # ╠═╣╠═╝╠═╝╚╗╔╝║╣ ╚╦╝║ ║╠╦╝ └┬┘││││ # 4 | # ╩ ╩╩ ╩ ╚╝ ╚═╝ ╩ ╚═╝╩╚═o ┴ ┴ ┴┴─┘ # 5 | # # 6 | # This file configures Appveyor CI. # 7 | # (i.e. how we run the tests on Windows) # 8 | # # 9 | # https://www.appveyor.com/docs/lang/nodejs-iojs/ # 10 | # # # # # # # # # # # # # # # # # # # # # # # # # # 11 | 12 | 13 | # Test against these versions of Node.js. 14 | environment: 15 | matrix: 16 | - nodejs_version: "4" 17 | - nodejs_version: "6" 18 | - nodejs_version: "8" 19 | 20 | # Install scripts. (runs after repo cloning) 21 | install: 22 | # Get the latest stable version of Node.js 23 | # (Not sure what this is for, it's just in Appveyor's example.) 24 | - ps: Install-Product node $env:nodejs_version 25 | # Install declared dependencies 26 | - npm install 27 | 28 | 29 | # Post-install test scripts. 30 | test_script: 31 | # Output Node and NPM version info. 32 | # (Presumably just in case Appveyor decides to try any funny business? 33 | # But seriously, always good to audit this kind of stuff for debugging.) 34 | - node --version 35 | - npm --version 36 | # Run the actual tests. 37 | - npm test 38 | 39 | 40 | # Don't actually build. 41 | # (Not sure what this is for, it's just in Appveyor's example. 42 | # I'm not sure what we're not building... but I'm OK with not 43 | # building it. I guess.) 44 | build: off 45 | -------------------------------------------------------------------------------- /test/util/test-route.util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Module dependencies 3 | */ 4 | 5 | var setupLifecycle = require('./setup-lifecycle.util'); 6 | var buildActionAndSendRequest = require('./build-action-and-send-request.util'); 7 | 8 | 9 | 10 | /** 11 | * [testRoute description] 12 | * @param {String} label 13 | * @param {Dictionary} opts 14 | * For the most part, this is a normal machine-as-action definition dictionary, 15 | * with normal machine things, plus the special machine-as-action things. 16 | * BUT it also recognizes an extra special property: `_testOpts`. 17 | * This is a dictionary, with the properties below: 18 | * _testOpts.routeAddress - the route address to bind 19 | * _testOpts.method - runtime request method 20 | * _testOpts.path - runtime request path 21 | * _testOpts.params - runtime request params (i.e. to send in the body or querystring, whichever is apropos) 22 | * 23 | * @param {[type]} testFn [description] 24 | * @return {[type]} [description] 25 | */ 26 | module.exports = function testRoute(label, opts, testFn) { 27 | var app = setupLifecycle(); 28 | describe(label, function() { 29 | this.timeout(5000); 30 | 31 | it('should respond as expected', function(done) { 32 | try { 33 | return buildActionAndSendRequest(app, opts, function(err, resp, body) { 34 | return testFn(err, resp, body, done); 35 | }); 36 | } catch (e) { 37 | return testFn(e, undefined, undefined, done); 38 | } 39 | }); // 40 | }); // 41 | }; 42 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | // ╔═╗╔═╗╦ ╦╔╗╔╔╦╗┬─┐┌─┐ 3 | // ║╣ ╚═╗║ ║║║║ ║ ├┬┘│ 4 | // o╚═╝╚═╝╩═╝╩╝╚╝ ╩ ┴└─└─┘ 5 | // A set of basic conventions designed to complement the .jshintrc file for any 6 | // arbitrary JavaScript / Node.js package -- inside or outside a Sails.js app. 7 | // For the master copy of this file, see the `.eslintrc` template file in 8 | // the `sails-generate` package (https://www.npmjs.com/package/sails-generate.) 9 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 10 | // For more information about any of the rules below, check out the relevant 11 | // reference page on eslint.org. For example, to get details on "no-sequences", 12 | // you would visit `http://eslint.org/docs/rules/no-sequences`. 13 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 14 | 15 | "env": { 16 | "node": true 17 | }, 18 | 19 | "parserOptions": { 20 | "ecmaVersion": 5 21 | }, 22 | 23 | "rules": { 24 | "callback-return": [2, ["callback", "cb", "next", "done", "proceed"]], 25 | "camelcase": [1, {"properties": "always"}], 26 | "comma-style": [2, "last"], 27 | "curly": [2], 28 | "eqeqeq": [2, "always"], 29 | "eol-last": [1], 30 | "handle-callback-err": [2], 31 | "indent": [1, 2, {"SwitchCase": 1}], 32 | "linebreak-style": [2, "unix"], 33 | "no-mixed-spaces-and-tabs": [2, "smart-tabs"], 34 | "no-return-assign": [2, "always"], 35 | "no-sequences": [2], 36 | "no-trailing-spaces": [1], 37 | "no-undef": [2], 38 | "no-unexpected-multiline": [1], 39 | "no-unused-vars": [1], 40 | "one-var": [2, "never"], 41 | "semi": [2, "always"] 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /test/util/build-action-and-send-request.util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Module dependencies 3 | */ 4 | 5 | var asAction = require('../..'); 6 | 7 | 8 | 9 | /** 10 | * [buildActionAndSendRequest description] 11 | * @param {SailsApp} app 12 | * @param {Dictionary?} opts 13 | * @param {Function} testResponseFn 14 | */ 15 | module.exports = function buildActionAndSendRequest(app, opts, testResponseFn) { 16 | 17 | // Default to reasonable test options to simplify authoring 18 | // when these things don't actually matter for the test at hand. 19 | opts._testOpts = opts._testOpts || { 20 | 21 | // The route address to bind. 22 | routeAddress: 'GET /', 23 | 24 | // Request options for the sample request that will be sent. 25 | method: 'GET', 26 | path: '/', 27 | params: {} 28 | }; 29 | 30 | // Freak out if `_testOpts` was passed in, but `routeAddress`, `method`, or `path` are unspecified. 31 | if (!opts._testOpts.routeAddress) { 32 | return testResponseFn(new Error('Bad test: If specifying `_testOpts`, then `_testOpts.routeAddress` must be specified.')); 33 | } 34 | if (!opts._testOpts.method) { 35 | return testResponseFn(new Error('Bad test: If specifying `_testOpts`, then `_testOpts.method` must be specified.')); 36 | } 37 | if (!opts._testOpts.path) { 38 | return testResponseFn(new Error('Bad test: If specifying `_testOpts`, then `_testOpts.path` must be specified.')); 39 | } 40 | 41 | // If unspecified, use `{}`. 42 | if (!opts._testOpts.params) { 43 | opts._testOpts.params = {}; 44 | } 45 | 46 | // Dump out the router and configure the new route. 47 | var newRoutesMapping = {}; 48 | newRoutesMapping[opts._testOpts.routeAddress] = { 49 | fn: asAction(opts), 50 | skipAssets: false 51 | }; 52 | app.router.flush(newRoutesMapping); 53 | 54 | // ¬ Should now be able to hit route w/ an appropriate request. 55 | app.request(opts._testOpts.method + ' ' + opts._testOpts.path, opts._testOpts.params, function(err, clientRes, body) { 56 | if (err) { 57 | return testResponseFn(err); 58 | } 59 | 60 | return testResponseFn(undefined, clientRes, body); 61 | 62 | }); 63 | 64 | }; 65 | -------------------------------------------------------------------------------- /lib/private/get-output-example.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Module dependencies 3 | */ 4 | 5 | var _ = require('@sailshq/lodash'); 6 | var rttc = require('rttc'); 7 | 8 | 9 | /** 10 | * getOutputExample() 11 | * 12 | * Look up the output example for the specified exit. 13 | * If the exit does not exist, this returns undefined. 14 | * The primary job of this helper is to normalize between `outputExample` & `example` 15 | * (for backwards compatibility) 16 | * 17 | * @required 18 | * {Dictionary} machineDef 19 | * Compact node machine definition. 20 | * -AND- 21 | * {Dictionary} exitCodeName 22 | * The code name of an exit. 23 | * 24 | * •--OR--• 25 | * {Dictionary} exitDef 26 | * The exit definition from a compact node machine. 27 | * 28 | * ------------------------------------------------------------------------------------------ 29 | * @returns {~Exemplar} 30 | * The supposed RTTC exemplar representing the output example for this exit. 31 | */ 32 | module.exports = function getOutputExample(options) { 33 | if (!_.isObject(options)) { 34 | throw new Error('Consistency violation: Options must be specified as a dictionary.'); 35 | } 36 | 37 | // Support both usages: 38 | var exitDef; 39 | if (_.isUndefined(options.exitDef)) { 40 | if (!_.isObject(options.machineDef)) { 41 | throw new Error('Consistency violation: `machineDef` should be a compact Node Machine definition (a dictionary).'); 42 | } 43 | if (!_.isString(options.exitCodeName)) { 44 | throw new Error('Consistency violation: `exitCodeName` should be specified as a string.'); 45 | } 46 | if (!_.isObject(options.machineDef.exits)) { 47 | throw new Error('Consistency violation: `machineDef` should have `exits`, specified as a dictionary of exit definitions.'); 48 | } 49 | exitDef = options.machineDef.exits[options.exitCodeName]; 50 | } else { 51 | if (!_.isObject(options.exitDef)) { 52 | throw new Error('Consistency violation: `exitDef` should be provided as a compact exit definition (a dictionary).'); 53 | } 54 | exitDef = options.exitDef; 55 | } 56 | 57 | // Look up the output example: 58 | if (_.isUndefined(exitDef)) { 59 | return undefined; 60 | } else if (!_.isObject(exitDef)) { 61 | throw new Error('Consistency violation: The specified exit (`' + options.exitCodeName + '`) is not a valid exit definition (should be a dictionary).'); 62 | } else if (!_.isUndefined(exitDef.like)) { 63 | throw new Error('Consistency violation: The specified exit (`' + options.exitCodeName + '`) cannot be used in machine-as-action (`like`, `itemOf`, and `getExample` are not currently supported).'); 64 | } else if (!_.isUndefined(exitDef.itemOf)) { 65 | throw new Error('Consistency violation: The specified exit (`' + options.exitCodeName + '`) cannot be used in machine-as-action (`like`, `itemOf`, and `getExample` are not currently supported).'); 66 | } else if (!_.isUndefined(exitDef.getExample)) { 67 | throw new Error('Consistency violation: The specified exit (`' + options.exitCodeName + '`) cannot be used in machine-as-action (`like`, `itemOf`, and `getExample` are not currently supported).'); 68 | } else if (!_.isUndefined(exitDef.outputExample)) { 69 | return exitDef.outputExample; 70 | } else if (!_.isUndefined(exitDef.outputType)) { 71 | return rttc.getDefaultExemplar(exitDef.outputType); 72 | } else { 73 | return undefined; 74 | } 75 | }; 76 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | // ┬┌─┐╦ ╦╦╔╗╔╔╦╗┬─┐┌─┐ 3 | // │└─┐╠═╣║║║║ ║ ├┬┘│ 4 | // o└┘└─┘╩ ╩╩╝╚╝ ╩ ┴└─└─┘ 5 | // 6 | // This file (`.jshintrc`) exists to help with consistency of code 7 | // throughout this package, and throughout Sails and the Node-Machine project. 8 | // 9 | // To review what each of these options mean, see: 10 | // http://jshint.com/docs/options 11 | // 12 | // (or: https://github.com/jshint/jshint/blob/master/examples/.jshintrc) 13 | 14 | 15 | 16 | ////////////////////////////////////////////////////////////////////// 17 | // NOT SUPPORTED IN SOME JSHINT VERSIONS SO LEAVING COMMENTED OUT: 18 | ////////////////////////////////////////////////////////////////////// 19 | // Prevent overwriting prototypes of native classes like `Array`. 20 | // (doing this is _never_ ok in any of our packages that are intended 21 | // to be used as dependencies of other developers' modules and apps) 22 | // "freeze": true, 23 | ////////////////////////////////////////////////////////////////////// 24 | 25 | 26 | ////////////////////////////////////////////////////////////////////// 27 | // EVERYTHING ELSE: 28 | ////////////////////////////////////////////////////////////////////// 29 | 30 | // Allow the use of ES6 features. 31 | // (re ES7, see https://github.com/jshint/jshint/issues/2297) 32 | "esversion": 6, 33 | 34 | // Allow the use of `eval` and `new Function()` 35 | // (we sometimes actually need to use these things) 36 | "evil": true, 37 | 38 | // Tolerate funny-looking dashes in RegExp literals. 39 | // (see https://github.com/jshint/jshint/issues/159#issue-903547) 40 | "regexdash": true, 41 | 42 | // The potential runtime "Environments" (as defined by jshint) 43 | // that the _style_ of code written in this package should be 44 | // compatible with (not the code itself, of course). 45 | "browser": true, 46 | "node": true, 47 | "wsh": true, 48 | 49 | // Tolerate the use `[]` notation when dot notation would be possible. 50 | // (this is sometimes preferable for readability) 51 | "sub": true, 52 | 53 | // Do NOT suppress warnings about mixed tabs and spaces 54 | // (two spaces always, please; see `.editorconfig`) 55 | "smarttabs": false, 56 | 57 | // Suppress warnings about trailing whitespace 58 | // (this is already enforced by the .editorconfig, so no need to warn as well) 59 | "trailing": false, 60 | 61 | // Suppress warnings about the use of expressions where fn calls or assignments 62 | // are expected, and about using assignments where conditionals are expected. 63 | // (while generally a good idea, without this setting, JSHint needlessly lights up warnings 64 | // in existing, working code that really shouldn't be tampered with. Pandora's box and all.) 65 | "expr": true, 66 | "boss": true, 67 | 68 | // Do NOT suppress warnings about using functions inside loops 69 | // (in the general case, we should be using iteratee functions with `_.each()` 70 | // or `Array.prototype.forEach()` instead of `for` or `while` statements 71 | // anyway. This warning serves as a helpful reminder.) 72 | "loopfunc": false, 73 | 74 | // Suppress warnings about "weird constructions" 75 | // i.e. allow code like: 76 | // ``` 77 | // (new (function OneTimeUsePrototype () { } )) 78 | // ``` 79 | // 80 | // (sometimes order of operations in JavaScript can be scary. There is 81 | // nothing wrong with using an extra set of parantheses when the mood 82 | // strikes or you get "that special feeling".) 83 | "supernew": true, 84 | 85 | // Do NOT allow backwards, node-dependency-style commas. 86 | // (while this code style choice was used by the project in the past, 87 | // we have since standardized these practices to make code easier to 88 | // read, albeit a bit less exciting) 89 | "laxcomma": false, 90 | 91 | // Do NOT allow avant garde use of commas in conditional statements. 92 | // (this prevents accidentally writing code like: 93 | // ``` 94 | // if (!_.contains(['+ci', '-ci', '∆ci', '+ce', '-ce', '∆ce']), change.verb) {...} 95 | // ``` 96 | // See the problem in that code? Neither did we-- that's the problem!) 97 | "nocomma": true, 98 | 99 | // Strictly enforce the consistent use of single quotes. 100 | // (this is a convention that was established primarily to make it easier 101 | // to grep [or FIND+REPLACE in Sublime] particular string literals in 102 | // JavaScript [.js] files. Note that JSON [.json] files are, of course, 103 | // still written exclusively using double quotes around key names and 104 | // around string literals.) 105 | "quotmark": "single", 106 | 107 | // Do NOT suppress warnings about the use of `==null` comparisons. 108 | // (please be explicit-- use Lodash or `require('util')` and call 109 | // either `.isNull()` or `.isUndefined()`) 110 | "eqnull": false, 111 | 112 | // Strictly enforce the use of curly braces with `if`, `else`, and `switch` 113 | // as well as, much less commonly, `for` and `while` statements. 114 | // (this is just so that all of our code is consistent, and to avoid bugs) 115 | "curly": true, 116 | 117 | // Strictly enforce the use of `===` and `!==`. 118 | // (this is always a good idea. Check out "Truth, Equality, and JavaScript" 119 | // by Angus Croll [the author of "If Hemmingway Wrote JavaScript"] for more 120 | // explanation as to why.) 121 | "eqeqeq": true, 122 | 123 | // Allow initializing variables to `undefined`. 124 | // For more information, see: 125 | // • https://jslinterrors.com/it-is-not-necessary-to-initialize-a-to-undefined 126 | // • https://github.com/jshint/jshint/issues/1484 127 | // 128 | // (it is often very helpful to explicitly clarify the initial value of 129 | // a local variable-- especially for folks new to more advanced JavaScript 130 | // and who might not recognize the subtle, yet critically important differences between our seemingly 131 | // between `null` and `undefined`, and the impact on `typeof` checks) 132 | "-W080": true 133 | 134 | } 135 | -------------------------------------------------------------------------------- /lib/private/normalize-responses.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Module dependencies 3 | */ 4 | 5 | var util = require('util'); 6 | var _ = require('@sailshq/lodash'); 7 | var flaverr = require('flaverr'); 8 | var getOutputExample = require('./get-output-example'); 9 | 10 | 11 | /** 12 | * Merge the provided `responses` metadata with the exits from the machine definition, 13 | * sanitize and validate the results, then return the normalized `responses` dictionary. 14 | * 15 | * @param {Dictionary} configuredResponses 16 | * @param {Dictionary} exits 17 | * @return {Dictionary} [normalized response metadata for each exit] 18 | * 19 | * NOTE THAT THIS FUNCTION MUTATES BOTH THE PROVIDED `configuredResponses` AND THE PROVIDED `exits`! 20 | * 21 | * @throws {Error} If exit/response metadata is invalid or if machine-as-action doesn't know how to handle it 22 | * @property {String} code (===E_INVALID_RES_METADATA_IN_EXIT_DEF) 23 | */ 24 | module.exports = function normalizeResponses(configuredResponses, exits) { 25 | 26 | // Note that we extend success and error exits here so that they will always exist 27 | // when this custom response metadata is being built. This only runs once when initially 28 | // building the action. 29 | exits = _.extend({ 30 | success: { 31 | description: 'Done.' 32 | }, 33 | error: { 34 | description: 'Unexpected error occurred.' 35 | } 36 | }, exits); 37 | 38 | 39 | // Return normalized exit definitions. 40 | return _.reduce(exits, function(memo, exitDef, exitCodeName) { 41 | 42 | // If a response def exists, merge its properties into the exit definition. 43 | if (configuredResponses[exitCodeName]) { 44 | _.extend(exitDef, configuredResponses[exitCodeName]); 45 | } 46 | 47 | // Backwards compatibility: 48 | // 49 | // • `view` (=> `viewTemplatePath`) 50 | if (!_.isUndefined(exitDef.view)) { 51 | console.warn('Deprecated: `view` is no longer supported by `machine-as-action`. Instead, use `viewTemplatePath`. (Automatically migrating for you this time.)'); 52 | exitDef.viewTemplatePath = exitDef.view; 53 | } 54 | // • `viewPath` (=> `viewTemplatePath`) 55 | else if (!_.isUndefined(exitDef.viewPath)) { 56 | console.warn('Deprecated: `viewPath` is no longer supported by `machine-as-action`. Instead, use `viewTemplatePath`. (Automatically migrating for you this time.)'); 57 | exitDef.viewTemplatePath = exitDef.viewPath; 58 | } 59 | 60 | 61 | // If response metadata was explicitly defined, use it. 62 | // (also validate each property on the way in to ensure it is valid) 63 | // ================================================================================================ 64 | 65 | // Response type (`responseType`) 66 | if (!_.isUndefined(exitDef.responseType)) { 67 | 68 | // Allow any response type, but make sure it's a string at least. 69 | if (!_.isString(exitDef.responseType)) { 70 | throw flaverr( 71 | 'E_INVALID_RES_METADATA_IN_EXIT_DEF', 72 | new Error(util.format('`machine-as-action` doesn\'t know how to handle the response type ("%s") specified for exit "%s". (Should be either omitted, or specified as a string.)', exitDef.responseType, exitCodeName)) 73 | ); 74 | } //-• 75 | 76 | } //>-• 77 | 78 | // Status code (`statusCode`) 79 | if (!_.isUndefined(exitDef.statusCode)) { 80 | 81 | // Ensure it's a number. 82 | exitDef.statusCode = +exitDef.statusCode; 83 | if (!_.isNumber(exitDef.statusCode) || _.isNaN(exitDef.statusCode) || exitDef.statusCode < 100 || exitDef.statusCode > 599) { 84 | throw flaverr( 85 | 'E_INVALID_RES_METADATA_IN_EXIT_DEF', 86 | new Error(util.format('`machine-as-action` doesn\'t know how to handle the status code ("%s") specified for exit "%s". To have this exit infer an appropriate default status code, just omit the `statusCode` property.', exitDef.statusCode, exitCodeName)) 87 | ); 88 | } 89 | 90 | } //>-• 91 | 92 | // View path (`viewTemplatePath`) 93 | if (!_.isUndefined(exitDef.viewTemplatePath)) { 94 | if (exitDef.viewTemplatePath === '' || !_.isString(exitDef.viewTemplatePath)) { 95 | throw flaverr( 96 | 'E_INVALID_RES_METADATA_IN_EXIT_DEF', 97 | new Error(util.format('`machine-as-action` doesn\'t know how to handle the view template path ("' + exitDef.viewTemplatePath + '") specified as the `viewTemplatePath` for exit "' + exitCodeName + '". This should be the relative path to a view file from the `views/` directory, minus the extension (`.ejs`).')) 98 | ); 99 | } 100 | } //>-• 101 | 102 | 103 | 104 | // Then set any remaining unspecified stuff to reasonable defaults. 105 | // (note that the code below makes decisions about how to respond based on the 106 | // static exit definition, not the runtime output value.) 107 | // ============================================================================================================== 108 | 109 | 110 | // If `responseType` is not set, we assume it must be "" (empty string / standard), UNLESS: 111 | // • if a `viewTemplatePath` was provided, in which case we assume it must be `view` 112 | // • or otherwise if this is the error exit, in which case we assume it must be `error` 113 | if (_.isUndefined(exitDef.responseType)) { 114 | 115 | if (exitDef.viewTemplatePath) { 116 | exitDef.responseType = 'view'; 117 | } else if (exitCodeName === 'error') { 118 | exitDef.responseType = 'error'; 119 | } else { 120 | exitDef.responseType = ''; // ("" <=> standard) 121 | } 122 | 123 | } //>- 124 | 125 | 126 | // Infer appropriate status code: 127 | // 128 | // If status code was not explicitly specified, infer an appropriate code based on the response type and/or exitCodeName. 129 | if (!exitDef.statusCode) { 130 | // First, if this is the error exit or this response is using the "error" response type: 131 | // `500` (response type: error) -OR- (error exit-- should always be response type: 'error' anyway, this is just a failsafe) 132 | if (exitDef.responseType === 'error' || exitCodeName === 'error') { 133 | exitDef.statusCode = 500; 134 | } 135 | // Otherwise, if this is a redirect: 136 | // `302` (redirect) 137 | else if (exitDef.responseType === 'redirect') { 138 | exitDef.statusCode = 302; 139 | } 140 | // Otherwise, if this is the success exit: 141 | // `200` (success exit) 142 | else if (exitCodeName === 'success') { 143 | exitDef.statusCode = 200; 144 | } 145 | // Otherwise, if this a view, always use the 200 status code by default. 146 | // `200` (view) 147 | else if (exitDef.responseType === 'view') { 148 | exitDef.statusCode = 200; 149 | } 150 | // Otherwise... well, this must be some other exit besides success and error 151 | // and it must not be doing a redirect, so use: 152 | // `500` (misc) 153 | else { 154 | exitDef.statusCode = 500; 155 | } 156 | } //>- 157 | 158 | // Look up the output example for this exit. 159 | var outputExample = getOutputExample({ 160 | exitDef: exitDef 161 | }); 162 | 163 | // Ensure response type is compatible with exit definition 164 | if (exitDef.responseType === 'redirect') { 165 | // Note that we tolerate the absense of an outputExample, since a redirect is assumed to always be a string. 166 | if (!_.isUndefined(outputExample) && !_.isString(outputExample)) { 167 | throw flaverr( 168 | 'E_INVALID_RES_METADATA_IN_EXIT_DEF', 169 | new Error(util.format('Cannot configure exit "%s" to redirect. The redirect URL is based on the return value from the exit, so the exit\'s `outputExample` must be a string. But instead, it is: ', exitCodeName, util.inspect(outputExample, false, null))) 170 | ); 171 | } //-• 172 | 173 | // If no outputExample was specified, modify the exit in-memory to make it a string. 174 | // This is criticial, otherwise the machine runner will convert the runtime output into an Error instance, 175 | // since it'll think the exit isn't expecting any output (note that we also set the `outputExample` local 176 | // variable, just for consistency.) 177 | if (_.isUndefined(outputExample)) { 178 | outputExample = '/some/other/place'; 179 | exitDef.outputExample = outputExample; 180 | // Currently, we have to set BOTH `outputExample` and `example`. 181 | // This will be normalized soon in a patch release of the machine runner, and at that 182 | // point, this line can be removed: 183 | // ------------------------------------------------ 184 | exitDef.example = outputExample; 185 | // ------------------------------------------------ 186 | } 187 | 188 | } else if (exitDef.responseType === 'view') { 189 | // Note that we tolerate `===` so that it can be used for performance reasons. 190 | // If no output example is provided, we treat it like `===`. 191 | if (!_.isUndefined(outputExample) && outputExample !== '===' && !_.isPlainObject(outputExample)) { 192 | throw flaverr( 193 | 'E_INVALID_RES_METADATA_IN_EXIT_DEF', 194 | new Error(util.format('Cannot configure exit "%s" to show a view. The return value from the exit is used as view locals (variables accessible inside the view HTML), so the exit\'s `outputExample` must be some sort of dictionary (`{}`). But instead, it\'s: ', exitCodeName, util.inspect(outputExample, false, null))) 195 | ); 196 | } 197 | } else if (exitDef.responseType === 'json') { 198 | // ** NOTE THAT THE `json` RESPONSE TYPE IS DEPRECATED ** 199 | if (!_.isUndefined(outputExample) && _.isUndefined(outputExample)) { 200 | throw flaverr( 201 | 'E_INVALID_RES_METADATA_IN_EXIT_DEF', 202 | new Error(util.format('Cannot configure exit "%s" to respond with JSON. The return value from the exit will be encoded as JSON, so something must be returned...but the exit\'s `outputExample` is undefined.', exitCodeName)) 203 | ); 204 | } 205 | } //>-• 206 | 207 | // Log warning if unnecessary stuff is provided (i.e. a `view` was provided along with responseType !== "view") 208 | if (exitDef.viewTemplatePath && exitDef.responseType !== 'view') { 209 | console.error('Warning: unnecessary `viewTemplatePath` (response metadata) provided for an exit which is not configured to respond with a view (actual responseType => "' + exitDef.responseType + '"). To resolve, set `responseType: \'view\'`.'); 210 | } //>- 211 | 212 | memo[exitCodeName] = exitDef; 213 | return memo; 214 | }, {}); 215 | 216 | }; 217 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # machine-as-action 2 | 3 | Build a modified version of a machine that proxies its inputs from request parameters, and proxies its exits through the response. 4 | 5 | ```sh 6 | $ npm install machine-as-action --save 7 | ``` 8 | 9 | 10 | ## Usage 11 | 12 | ```js 13 | var asAction = require('machine-as-action'); 14 | var OpenWeather = require('machinepack-openweather'); 15 | 16 | // WeatherController.js 17 | module.exports = { 18 | 19 | traditionalReqRes: function (req, res) { /* ... */ }, 20 | 21 | getLatest: asAction(OpenWeather.getCurrentConditions), 22 | 23 | doSomethingCustom: asAction({ 24 | exits: { 25 | success: { 26 | outputExample: 'Some dynamic message like this.' 27 | } 28 | }, 29 | fn: function (inputs, exits) { 30 | return exits.success('Hello world!'); 31 | } 32 | }), 33 | 34 | // etc... 35 | 36 | } 37 | ``` 38 | 39 | Now you can run your machine using a HTTP or Socket.io request: 40 | 41 | ```js 42 | // For example, using jQuery and an out-of-the-box Sails.js route/blueprint configuration: 43 | $.get('/weather/getLatest', { 44 | city: 'San Francisco' 45 | }, function (weatherData){ 46 | console.log(weatherData); 47 | }); 48 | ``` 49 | 50 | > Note that the machine definition you provide here doesn't have to come from an already-published machinepack-- it can be required locally from your project, or declared inline. 51 | 52 | 53 | 54 | #### Customizing the response 55 | 56 | So sending down data is great, but sometimes you need to render view templates, redirect to dynamic URLs, use a special status code, stream down a file, etc. No problem. You can customize the response from each exit using a number of additional, machine-as-action specific options. 57 | 58 | 59 | ```js 60 | var asAction = require('machine-as-action'); 61 | 62 | // WeatherController.js 63 | module.exports = { 64 | 65 | showHomepage: asAction({ 66 | 67 | 68 | exits: { 69 | 70 | success:{ 71 | responseType: 'view', 72 | viewTemplatePath: 'homepage' 73 | // The view will be provided with a "local" called `stuff`, 74 | } 75 | 76 | }, 77 | 78 | 79 | fn: function(inputs,exits){ 80 | return exits.success({ stuff: 'things' }); 81 | } 82 | 83 | 84 | }) 85 | }; 86 | ``` 87 | 88 | 89 | For each of your exits, you can optionally specify a `responseType`, `status`, and/or `view`. 90 | 91 | **responseType** is one of the following: 92 | + "" (the standard response: Determine an appropriate response based on context: this might send plain text, download a file, transmit data as JSON, or send no response body at all.) 93 | + "view" (render and respond with a view; exit output will be provided as view locals) 94 | + "redirect" (redirect to the URL returned as the exit output) 95 | 96 | 97 | 98 | **statusCode** is the status code to respond with. (This works just like [status codes in Sails/Node](http://sailsjs.org/documentation/reference/response-res/res-status)). 99 | 100 | **viewTemplatePath** is the relative path (from the `views/` directory) of the view to render. It is only relevant if `responseType` is set to "view". (This works just like [views in Sails/Express](http://sailsjs.org/documentation/concepts/views)). 101 | 102 | > If any of the above are not set explicitly, they will fall back to reasonable defaults (based on available information). 103 | > 104 | > For example, if a non-success exit is set up to serve a view, then it will use the 200 response code. 105 | > But if a non-success exit has no explicit response type configured (meaning it will respond with plain text, 106 | > JSON-encoded data, or with no data and just a status code), then machine-as-action will default to using 107 | > the 500 status code. Similarly, in the same same scenario, but with `responseType: 'redirect'`, the status 108 | > code will default to 302. The success exit always has a default status code of 200, unless it is also 109 | > `responseType: 'redirect'` (in which case it defaults to 302.) 110 | 111 | 112 | 113 | 114 | #### File uploads 115 | 116 | You can use the special `files` option to map a file parameter containing an incoming Skipper upstream to a machine input: 117 | 118 | 119 | ```js 120 | var asAction = require('machine-as-action'); 121 | 122 | // WeatherController.js 123 | module.exports = { 124 | 125 | 126 | uploadPhoto: asAction({ 127 | 128 | 129 | files: ['photo'] 130 | 131 | 132 | inputs: { 133 | 134 | photo: { 135 | example: '===', 136 | required: true 137 | } 138 | 139 | }, 140 | 141 | 142 | fn: function (inputs, exits){ 143 | inputs.photo.upload(function (err, uploadedFiles){ 144 | if (err) return exits.error(err); 145 | exits.success(); 146 | }); 147 | } 148 | 149 | 150 | }) 151 | 152 | 153 | }; 154 | ``` 155 | 156 | 157 | ## Available Options 158 | 159 | Aside from the [normal properties that go into a Node Machine definition](http://node-machine.org/spec), the following additional options are supported: 160 | 161 | | Option | Type | Description | 162 | |:---------------------------|-----------------|:-------------------------------------------------------| 163 | | `files` | ((array?)) | An array of input code names identifying inputs which expect to receive file uploads instead of text parameters. These file inputs must have `example: '==='`, but they needn't necessarily be `required`. 164 | | `urlWildcardSuffix` | ((string?)) | If this action is handling a route with a wildcard suffix (e.g. `/foo/bar/*`), then specify this option as the code name of the machine input which should receive the string at runtime (i.e. the actual value of the "*" in the request URL). 165 | | `disableDevelopmentHeaders`| ((boolean?)) | If set, then do not automatically set headers w/ exit info during development. 166 | | `disableXExitHeader` | ((boolean?)) | If set, then do not automatically send the `X-Exit` response header for any exit, regardless of whether this is a prod or dev environment. 167 | | `simulateLatency` | ((number?)) | If set, then simulate a latency of the specified number of milliseconds (e.g. 500) 168 | | `logDebugOutputFn` | ((function?)) | An optional override function to call when any output other than `undefined` is received from a void exit (i.e. an exit w/ no outputExample). By default, machine-as-action uses `sails.log.warn()` if available, or `console.warn()` otherwise. 169 | 170 | > ##### NOTE 171 | > 172 | > + For **more details** on any of these options, see https://github.com/treelinehq/machine-as-action/blob/02ae23ef1d052dfe7fa6139ac14516c83c12fe1b/index.js#L30. 173 | > + Any of the options above should be provided as **top-level properties** of the `options` dictionary. 174 | > + `machine-as-action` also supports **response directives** that can be provided as additional properties within nested exit definitions. They are `responseType`, `statusCode`, and `viewTemplatePath`. See examples above for more information. 175 | 176 | 177 | ## Extended example 178 | 179 | This is a more detailed example, based on the simple intro example at the top of this README. 180 | 181 | ```js 182 | var asAction = require('machine-as-action'); 183 | var OpenWeather = require('machinepack-openweather'); 184 | 185 | // WeatherController.js 186 | module.exports = { 187 | 188 | traditionalReqRes: function (req, res) { /* ... */ }, 189 | 190 | getLatest: asAction(OpenWeather.getCurrentConditions), 191 | 192 | doSomethingCustom: asAction({ 193 | description: 'Send a plaintext response.', 194 | exits: { 195 | success: { 196 | outputExample: 'Some dynamic message like this.' 197 | } 198 | }, 199 | fn: function (inputs, exits) { 200 | return exits.success('Hello world!'); 201 | } 202 | }), 203 | 204 | getForecastData: asAction({ 205 | description: 'Fetch data for the forecast with the specified id.', 206 | inputs: { 207 | id: { required: true, example: 325 } 208 | }, 209 | exits: { 210 | success: { 211 | outputExample: { 212 | weatherPerson: 'Joaquin', 213 | days: [ 214 | { tempCelsius: 21, windSpeedMph: 392 } 215 | ] 216 | } 217 | }, 218 | notFound: { 219 | description: 'Could not find forecast with that id.', 220 | statusCode: 404 221 | } 222 | }, 223 | fn: function (inputs, exits) { 224 | Forecast.find({ id: inputs.id }).exec(function (err, forecastRecord) { 225 | if (err) { return exits.error(err); } 226 | if (!forecastRecord) { return exits.notFound(); } 227 | return exits.success(forecastRecord); 228 | }); 229 | } 230 | }), 231 | 232 | show7DayForecast: asAction({ 233 | description: 'Show the current 7 day forecast page.', 234 | exits: { 235 | success: { 236 | responseType: 'view', 237 | viewTemplatePath: 'pages/weather/7-day-forecast' 238 | } 239 | }, 240 | fn: function (inputs, exits) { 241 | return exits.success('http://sailsjs.org'); 242 | } 243 | }), 244 | 245 | redirectToExternalForecastMaybe: asAction({ 246 | description: 'Redirect the requesting user agent to http://weather.com, or to http://omfgdogs.com.', 247 | exits: { 248 | success: { responseType: 'redirect' } 249 | }, 250 | fn: function (inputs, exits) { 251 | if (Math.random() > 0.5) { 252 | return exits.success('http://weather.com'); 253 | } 254 | else { 255 | return exits.success('http://omfgdogs.com'); 256 | } 257 | } 258 | }) 259 | 260 | }; 261 | 262 | ``` 263 | 264 | 265 | 266 | ## Bugs   [![NPM version](https://badge.fury.io/js/machine-as-action.svg)](http://npmjs.com/package/machine-as-action) 267 | 268 | To report a bug, [click here](http://sailsjs.com/bugs). 269 | 270 | 271 | ## Contributing   [![Build Status](https://travis-ci.org/treelinehq/machine-as-action.svg?branch=master)](https://travis-ci.org/treelinehq/machine-as-action) 272 | 273 | Please observe the guidelines and conventions laid out in the [Sails project contribution guide](http://sailsjs.com/documentation/contributing) when opening issues or submitting pull requests. 274 | 275 | [![NPM](https://nodei.co/npm/machine-as-action.png?downloads=true)](http://npmjs.com/package/machine-as-action) 276 | 277 | 278 | ## License 279 | 280 | MIT © 2015-2016 Mike McNeil 281 | 282 | _Incorporated as a core part of the Sails framework in 2016._ 283 | 284 | The [Sails framework](http://sailsjs.com) is free and open-source under the [MIT License](http://sailsjs.com/license). 285 | -------------------------------------------------------------------------------- /test/sanity.test.js: -------------------------------------------------------------------------------- 1 | var util = require('util'); 2 | var assert = require('assert'); 3 | var testRoute = require('./util/test-route.util'); 4 | 5 | 6 | testRoute('sanity check (ridiculously simplistic usage should work)', { 7 | machine: { 8 | inputs: {}, 9 | exits: {}, 10 | fn: function(inputs, exits) { 11 | return exits.success(); 12 | } 13 | }, 14 | }, function(err, resp, body, done) { 15 | if (err) { 16 | return done(err); 17 | } 18 | return done(); 19 | }); 20 | 21 | 22 | 23 | testRoute('should not JSON-encode top-level strings in output', { 24 | machine: { 25 | inputs: {}, 26 | exits: { 27 | success: { 28 | outputExample: '*' 29 | } 30 | }, 31 | fn: function(inputs, exits) { 32 | return exits.success('hello world'); 33 | } 34 | }, 35 | }, function(err, resp, body, done) { 36 | if (err) { 37 | return done(err); 38 | } 39 | assert.equal(body, 'hello world'); 40 | // ^^ i.e. 'hello world', not '"hello world"' 41 | return done(); 42 | }); 43 | 44 | 45 | 46 | testRoute('should be able to access `env.req` and `env.res`', { 47 | _testOpts: { 48 | routeAddress: 'POST /something', 49 | method: 'POST', 50 | path: '/something' 51 | }, 52 | machine: { 53 | inputs: {}, 54 | exits: {}, 55 | fn: function(inputs, exits, env) { 56 | if (!env.req || !env.res || env.req.method !== 'POST') { 57 | return exits.error(); 58 | } 59 | env.res.set('x-test', 'itworked'); 60 | return exits.success(); 61 | } 62 | }, 63 | }, function(err, resp, body, done) { 64 | if (err) { 65 | return done(err); 66 | } 67 | if (resp.headers['x-test'] !== 'itworked') { 68 | return done(new Error('Machine should have been able to set response header (`x-test`) to "itworked"!')); 69 | } 70 | return done(); 71 | }); 72 | 73 | 74 | 75 | testRoute('if exit def + compatible output example is specified, actual result should be sent as the response body (i.e. responseType==="json")', { 76 | machine: { 77 | inputs: {}, 78 | exits: { 79 | success: { 80 | outputExample: 'some string' 81 | } 82 | }, 83 | fn: function(inputs, exits) { 84 | return exits.success('hello world!'); 85 | } 86 | } 87 | }, function(err, resp, body, done) { 88 | if (err) { 89 | return done(err); 90 | } 91 | if (body !== 'hello world!') { 92 | return done(new Error('should have gotten "hello world!" as the response body, but instead got: ' + util.inspect(body))); 93 | } 94 | return done(); 95 | }); 96 | 97 | 98 | testRoute('if input def + compatible input examples are specified, parameters should be provided as inputs', { 99 | machine: { 100 | inputs: { 101 | x: { 102 | example: 'hi', 103 | required: true 104 | } 105 | }, 106 | exits: { 107 | success: { 108 | outputExample: 'some string' 109 | } 110 | }, 111 | fn: function(inputs, exits) { 112 | return exits.success(inputs.x); 113 | } 114 | }, 115 | _testOpts: { 116 | routeAddress: 'GET /something', 117 | method: 'GET', 118 | path: '/something', 119 | params: { 120 | x: 'hello world!' 121 | } 122 | }, 123 | }, function(err, resp, body, done) { 124 | if (err) { 125 | return done(err); 126 | } 127 | if (body !== 'hello world!') { 128 | return done(new Error('should have gotten "hello world!" as the response body, but instead got: ' + util.inspect(body))); 129 | } 130 | return done(); 131 | }); 132 | 133 | 134 | 135 | testRoute('ignore extra parameters', { 136 | machine: { 137 | inputs: { 138 | x: { 139 | example: 'hi', 140 | required: true 141 | } 142 | }, 143 | exits: { 144 | success: { 145 | outputExample: 'some string' 146 | } 147 | }, 148 | fn: function(inputs, exits) { 149 | return exits.success(inputs.y); 150 | } 151 | }, 152 | _testOpts: { 153 | routeAddress: 'GET /something', 154 | method: 'GET', 155 | path: '/something', 156 | params: { 157 | x: 'some value for x', 158 | y: 'some value for y' 159 | } 160 | }, 161 | }, function(err, resp, body, done) { 162 | if (err) { 163 | return done(err); 164 | } 165 | 166 | // NOTE: Here we'd assume that the `undefined` passed in to exits.success() inside of `fn` would have 167 | // automatically been coerced into empty string (''). Then, here, we'd actually expect it never to have been 168 | // treated that way (and instead just have been left as undefined, from the perspective of our request client). 169 | // This is only because '' is interpeted as `undefined` in the streaming logic inside the VRI/`sails.request()`. 170 | // Otherwise, we'd have expected to get empty string ('') here instead. 171 | // Here's that approach: 172 | // ``` 173 | if (body !== 'OK') { 174 | return done(new Error('should have gotten `OK` as the response body, but instead got: ' + util.inspect(body))); 175 | } 176 | 177 | return done(); 178 | }); 179 | 180 | 181 | 182 | testRoute('optional inputs should show up as `undefined` when parameter val is not provided', { 183 | machine: { 184 | inputs: { 185 | x: { 186 | example: 'hi' 187 | } 188 | }, 189 | exits: { 190 | success: { 191 | outputExample: 'some string' 192 | } 193 | }, 194 | fn: function(inputs, exits) { 195 | if (inputs.x !== undefined) { 196 | return exits.error(); 197 | } 198 | return exits.success(); 199 | } 200 | } 201 | }, function(err, resp, body, done) { 202 | if (err) { 203 | return done(err); 204 | } 205 | return done(); 206 | }); 207 | 208 | 209 | 210 | testRoute('when no param val is specified for required input, should respond w/ bad request error', { 211 | machine: { 212 | inputs: { 213 | x: { 214 | example: 'hi', 215 | required: true 216 | } 217 | }, 218 | exits: { 219 | success: { 220 | outputExample: 'some string' 221 | } 222 | }, 223 | fn: function (inputs, exits) { 224 | return exits.success(); 225 | } 226 | }, 227 | }, function (err, resp, body, done){ 228 | if (err) { 229 | if (err.status !== 400) { 230 | return done(new Error('Should have responded with a 400 status code (instead got '+err.status+')')); 231 | } 232 | return done(); 233 | } 234 | return done(new Error('Should have responded with a bad request error! Instead got status code 200.')); 235 | }); 236 | 237 | 238 | testRoute('when param val of incorrect type is specified, should respond w/ bad request error', { 239 | machine: { 240 | inputs: { 241 | x: { 242 | example: 'hi' 243 | } 244 | }, 245 | exits: { 246 | success: { 247 | outputExample: 'some string' 248 | } 249 | }, 250 | fn: function (inputs, exits) { 251 | return exits.success(); 252 | } 253 | }, 254 | _testOpts: { 255 | routeAddress: 'GET /something', 256 | method: 'GET', 257 | path: '/something', 258 | params: { 259 | x: { 260 | foo: [[4]] 261 | } 262 | } 263 | }, 264 | }, function (err, resp, body, done){ 265 | if (err) { 266 | if (err.status !== 400) { 267 | return done(new Error('Should have responded with a 400 status code (instead got '+err.status+')')); 268 | } 269 | return done(); 270 | } 271 | return done(new Error('Should have responded with a bad request error! Instead got status code 200.')); 272 | }); 273 | 274 | 275 | testRoute('when param val of incorrect type is specified, should respond w/ bad request error', { 276 | machine: { 277 | inputs: { 278 | x: { 279 | example: 'hi', 280 | required: true 281 | } 282 | }, 283 | exits: { 284 | success: { 285 | outputExample: 'some string' 286 | } 287 | }, 288 | fn: function (inputs, exits) { 289 | return exits.success(); 290 | } 291 | }, 292 | _testOpts: { 293 | routeAddress: 'GET /something', 294 | method: 'GET', 295 | path: '/something', 296 | params: { 297 | x: [4, 3] 298 | } 299 | }, 300 | }, function (err, resp, body, done){ 301 | if (err) { 302 | if (err.status !== 400) { 303 | return done(new Error('Should have responded with a 400 status code (instead got '+err.status+')')); 304 | } 305 | return done(); 306 | } 307 | return done(new Error('Should have responded with a bad request error! Instead got status code 200.')); 308 | }); 309 | 310 | 311 | 312 | testRoute('customizing success exit to use a special status code in the response should work', { 313 | machine: { 314 | inputs: {}, 315 | exits: { 316 | success: { 317 | outputExample: 'some string' 318 | } 319 | }, 320 | fn: function(inputs, exits) { 321 | return exits.success(); 322 | } 323 | }, 324 | responses: { 325 | success: { 326 | responseType: '', 327 | statusCode: 201 328 | } 329 | } 330 | }, function(err, resp, body, done) { 331 | if (err) { 332 | return done(err); 333 | } 334 | if (resp.statusCode !== 201) { 335 | return done(new Error('Should have responded with a 201 status code (instead got ' + resp.statusCode + ')')); 336 | } 337 | assert.equal(body, 'Created'); 338 | return done(); 339 | }); 340 | 341 | 342 | 343 | testRoute('customizing success exit to do a redirect should work', { 344 | machine: { 345 | inputs: {}, 346 | exits: { 347 | success: { 348 | outputExample: 'some string' 349 | } 350 | }, 351 | fn: function(inputs, exits) { 352 | return exits.success('http://google.com'); 353 | } 354 | }, 355 | responses: { 356 | success: { 357 | responseType: 'redirect', 358 | outputExample: 'http://whatever.com', 359 | statusCode: 301 360 | } 361 | } 362 | }, function(err, resp, body, done) { 363 | if (err) { 364 | return done(err); 365 | } 366 | if (resp.statusCode !== 301) { 367 | return done(new Error('Should have responded with a 301 status code (instead got ' + resp.statusCode + ')')); 368 | } 369 | if (resp.headers.location !== 'http://google.com') { 370 | return done(new Error('Should have sent the appropriate "Location" response header')); 371 | } 372 | return done(); 373 | }); 374 | 375 | 376 | 377 | testRoute('redirecting should work, even without specifying a status code or output example', { 378 | machine: { 379 | inputs: {}, 380 | exits: { 381 | success: { 382 | outputExample: 'some string' 383 | } 384 | }, 385 | fn: function(inputs, exits) { 386 | return exits.success('/foo/bar'); 387 | } 388 | }, 389 | responses: { 390 | success: { 391 | responseType: 'redirect' 392 | } 393 | } 394 | }, function(err, resp, body, done) { 395 | if (err) { 396 | return done(err); 397 | } 398 | if (resp.statusCode !== 302) { 399 | return done(new Error('Should have responded with a 302 status code (instead got ' + resp.statusCode + ')')); 400 | } 401 | if (resp.headers.location !== '/foo/bar') { 402 | return done(new Error('Should have sent the appropriate "Location" response header')); 403 | } 404 | return done(); 405 | }); 406 | 407 | 408 | 409 | testRoute('customizing success exit to do JSON should work', { 410 | machine: { 411 | inputs: {}, 412 | exits: { 413 | success: { 414 | outputExample: 'some string' 415 | } 416 | }, 417 | fn: function(inputs, exits) { 418 | return exits.success('some output value'); 419 | } 420 | }, 421 | responses: { 422 | success: { 423 | responseType: '' 424 | } 425 | } 426 | }, function(err, resp, body, done) { 427 | if (err) { 428 | return done(err); 429 | } 430 | if (resp.statusCode !== 200) { 431 | return done(new Error('Should have responded with a 200 status code (instead got ' + resp.statusCode + ')')); 432 | } 433 | if (body !== 'some output value') { 434 | return done(new Error('Should have sent the appropriate response body')); 435 | } 436 | return done(); 437 | }); 438 | 439 | 440 | 441 | testRoute('exits other than success should default to status code 500', { 442 | machine: { 443 | inputs: {}, 444 | exits: { 445 | success: { 446 | outputExample: 'some string' 447 | }, 448 | whatever: {} 449 | }, 450 | fn: function(inputs, exits) { 451 | return exits.whatever('some output value'); 452 | } 453 | }, 454 | responses: { 455 | success: { 456 | responseType: '' 457 | } 458 | } 459 | }, function(err, resp, body, done) { 460 | if (err) { 461 | if (err.status !== 500) { 462 | return done(new Error('Should have responded with status code 500 (but instead got status code ' + err.status + ')')); 463 | } 464 | return done(); 465 | } 466 | return done(new Error('Should have responded with status code 500 (but instead got status code 200)')); 467 | }); 468 | 469 | 470 | 471 | testRoute('exits other than success can have their status codes overriden too', { 472 | machine: { 473 | inputs: {}, 474 | exits: { 475 | success: { 476 | outputExample: 'some string' 477 | }, 478 | whatever: {} 479 | }, 480 | fn: function(inputs, exits) { 481 | return exits.whatever('some output value'); 482 | } 483 | }, 484 | responses: { 485 | success: { 486 | responseType: '' 487 | }, 488 | whatever: { 489 | responseType: '', 490 | statusCode: 204 491 | } 492 | } 493 | }, function(err, resp, body, done) { 494 | if (err) { 495 | console.log(err.status); 496 | return done(err); 497 | } 498 | if (resp.statusCode !== 204) { 499 | return done(new Error('Should have responded with status code 204 (but instead got status code ' + resp.statusCode + ')')); 500 | } 501 | return done(); 502 | }); 503 | 504 | 505 | 506 | testRoute('ceteris paribus, overriding status code should change response type inference for non-default exit (e.g. status==203 sets unspecified response type to `status` or `json`)', { 507 | machine: { 508 | inputs: {}, 509 | exits: { 510 | success: { 511 | outputExample: 'some string' 512 | }, 513 | whatever: {} 514 | }, 515 | fn: function(inputs, exits) { 516 | return exits.whatever('some output value'); 517 | } 518 | }, 519 | responses: { 520 | success: { 521 | responseType: '' 522 | }, 523 | whatever: { 524 | statusCode: 204 525 | } 526 | } 527 | }, function(err, resp, body, done) { 528 | if (err) { 529 | return done(new Error('Should have responded with status code 204-- instead got ' + err.status)); 530 | } 531 | if (resp.statusCode !== 204) { 532 | return done(new Error('Should have responded with status code 204 (but instead got status code ' + resp.statusCode + ')')); 533 | } 534 | return done(); 535 | }); 536 | 537 | 538 | 539 | testRoute('ceteris paribus, overriding status code should change response type inference for default exit (i.e. status==503 sets unspecified response type to `error`)', { 540 | machine: { 541 | inputs: {}, 542 | exits: { 543 | success: { 544 | outputExample: 'some string' 545 | }, 546 | whatever: {} 547 | }, 548 | fn: function(inputs, exits) { 549 | return exits.success('some output value'); 550 | } 551 | }, 552 | responses: { 553 | success: { 554 | statusCode: 503 555 | }, 556 | whatever: {} 557 | } 558 | }, function(err, resp, body, done) { 559 | if (err) { 560 | if (err.status !== 503) { 561 | return done(new Error('Should have responded with status code 503-- instead got ' + err.status)); 562 | } 563 | return done(); 564 | } 565 | return done(new Error('Should have responded with status code 503 (but instead got status code ' + resp.statusCode + ')')); 566 | }); 567 | 568 | 569 | 570 | testRoute('`redirect` with custom status code', { 571 | machine: { 572 | inputs: {}, 573 | exits: { 574 | success: { 575 | outputExample: 'some string' 576 | }, 577 | whatever: {} 578 | }, 579 | fn: function(inputs, exits) { 580 | return exits.success('http://google.com'); 581 | } 582 | }, 583 | responses: { 584 | success: { 585 | statusCode: 301, 586 | responseType: 'redirect' 587 | }, 588 | whatever: {} 589 | } 590 | }, function(err, resp, body, done) { 591 | if (err) { 592 | return done(err); 593 | } 594 | if (resp.statusCode !== 301) { 595 | return done(new Error('Should have responded with a 301 status code (instead got ' + resp.statusCode + ')')); 596 | } 597 | if (resp.headers.location !== 'http://google.com') { 598 | return done(new Error('Should have sent the appropriate "Location" response header')); 599 | } 600 | return done(); 601 | }); 602 | 603 | 604 | 605 | testRoute('`redirect` with custom status code', { 606 | machine: { 607 | inputs: {}, 608 | exits: { 609 | success: { 610 | outputExample: 'some string' 611 | }, 612 | whatever: { 613 | outputExample: 'some string' 614 | } 615 | }, 616 | fn: function(inputs, exits) { 617 | return exits.whatever('http://google.com'); 618 | } 619 | }, 620 | responses: { 621 | success: {}, 622 | whatever: { 623 | statusCode: 301, 624 | responseType: 'redirect' 625 | } 626 | } 627 | }, function(err, resp, body, done) { 628 | if (err) { 629 | return done(err); 630 | } 631 | if (resp.statusCode !== 301) { 632 | return done(new Error('Should have responded with a 301 status code (instead got ' + resp.statusCode + ')')); 633 | } 634 | if (resp.headers.location !== 'http://google.com') { 635 | return done(new Error('Should have sent the appropriate "Location" response header')); 636 | } 637 | return done(); 638 | }); 639 | 640 | 641 | 642 | // var _loggerRan; 643 | // var _loggerRanWithArgs; 644 | // testRoute('should call `logDebugOutputFn` with expected argument', { 645 | // logDebugOutputFn: function (unexpectedOutput) { 646 | // _loggerRan = true; 647 | // _loggerRanWithArgs = Array.prototype.slice.call(arguments); 648 | // }, 649 | // machine: { 650 | // inputs: {}, 651 | // exits: { 652 | // notFound: { 653 | // description: 'Something fake happened. Because this is fake.' 654 | // } 655 | // }, 656 | // fn: function (inputs, exits) { 657 | // return exits.notFound(new Error('Could not find targets. Puppies are still lost. Maybe call Cruella?')); 658 | // } 659 | // }, 660 | // }, function (err, resp, body, done){ 661 | 662 | // try { 663 | // assert(err); 664 | // assert.equal(err.status, 500); 665 | // } catch (e) { return done(e); } 666 | 667 | // if (!_loggerRan) { 668 | // return done(new Error('Consistency violation: Should have run custom log function! (But `_loggerRan` was not true!)')); 669 | // } 670 | // if (!_.isArray(_loggerRanWithArgs) || _loggerRanWithArgs.length !== 1) { 671 | // return done(new Error('Consistency violation: `_loggerRanWithArgs` should be a single-item array! Maybe the wrong stuff was passed in to the custom log function from inside machine-as-action...')); 672 | // } 673 | 674 | // try { 675 | // assert(_.isError(_loggerRanWithArgs[0])); 676 | // assert(_loggerRanWithArgs[0].message.match('Could not find targets. Puppies are still lost. Maybe call Cruella?')); 677 | // } catch (e) { return done(e); } 678 | 679 | // return done(); 680 | // }); 681 | 682 | 683 | 684 | // var _loggerRanButItShouldntHave; 685 | // testRoute('should call `logDebugOutputFn` with auto-generated error if no unexpected output is sent', { 686 | // logDebugOutputFn: function (unexpectedOutput) { 687 | // _loggerRanButItShouldntHave = true; 688 | // }, 689 | // machine: { 690 | // inputs: {}, 691 | // exits: { 692 | // notFound: { 693 | // description: 'Something fake happened. Because this is fake.' 694 | // } 695 | // }, 696 | // fn: function (inputs, exits) { 697 | // return exits.notFound(); 698 | // } 699 | // }, 700 | // }, function (err, resp, body, done){ 701 | // // Should get error even though nothing was passed through 702 | // // (the machine runner builds this automatically) 703 | // try { 704 | // assert(_.isError(err)); 705 | // } catch (e) { return done(e); } 706 | 707 | // return done(); 708 | // }); 709 | 710 | 711 | 712 | testRoute('should work when lobbed another random sanity check', { 713 | 714 | machine: { 715 | inputs: {}, 716 | exits: { 717 | notFound: { 718 | description: 'Something fake happened. Because this is fake.' 719 | } 720 | }, 721 | fn: function(inputs, exits) { 722 | return exits.notFound(new Error('Could not locate polar bears. Don\'t panic, but get help as soon as possible.')); 723 | } 724 | }, 725 | }, function(err, resp, body, done) { 726 | 727 | try { 728 | assert(err); 729 | assert.equal(err.status, 500); 730 | } catch (e) { 731 | return done(e); 732 | } 733 | 734 | return done(); 735 | }); 736 | 737 | 738 | 739 | // 740 | // Not implemented in core `sails.request()` yet 741 | // 742 | 743 | // testRoute('serving a `view` should work', { 744 | // machine: { 745 | // inputs: {}, 746 | // exits: { 747 | // success: { 748 | // example: {} 749 | // }, 750 | // whatever: {} 751 | // }, 752 | // fn: function (inputs, exits) { 753 | // return exits.success(); 754 | // } 755 | // }, 756 | // responses: { 757 | // success: { 758 | // responseType: 'view', 759 | // view: 'homepage' 760 | // }, 761 | // whatever: {} 762 | // } 763 | // }, function (err, resp, body, done){ 764 | // if (err) { return done(err); } 765 | // return done(); 766 | // }); 767 | 768 | 769 | // testRoute('`view` with custom status code', { 770 | // machine: { 771 | // inputs: {}, 772 | // exits: { 773 | // success: { 774 | // example: {} 775 | // }, 776 | // whatever: {} 777 | // }, 778 | // fn: function (inputs, exits) { 779 | // return exits.success(); 780 | // } 781 | // }, 782 | // responses: { 783 | // success: { 784 | // statusCode: 205, 785 | // responseType: 'view', 786 | // view: 'homepage' 787 | // }, 788 | // whatever: {} 789 | // } 790 | // }, function (err, resp, body, done){ 791 | // if (err) { return done(err); } 792 | // if (resp.statusCode !== 205) { 793 | // return done(new Error('Should have responded with a 205 status code (instead got '+resp.statusCode+')')); 794 | // } 795 | // return done(); 796 | // }); 797 | 798 | 799 | 800 | // 801 | // Cannot test `files` here w/ `sails.request()` 802 | // 803 | -------------------------------------------------------------------------------- /lib/machine-as-action.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Module dependencies 3 | */ 4 | 5 | var util = require('util'); 6 | var Stream = require('stream'); 7 | var _ = require('@sailshq/lodash'); 8 | var Streamifier = require('streamifier'); 9 | var rttc = require('rttc'); 10 | var flaverr = require('flaverr'); 11 | var Machine = require('machine'); 12 | var normalizeResponses = require('./private/normalize-responses'); 13 | var getOutputExample = require('./private/get-output-example'); 14 | 15 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 16 | // FUTURE: Pull this into Sails core to ease maintenance. 17 | // > In general, we're looking to reduce the number of separate repos 18 | // > and NPM packages we have to maintain-- so any time there's 19 | // > something that was extrapolated without it _actually_ needing 20 | // > to be separate, we're folding it back in. 21 | // > 22 | // > If you're using this module in your Express/misc Node.js project 23 | // > that doesn't use Sails, and would prefer it didn't get folded 24 | // > into core, please let us know (https://sailsjs.com/support). 25 | // > 26 | // > -mikermcneil (Nov 11, 2017) 27 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 28 | 29 | /** 30 | * machine-as-action 31 | * 32 | * Build a conventional controller action (i.e. route handling function) 33 | * from a machine definition. This wraps the machine in a function which 34 | * negotiates exits to the appropriate response behavior, and passes in all 35 | * of the request parameters as inputs, as well as a few other useful properties 36 | * on `env` including: 37 | * • req 38 | * • res 39 | * 40 | * 41 | * 42 | * Usage: 43 | * ------------------------------------------------------------------------------------------------ 44 | * @param {Dictionary} optsOrMachineDef 45 | * @required {Dictionary} machine 46 | * A machine definition, with action-specific extensions (e.g. `statusCode` in exits) 47 | * Note that the top-level properties of the machine definition may alternatively 48 | * just be included inline amongst the other machine-as-action specific options. 49 | * * * This inline inclusion is the **RECOMMENDED APPROACH** (see README.md). * * 50 | * 51 | * @optional {Array} files 52 | * An array of input code names identifying inputs which expect to 53 | * receive file uploads instead of text parameters. These file inputs 54 | * must have `example: '==='`, but they needn't necessarily be 55 | * `required`. 56 | * @default [] 57 | * 58 | * e.g. 59 | * [ 'avatar' ] 60 | * 61 | * 62 | * 63 | * @optional {String} urlWildcardSuffix 64 | * if '' or unspecified, then there is no wildcard suffix. Otherwise, 65 | * this is the code name of the machine input which is being referenced 66 | * by the pattern variable serving as the wildcard suffix. 67 | * @default '' 68 | * 69 | * e.g. 70 | * 'docPath' 71 | * 72 | * @optional {Boolean} disableXExitHeader 73 | * if set, then do not set the `X-Exit` response header for any exit. 74 | * @default false 75 | * 76 | * @optional {Boolean} disableDevelopmentHeaders 77 | * if set, then do not set headers w/ exit info during development. 78 | * Development headers include: 79 | * • `X-Exit-Friendly-Name` 80 | * • `X-Exit-Description` 81 | * • `X-Exit-Extended-Description` 82 | * • `X-Exit-More-Info-Url` 83 | * • `X-Exit-Output-Friendly-Name` 84 | * • `X-Exit-Output-Description` 85 | * These development headers are never shown in a production env 86 | * (i.e. when process.env.NODE_ENV === 'production') or when they 87 | * are not relevant. 88 | * @default false 89 | * 90 | * @optional {Number} simulateLatency 91 | * if set, then simulate a latency of the specified number of milliseconds (e.g. 500) 92 | * @default 0 93 | * 94 | * @optional {Boolean} logDebugOutputFn 95 | * An optional override function to call when any output other than `undefined` is 96 | * received from a void exit (i.e. an exit w/ no outputExample). 97 | * @default (use `sails.log.warn()` if available, or `console.warn()` otherwise.) 98 | * 99 | * 100 | * -OR- 101 | * 102 | * 103 | * @param {Dictionary} optsOrMachineDef 104 | * A machine definition. 105 | * 106 | *=== 107 | * 108 | * @return {Function} 109 | * @param {Request} req 110 | * @param {Response} res 111 | * ------------------------------------------------------------------------------------------------ 112 | */ 113 | 114 | module.exports = function machineAsAction(optsOrMachineDef) { 115 | 116 | optsOrMachineDef = optsOrMachineDef||{}; 117 | 118 | // Use either `optsOrMachineDef` or `optsOrMachineDef.machine` as the node machine definition. 119 | // If `optsOrMachineDef.machine` is truthy, we'll use that as the machine definition. 120 | // Otherwise, we'll understand the entire `optsOrMachineDef` dictionary to be the machine 121 | // definition. All other miscellaneous options are whitelisted. 122 | var machineDef; 123 | var options; 124 | var MISC_OPTIONS = [ 125 | 'files', 126 | 'urlWildcardSuffix', 127 | 'disableDevelopmentHeaders', 128 | 'disableXExitHeader', 129 | 'simulateLatency', 130 | 'logDebugOutputFn', 131 | 'implementationSniffingTactic', 132 | 'responses'//<< deprecated, will be removed soon! 133 | ]; 134 | if (!optsOrMachineDef.machine) { 135 | machineDef = optsOrMachineDef; 136 | options = _.pick(optsOrMachineDef, MISC_OPTIONS); 137 | } 138 | else { 139 | machineDef = optsOrMachineDef.machine; 140 | options = _.pick(optsOrMachineDef, MISC_OPTIONS); 141 | } 142 | 143 | if (!_.isObject(machineDef)) { 144 | throw new Error('Consistency violation: Machine definition must be provided as a dictionary.'); 145 | } 146 | 147 | // https://github.com/treelinehq/machine-as-action/commit/d156299bc9cd85400bac3ab21b22dcbc3040bbda 148 | // Determine whether we are currently running in production. 149 | var IS_RUNNING_IN_PRODUCTION = ( 150 | process.env.NODE_ENV === 'production' 151 | ); 152 | 153 | 154 | // Set up default options: 155 | options = _.defaults(options, { 156 | simulateLatency: 0, 157 | // Note that the default implementation of `logDebugOutputFn` is inline below 158 | // (this is so that it has closure scope access to `req._sails`) 159 | }); 160 | 161 | 162 | // If a function was provided, freak out. 163 | // (Unless this is a wet machine-- in which case it's ok) 164 | if (_.isFunction(machineDef)) { 165 | 166 | // If this is clearly an already "-as-action"-ified thing, then freak out in a more helpful way. 167 | if (machineDef.IS_MACHINE_AS_ACTION) { 168 | var doubleWrapErr = new Error('Cannot build action: Provided machine definition appears to have already been run through `machine-as-action`, or somehow otherwise decided to masquerade as an already-instantiated, live machine from MaA!'); 169 | doubleWrapErr.code = 'E_DOUBLE_WRAP'; 170 | throw doubleWrapErr; 171 | } 172 | // Otherwise, if this is a wet machine, that's OK-- we know how to handle it. 173 | else if (machineDef.isWetMachine) { 174 | // No worries. It's ok. Keep going. 175 | } 176 | // Otherwise just freak out. 177 | else { 178 | var invalidMachineDefErr = new Error('Cannot build action: Provided machine definition must be a dictionary, with an `fn`. See http://node-machine.org/spec/machine for details.'); 179 | invalidMachineDefErr.code = 'E_INVALID_MACHINE_DEF'; 180 | throw invalidMachineDefErr; 181 | } 182 | } 183 | // --• 184 | 185 | // Extend a default def with the actual provided def to allow for a laxer specification. 186 | machineDef = _.extend({ 187 | identity: machineDef.friendlyName ? _.kebabCase(machineDef.friendlyName) : 'anonymous-action', 188 | inputs: {}, 189 | exits: {}, 190 | }, machineDef); 191 | 192 | // If no `fn` was provided, dynamically build a stub fn that always responds with `success`, 193 | // using the `example` as output data, if one was specified. 194 | if (!machineDef.fn) { 195 | machineDef.fn = function (inputs, exits) { 196 | 197 | // This is a generated `fn`. 198 | // (Note that this is fine for production in some cases-- e.g. static views.) 199 | 200 | // Look up the output example for the success exit. 201 | var successExitOutputExample = getOutputExample({ 202 | machineDef: machineDef, 203 | exitCodeName: 'success' 204 | }); 205 | 206 | // If there's no output example, just exit through the success exit w/ no output. 207 | // (This is fine for production. Because static views.) 208 | if (_.isUndefined(successExitOutputExample)) { 209 | return exits.success(); 210 | } 211 | // Otherwise, still exit success, but use the output example (i.e. an exemplar) 212 | // as fake data. This will be used as the locals, response data, or redirect URL 213 | // (depending on the exit's responseType, of course.) 214 | else { 215 | 216 | // Set a header to as a debug flag indicating this is just a stub. 217 | this.res.set('X-Stub', machineDef.identity); 218 | 219 | // But if you're in production, since this would respond with 220 | // a stub (i.e. fake data) then log a warning about this happening. 221 | // (since you probably don't actually want this to happen) 222 | if (IS_RUNNING_IN_PRODUCTION) { 223 | console.warn('Using stub implementation for action (`'+machineDef.identity+'`) because it has no `fn`!\n'+ 224 | 'That means the output sent from this action will be completely fake! To do this, using the `outputExample` '+ 225 | 'from the success exit and using that as output.\n'+ 226 | '(This warning is being logged because you are in a production environment according to NODE_ENV)'); 227 | }// 228 | //>- 229 | 230 | return exits.success(successExitOutputExample); 231 | } 232 | 233 | 234 | }; 235 | } 236 | 237 | 238 | // Mutate the machine definition. 239 | // Ensure the machine def has "success" and "error" exits. 240 | machineDef.exits = machineDef.exits || {}; 241 | _.defaults(machineDef.exits, { 242 | error: { description: 'An unexpected error occurred.' }, 243 | success: { description: 'Done.' } 244 | }); 245 | 246 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 247 | // See `FUTURE` note below, as well as: 248 | // https://github.com/sailshq/machine-as-action/commit/b30e5b8cb1cc0522ca1fa1487896bcd3b83600c0 249 | // 250 | // This was removed to allow for new types of result validation to work properly. 251 | // (primarily useful for debugging why things aren't working) 252 | // ``` 253 | // (In the current version of machine-as-action, as a way of minimizing complexity, we treat void exits 254 | // as if they are `outputExample: '==='`. But to do this, we have to change the def beforehand.) 255 | // 256 | // _.each(_.keys(machineDef.exits), function(exitCodeName) { 257 | // var exitDef = machineDef.exits[exitCodeName]; 258 | // if (undefined === exitDef.outputExample && undefined === exitDef.outputType && undefined === exitDef.like && undefined === exitDef.itemOf && undefined === exitDef.getExample) { 259 | // exitDef.outputExample = '==='; 260 | // } 261 | // }); 262 | // ``` 263 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 264 | 265 | 266 | 267 | // Build Callable (aka "wet" machine instance) 268 | // (This is just like a not-yet-configured "part" or "machine instruction".) 269 | // 270 | // This gives us access to the instantiated inputs and exits. 271 | var wetMachine = Machine.buildWithCustomUsage({ 272 | def: machineDef, 273 | implementationSniffingTactic: options.implementationSniffingTactic||undefined 274 | }); 275 | 276 | // If any static response customizations/metadata were specified via `optsOrMachineDef`, combine 277 | // them with the exit definitions of the machine to build a normalized response mapping that will 278 | // be cached so it does not need to be recomputed again and again at runtime with each incoming 279 | // request. (e.g. non-dyamic things like status code, response type, view name, etc) 280 | var responses; 281 | try { 282 | responses = normalizeResponses(options.responses || {}, wetMachine.getDef().exits); 283 | } catch (e) { 284 | switch (e.code) { 285 | case 'E_INVALID_RES_METADATA_IN_EXIT_DEF': 286 | // FUTURE: any additional error handling 287 | throw e; 288 | default: throw e; 289 | } 290 | }// 291 | wetMachine.getDef().exits = responses; 292 | // Be warned that this caching is **destructive**. In other words, if a dictionary was provided 293 | // for `options.responses`, it will be irreversibly modified. Also the exits in the 294 | // machine definition will be irreversibly modified. 295 | 296 | 297 | // ██████╗ ██╗ ██╗██╗██╗ ██████╗ █████╗ ██████╗████████╗██╗ ██████╗ ███╗ ██╗ 298 | // ██╔══██╗██║ ██║██║██║ ██╔══██╗ ██╔══██╗██╔════╝╚══██╔══╝██║██╔═══██╗████╗ ██║ 299 | // ██████╔╝██║ ██║██║██║ ██║ ██║ ███████║██║ ██║ ██║██║ ██║██╔██╗ ██║ 300 | // ██╔══██╗██║ ██║██║██║ ██║ ██║ ██╔══██║██║ ██║ ██║██║ ██║██║╚██╗██║ 301 | // ██████╔╝╚██████╔╝██║███████╗██████╔╝ ██║ ██║╚██████╗ ██║ ██║╚██████╔╝██║ ╚████║ 302 | // ╚═════╝ ╚═════╝ ╚═╝╚══════╝╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ 303 | // 304 | 305 | /** 306 | * `_requestHandler()` 307 | * 308 | * At runtime, this code will be invoked each time the router receives a request and sends it to this action. 309 | * -------------------------------------------------------------------------------------------------------------- 310 | * @param {Request} req 311 | * @param {Response} res 312 | */ 313 | var action = function _requestHandler(req, res) { 314 | 315 | // Set up a local variable that will be used to hold the "live machine" 316 | // (which is a lot like a configured part or machine instruction) 317 | var deferred; 318 | 319 | 320 | // Validate `req` and `res` 321 | /////////////////////////////////////////////////////////////////////////////////////////////////// 322 | // Note: we really only need to do these checks once, but they're a neglible hit to performance, 323 | // and the extra µs is worth it to ensure continued compatibility when coexisting with other 324 | // middleware, policies, frameworks, packages, etc. that might tamper with the global `req` 325 | // object (e.g. Passport). 326 | /////////////////////////////////////////////////////////////////////////////////////////////////// 327 | 328 | // Sails/Express App Requirements 329 | if (!res.json) { 330 | throw new Error('Needs `res.json()` to exist (i.e. a Sails.js or Express app)'); 331 | } 332 | if (!res.send) { 333 | throw new Error('Needs `res.send()` to exist (i.e. a Sails.js or Express app)'); 334 | } 335 | 336 | // Specify arguments (aka "input configurations") for the machine. 337 | /////////////////////////////////////////////////////////////////////////////////////////////// 338 | // 339 | // Machine arguments can be derived from any of the following sources: 340 | // 341 | // (1) TEXT PARAMETERS: 342 | // Use a request parameter as an argument. 343 | // - Any conventional Sails/Express request parameter is supported; 344 | // i.e. from any combination of the following sources: 345 | // ° URL pattern variables (match groups in path; e.g. `/monkeys/:id/uploaded-files/*`) 346 | // ° The querystring (e.g. `?foo=some%20string`) 347 | // ° The request body (may be URL-encoded or JSON-serialized) 348 | // 349 | // (2) FILES: 350 | // Use one or more incoming file upstreams as an argument. 351 | // - Upstreams are multifile upload streams-- they are like standard multipart file upload 352 | // streams except that they support multiple files at a time. To manage RAM usage, they 353 | // support TCP backpressure. Upstreams also help prevent DoS attacks by removing the 354 | // buffering delay between the time a potentially malicious file starts uploading and 355 | // when your validation logic runs. That means no incoming bytes are written to disk 356 | // before your code has had a chance to take a look. If your use case demands it, you 357 | // can even continue to perform incremental validations as the file uploads (i.e. to 358 | // scan for malicious code or unexpectedly formatted data) or gradually pipe the stream 359 | // to `/dev/null` (a phony destination) as a honeypot to fool would-be attackers into 360 | // thinking their upload was successful. 361 | // - Upstream support is implemented by the Skipper body parser (a piece of middleware). 362 | // Skipper is the default body parser in Sails, but it is compatible with Express, 363 | // Connect, Hapi, or any other framework that exposes a conventional `req`/`res`/`next` 364 | // interface for its middleware stack. 365 | // body parser. event streams that emit multipart file upload streams) via Skipper. 366 | // - Any receiving input(s) may continue to be either required or optional, but they must 367 | // declare themselves refs by setting `example: '==='`. If not, then `machine-as-action` 368 | // will refuse to rig this machine. 369 | // 370 | // (3) HEADERS: 371 | // Use an HTTP request headers as an argument. (-NOT YET SUPPORTED-) 372 | // - Any receiving input(s) may continue to be either required or optional, but they must 373 | // declare a string example. 374 | // 375 | /////////////////////////////////////////////////////////////////////////////////////////////// 376 | 377 | // Build `argins` (aka input configurations), a dictionary that maps each input's codeName to the 378 | // appropriate argument. 379 | var argins = _.reduce(wetMachine.getDef().inputs, function (memo, inputDef, inputCodeName) { 380 | 381 | // If this input is called out by the `urlWildcardSuffix`, then we understand it as "*" from the 382 | // URL pattern. This is indicating it's special; that it represents a special, agressive kind of match 383 | // group that sometimes appears in URL patterns. This special match group is known as a "wildcard suffix". 384 | // It is just like any other match group except that it (1) can match forward slashes, (2) can only appear 385 | // at the very end of the URL pattern, and (3) there can only be one like it per route. 386 | // 387 | // Note that we compare against the code name in the input definition. The `urlWildcardSuffix` provided to 388 | // machine-as-action should reference the c-input by code name, not by any other sort of ID (i.e. if you are 389 | // using a higher-level immutable ID abstraction, rewrite the urlWildcardSuffix to the code name beforehand) 390 | if (options.urlWildcardSuffix && options.urlWildcardSuffix === inputCodeName ) { 391 | memo[inputCodeName] = req.param('0'); 392 | } 393 | // Otherwise, this is just your standard, run of the mill parameter. 394 | else { 395 | memo[inputCodeName] = req.param(inputCodeName); 396 | } 397 | 398 | // If a querystring-encoded parameter comes in as "" (empty string) for an input expecting a boolean 399 | // value, interpret that special case as `true`. 400 | if (inputDef.type === 'boolean' && req.query && req.query[inputCodeName] === '') { 401 | memo[inputCodeName] = true; 402 | } 403 | 404 | // If a querystring-encoded parameter comes in as "" (empty string) for an input expecting a NUMERIC value, 405 | // then tolerate that by ignoring the value altogether. 406 | if (inputDef.type === 'number' && req.query && req.query[inputCodeName] === '') { 407 | delete memo[inputCodeName]; 408 | } 409 | 410 | return memo; 411 | }, {}); 412 | 413 | 414 | 415 | // Handle `files` option (to provide access to upstreams) 416 | if (_.isArray(options.files)) { 417 | if (!req.file) { 418 | throw new Error('In order to use the `files` option, needs `req.file()` to exist (i.e. a Sails.js or Express app using Skipper)'); 419 | } 420 | _.each(options.files, function (fileParamName){ 421 | // Supply this upstream as an argument for the specified input. 422 | argins[fileParamName] = req.file(fileParamName); 423 | // Also bind an `error` event so that, if the machine's implementation (`fn`) 424 | // doesn't handle the upstream, or anything else goes wrong with the upstream, 425 | // it won't crash the server. 426 | argins[fileParamName].on('error', function (err){ 427 | console.error('Upstream (file upload: `'+fileParamName+'`) emitted an error:', err); 428 | });//æ 429 | });//∞ 430 | }//fi 431 | 432 | // Eventually, we may consider implementing support for sourcing inputs from headers. 433 | // (if so, we'll likely map as closely as possible to Swagger's syntax -- 434 | // not just for familiarity, but also to maintain and strengthen the underlying 435 | // conventions) 436 | 437 | 438 | // Pass argins to the machine. 439 | deferred = wetMachine(argins); 440 | 441 | 442 | // Build and set metadata (aka "context" aka "habitat vars") 443 | /////////////////////////////////////////////////////////////////////////////////////////////// 444 | 445 | // Provide `this.req` and `this.res` 446 | var _meta = { 447 | req: req, 448 | res: res 449 | }; 450 | 451 | // If this is a Sails app, provide `this.sails` for convenience. 452 | if (req._sails) { 453 | _meta.sails = req._sails; 454 | } 455 | 456 | // Set context for machine `fn`. 457 | deferred.meta(_meta); 458 | 459 | 460 | 461 | // Now prepare some exit callbacks that map each exit to a particular response. 462 | ///////////////////////////////////////////////////////////////////////////////////////////////// 463 | // 464 | // Just like a machine's `fn` _must_ call one of its exits, this action _must_ send a response. 465 | // But it can do so in a number of different ways: 466 | // 467 | // (1) ACK: Do not send a response body. 468 | // /\ - Useful in situations where response data is unnecessary/wasteful, 469 | // || nice-to-have e.g. after successfully updating a resource like `PUT /discoparty/7`. 470 | // || like plaintext - The status code and any response headers will still be sent. 471 | // || kinda advanced - Even if the machine exit returns any output, it will be ignored. 472 | // (can use "" (aka standard) to achieve same effect) 473 | // 474 | // (2) PLAIN TEXT: Send plain text. 475 | // - Useful for sending raw data in a format like CSV or XML. 476 | // /\ - The *STRING* output from the machine exit will be sent verbatim as the 477 | // || prbly wont be response body. Custom response headers like "Content-Type" can be sent 478 | // || implemented using `this.res.set()` or mp-headers. For more info, see "FILE" below. 479 | // since you can just - If the exit does not guarantee a *STRING* output, then `machine-as-action` 480 | // use "" to will refuse to rig this machine. 481 | // achieve the same 482 | // effect. 483 | // 484 | // 485 | // (3) JSON: Send data encoded as JSON. 486 | // - Useful for a myriad of purposes; e.g. mobile apps, IoT devices, CLI 487 | // /\ scripts or daemons, SPAs (single-page apps) or any other webpage 488 | // || nice-to-have using AJAX (whether over HTTP or WebSockets), other API servers, and 489 | // || but generally pretty much anything else you can imagine. 490 | // || achieveable w/ - The output from the machine exit will be stringified before it is sent 491 | // || "". as the response body, so it must be JSON-compatible in the eyes of the 492 | // || Like plain text, machine spec (i.e. lossless across JSON serialization and without circular 493 | // || kinda advanced. references). 494 | // || - That is, if the exit's output example contains any lamda (`->`) or 495 | // ref (`===`) hollows, `machine-as-action` will refuse to rig this machine. 496 | // 497 | // 498 | // (4) "" (STANDARD): Send a response as versatile as you. 499 | // - Depending on the context, this might send plain text, download a file, 500 | // transmit data as JSON, or send no response body at all. 501 | // - Note that any response headers you might want to use such as `content-type` 502 | // and `content-disposition` should be set in the implementation of your 503 | // machine using `this.res.set()`. 504 | // - For advanced documentation on `this.res.set()`, check out Sails docs: 505 | // [Docs](http://sailsjs.org/documentation/reference/response-res/res-set) 506 | // - Or if you're looking for something higher-level: 507 | // [Install](http://node-machine.org/machinepack-headers/set-response-header) 508 | // 509 | // - If the |_output example_| guaranteed from the machine exit is: 510 | // • `null`/`undefined` - then that means there is no output. Send only the 511 | // status code and headers (no response body). 512 | // • a number, boolean, generic dictionary, array, JSON-compatible (`*`), or a 513 | // faceted dictionary that DOES NOT contain ANY nested lamda (`->`) or ref 514 | // (`===`) hollows: 515 | // ...then the runtime output will be encoded with rttc.dehydrate() and 516 | // sent as JSON in the response body. A JSON response header will be 517 | // automatically set ("Content-type: application/json"). 518 | // • a lamda or a faceted dictionary that contains one or more lamda (`->`) and/or 519 | // ref (`===`) hollows: 520 | // ...then the runtime output will be encoded with rttc.dehydrate() and 521 | // sent as JSON in the response body. A JSON response header will be 522 | // automatically set ("Content-type: application/json"). 523 | // ************************************************************************** 524 | // ******************************* WARNING ********************************** 525 | // Since the output example indicates it might contain non-JSON-compatible 526 | // data, it is important to realize that transmitting this type of data in 527 | // the response body could be lossy. For example, when rttc.dehydrate() 528 | // called, it toStrings functions into dehydrated cadavers andhumiliates 529 | // instances of JavaScript objects by wiping out their prototypal methods, 530 | // getters, setters, and any other hint of creativity that it finds. Objects 531 | // with circular references are spun around until they're dizzy, and their 532 | // circular references are replaced with strings (like doing util.inspect() 533 | // with a `null` depth). 534 | // ************************************************************************** 535 | // ************************************************************************** 536 | // • a ref: 537 | // ...then at runtime, the outgoing value will be sniffed. If: 538 | // 539 | // (A) it is a (hopefully readable) STREAM of binary or UTF-8 chunks (i.e. NOT in 540 | // object mode): 541 | // ...then it will be piped back to the requesting client in the response. 542 | // 543 | // (B) it is a buffer: 544 | // ...then it will be converted to a readable binary stream... 545 | // ...and piped back to the requesting client in the response. 546 | // ------------------------------------------------------------------------------------- 547 | // ^ IT IS IMPORTANT TO POINT OUT THAT, WHEN PIPING EITHER BUFFERS OR STREAMS, THE 548 | // CONTENT-TYPE IS SET TO OCTET STREAM UNLESS IT HAS ALREADY BEEN EXPLICITLY SPECIFIED 549 | // USING `this.res.set()` (in which case it is left alone). 550 | // ------------------------------------------------------------------------------------- 551 | // ----- Note about responding w/ plain text: ------------------------------------------------------ 552 | // If you need to respond with programatically-generated plain text, and you don't want it 553 | // encoded as JSON (or if you MUST NOT encode it as JSON for some reason), then you just need 554 | // to convert the plain text string variable into a readable stream (`===`) and feed it into 555 | // standard response. 556 | // ----- ==================================== ------------------------------------------------------ 557 | // 558 | // (C) Finally, if the outgoing value at runtime does not match one of the two criteria above 559 | // (e.g. if it is a readable stream in object mode, or an array of numbers, or a haiku-- 560 | // OR LITERALLY ANYTHING ELSE): 561 | // ...then the runtime output will be encoded with rttc.dehydrate() and 562 | // sent as JSON in the response body. A JSON response header will be 563 | // automatically set to ("Content-type: application/json"). 564 | // *** PLEASE SEE WARNING ABOVE ABOUT `rttc.dehydrate()` *** 565 | // 566 | // 567 | // (5) REDIRECT: Redirect the requesting user-agent to a different URL. 568 | // - When redirecting, no response body is sent. Instead, the *STRING* output 569 | // from the machine is sent as the "Location" response header. This tells 570 | // the requesting device to go talk to that URL instead. 571 | // - If the exit's output example is not a string, then `machine-as-action` 572 | // will refuse to rig this machine. 573 | // 574 | // 575 | // (6) VIEW: Responds with an HTML webpage. 576 | // - The dictionary output from the machine exit will be passed to the view 577 | // template as "locals". Each key from this dictionary will be accessible 578 | // as a local variable in the view template. 579 | // - If the exit's output example is not a generic or faceted dictionary, 580 | // then `machine-as-action` will refuse to rig this machine. 581 | // 582 | // (7) ERROR: Handle an error with an appropriate response. 583 | // /\ - Useful exclusively for error handling. This just calls res.serverError() 584 | // || warning: and passes through the output. If there is no output, it generates a 585 | // || this will not nicer error message and sends that through instead. 586 | // || necessarily be - If this is a Sails app, the server error response method in `api/responses/` 587 | // || available for will be used, and in some cases it will render the default error page (500.ejs) 588 | // || exits other - Note that, if the requesting user-agent is accessing the route from a browser, 589 | // than `error` its headers give it away. The "error" response implements content negotiation-- 590 | // exits. if a user-agent clearly accessed the "error" response by typing in the URL 591 | // of a web browser, then it should see an error page (which error page depends on the output). 592 | // Alternately, if the same exact parameters were sent to the same exact URL, 593 | // but via AJAX or cURL, we would receive a JSON response instead. 594 | // 595 | ///////////////////////////////////////////////////////////////////////////////////////////////// 596 | 597 | // We use a local variable (`exitAttempts`) as a spinlock. 598 | // (it tracks the code names of _which_ exit(s) were already triggered) 599 | var exitAttempts = []; 600 | 601 | var callbacks = {}; 602 | _.each(_.keys(wetMachine.getDef().exits), function builtExitCallback(exitCodeName){ 603 | 604 | // Build a callback for this exit that sends the appropriate response. 605 | callbacks[exitCodeName] = function respondApropos(output){ 606 | 607 | // This spinlock protects against the machine calling more than one 608 | // exit, or the same exit twice. 609 | if (exitAttempts.length > 0) { 610 | console.warn('Consistency violation: When fulfilling this request (`'+req.method+' '+req.path+'`) '+ 611 | 'the action attempted to respond (i.e. call its exits) more than once! An action should _always_ '+ 612 | 'send exactly one response. This particular unexpected extra response was attempted via the `'+exitCodeName+'` '+ 613 | 'exit. It was ignored. For debugging purposes, here is a list of all exit/response attempts made '+ 614 | 'by this action:',exitAttempts); 615 | return; 616 | } 617 | exitAttempts.push(exitCodeName); 618 | 619 | (function _waitForSimulatedLatencyIfRelevant(_cb){ 620 | if (!options.simulateLatency) { return _cb(); } 621 | setTimeout(_cb, options.simulateLatency); 622 | })(function afterwards(){ 623 | // Use a `try` to be safe, since this callback might be invoked in 624 | // an asynchronous execution context. 625 | try { 626 | 627 | if (!res.headersSent) { 628 | 629 | // Unless being prevented with the `disableXExitHeader` option, 630 | // encode exit code name as the `X-Exit` response header. 631 | if (!options.disableXExitHeader) { 632 | res.set('X-Exit', exitCodeName); 633 | } 634 | 635 | // If running in development and development headers have not been explicitly disabled, 636 | // then send down other available metadata about the exit for convenience for developers 637 | // integrating with this API endpoint. 638 | var doSendDevHeaders = ( 639 | !IS_RUNNING_IN_PRODUCTION && 640 | !options.disableDevelopmentHeaders 641 | ); 642 | if (doSendDevHeaders) { 643 | var responseInfo = responses[exitCodeName]; 644 | if (responseInfo.friendlyName) { 645 | res.set('X-Exit-Friendly-Name', responseInfo.friendlyName.replace(/\s*\n+\s*/g, ' ')); 646 | } 647 | if (responseInfo.description) { 648 | res.set('X-Exit-Description', responseInfo.description.replace(/\s*\n+\s*/g, ' ')); 649 | } 650 | if (responseInfo.extendedDescription) { 651 | res.set('X-Exit-Extended-Description', responseInfo.extendedDescription.replace(/\s*\n+\s*/g, ' ')); 652 | } 653 | if (responseInfo.moreInfoUrl) { 654 | res.set('X-Exit-More-Info-Url', responseInfo.moreInfoUrl.replace(/\s*\n+\s*/g, ' ')); 655 | } 656 | // Only include output headers if there _is_ output and 657 | // this is a standard response: 658 | if (responseInfo.responseType === '' && !_.isUndefined(output)) { 659 | if (responseInfo.outputFriendlyName) { 660 | res.set('X-Exit-Output-Friendly-Name', responseInfo.outputFriendlyName.replace(/\s*\n+\s*/g, ' ')); 661 | } 662 | if (responseInfo.outputDescription) { 663 | res.set('X-Exit-Output-Description', responseInfo.outputDescription.replace(/\s*\n+\s*/g, ' ')); 664 | } 665 | } 666 | // Otherwise if this is a view response, include the view path. 667 | else if (responseInfo.responseType === 'view') { 668 | res.set('X-Exit-View-Template-Path', responseInfo.viewTemplatePath.replace(/\s*\n+\s*/g, ' ')); 669 | } 670 | }// 671 | // >- 672 | // 673 | } 674 | 675 | 676 | // If this is the handler for the error exit, and it's clear from the output 677 | // that this is a runtime validation error _from this specific machine_ (and 678 | // not from any machines it might call internally in its `fn`), then send back 679 | // send back a 400 (using the built-in `badRequest()` response, if it exists.) 680 | var isValidationError = ( 681 | exitCodeName === 'error' && 682 | output.name === 'UsageError' && 683 | output.code === 'E_INVALID_ARGINS' 684 | ); 685 | 686 | if (isValidationError) { 687 | // Sanity check: 688 | if (!_.isArray(output.problems)) { throw new Error('Consistency violation: E_INVALID_ARGINS errors should _always_ have a `problems` array.'); } 689 | 690 | // Build a new error w/ more specific verbiage. 691 | // (stack trace is more useful starting from here anyway) 692 | var prettyPrintedValidationErrorsStr = _.map(output.problems, function (problem){ 693 | return ' • '+problem; 694 | }).join('\n'); 695 | var baseValidationErrMsg = 696 | 'Received incoming request (`'+req.method+' '+req.path+'`), '+ 697 | 'but could not run action (`'+machineDef.identity+'`) '+ 698 | 'due to '+output.problems.length+' missing or invalid '+ 699 | 'parameter'+(output.problems.length!==1?'s':''); 700 | var err = new Error(baseValidationErrMsg+':\n'+prettyPrintedValidationErrorsStr); 701 | err.code = 'E_MISSING_OR_INVALID_PARAMS'; 702 | err.problems = output.problems; 703 | 704 | // Attach a toJSON function to the error. This will be run automatically 705 | // when this error is being stringified. This is our chance to make this 706 | // error easier to read/programatically parse from the client. 707 | err.toJSON = function (){ 708 | // Include the error code and the array of RTTC validation errors 709 | // for easy programmatic parsing. 710 | var jsonReadyErrDictionary = _.pick(err, ['code', 'problems']); 711 | // And also include a more front-end-friendly version of the error message. 712 | var preamble = 713 | 'The server could not fulfill this request (`'+req.method+' '+req.path+'`) '+ 714 | 'due to '+output.problems.length+' missing or invalid '+ 715 | 'parameter'+(output.problems.length!==1?'s':'')+'.'; 716 | 717 | // If NOT running in production, then provide additional details and tips. 718 | if (!IS_RUNNING_IN_PRODUCTION) { 719 | jsonReadyErrDictionary.message = preamble+' '+ 720 | '**The following additional tip will not be shown in production**: '+ 721 | 'Tip: Check your client-side code to make sure that the request data it '+ 722 | 'sends matches the expectations of the corresponding parameters in your '+ 723 | 'server-side route/action. Also check that your client-side code sends '+ 724 | 'data for every required parameter. Finally, for programmatically-parseable '+ 725 | 'details about each validation error, `.problems`. '+ 726 | '(Just remember, any time you inject dynamic data into the HTML, be sure to escape the strings at the point of injection.)'; 727 | } 728 | // If running in production, use a message that is more terse. 729 | else { 730 | jsonReadyErrDictionary.message = preamble; 731 | } 732 | //>- 733 | 734 | return jsonReadyErrDictionary; 735 | 736 | };// 737 | 738 | 739 | // If `res.badRequest` exists, use that. 740 | if (_.isFunction(res.badRequest)) { 741 | return res.badRequest(err); 742 | } 743 | // Otherwise just send a 400 response with the error encoded as JSON. 744 | else { 745 | return res.status(400).json(err); 746 | } 747 | 748 | }// 749 | 750 | 751 | // -• 752 | switch (responses[exitCodeName].responseType) { 753 | 754 | // ┬─┐┌─┐┌─┐┌─┐┌─┐┌┐┌┌─┐┌─┐ ┌┬┐┬ ┬┌─┐┌─┐ ╔═╗╔╦╗╔═╗╔╗╔╔╦╗╔═╗╦═╗╔╦╗ 755 | // ├┬┘├┤ └─┐├─┘│ ││││└─┐├┤ │ └┬┘├─┘├┤ ╚═╗ ║ ╠═╣║║║ ║║╠═╣╠╦╝ ║║ 756 | // ┴└─└─┘└─┘┴ └─┘┘└┘└─┘└─┘ ┴ ┴ ┴ └─┘ ╚═╝ ╩ ╩ ╩╝╚╝═╩╝╩ ╩╩╚══╩╝ 757 | case '': (function(){ 758 | 759 | // var outputExample = getOutputExample({ machineDef: wetMachine.getDef(), exitCodeName: exitCodeName }); 760 | 761 | // • Undefined output example: We take that to mean void...mostly (see below.) 762 | // (But currently, we just treat it the same as if it is outputExample: '===') 763 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 764 | // FUTURE: bring this back, but behind a flag: 765 | // (see https://github.com/sailshq/machine-as-action/pull/12/commits/adeae40aac1daa448401f40113a625c3f1980200 for more context) 766 | // 767 | // ``` 768 | // var willNotTolerateOutput = false || _.isUndefined(outputExample); 769 | // if (willNotTolerateOutput) { 770 | 771 | // // Expose a more specific varname for clarity. 772 | // var unexpectedOutput = output; 773 | 774 | // // Technically the machine `fn` could still send through data. 775 | // // No matter what, we NEVER send that runtime data to the response. 776 | // // 777 | // // BUT we still log that data to the console using `sails.log.warn()` if available 778 | // // (otherwise `console.warn()`). We use an overridable log function to do this. 779 | // if (!_.isUndefined(unexpectedOutput)) { 780 | 781 | // try { 782 | // // If provided, use custom implementation. 783 | // if (!_.isUndefined(options.logDebugOutputFn)) { 784 | // options.logDebugOutputFn(unexpectedOutput); 785 | // } 786 | // // Otherwise, use the default implementation: 787 | // else { 788 | // var logMsg = 'Received incoming request (`'+req.method+' '+req.path+'`) '+ 789 | // 'and ran action (`'+machineDef.identity+'`), which exited with '+ 790 | // 'its `'+exitCodeName+'` response and the following data:\n'+ 791 | // util.inspect(unexpectedOutput, {depth: null})+ 792 | // '\n'+ 793 | // '(^^ this data was not sent in the response)'; 794 | 795 | // // Only log unexpected output in development. 796 | // if (!IS_RUNNING_IN_PRODUCTION) { 797 | 798 | // if (_.isObject(req._sails) && _.isObject(req._sails.log) && _.isFunction(req._sails.log.debug)) { 799 | // req._sails.log.debug(logMsg); 800 | // } 801 | // else { 802 | // console.warn(logMsg); 803 | // } 804 | 805 | // }// 806 | 807 | // }// 808 | // } catch (e) { console.warn('The configured log function for unexpected output (`logDebugOutputFn`) threw an error. Proceeding to send response anyway... Error details:',e); } 809 | // }// 810 | 811 | // // >- 812 | // // Regardless of whether there's unexpected output or not... 813 | 814 | // // Set the status code. 815 | // res = res.status(responses[exitCodeName].statusCode); 816 | 817 | // // And send the response. 818 | // if (res.finished) { 819 | // // If res.end() has already been called somehow, then this is definitely an error. 820 | // // Currently, in this case, we handle this simply by trying to call res.send(), 821 | // // deliberately causing the normal "headers were already sent" error. 822 | // // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 823 | // // FUTURE: use a better, custom error here (e.g. you seem to be trying to send 824 | // // a response to this request more than once! Note that the case of triggering 825 | // // more than one exit, or the same exit more than once, is already handled w/ a 826 | // // custom error msg elsewhere) 827 | // // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 828 | // return res.send(); 829 | // } 830 | // else if (res.headersSent) { 831 | // // Calling `exits.success()` after having done res.write() calls 832 | // // earlier (and thus sending headers) is fine-- as long as you 833 | // // haven't done something that ended the response yet. 834 | // // We gracefully tolerate it here. 835 | // return res.end(); 836 | // } 837 | // else { return res.send(); } 838 | 839 | // }// -• 840 | 841 | // ``` 842 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 843 | 844 | // • Expecting ref: (+ currently also if exit was left with no output declaration at all) 845 | 846 | 847 | if (res.finished) { 848 | // If res.end() has already been called somehow, then this is definitely an error. 849 | // Currently, in this case, we handle this simply by trying to call res.send(), 850 | // deliberately causing the normal "headers were already sent" error. 851 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 852 | // FUTURE: use a better, custom error here (e.g. you seem to be trying to send 853 | // a response to this request more than once! Note that the case of triggering 854 | // more than one exit, or the same exit more than once, is already handled w/ a 855 | // custom error msg elsewhere) 856 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 857 | return res.send(); 858 | } 859 | else if (res.headersSent) { 860 | // Calling `exits.success()` after having done res.write() calls 861 | // earlier (and thus sending headers) is fine-- as long as you 862 | // haven't done something that ended the response yet. 863 | // We gracefully tolerate it here. 864 | return res.end(); 865 | } 866 | 867 | // If `null`, use res.sendStatus(). 868 | if (_.isUndefined(output) || _.isNull(output)) { 869 | return res.sendStatus(responses[exitCodeName].statusCode); 870 | } 871 | // If the output is an Error instance and it doesn't have a custom .toJSON(), 872 | // then util.inspect() it instead (otherwise res.json() will turn it into an empty dictionary). 873 | // Note that we don't use the `stack`, since `res.badRequest()` might be used in production, 874 | // and we wouldn't want to inadvertently dump a stack trace. 875 | if (_.isError(output)) { 876 | 877 | // Set the status code. 878 | res = res.status(responses[exitCodeName].statusCode); 879 | 880 | if (!_.isFunction(output.toJSON)) { 881 | // Don't send the stack trace in the response in production. 882 | if (IS_RUNNING_IN_PRODUCTION) { 883 | return res.sendStatus(responses[exitCodeName].statusCode); 884 | } 885 | else { 886 | // No need to JSON stringify (this is already a string). 887 | return res.send(util.inspect(output)); 888 | } 889 | } 890 | else { 891 | return res.json(output); 892 | } 893 | }//-• 894 | 895 | // • Stream (hopefully a Readable one) 896 | if (output instanceof Stream) { 897 | output.once('error', function (rawDownloadError){ 898 | try { 899 | var err = flaverr({ 900 | message: 'Encountered error during file download: '+rawDownloadError.message, 901 | raw: rawDownloadError 902 | }, rawDownloadError); 903 | 904 | if (res.finished) { 905 | return res.send(); 906 | } 907 | else if (res.headersSent) { 908 | return res.end(); 909 | } 910 | else if (_.isFunction(res.serverError)) { 911 | return res.serverError(err); 912 | } 913 | else { 914 | // Log the error. 915 | if (_.isObject(req._sails) && _.isObject(req._sails.log) && _.isFunction(req._sails.log.error)) { 916 | req._sails.log.error(err); 917 | } 918 | else { 919 | console.error(err); 920 | } 921 | 922 | // Don't send the error in the response in production. 923 | if (IS_RUNNING_IN_PRODUCTION) { 924 | return res.sendStatus(500); 925 | } 926 | // Otherwise, send the error message in the response. 927 | else { 928 | return res.status(500).send(util.inspect(err,{depth:null})); 929 | } 930 | } 931 | } catch (err) { console.error('Consistency violation: Unexpected internal error:',err); } 932 | });//æ 933 | 934 | // Construct a pass-through stream to adjust for any weirdness 935 | // in the output stream (e.g. otherwise if it came from the 936 | // `request` package aka sails.helpers.http.*, then it could 937 | // leak headers or other potentially sensitive information, or 938 | // cause compatibility issues.) 939 | // > Note: This PassThrough approach isn't free as far as performance, 940 | // > but we feel it's an acceptable price. For more on that, see: 941 | // > https://www.vperi.com/archives/520 942 | var passThrough = new Stream.PassThrough(); 943 | res.status(responses[exitCodeName].statusCode); 944 | return output.pipe(passThrough).pipe(res); 945 | } 946 | // • Buffer 947 | else if (output instanceof Buffer) { 948 | res.status(responses[exitCodeName].statusCode); 949 | return Streamifier.createReadStream(output).pipe(res); 950 | } 951 | // - else just continue on to our `res.send()` catch-all below 952 | 953 | 954 | // • Actual output is number: 955 | // 956 | // If this is a number, handle it as a special case to avoid tricking Express 957 | // into thinking it is a status code. 958 | if (_.isNumber(output)) { 959 | return res.status(responses[exitCodeName].statusCode).json(output); 960 | }//-• 961 | 962 | 963 | // • Anything else: (i.e. rttc.dehydrate()) 964 | var dehydrated = rttc.dehydrate(output, true, undefined, undefined, true); 965 | //^ allowNull 966 | // ^dontStringifyFunctions 967 | // ^allowNaNAndFriends 968 | // ^doRunToJSONMethods 969 | return res.status(responses[exitCodeName].statusCode).send(dehydrated); 970 | 971 | })(); return; // 972 | 973 | 974 | // ┬─┐┌─┐┌─┐┌─┐┌─┐┌┐┌┌─┐┌─┐ ┌┬┐┬ ┬┌─┐┌─┐ ╦═╗╔═╗╔╦╗╦╦═╗╔═╗╔═╗╔╦╗ 975 | // ├┬┘├┤ └─┐├─┘│ ││││└─┐├┤ │ └┬┘├─┘├┤ ╠╦╝║╣ ║║║╠╦╝║╣ ║ ║ 976 | // ┴└─└─┘└─┘┴ └─┘┘└┘└─┘└─┘ ┴ ┴ ┴ └─┘ ╩╚═╚═╝═╩╝╩╩╚═╚═╝╚═╝ ╩ 977 | case 'redirect': (function (){ 978 | // If `res.redirect()` is missing, we have to complain. 979 | // (but if this is a Sails app and this is a Socket request, let the framework handle it) 980 | if (!_.isFunction(res.redirect) && !(req._sails && req.isSocket)) { 981 | throw new Error('Cannot redirect this request because `res.redirect()` does not exist. Is this an HTTP request to a conventional server (i.e. Sails.js/Express)?'); 982 | } 983 | 984 | // Set status code. 985 | res = res.status(responses[exitCodeName].statusCode); 986 | 987 | if (_.isUndefined(output)) { 988 | return res.redirect(); 989 | } 990 | else { 991 | return res.redirect(output); 992 | } 993 | 994 | })(); return;// 995 | 996 | // ┬─┐┌─┐┌─┐┌─┐┌─┐┌┐┌┌─┐┌─┐ ┌┬┐┬ ┬┌─┐┌─┐ ╦ ╦╦╔═╗╦ ╦ 997 | // ├┬┘├┤ └─┐├─┘│ ││││└─┐├┤ │ └┬┘├─┘├┤ ╚╗╔╝║║╣ ║║║ 998 | // ┴└─└─┘└─┘┴ └─┘┘└┘└─┘└─┘ ┴ ┴ ┴ └─┘ ╚╝ ╩╚═╝╚╩╝ 999 | case 'view': (function (){ 1000 | // If `res.view()` is missing, we have to complain. 1001 | // (but if this is a Sails app and this is a Socket request, let the framework handle it) 1002 | if (!_.isFunction(res.view) && !(req._sails && req.isSocket)) { 1003 | throw new Error('Cannot render a view for this request because `res.view()` does not exist. Are you sure this an HTTP request to a Sails.js server with the views hook enabled?'); 1004 | } 1005 | 1006 | // Set status code. 1007 | res = res.status(responses[exitCodeName].statusCode); 1008 | 1009 | if (_.isUndefined(output) || _.isNull(output)) { 1010 | return res.view(responses[exitCodeName].viewTemplatePath); 1011 | } 1012 | else if (_.isObject(output) && !_.isArray(output) && !_.isFunction(output)) { 1013 | return res.view(responses[exitCodeName].viewTemplatePath, output); 1014 | } 1015 | else { 1016 | throw new Error( 1017 | 'Cannot render a view for this request because the provided view locals data '+ 1018 | '(the value passed in to `exits.'+exitCodeName+'()`) is not a dictionary. '+ 1019 | 'In order to respond with a view, either send through a dictionary (e.g. '+ 1020 | '`return exits.'+exitCodeName+'({foo: \'bar\'})`), or don\'t send through '+ 1021 | 'anything at all. Here is what was passed in: '+util.inspect(output,{depth:null}) 1022 | ); 1023 | } 1024 | 1025 | })(); return;// 1026 | 1027 | 1028 | 1029 | // ┬─┐┌─┐┌─┐┌─┐┌─┐┌┐┌┌─┐┌─┐ ┌┬┐┬ ┬┌─┐┌─┐ ╔═╗╦═╗╦═╗╔═╗╦═╗ 1030 | // ├┬┘├┤ └─┐├─┘│ ││││└─┐├┤ │ └┬┘├─┘├┤ ║╣ ╠╦╝╠╦╝║ ║╠╦╝ 1031 | // ┴└─└─┘└─┘┴ └─┘┘└┘└─┘└─┘ ┴ ┴ ┴ └─┘ ╚═╝╩╚═╩╚═╚═╝╩╚═ 1032 | case 'error': (function (){ 1033 | if (!_.isFunction(res.serverError)) { 1034 | throw new Error('Need `res.serverError()` to exist as a function in order to use the `error` response type. Is this a Sails.js app with the responses hook enabled?'); 1035 | }//-• 1036 | 1037 | // Use our output as the argument to `res.serverError()`. 1038 | var catchallErr = output; 1039 | // ...unless there is NO output, in which case we build an error message explaining what happened and pass THAT in. 1040 | if (_.isUndefined(output)) { 1041 | catchallErr = new Error(util.format('Action (triggered by a `%s` request to `%s`) encountered an error, triggering its "%s" exit. No additional error data was provided.', req.method, req.path, exitCodeName) ); 1042 | } 1043 | 1044 | // If this is an internal error, adjust it so that it doesn't contain 1045 | // the useless stack trace from inside machine-as-action. 1046 | // (This is because MaA is a top-level runner.) 1047 | if (catchallErr.code === 'E_INTERNAL_ERROR' && _.isError(catchallErr.raw)) { 1048 | catchallErr = flaverr({ 1049 | name: 'Error', 1050 | code: 'E_INTERNAL_ERROR', 1051 | message: 'Internal error occurred while running this action: '+catchallErr.raw.message 1052 | }, catchallErr.raw); 1053 | } 1054 | 1055 | return res.serverError(catchallErr); 1056 | 1057 | })(); return;// 1058 | 1059 | 1060 | //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// 1061 | // Currently here strictly for backwards compatibility- 1062 | // this response type may be removed (or more likely have its functionality tweaked) in a future release: 1063 | case 'status': 1064 | console.warn('The `status` response type will be deprecated in an upcoming release. Please use `` (standard) instead. Please use `` (standard) instead (i.e. remove `responseType` from the `'+exitCodeName+'` exit.)'); 1065 | return res.status(responses[exitCodeName].statusCode).send(); 1066 | //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// 1067 | 1068 | //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// 1069 | // Currently here strictly for backwards compatibility- 1070 | // this response type may be removed (or more likely have its functionality tweaked) in a future release: 1071 | case 'json': 1072 | console.warn('The `json` response type will be deprecated in an upcoming release. Please use `` (standard) instead (i.e. remove `responseType` from the `'+exitCodeName+'` exit.)'); 1073 | return res.status(responses[exitCodeName].statusCode).json(output); 1074 | //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// 1075 | 1076 | 1077 | // ┬ ┬┌┐┌┬─┐┌─┐┌─┐┌─┐┌─┐┌┐┌┬┌─┐┌─┐┌┬┐ ┬─┐┌─┐┌─┐┌─┐┌─┐┌┐┌┌─┐┌─┐ ┌┬┐┬ ┬┌─┐┌─┐ 1078 | // │ ││││├┬┘├┤ │ │ ││ ┬││││┌─┘├┤ ││ ├┬┘├┤ └─┐├─┘│ ││││└─┐├┤ │ └┬┘├─┘├┤ 1079 | // └─┘┘└┘┴└─└─┘└─┘└─┘└─┘┘└┘┴└─┘└─┘─┴┘ ┴└─└─┘└─┘┴ └─┘┘└┘└─┘└─┘ ┴ ┴ ┴ └─┘ 1080 | default: (function(){ 1081 | 1082 | var declaredResponseType = responses[exitCodeName].responseType; 1083 | var supposedResponseMethod = res[declaredResponseType]; 1084 | 1085 | if (_.isUndefined(supposedResponseMethod)) { 1086 | throw new Error('Attempting to use `res.'+declaredResponseType+'()`, but there is no such method. Make sure you\'ve defined `api/responses/'+supposedResponseMethod+'.js`.'); 1087 | }//-• 1088 | 1089 | if (!_.isFunction(supposedResponseMethod)) { 1090 | throw new Error('Attempting to use `res.'+declaredResponseType+'()`, but it is invalid! Instead of a function, `res.'+declaredResponseType+'` is: '+util.inspect(supposedResponseMethod,{depth:null})); 1091 | }//-• 1092 | 1093 | // Otherwise, we recognized this as a (hopefully) usable method on `res`. 1094 | 1095 | // So first, set the status code. 1096 | res = res.status(responses[exitCodeName].statusCode); 1097 | 1098 | // Handle special case of `null` output. 1099 | // (Because, when preparing a standard response, we treat `null` as equivalent to undefined.) 1100 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1101 | // FUTURE: Potentially remove this. See other "FUTURE" blocks in this file for more information/context. 1102 | // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1103 | if (_.isNull(output)) { 1104 | output = undefined; 1105 | } 1106 | 1107 | // And then try calling the method. 1108 | try { 1109 | supposedResponseMethod(output); 1110 | } catch (e) { throw new Error('Tried to call `res.'+declaredResponseType+'('+(_.isUndefined(output)?'':'output')+')`, but it threw an error: '+(_.isError(e) ? e.stack : util.inspect(e,{depth:null}))); } 1111 | 1112 | })(); return; // 1113 | 1114 | }//--• 1115 | } catch (e) { 1116 | 1117 | var errAsString; 1118 | if (_.isError(e)) { 1119 | errAsString = e.stack; 1120 | } 1121 | else { 1122 | errAsString = util.inspect(e,{depth:null}); 1123 | } 1124 | 1125 | var errMsg = 1126 | 'Handled a `'+req.method+'` request to `'+req.path+'`, by running an action, '+ 1127 | 'which called its `'+exitCodeName+'` exit. But then an error occurred: '+errAsString; 1128 | 1129 | // Log the error. 1130 | if (_.isObject(req._sails) && _.isObject(req._sails.log) && _.isFunction(req._sails.log.error)) { 1131 | req._sails.log.error(errMsg); 1132 | } 1133 | else { 1134 | console.error(errMsg); 1135 | } 1136 | 1137 | // Don't send the error in the response in production. 1138 | if (IS_RUNNING_IN_PRODUCTION) { 1139 | return res.sendStatus(500); 1140 | } 1141 | // Otherwise, send the error message in the response. 1142 | else { 1143 | return res.status(500).send(errMsg); 1144 | } 1145 | 1146 | } 1147 | });// 1148 | 1149 | };// 1150 | });// 1151 | 1152 | // Then attach them and execute the machine. 1153 | return deferred.switch(callbacks); 1154 | 1155 | };//ƒ 1156 | 1157 | // Set `IS_MACHINE_AS_ACTION` flag to prevent accidentally attempting to wrap the same thing twice. 1158 | action.IS_MACHINE_AS_ACTION = true; 1159 | 1160 | // Attach toJSON method that exposes this action's definition. 1161 | action.toJSON = function(){ 1162 | return wetMachine.toJSON(); 1163 | };//ƒ 1164 | 1165 | // Finally, return the action. 1166 | return action; 1167 | }; 1168 | 1169 | 1170 | --------------------------------------------------------------------------------