├── .gitignore ├── .jscsrc ├── .jshintrc ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── example ├── keys │ ├── 60638403-localhost.cert │ └── 60638403-localhost.key ├── server.js └── todos │ ├── index.js │ └── todo-schema.js ├── gulpfile.js ├── package.json └── src ├── config.js ├── index.js ├── lib ├── app.js ├── errors.js ├── get-full-base-url.js └── graceful-shutdown.js ├── middleware ├── domain-errors.js ├── enforce-ssl.js ├── enforce-ssl.spec.js ├── error-handler.js ├── error-handler.spec.js ├── pretty-print.js ├── pretty-print.spec.js ├── request-logger.js └── request-logger.spec.js └── server.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | npm-debug.log 4 | -------------------------------------------------------------------------------- /.jscsrc: -------------------------------------------------------------------------------- 1 | { 2 | "esnext": true, 3 | "requireCurlyBraces": [ 4 | "if", 5 | "else", 6 | "for", 7 | "while", 8 | "do", 9 | "try", 10 | "catch" 11 | ], 12 | "requireOperatorBeforeLineBreak": true, 13 | "requireCamelCaseOrUpperCaseIdentifiers": true, 14 | "maximumLineLength": { 15 | "value": 120, 16 | "allowComments": true, 17 | "allowRegex": true 18 | }, 19 | "validateIndentation": 4, 20 | "validateQuoteMarks": "'", 21 | "disallowMultipleLineStrings": true, 22 | "disallowMixedSpacesAndTabs": true, 23 | "disallowTrailingWhitespace": true, 24 | "disallowSpaceAfterPrefixUnaryOperators": true, 25 | "disallowMultipleVarDecl": true, 26 | "disallowKeywordsOnNewLine": ["else"], 27 | "requireSpaceAfterKeywords": [ 28 | "if", 29 | "else", 30 | "for", 31 | "while", 32 | "do", 33 | "switch", 34 | "return", 35 | "try", 36 | "catch" 37 | ], 38 | "requireSpaceBeforeBinaryOperators": [ 39 | "=", 40 | "+=", 41 | "-=", 42 | "*=", 43 | "/=", 44 | "%=", 45 | "<<=", 46 | ">>=", 47 | ">>>=", 48 | "&=", 49 | "|=", 50 | "^=", 51 | "+=", 52 | "+", 53 | "-", 54 | "*", 55 | "/", 56 | "%", 57 | "<<", 58 | ">>", 59 | ">>>", 60 | "&", 61 | "|", 62 | "^", 63 | "&&", 64 | "||", 65 | "===", 66 | "==", 67 | ">=", 68 | "<=", 69 | "<", 70 | ">", 71 | "!=", 72 | "!==" 73 | ], 74 | "requireSpaceAfterBinaryOperators": true, 75 | "requireSpacesInConditionalExpression": true, 76 | "requireSpaceBeforeBlockStatements": true, 77 | "requireSpacesInForStatement": true, 78 | "requireLineFeedAtFileEnd": true, 79 | "requireSpacesInFunctionExpression": { 80 | "beforeOpeningCurlyBrace": true 81 | }, 82 | "disallowSpacesInAnonymousFunctionExpression": { 83 | "beforeOpeningRoundBrace": true 84 | }, 85 | //"disallowSpacesInsideObjectBrackets": "all", 86 | "disallowSpacesInsideArrayBrackets": "all", 87 | "disallowSpacesInsideParentheses": true, 88 | "disallowMultipleLineBreaks": true, 89 | "disallowNewlineBeforeBlockStatements": true 90 | } 91 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | // JSHint Default Configuration File (as on JSHint website) 3 | // See http://jshint.com/docs/ for more details 4 | 5 | "maxerr" : 50, // {int} Maximum error before stopping 6 | 7 | // Enforcing 8 | "bitwise" : true, // true: Prohibit bitwise operators (&, |, ^, etc.) 9 | "camelcase" : false, // true: Identifiers must be in camelCase 10 | "curly" : true, // true: Require {} for every new block or scope 11 | "eqeqeq" : true, // true: Require triple equals (===) for comparison 12 | "forin" : true, // true: Require filtering for..in loops with obj.hasOwnProperty() 13 | "freeze" : true, // true: prohibits overwriting prototypes of native objects such as Array, Date etc. 14 | "immed" : false, // true: Require immediate invocations to be wrapped in parens e.g. `(function () { } ());` 15 | "indent" : 4, // {int} Number of spaces to use for indentation 16 | "latedef" : false, // true: Require variables/functions to be defined before being used 17 | "newcap" : false, // true: Require capitalization of all constructor functions e.g. `new F()` 18 | "noarg" : true, // true: Prohibit use of `arguments.caller` and `arguments.callee` 19 | "noempty" : true, // true: Prohibit use of empty blocks 20 | "nonbsp" : true, // true: Prohibit "non-breaking whitespace" characters. 21 | "nonew" : false, // true: Prohibit use of constructors for side-effects (without assignment) 22 | "plusplus" : false, // true: Prohibit use of `++` & `--` 23 | "quotmark" : false, // Quotation mark consistency: 24 | // false : do nothing (default) 25 | // true : ensure whatever is used is consistent 26 | // "single" : require single quotes 27 | // "double" : require double quotes 28 | "undef" : true, // true: Require all non-global variables to be declared (prevents global leaks) 29 | "unused" : false, // true: Require all defined variables be used 30 | "strict" : false, // true: Requires all functions run in ES5 Strict Mode 31 | "maxparams" : false, // {int} Max number of formal params allowed per function 32 | "maxdepth" : false, // {int} Max depth of nested blocks (within functions) 33 | "maxstatements" : false, // {int} Max number statements per function 34 | "maxcomplexity" : false, // {int} Max cyclomatic complexity per function 35 | "maxlen" : false, // {int} Max number of characters per line 36 | 37 | // Relaxing 38 | "asi" : false, // true: Tolerate Automatic Semicolon Insertion (no semicolons) 39 | "boss" : false, // true: Tolerate assignments where comparisons would be expected 40 | "debug" : false, // true: Allow debugger statements e.g. browser breakpoints. 41 | "eqnull" : false, // true: Tolerate use of `== null` 42 | "es5" : false, // true: Allow ES5 syntax (ex: getters and setters) 43 | "esnext" : true, // true: Allow ES.next (ES6) syntax (ex: `const`) 44 | "moz" : false, // true: Allow Mozilla specific syntax (extends and overrides esnext features) 45 | // (ex: `for each`, multiple try/catch, function expression…) 46 | "evil" : false, // true: Tolerate use of `eval` and `new Function()` 47 | "expr" : false, // true: Tolerate `ExpressionStatement` as Programs 48 | "funcscope" : false, // true: Tolerate defining variables inside control statements 49 | "globalstrict" : false, // true: Allow global "use strict" (also enables 'strict') 50 | "iterator" : false, // true: Tolerate using the `__iterator__` property 51 | "lastsemic" : false, // true: Tolerate omitting a semicolon for the last statement of a 1-line block 52 | "laxbreak" : false, // true: Tolerate possibly unsafe line breakings 53 | "laxcomma" : false, // true: Tolerate comma-first style coding 54 | "loopfunc" : false, // true: Tolerate functions being defined in loops 55 | "multistr" : false, // true: Tolerate multi-line strings 56 | "noyield" : true, // true: Tolerate generator functions with no yield statement in them. 57 | "notypeof" : false, // true: Tolerate invalid typeof operator values 58 | "proto" : false, // true: Tolerate using the `__proto__` property 59 | "scripturl" : false, // true: Tolerate script-targeted URLs 60 | "shadow" : false, // true: Allows re-define variables later in code e.g. `var x=1; x=2;` 61 | "sub" : false, // true: Tolerate using `[]` notation when it can still be expressed in dot notation 62 | "supernew" : false, // true: Tolerate `new function () { ... };` and `new Object;` 63 | "validthis" : false, // true: Tolerate using this in a non-constructor function 64 | 65 | // Environments 66 | "browser" : true, // Web Browser (window, document, etc) 67 | "browserify" : true, // Browserify (node.js code in the browser) 68 | "couch" : false, // CouchDB 69 | "devel" : true, // Development/debugging (alert, confirm, etc) 70 | "dojo" : false, // Dojo Toolkit 71 | "jasmine" : false, // Jasmine 72 | "jquery" : false, // jQuery 73 | "mocha" : true, // Mocha 74 | "mootools" : false, // MooTools 75 | "node" : true, // Node.js 76 | "nonstandard" : false, // Widely adopted globals (escape, unescape, etc) 77 | "prototypejs" : false, // Prototype and Scriptaculous 78 | "qunit" : false, // QUnit 79 | "rhino" : false, // Rhino 80 | "shelljs" : false, // ShellJS 81 | "worker" : false, // Web Workers 82 | "wsh" : false, // Windows Scripting Host 83 | "yui" : false, // Yahoo User Interface 84 | 85 | // Custom Globals 86 | "globals" : { // additional predefined global variables 87 | "angular" : true, 88 | "inject": true, 89 | "expect": true 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .* 4 | gulpfile.js 5 | **/*.spec.js 6 | 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '0.10' 4 | - '0.12' 5 | - iojs 6 | 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 0.0.8 / 2015-07-19 2 | ================== 3 | 4 | - update: Allow request logging to be skipped via request flag: `req.skipRequestLog`. 5 | 6 | 0.0.7 / 2015-06-28 7 | ================== 8 | 9 | - fix: Request logs are not reporting the correct (full) url path 10 | 11 | 12 | 0.0.6 / 2015-06-21 13 | ================== 14 | 15 | - update: Improve error handling of uncaught exceptions during a request. Now uses a domain to catch, log the error, and send a response before rethrowing to the graceful shutdown handler (if enabled). 16 | 17 | 0.0.5 / 2015-06-20 18 | ================== 19 | 20 | - fix: Error type has incorrect app/custom code property name. Changed HttpError `appCode`/`customCode` to `code`, and `statusCode` to `status` 21 | 22 | 0.0.4 / 2015-06-18 23 | ================== 24 | 25 | - fix: Republish due to NPM registry checksum issue 26 | 27 | 0.0.3 / 2015-06-16 28 | ================== 29 | 30 | - update: Add method override middleware for browser clients 31 | 32 | 0.0.2 / 2015-06-16 33 | ================== 34 | 35 | - update: Support all HTTP Error types - Dynamically creates all error types from HTTP codes 36 | - update: Log unhandled errors 37 | 38 | 0.0.1 39 | ===== 40 | 41 | This file was started after the release of 0.0.1. 42 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © 2015 Christopher Martin 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # express-api-server 2 | 3 | [![No Maintenance Intended](http://unmaintained.tech/badge.svg)](http://unmaintained.tech/) 4 | 5 | An opinionated Web API server library using Express, Node.js, and REST best practices. 6 | 7 | [![Build Status](https://travis-ci.org/cgmartin/express-api-server.svg?branch=master)](https://travis-ci.org/cgmartin/express-api-server) 8 | [![Dependency Status](https://david-dm.org/cgmartin/express-api-server.svg)](https://david-dm.org/cgmartin/express-api-server) 9 | [![npm version](https://badge.fury.io/js/express-api-server.svg)](http://badge.fury.io/js/express-api-server) 10 | 11 | ## Synopsis 12 | 13 | The express-api-server is meant as a reusable web API server with built-in security, error handling, 14 | and logging. Focus on writing just your custom API routes and leave rest of the server set up to this library. 15 | 16 | This web API server project is meant to accompany a [SPA client](https://github.com/cgmartin/angular-spa-browserify-example) 17 | as part of a set of Node.js microservices (Static Web Server, Web API Server, Chat Server, Reverse Proxy). 18 | It is designed with portability and scalability in mind (see [Twelve Factors](http://12factor.net/)). 19 | 20 | Configuration options are passed in by the consumer or via environment variables at runtime. 21 | 22 | ## Quick Start / Usage 23 | 24 | ```bash 25 | $ npm install express-api-server --save 26 | ``` 27 | 28 | Create a `server.js` wrapper script, passing in the configuration options that apply for your app: 29 | ```js 30 | // server.js : Web API Server entrypoint 31 | var apiServer = require('express-api-server'); 32 | 33 | var options = { 34 | baseUrlPath: '/api', 35 | cors: {}, 36 | sslKeyFile: './keys/my-domain.key'), 37 | sslCertFile: './keys/my-domain.cert') 38 | }; 39 | 40 | var initRoutes = function(app, options) { 41 | // Set up routes off of base URL path 42 | app.use(options.baseUrlPath, [ 43 | require('./todo-routes') 44 | ]); 45 | }; 46 | 47 | apiServer.start(initRoutes, options); 48 | ``` 49 | 50 | An example express router for "todo" resources: 51 | ```js 52 | // todo-routes.js 53 | var express = require('express'); 54 | var errors = require('express-api-server').errors; 55 | var jsonParser = require('body-parser').json(); 56 | 57 | var router = module.exports = express.Router(); 58 | 59 | router.route('/todos') 60 | // GET /todos 61 | .get(function(req, res, next) { 62 | var todos; // ...Get resources from backend... 63 | res.json(todos); 64 | }) 65 | // POST /todos 66 | .post(jsonParser, function(req, res, next) { 67 | if (!req.body) { return next(new errors.BadRequestError()); } 68 | 69 | // Validate JSON body using whatever method you choose 70 | var newTodo = filter(req.body); 71 | if (!validate(newTodo)) { 72 | req.log.error('Invalid todo body'); // Bunyan logger available on req 73 | return next(new errors.UnprocessableEntityError('Invalid todo resource body', {errors: validate.errors})); 74 | } 75 | 76 | // ...Save to backend... 77 | 78 | res.location(req.protocol + '://' + req.get('Host') + req.baseUrl + '/todos/' + newTodo.id); 79 | res.status(201); // Created 80 | res.json(newTodo); 81 | }); 82 | ``` 83 | 84 | Run your `server.js` with optional runtime environment variables: 85 | ```bash 86 | $ API_COMPRESSION=1 API_SSL=1 API_PORT=4443 node server.js 87 | ``` 88 | 89 | See [src/config.js](src/config.js) for a full list of the available configuration options. 90 | 91 | See [src/lib/errors.js](src/lib/errors.js) for built-in Error sub-types appropriate for API/HTTP scenarios. 92 | 93 | See [example/server.js](example/server.js) for a runnable example. 94 | 95 | ### Default Environment Variables 96 | 97 | * `API_BASE_URL` : A base URL path prefix, i.e. "/api" 98 | * `API_COMPRESSION` : Enables gzip compression when set to "1". 99 | * `API_GRACEFUL_SHUTDOWN` : Wait for connections to close before stopping server when set to "1". 100 | * `API_SESSION_MAXAGE` : The time in ms until the session ID cookie should expire (default: 2 hours). This is just a tracking cookie, no session storage is used here. 101 | * `API_REV_PROXY` : The server is behind a reverse proxy when set to "1". 102 | * `API_PORT` : The port to run on (default: 8000). 103 | * `API_SSL` : Use a HTTPS server when set to "1". Enforces HTTPS by redirecting HTTP users when used with a reverse HTTP/HTTPS proxy. 104 | * `API_SSL_KEY` : Path to the SSL key file. 105 | * `API_SSL_CERT` : Path to the SSL cert file. 106 | 107 | ## Features 108 | 109 | * **Security headers** using [Helmet](https://github.com/helmetjs/helmet) middleware. 110 | * **Correlation IDs**: Creates unique request and "conversation" ids. Useful for tracking requests from client to backend services. 111 | * **Graceful shutdown**: Listens for SIGTERM/SIGINT and unhandled exceptions, and waits for open connections to complete before exiting. 112 | * **JSON format access logs**: Great for log analysis and collectors such as Splunk, Fluentd, Graylog, Logstash, etc. 113 | * **Enforce HTTPS**: Redirects users from HTTP urls to HTTPS. 114 | * **API Error Types**: Conveniently create errors for bad, unauthorized, or unprocessable requests. 115 | * **Error Handler**: API Errors fall through to the built-in error handler to send standard error responses with custom error codes, headers, and validation field errors. 116 | * **Pretty Print**: Format your JSON reponses using `?pretty=true` query param on any endpoint. 117 | 118 | ## Contributing 119 | 120 | 1. Install [Node.js](https://nodejs.org/download/) 121 | 1. Install Gulp: `npm -g i gulp` 122 | 1. Clone this repo 123 | 1. Install dependencies: `npm i` 124 | 1. Start the app in dev mode: `npm start` 125 | 1. Point browser to and watch the console for server logs 126 | 127 | After installation, the following actions are available: 128 | 129 | * `npm start` : Runs in development mode, starting the server and a local webserver, running linting and unit tests, and restarting upon file changes. 130 | * `npm test` : Runs JavaScript file linting and unit tests. 131 | * `npm run watch` : Alternative development mode - does not run servers. Only runs linting and unit tests upon file changes. 132 | 133 | ## Folder Structure 134 | 135 | ``` 136 | ├── coverage # Coverage reports 137 | ├── example # Example REST API server for testing 138 | └── src 139 | ├── middleware # Express middleware utilities 140 | ├── lib 141 | │ ├── app.js # Creates and configures an express app 142 | │ ├── errors.js # Custom error classes 143 | │ └── graceful-shutdown.js # Attempts a graceful server shutdown 144 | │ 145 | ├── config.js # Configuration options 146 | └── server.js # Starts the express API server on a port 147 | ``` 148 | 149 | ## Libraries & Tools 150 | 151 | The functionality has been implemented by integrating the following 3rd-party tools and libraries: 152 | 153 | - [Express](https://github.com/strongloop/express): Fast, minimalist web framework for node 154 | - [Helmet](https://github.com/helmetjs/helmet): Secure Express apps with various HTTP headers 155 | - [Bunyan](https://github.com/trentm/node-bunyan): A simple and fast JSON logging module for node.js services 156 | - [Gulp](http://gulpjs.com/): Streaming build system and task runner 157 | - [Node.js](http://nodejs.org/api/): JavaScript runtime environment for server-side development 158 | - [Mocha](http://mochajs.org/): The fun, simple, flexible JavaScript test framework 159 | - [Chai](http://chaijs.com/): BDD/TDD assertion library for node and the browser 160 | - [Sinon](http://sinonjs.org/): Standalone test spies, stubs and mocks for JavaScript 161 | - [Mockery](https://github.com/mfncooper/mockery): Mock Node.js module dependencies during testing 162 | 163 | ## REST API Resources 164 | 165 | * 166 | * 167 | 168 | ## License 169 | 170 | [MIT License](http://cgm.mit-license.org/) Copyright © 2015 Christopher Martin 171 | -------------------------------------------------------------------------------- /example/keys/60638403-localhost.cert: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIC+zCCAeOgAwIBAgIJAMYSgxOM9l+FMA0GCSqGSIb3DQEBBQUAMBQxEjAQBgNV 3 | BAMMCWxvY2FsaG9zdDAeFw0xNTA1MjMxODAyMDZaFw0yNTA1MjAxODAyMDZaMBQx 4 | EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC 5 | ggEBALetR4bfuHK45i+eCHh4acy9Pkc/2hTw8K/ukA/QBwXWN38O+wv4zMplWbmc 6 | wElneay3VI1445CQs1bEFiN0HqhYgEGS9YftlKid7kUKxcyvxiCZ+R9bXEJWnfoU 7 | JWbrawSl0jvZETfuR3skv4vOazVhjes9QQETwD+YSmz3h4An5geu5Og4IH32w9Y/ 8 | ozybnx8GZYVFChW9y4F5EKhMEXHSPRtfJ3hSuYo7W9dZwVohPDDFthx3+bUxBE/2 9 | uW47eNe1Pz3/VCNE5t6WVC7kFpgcsSGjUOsOw4eDmrqJrG1oTN9ZdK+lbJKlIf84 10 | jJgGmyzGLNy8Y2JwrrZqeLNTHKcCAwEAAaNQME4wHQYDVR0OBBYEFA/EbPSjHLHU 11 | z5zF6EQs4qvCm9LZMB8GA1UdIwQYMBaAFA/EbPSjHLHUz5zF6EQs4qvCm9LZMAwG 12 | A1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAJ9mvFnJ3eezMJL6hJXQCbFq 13 | ciqvcvLSBUrlitxhgMOTfdf6GXylXE7jbGW7rQEkf0iXvuadJI92XRKQrB66Lf0x 14 | 2jjA+3dyQF0f+8/0r1jJsTDtM39xMZaUuTJVHEVnKtyOIL6J6PVtczMSIGnPeeNy 15 | kitF6FnglvR4XM8cnSzv9QsxeobEYyahmi8FpmmbRvc7xC+CEBfux21mhYFba9hJ 16 | uoIeSpoC8MJlQbUUkZXJ3pvrmb4SN0qaGas6mwfAPEX2fIuO+MzKWRlxjXN3AD1v 17 | gIolH7/S4X3Ve1RQz82NBtXfC9qGvSglZFkt8HQStgCooAiu51QymEzeih53Xi4= 18 | -----END CERTIFICATE----- 19 | -------------------------------------------------------------------------------- /example/keys/60638403-localhost.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEAt61Hht+4crjmL54IeHhpzL0+Rz/aFPDwr+6QD9AHBdY3fw77 3 | C/jMymVZuZzASWd5rLdUjXjjkJCzVsQWI3QeqFiAQZL1h+2UqJ3uRQrFzK/GIJn5 4 | H1tcQlad+hQlZutrBKXSO9kRN+5HeyS/i85rNWGN6z1BARPAP5hKbPeHgCfmB67k 5 | 6DggffbD1j+jPJufHwZlhUUKFb3LgXkQqEwRcdI9G18neFK5ijtb11nBWiE8MMW2 6 | HHf5tTEET/a5bjt417U/Pf9UI0Tm3pZULuQWmByxIaNQ6w7Dh4OauomsbWhM31l0 7 | r6VskqUh/ziMmAabLMYs3LxjYnCutmp4s1McpwIDAQABAoIBAQC2amoyAfgOP6Gk 8 | QuAIvRXeF0hFvJ1a1NtE7rm2apS+8DvXfUpIXoUMsVFF7O5vOEv4A27+vcPLrFnf 9 | 3whZl5Zm/NYLyeb9Yy+Tsp2ThhphqWQstp5azQI4hAuK9P0cVMvJJuI/O9pE4Omu 10 | 3BU7xPpmXodyzDfF2RbCUf4AcGjrFYTows8h/o1GHQ7XvF7HmIfsE+hLL1czcsgM 11 | OlZ3p66vPlij+p3+s6NUZ5E30dYVdmDWPSe5hyMxlJPuJ58lT6jQFRRXsh2vQISB 12 | 0zjXGN86XKsX/wfA8Ds0Uq+2AP0wbliwicRCN9ICz1sZ9YfbRi/MgHTqz/ew7i2T 13 | LjfHHcQRAoGBAOqmS+DsFr3cy86UzYU05bM4kPG8eKPgJIoI/AyX/Rtb5dEVu3jP 14 | vKB0SIid1raWkSTfUbAbqltm57es3t9vkZ3p4PRuPyG63fYhLw1rse/c5nVzCjyN 15 | irQReX84ndL7zeu60mRzTLHiWzv1cKZL/Qf5admfWWDqW77u3H9zt7HJAoGBAMhj 16 | rE6jQtUGI7ibR3a0RUPLAXj+2I8mnf9KZ84qyUA7Cn3goYPiPpeTwpcAf/6Dx8Ko 17 | WXBpwNQnAqQy75YSmgUH7i3KEsffLhA5R9DrMJWoiJOmOELN/1uR+TXWaVZYP19n 18 | mDyPGcsTZl0NNEWOTsN6WJI8m8J//O8tI1lHtBLvAoGBAJCWfgUPlQfTCOa3fFiL 19 | esrPnUjHqNLZ58oCtUURVo5INzl6GbXc089PN+6uy8JgzvkYfp50valqpHfilsa5 20 | WdIjblFPqakgG2txkSvE47T4ui0/ANzFHuXMKsCA44dBT+bkjIYHIggugadVmt9t 21 | zXHfdyD49rsoTfY9+zKx3Ew5AoGAcZID+wGOhBsZravbwcwDoZtxdzjAVclmLGTo 22 | FjGro8qSdKsV/x//p3qoA1rWL9JSKeGt5wcYsWR2m8b+gIiEYCuRcsQfBsZXXfyI 23 | 1kAlZfyBg2TmZ/5GJojBvCCLzNLw/8o/vrq/vJd/IWe1Y2J3A1TY0/CjuoU9PfTQ 24 | Hu6DgWMCgYAi0R3/3tlBi9T1SVSBhctOcgtqPV+pZ4eny+Mo6DhGjw2wYgyLZ8Ea 25 | RPs2GeavcKQWWzyWO2/6Vj/vQGHDmeh0bvfrxbY7DDbm/U5/8mK0fQcXqAmZ4vNY 26 | TqXDX0PhXR0jWikOryP8Lk5qEpX8gzl2yR7XlFpLwHuL4HUuLiS8nQ== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /example/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /** 3 | * Example: 4 | * How to use the api server with custom routes and config 5 | */ 6 | var path = require('path'); 7 | var expressApiServer = require('../src'); // require('express-api-server'); 8 | 9 | var apiOptions = { 10 | baseUrlPath: '/api', 11 | sslKeyFile: path.join(__dirname, '/keys/60638403-localhost.key'), 12 | sslCertFile: path.join(__dirname, '/keys/60638403-localhost.cert'), 13 | cors: {}, 14 | isGracefulShutdownEnabled: false 15 | }; 16 | 17 | var initApiRoutes = function(app, options) { 18 | // Set up routes off of base URL path 19 | app.use(options.baseUrlPath, [ 20 | require('./todos') 21 | ]); 22 | }; 23 | 24 | expressApiServer.start(initApiRoutes, apiOptions); 25 | 26 | // Use environment variables for other options: 27 | // API_COMPRESSION=1 API_SSL=1 API_PORT=4443 node example/start.js 28 | -------------------------------------------------------------------------------- /example/todos/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /** 3 | * Example express router for "todo" resources 4 | */ 5 | var express = require('express'); 6 | var jsonParser = require('body-parser').json(); 7 | var expressApiServer = require('../../src'); // require('express-api-server'); 8 | var errors = expressApiServer.errors; 9 | var getFullBaseUrl = expressApiServer.getFullBaseUrl; 10 | var validator = require('is-my-json-valid'); 11 | var todoSchema = require('./todo-schema'); 12 | var validate = validator(todoSchema); 13 | var filter = validator.filter(todoSchema); 14 | 15 | var router = module.exports = express.Router(); 16 | 17 | // 18 | // Route Definitions 19 | // ----------------------------------------------------------------- 20 | 21 | router.all('*', requireAuthentication); // authenticate all methods 22 | 23 | router.route('/todos') 24 | .get(retrieveTodoList) // GET /todos 25 | .post(jsonParser, createTodo); // POST /todos 26 | 27 | router.param('todo_id', fetchTodoParam); // fetch the resource by param id 28 | 29 | router.route('/todos/:todo_id') 30 | .get(retrieveTodo) // GET /todos/1 31 | .put(updateTodo) // PUT /todos/1 32 | .delete(deleteTodo); // DELETE /todos/1 33 | 34 | // 35 | // Route Actions 36 | // ----------------------------------------------------------------- 37 | 38 | function requireAuthentication(req, res, next) { 39 | // Unauthorized example: 40 | //return next((new errors.UnauthorizedError()).authBearerHeader()); 41 | next(); 42 | } 43 | 44 | function retrieveTodoList(req, res, next) { 45 | // ...Retrieve from backend... 46 | var todos = [ 47 | {id: 1, title: 'Do something', isComplete: true}, 48 | {id: 2, title: 'Do something else', isComplete: false} 49 | ]; 50 | res.json(todos); 51 | } 52 | 53 | function createTodo(req, res, next) { 54 | if (!req.body) { return next(new errors.BadRequestError()); } 55 | 56 | // Validate JSON with schema 57 | var newTodo = filter(req.body); 58 | if (!validate(newTodo)) { 59 | req.log.error('Invalid todo body'); // Bunyan logger available on req 60 | return next(new errors.UnprocessableEntityError('Invalid todo resource body', {errors: validate.errors})); 61 | } 62 | 63 | // ...Save in backend... 64 | newTodo.id = '3'; 65 | 66 | res.status(201); // Created 67 | res.location(getFullBaseUrl(req) + '/todos/' + newTodo.id); 68 | res.json(newTodo); 69 | } 70 | 71 | function fetchTodoParam(req, res, next, id) { 72 | // Retrieve resource from backend, attach to request... 73 | req.todo = { 74 | id: id, 75 | title: 'Do something', 76 | isComplete: false 77 | }; 78 | next(); 79 | } 80 | 81 | function retrieveTodo(req, res, next) { 82 | res.json(req.todo); // Already retrieved by param function 83 | } 84 | 85 | function updateTodo(req, res, next) { 86 | var todo = req.todo; 87 | // Example: Resource is forbidden to this user 88 | return next(new errors.ForbiddenError()); 89 | } 90 | 91 | function deleteTodo(req, res, next) { 92 | var todo = req.todo; 93 | // Example: Method is not allowed for this user 94 | return next(new errors.MethodNotAllowedError()); 95 | } 96 | 97 | -------------------------------------------------------------------------------- /example/todos/todo-schema.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | // https://github.com/mafintosh/is-my-json-valid 3 | // http://json-schema.org/documentation.html 4 | module.exports = { 5 | name: 'todo', 6 | type: 'object', 7 | additionalProperties: false, 8 | required: ['title'], 9 | properties: { 10 | id: { type: 'string', maxLength: 64 }, 11 | title: { type: 'string', maxLength: 255 }, 12 | isComplete: { type: 'boolean' } 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var path = require('path'); 3 | var _ = require('lodash'); 4 | var gulp = require('gulp-help')(require('gulp')); 5 | var $ = require('gulp-load-plugins')({lazy: true}); 6 | var runSequence = require('run-sequence'); 7 | var args = require('yargs').argv; 8 | var notifier = require('node-notifier'); 9 | var del = require('del'); 10 | 11 | process.setMaxListeners(0); // Disable max listeners for gulp 12 | 13 | var isVerbose = args.verbose; // Enable extra verbose logging with --verbose 14 | var isProduction = args.prod; // Run extra steps with production flag --prod 15 | var isWatching = false; // Enable/disable tasks when running watch 16 | 17 | /************************************************************************ 18 | * Functions/Utilities 19 | */ 20 | 21 | // Desktop notifications of errors 22 | function onError(err) { 23 | // jshint validthis: true 24 | notifier.notify({ 25 | title: err.plugin + ' Error', 26 | message: err.message 27 | }); 28 | $.util.log(err.toString()); 29 | $.util.beep(); 30 | if (isWatching) { 31 | this.emit('end'); 32 | } else { 33 | process.exit(1); 34 | } 35 | } 36 | 37 | function verbosePrintFiles(taskName) { 38 | return $.if(isVerbose, $.print(function(filepath) { 39 | return taskName + ': ' + filepath; 40 | })) 41 | } 42 | 43 | /************************************************************************ 44 | * Clean temporary folders and files 45 | */ 46 | 47 | gulp.task('clean-coverage', false, function(cb) { 48 | del(['coverage'], cb); 49 | }); 50 | 51 | gulp.task('clean', 'Remove all temporary files', ['clean-coverage']); 52 | 53 | /************************************************************************ 54 | * JavaScript tasks 55 | */ 56 | 57 | gulp.task('lint', 'Lints all JavaScript files', function() { 58 | return gulp.src('src/**/*.js') 59 | .pipe($.plumber({errorHandler: onError})) 60 | .pipe(verbosePrintFiles('lint-js')) 61 | .pipe($.jscs()) 62 | .pipe($.jshint()) 63 | .pipe($.jshint.reporter('jshint-stylish', {verbose: true})) 64 | .pipe($.if(!isWatching, $.jshint.reporter('fail'))); 65 | }); 66 | 67 | /************************************************************************ 68 | * Unit testing tasks 69 | */ 70 | 71 | function serverTestStream() { 72 | process.env.NODE_ENV = 'test'; 73 | return gulp.src('src/**/*.spec.js', {read: false}) 74 | .pipe($.plumber({errorHandler: onError})) 75 | .pipe($.if(args.verbose, $.print())) 76 | .pipe($.mocha({ 77 | reporter: 'spec' 78 | })); 79 | } 80 | 81 | gulp.task('test-server', function() { 82 | return serverTestStream(); 83 | }); 84 | 85 | gulp.task('test-server-coverage', ['clean-coverage'], function(cb) { 86 | var coverageDir = './coverage'; 87 | gulp.src(['src/**/*.js', '!**/*.spec.js']) 88 | .pipe($.if(args.verbose, $.print())) 89 | .pipe($.istanbul({ // Covering files 90 | //instrumenter: isparta.Instrumenter, 91 | includeUntested: true 92 | })) 93 | .pipe($.istanbul.hookRequire()) // Force `require` to return covered files 94 | .on('finish', function() { 95 | serverTestStream() 96 | .pipe($.istanbul.writeReports({ 97 | dir: coverageDir, 98 | reportOpts: {dir: coverageDir}, 99 | reporters: ['text', 'text-summary', 'json', 'html'] 100 | })) 101 | .pipe($.istanbulEnforcer({ 102 | thresholds: { 103 | statements: 0, 104 | branches: 0, 105 | lines: 0, 106 | functions: 0 107 | }, 108 | coverageDirectory: coverageDir, 109 | rootDirectory: '' 110 | })) 111 | .on('end', cb); 112 | }); 113 | }); 114 | 115 | gulp.task('test', 'Run unit tests', function(cb) { 116 | runSequence('clean-coverage', 'lint', 'test-server-coverage', cb); 117 | }); 118 | 119 | /************************************************************************ 120 | * Watch / Reload tasks 121 | */ 122 | 123 | gulp.task('watch-iterate', false, function(cb) { 124 | runSequence('lint', 'test-server', cb); 125 | }); 126 | 127 | gulp.task('watch', false, function() { 128 | isWatching = true; 129 | gulp.watch('src/**/*.js', ['watch-iterate']); 130 | }); 131 | 132 | gulp.task('nodemon', false, function(cb) { 133 | var firstStart = true; 134 | var serverPort = 8000; 135 | $.nodemon({ 136 | script: 'example/server.js', 137 | ext: 'js', 138 | env: { 139 | 'NODE_ENV': 'development', 140 | 'PORT': serverPort 141 | }, 142 | nodeArgs: ['--debug'], 143 | ignore: [ 144 | 'coverage/**', 'node_modules/**', 145 | 'gulpfile.js', '.idea/**', '.git/**' 146 | ], 147 | stdout: false // important for 'readable' event 148 | }) 149 | // The http server might not have started listening yet when 150 | // the `restart` event has been triggered. It's best to check 151 | // whether it is already listening for connections or not. 152 | .on('readable', function() { 153 | this.stdout.on('data', function(chunk) { 154 | process.stdout.write(chunk); 155 | if (/listening at http/.test(chunk)) { 156 | if (firstStart) { 157 | firstStart = false; 158 | cb(); 159 | } 160 | } 161 | }); 162 | this.stderr.pipe(process.stdout); 163 | }); 164 | //.on('change', ['test-server']) 165 | //.on('start', function() {}); 166 | }); 167 | 168 | gulp.task('serve', 'Watch for file changes and re-run build and lint tasks', function(cb) { 169 | // When watch and nodemon tasks run at same time 170 | // the server seems to randomly blow up (??) 171 | runSequence( 172 | 'watch', 173 | 'nodemon', 174 | cb 175 | ); 176 | }); 177 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "express-api-server", 3 | "version": "0.0.8", 4 | "description": "An opinionated Express Web API server library", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "start": "gulp serve", 8 | "watch": "gulp watch", 9 | "test": "gulp test" 10 | }, 11 | "author": "Christopher Martin ", 12 | "repository": "cgmartin/express-api-server", 13 | "homepage": "https://github.com/cgmartin/express-api-server", 14 | "license": "MIT", 15 | "engines": { 16 | "node": "0.12.x" 17 | }, 18 | "keywords": [ 19 | "express", 20 | "api", 21 | "rest", 22 | "service" 23 | ], 24 | "dependencies": { 25 | "bunyan": "^1.4.0", 26 | "compression": "^1.5.1", 27 | "cors": "^2.7.1", 28 | "express": "^4.13.1", 29 | "helmet": "^0.10.0", 30 | "lodash": "^3.10.0", 31 | "method-override": "^2.3.4", 32 | "uuid": "^2.0.1" 33 | }, 34 | "devDependencies": { 35 | "body-parser": "^1.13.2", 36 | "chai": "^3.1.0", 37 | "del": "^1.2.0", 38 | "gulp": "^3.9.0", 39 | "gulp-help": "^1.6.0", 40 | "gulp-if": "^1.2.5", 41 | "gulp-istanbul": "^0.10.0", 42 | "gulp-istanbul-enforcer": "^1.0.3", 43 | "gulp-jscs": "^1.6.0", 44 | "gulp-jshint": "^1.11.2", 45 | "gulp-load-plugins": "^1.0.0-rc", 46 | "gulp-mocha": "^2.1.3", 47 | "gulp-nodemon": "^2.0.3", 48 | "gulp-plumber": "^1.0.1", 49 | "gulp-print": "^1.1.0", 50 | "gulp-util": "^3.0.6", 51 | "is-my-json-valid": "^2.12.0", 52 | "jshint-stylish": "^2.0.1", 53 | "mkdirp": "^0.5.1", 54 | "mocha": "^2.2.5", 55 | "mockery": "^1.4.0", 56 | "node-notifier": "^4.2.3", 57 | "run-sequence": "^1.1.1", 58 | "sinon": "^1.15.4", 59 | "yargs": "^3.15.0" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | // A base URL path prefix, i.e. "/api" 5 | baseUrlPath: process.env.API_BASE_URL, 6 | 7 | // Enable gzip compression for response output 8 | isCompressionEnabled: (process.env.API_COMPRESSION === '1'), 9 | 10 | // Wait for connections to close before stopping server 11 | isGracefulShutdownEnabled: (process.env.API_GRACEFUL_SHUTDOWN === '1'), 12 | 13 | // See https://github.com/expressjs/compression#options 14 | compressionOptions: { 15 | threshold: 4000 16 | }, 17 | 18 | // https://github.com/expressjs/method-override 19 | methodOverrideHeader: 'X-HTTP-Method-Override', 20 | 21 | // Enable this if behind a secure reverse proxy, like heroku 22 | isBehindProxy: (process.env.API_REV_PROXY === '1'), 23 | 24 | // Server port. For ports 80 or 443, must be started as superuser 25 | port: parseInt(process.env.API_PORT || process.env.PORT || 8000), 26 | 27 | // Enable for HTTPS 28 | isSslEnabled: (process.env.API_SSL === '1'), 29 | 30 | // HTTPS key/cert file paths 31 | sslKeyFile: process.env.API_SSL_KEY, 32 | sslCertFile: process.env.API_SSL_CERT, 33 | 34 | // HTTP Strict Transport Security options 35 | // see: https://github.com/helmetjs/hsts 36 | hsts: { 37 | maxAge: 7776000000, // ninety days in ms 38 | includeSubdomains: true, 39 | preload: true 40 | }, 41 | 42 | // Limits maximum incoming headers count. If set to 0 - no limit will be applied. 43 | maxHeadersCount: 1000, 44 | 45 | // The number of milliseconds of inactivity before a socket is presumed to have timed out. 46 | serverTimeout: 2 * 60 * 1000, // 2 minutes 47 | 48 | // Cross-site HTTP requests 49 | // https://github.com/expressjs/cors#configuring-cors 50 | cors: false 51 | }; 52 | 53 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | start: require('./server'), 5 | errors: require('./lib/errors'), 6 | getFullBaseUrl: require('./lib/get-full-base-url') 7 | }; 8 | -------------------------------------------------------------------------------- /src/lib/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('lodash'); 4 | var path = require('path'); 5 | var express = require('express'); 6 | var cors = require('cors'); 7 | var helmet = require('helmet'); 8 | var enforceSsl = require('../middleware/enforce-ssl'); 9 | var logger = require('../middleware/request-logger'); 10 | var errorHandler = require('../middleware/error-handler'); 11 | var prettyPrint = require('../middleware/pretty-print'); 12 | var compression = require('compression'); 13 | var methodOverride = require('method-override'); 14 | var domainErrors = require('../middleware/domain-errors'); 15 | var errors = require('./errors'); 16 | 17 | module.exports = function createApp(appInitCb, options) { 18 | var app = express(); 19 | 20 | if (options.isBehindProxy) { 21 | // http://expressjs.com/api.html#trust.proxy.options.table 22 | app.enable('trust proxy'); 23 | } 24 | 25 | // Unhandled exception domain handler 26 | app.use(domainErrors); 27 | 28 | // Logging requests 29 | app.use(logger({logger: options.logger})); 30 | 31 | // CORS Requests 32 | if (options.cors) { 33 | app.use(cors(options.cors)); 34 | } 35 | 36 | // Security middleware 37 | app.use(helmet.hidePoweredBy()); 38 | app.use(helmet.ieNoOpen()); 39 | app.use(helmet.noSniff()); 40 | app.use(helmet.frameguard()); 41 | app.use(helmet.xssFilter()); 42 | if (options.isSslEnabled) { 43 | app.use(helmet.hsts(options.hsts)); 44 | app.use(enforceSsl()); 45 | } 46 | 47 | // Compression settings 48 | if (options.isCompressionEnabled) { 49 | app.use(compression(options.compressionOptions)); 50 | } 51 | 52 | // Convenient pretty json printing via `?pretty=true` 53 | app.use(prettyPrint(app)); 54 | 55 | // Allow clients without PUT or DELETE verb support to override POST method 56 | app.use(methodOverride(options.methodOverrideHeader)); 57 | 58 | // Userland callback 59 | appInitCb(app, options); 60 | 61 | // 404 catch-all 62 | app.use(function(req, res, next) { 63 | next(new errors.NotFoundError()); 64 | }); 65 | 66 | // Error handler 67 | app.use(errorHandler); 68 | 69 | return app; 70 | }; 71 | -------------------------------------------------------------------------------- /src/lib/errors.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var assert = require('assert'); 4 | var http = require('http'); 5 | var util = require('util'); 6 | 7 | var errors = module.exports; 8 | 9 | /** 10 | * All HTTP Errors will extend from this object 11 | */ 12 | function HttpError(message, options) { 13 | // handle constructor call without 'new' 14 | if (!(this instanceof HttpError)) { 15 | return new HttpError(message, options); 16 | } 17 | 18 | HttpError.super_.call(this); 19 | Error.captureStackTrace(this, this.constructor); 20 | this.name = 'HttpError'; 21 | this.message = message; 22 | this.status = 500; 23 | 24 | options = options || {}; 25 | if (options.code) { this.code = options.code; } 26 | if (options.errors) { this.errors = options.errors; } 27 | if (options.headers) { this.headers = options.headers; } 28 | if (options.cause) { this.cause = options.cause; } 29 | } 30 | util.inherits(HttpError, Error); 31 | 32 | /** 33 | * Helper method to add a WWW-Authenticate header 34 | * https://tools.ietf.org/html/rfc6750#section-3 35 | */ 36 | HttpError.prototype.authBearerHeader = function(realm, error, description) { 37 | if (!this.headers) { 38 | this.headers = {}; 39 | } 40 | realm = realm || 'Secure Area'; 41 | var authHeader = 'Bearer realm="' + realm + '"'; 42 | if (error) { 43 | authHeader += ',error="' + error + '"'; 44 | } 45 | if (description) { 46 | authHeader += ',error_description="' + description + '"'; 47 | } 48 | this.headers['WWW-Authenticate'] = authHeader; 49 | 50 | return this; 51 | }; 52 | 53 | /** 54 | * Creates a custom API Error sub type 55 | */ 56 | HttpError.extend = function(subTypeName, statusCode, defaultMessage) { 57 | assert(subTypeName, 'subTypeName is required'); 58 | 59 | var SubTypeError = function(message, options) { 60 | // handle constructor call without 'new' 61 | if (!(this instanceof SubTypeError)) { 62 | return new SubTypeError(message, options); 63 | } 64 | 65 | SubTypeError.super_.call(this, message, options); 66 | Error.captureStackTrace(this, this.constructor); 67 | 68 | this.name = subTypeName; 69 | this.status = parseInt(statusCode || 500, 10); 70 | this.message = message || defaultMessage; 71 | }; 72 | 73 | // Inherit the parent's prototype chain 74 | util.inherits(SubTypeError, this); 75 | SubTypeError.extend = this.extend; 76 | 77 | return SubTypeError; 78 | }; 79 | 80 | errors.HttpError = HttpError; 81 | 82 | // Create an error type for each of the 400/500 status codes 83 | var httpCodes = Object.keys(http.STATUS_CODES); 84 | httpCodes.forEach(function(statusCode) { 85 | if (statusCode < 400) { return; } 86 | 87 | var name = getErrorNameFromStatusCode(statusCode); 88 | errors[name] = HttpError.extend(name, statusCode, http.STATUS_CODES[statusCode]); 89 | }); 90 | 91 | /** 92 | * Convert status description to error name 93 | */ 94 | function getErrorNameFromStatusCode(statusCode) { 95 | statusCode = parseInt(statusCode, 10); 96 | var status = http.STATUS_CODES[statusCode]; 97 | if (!status) { return false; } 98 | 99 | var name = ''; 100 | var words = status.split(/\s+/); 101 | words.forEach(function(w) { 102 | name += w.charAt(0).toUpperCase() + w.slice(1).toLowerCase(); 103 | }); 104 | 105 | name = name.replace(/\W+/g, ''); 106 | 107 | if (!/\w+Error$/.test(name)) { 108 | name += 'Error'; 109 | } 110 | return name; 111 | } 112 | 113 | // For reference: 114 | //var http.STATUS_CODES = { 115 | // '400': 'Bad Request', 116 | // '401': 'Unauthorized', 117 | // '402': 'Payment Required', 118 | // '403': 'Forbidden', 119 | // '404': 'Not Found', 120 | // '405': 'Method Not Allowed', 121 | // '406': 'Not Acceptable', 122 | // '407': 'Proxy Authentication Required', 123 | // '408': 'Request Time-out', 124 | // '409': 'Conflict', 125 | // '410': 'Gone', 126 | // '411': 'Length Required', 127 | // '412': 'Precondition Failed', 128 | // '413': 'Request Entity Too Large', 129 | // '414': 'Request-URI Too Large', 130 | // '415': 'Unsupported Media Type', 131 | // '416': 'Requested Range Not Satisfiable', 132 | // '417': 'Expectation Failed', 133 | // '418': 'I\'m a teapot', 134 | // '422': 'Unprocessable Entity', 135 | // '423': 'Locked', 136 | // '424': 'Failed Dependency', 137 | // '425': 'Unordered Collection', 138 | // '426': 'Upgrade Required', 139 | // '428': 'Precondition Required', 140 | // '429': 'Too Many Requests', 141 | // '431': 'Request Header Fields Too Large', 142 | // '500': 'Internal Server Error', 143 | // '501': 'Not Implemented', 144 | // '502': 'Bad Gateway', 145 | // '503': 'Service Unavailable', 146 | // '504': 'Gateway Time-out', 147 | // '505': 'HTTP Version Not Supported', 148 | // '506': 'Variant Also Negotiates', 149 | // '507': 'Insufficient Storage', 150 | // '509': 'Bandwidth Limit Exceeded', 151 | // '510': 'Not Extended', 152 | // '511': 'Network Authentication Required' 153 | //}; 154 | -------------------------------------------------------------------------------- /src/lib/get-full-base-url.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = getFullBaseUrl; 4 | 5 | function getFullBaseUrl(req) { 6 | return req.protocol + '://' + req.get('Host') + req.baseUrl; 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/graceful-shutdown.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Shutdown a http server gracefully in production environments 5 | * 6 | * @param {object} server Node http server 7 | */ 8 | module.exports = function gracefulShutdown(server) { 9 | var shutdownInProgress = false; 10 | 11 | // Shut down gracefully 12 | var shutdownGracefully = function(retCode) { 13 | retCode = (typeof retCode !== 'undefined') ? retCode : 0; 14 | 15 | if (server) { 16 | if (shutdownInProgress) { return; } 17 | 18 | shutdownInProgress = true; 19 | console.info('Shutting down gracefully...'); 20 | server.close(function() { 21 | console.info('Closed out remaining connections'); 22 | process.exit(retCode); 23 | }); 24 | 25 | setTimeout(function() { 26 | console.error('Could not close out connections in time, force shutdown'); 27 | process.exit(retCode); 28 | }, 10 * 1000).unref(); 29 | 30 | } else { 31 | console.debug('Http server is not running. Exiting'); 32 | process.exit(retCode); 33 | } 34 | }; 35 | 36 | process.on('uncaughtException', function(err) { 37 | console.error('uncaughtException', err); 38 | shutdownGracefully(1); 39 | }); 40 | 41 | process.on('SIGTERM', shutdownGracefully); 42 | process.on('SIGINT', shutdownGracefully); 43 | }; 44 | -------------------------------------------------------------------------------- /src/middleware/domain-errors.js: -------------------------------------------------------------------------------- 1 | var errors = require('../lib/errors'); 2 | var createDomain = require('domain').create; 3 | 4 | var domainMiddleware = module.exports = function(req, res, next) { 5 | var domain = createDomain(); 6 | domain.add(req); 7 | domain.add(res); 8 | domain.run(function() { 9 | next(); 10 | }); 11 | domain.on('error', function(err) { 12 | if (req.log) { req.log.error(err, 'error'); } 13 | next(new errors.InternalServerError('Unhandled exception', {cause: err})); 14 | throw err; // rethrow to global error handler 15 | }); 16 | }; 17 | 18 | -------------------------------------------------------------------------------- /src/middleware/enforce-ssl.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function enforceSsl() { 4 | return function(req, res, next) { 5 | if (req.secure) { 6 | next(); 7 | } else { 8 | redirectUrl(req, res); 9 | } 10 | }; 11 | }; 12 | 13 | function redirectUrl(req, res) { 14 | if (req.method === 'GET') { 15 | res.redirect(301, 'https://' + req.headers.host + req.originalUrl); 16 | } else { 17 | res.send(403, 'Please use HTTPS when submitting data to this server.'); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/middleware/enforce-ssl.spec.js: -------------------------------------------------------------------------------- 1 | /* jshint -W030 */ 2 | 'use strict'; 3 | var mockery = require('mockery'); 4 | var expect = require('chai').expect; 5 | var sinon = require('sinon'); 6 | 7 | describe('Enforce SSL', function() { 8 | var moduleUnderTest = './enforce-ssl'; 9 | var module; 10 | 11 | before(function() { 12 | mockery.enable({ 13 | //warnOnUnregistered: false, 14 | useCleanCache: true 15 | }); 16 | }); 17 | 18 | beforeEach(function() { 19 | // Load module under test for each test 20 | mockery.registerAllowable(moduleUnderTest, true); 21 | module = require(moduleUnderTest); 22 | }); 23 | 24 | afterEach(function() { 25 | // Unload module under test each time to reset 26 | mockery.deregisterAllowable(moduleUnderTest); 27 | }); 28 | 29 | after(function() { 30 | mockery.deregisterAll(); 31 | mockery.disable(); 32 | }); 33 | 34 | it('should redirect if GET not secure', function() { 35 | var middleware = module(); 36 | var req = { 37 | method: 'GET', 38 | originalUrl: '/ORIGINAL_URL', 39 | headers: {host: 'HOST'} 40 | }; 41 | var res = { 42 | redirect: sinon.spy() 43 | }; 44 | var next = sinon.spy(); 45 | middleware(req, res, next); 46 | expect(res.redirect.called).to.be.true; 47 | expect(next.called).to.be.false; 48 | }); 49 | 50 | it('should send 403 if other methods not secure', function() { 51 | var middleware = module(); 52 | var req = { 53 | method: 'POST' 54 | }; 55 | var res = { 56 | send: sinon.spy() 57 | }; 58 | var next = sinon.spy(); 59 | middleware(req, res, next); 60 | expect(res.send.called).to.be.true; 61 | expect(next.called).to.be.false; 62 | }); 63 | 64 | it('should proceed if secure', function() { 65 | var middleware = module(); 66 | var req = { 67 | secure: true 68 | }; 69 | var res = {}; 70 | var next = sinon.spy(); 71 | middleware(req, res, next); 72 | expect(next.called).to.be.true; 73 | }); 74 | 75 | }); 76 | -------------------------------------------------------------------------------- /src/middleware/error-handler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var errors = require('../lib/errors'); 4 | 5 | /** 6 | * Express route error handler: send back JSON error responses 7 | */ 8 | module.exports = function errorHandler(err, req, res, next) { 9 | // Set optional headers 10 | if (err.headers) { 11 | res.set(err.headers); 12 | } 13 | 14 | var response = { 15 | message: err.message // Description of error 16 | }; 17 | 18 | var status = err.status || 500; 19 | 20 | // Unique application error code 21 | response.code = err.code || status; 22 | 23 | // Additional field error messages 24 | if (err.errors) { response.errors = err.errors; } 25 | 26 | res.status(status).json(response); 27 | }; 28 | 29 | -------------------------------------------------------------------------------- /src/middleware/error-handler.spec.js: -------------------------------------------------------------------------------- 1 | /* jshint -W030 */ 2 | 'use strict'; 3 | var mockery = require('mockery'); 4 | var expect = require('chai').expect; 5 | var sinon = require('sinon'); 6 | 7 | describe('Error Handler', function() { 8 | var moduleUnderTest = './error-handler'; 9 | var errorHandler; 10 | 11 | before(function() { 12 | mockery.enable({ 13 | warnOnUnregistered: false, 14 | useCleanCache: true 15 | }); 16 | }); 17 | 18 | beforeEach(function() { 19 | // Load module under test for each test 20 | mockery.registerAllowable(moduleUnderTest, true); 21 | errorHandler = require(moduleUnderTest); 22 | }); 23 | 24 | afterEach(function() { 25 | // Unload module under test each time to reset 26 | mockery.deregisterAllowable(moduleUnderTest); 27 | }); 28 | 29 | after(function() { 30 | mockery.deregisterAll(); 31 | mockery.disable(); 32 | }); 33 | 34 | it('should respond with default 500 error', function() { 35 | var err = {message: 'ERROR_MESSAGE'}; 36 | var req = {log: {error: sinon.spy()}}; 37 | var res = { 38 | set: sinon.spy(), 39 | status: sinon.stub(), 40 | json: sinon.spy() 41 | }; 42 | res.status.returns(res); 43 | var next = sinon.spy(); 44 | errorHandler(err, req, res, next); 45 | expect(res.set.called).to.be.false; 46 | expect(res.status.calledWith(500)).to.be.true; 47 | expect(next.called).to.be.false; 48 | expect(res.json.calledWith({message: 'ERROR_MESSAGE', code: 500})); 49 | }); 50 | 51 | it('should set optional headers', function() { 52 | var err = {message: 'ERROR_MESSAGE', headers: {test: 'TEST_HEADER'}}; 53 | var req = {log: {error: sinon.spy()}}; 54 | var res = { 55 | set: sinon.spy(), 56 | status: sinon.stub(), 57 | json: sinon.spy() 58 | }; 59 | res.status.returns(res); 60 | var next = sinon.spy(); 61 | errorHandler(err, req, res, next); 62 | expect(res.set.calledWith({test: 'TEST_HEADER'})).to.be.true; 63 | }); 64 | 65 | it('should set status and app codes', function() { 66 | var err = {message: 'ERROR_MESSAGE', status: 404, code: 1337}; 67 | var req = {log: {error: sinon.spy()}}; 68 | var res = { 69 | set: sinon.spy(), 70 | status: sinon.stub(), 71 | json: sinon.spy() 72 | }; 73 | res.status.returns(res); 74 | var next = sinon.spy(); 75 | errorHandler(err, req, res, next); 76 | expect(res.status.calledWith(404)).to.be.true; 77 | expect(res.json.calledWith({message: 'ERROR_MESSAGE', code: 1337})); 78 | }); 79 | 80 | it('should set extra field errors', function() { 81 | var err = {message: 'ERROR_MESSAGE', errors: []}; 82 | var req = {log: {error: sinon.spy()}}; 83 | var res = { 84 | set: sinon.spy(), 85 | status: sinon.stub(), 86 | json: sinon.spy() 87 | }; 88 | res.status.returns(res); 89 | var next = sinon.spy(); 90 | errorHandler(err, req, res, next); 91 | expect(res.json.calledWith({message: 'ERROR_MESSAGE', code: 500, errors: []})); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /src/middleware/pretty-print.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function prettyPrint(app) { 4 | return function(req, res, next) { 5 | if (req.query.pretty === 'true' || req.query.pretty === '1') { 6 | app.set('json spaces', 2); 7 | } 8 | next(); 9 | }; 10 | }; 11 | 12 | -------------------------------------------------------------------------------- /src/middleware/pretty-print.spec.js: -------------------------------------------------------------------------------- 1 | /* jshint -W030 */ 2 | 'use strict'; 3 | var mockery = require('mockery'); 4 | var expect = require('chai').expect; 5 | var sinon = require('sinon'); 6 | 7 | describe('Enforce SSL', function() { 8 | var moduleUnderTest = './pretty-print'; 9 | var module; 10 | 11 | before(function() { 12 | mockery.enable({ 13 | //warnOnUnregistered: false, 14 | useCleanCache: true 15 | }); 16 | }); 17 | 18 | beforeEach(function() { 19 | // Load module under test for each test 20 | mockery.registerAllowable(moduleUnderTest, true); 21 | module = require(moduleUnderTest); 22 | }); 23 | 24 | afterEach(function() { 25 | // Unload module under test each time to reset 26 | mockery.deregisterAllowable(moduleUnderTest); 27 | }); 28 | 29 | after(function() { 30 | mockery.deregisterAll(); 31 | mockery.disable(); 32 | }); 33 | 34 | it('should set json spaces with pretty query param true', function() { 35 | var app = { 36 | set: sinon.spy() 37 | }; 38 | var middleware = module(app); 39 | var req = { 40 | query: {pretty: 'true'} 41 | }; 42 | var next = sinon.spy(); 43 | middleware(req, null, next); 44 | expect(app.set.called).to.be.true; 45 | expect(next.called).to.be.true; 46 | }); 47 | 48 | it('should not set json spaces with untrue pretty query param', function() { 49 | var app = { 50 | set: sinon.spy() 51 | }; 52 | var middleware = module(app); 53 | var req = { 54 | query: {pretty: 'false'} 55 | }; 56 | var next = sinon.spy(); 57 | middleware(req, null, next); 58 | expect(app.set.called).to.be.false; 59 | expect(next.called).to.be.true; 60 | }); 61 | 62 | }); 63 | -------------------------------------------------------------------------------- /src/middleware/request-logger.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var bunyan = require('bunyan'); 3 | var uuid = require('uuid'); 4 | var _ = require('lodash'); 5 | var path = require('path'); 6 | 7 | module.exports = function requestLogger(options) { 8 | options = _.merge({ 9 | requestIdHeader: 'x-request-id', 10 | conversationIdHeader: 'x-conversation-id' 11 | }, options); 12 | 13 | var logger = options.logger || createLogger(); 14 | 15 | return function(req, res, next) { 16 | var startTime = Date.now(); 17 | 18 | // create child logger with custom tracking ids 19 | req.log = logger.child({ 20 | reqId: getHeaderTrackingId(req, res, options.requestIdHeader, 'requestId'), 21 | conversationId: getHeaderTrackingId(req, res, options.conversationIdHeader, 'conversationId') 22 | }); 23 | 24 | res.on('finish', function responseSent() { 25 | if (req.skipRequestLog) { return; } 26 | req.log.info(createLogMeta(req, res, startTime), 'request'); 27 | }); 28 | 29 | next(); 30 | }; 31 | }; 32 | 33 | function createLogMeta(req, res, startTime) { 34 | return { 35 | method: req.method, 36 | url: req.originalUrl, 37 | httpVersion: getHttpVersion(req), 38 | statusCode: res.statusCode, 39 | contentLength: res['content-length'], 40 | referrer: getReferrer(req), 41 | userAgent: req.headers['user-agent'], 42 | remoteAddress: getIp(req), 43 | duration: Date.now() - startTime 44 | }; 45 | } 46 | 47 | function createLogger() { 48 | var pkg = require(path.resolve('package.json')); 49 | return bunyan.createLogger({ 50 | name: pkg.name 51 | }); 52 | } 53 | 54 | function getHeaderTrackingId(req, res, headerName, reqKey) { 55 | var trackingId = req.headers[headerName] || uuid.v1(); 56 | req[reqKey] = trackingId; 57 | res.setHeader(headerName, trackingId); 58 | return trackingId; 59 | } 60 | 61 | function getIp(req) { 62 | return req.ip || 63 | req._remoteAddress || 64 | (req.connection && req.connection.remoteAddress) || 65 | undefined; 66 | } 67 | 68 | function getHttpVersion(req) { 69 | return req.httpVersionMajor + '.' + req.httpVersionMinor; 70 | } 71 | 72 | function getReferrer(req) { 73 | return req.headers.referer || req.headers.referrer; 74 | } 75 | -------------------------------------------------------------------------------- /src/middleware/request-logger.spec.js: -------------------------------------------------------------------------------- 1 | /* jshint -W030 */ 2 | 'use strict'; 3 | var mockery = require('mockery'); 4 | var expect = require('chai').expect; 5 | var sinon = require('sinon'); 6 | 7 | describe('Request Logger', function() { 8 | var moduleUnderTest = './request-logger'; 9 | var module; 10 | var loggerStub; 11 | var logStub; 12 | 13 | before(function() { 14 | mockery.enable({ 15 | warnOnUnregistered: false, 16 | useCleanCache: true 17 | }); 18 | }); 19 | 20 | beforeEach(function() { 21 | logStub = { 22 | info: sinon.stub() 23 | }; 24 | loggerStub = { 25 | child: sinon.stub().returns(logStub) 26 | }; 27 | var bunyanMock = { 28 | createLogger: sinon.stub().returns(loggerStub) 29 | }; 30 | mockery.registerMock('bunyan', bunyanMock); 31 | 32 | // Load module under test for each test 33 | mockery.registerAllowable(moduleUnderTest, true); 34 | module = require(moduleUnderTest); 35 | }); 36 | 37 | afterEach(function() { 38 | // Unload module under test each time to reset 39 | mockery.deregisterAllowable(moduleUnderTest); 40 | }); 41 | 42 | after(function() { 43 | mockery.deregisterAll(); 44 | mockery.disable(); 45 | }); 46 | 47 | it('should log request', function() { 48 | var options = {}; 49 | var middleware = module(options); 50 | var req = { 51 | method: 'METHOD', 52 | originalUrl: 'URL', 53 | httpVersionMajor: 'MAJ', 54 | httpVersionMinor: 'MIN', 55 | headers: { 56 | referrer: 'REFERRER', 57 | 'x-request-id': 'REQID', 58 | 'x-conversation-id': 'CONVID' 59 | }, 60 | connection: {remoteAddress: false}, 61 | 'user-agent': 'USERAGENT' 62 | }; 63 | var res = { 64 | setHeader: sinon.stub(), 65 | statusCode: 'STATUSCODE', 66 | 'content-length': 'CONTENTLEN', 67 | on: sinon.stub().yields() 68 | }; 69 | var next = sinon.spy(); 70 | middleware(req, res, next); 71 | expect(res.on.called).to.be.true; 72 | sinon.assert.calledWith(loggerStub.child, { 73 | reqId: 'REQID', 74 | conversationId: 'CONVID' 75 | }); 76 | sinon.assert.calledWith(logStub.info, sinon.match({ 77 | method: 'METHOD', 78 | url: 'URL', 79 | httpVersion: 'MAJ.MIN', 80 | contentLength: 'CONTENTLEN', 81 | referrer: 'REFERRER', 82 | remoteAddress: undefined 83 | }), 'request'); 84 | expect(next.called).to.be.true; 85 | }); 86 | 87 | it('should generate tracking ids', function() { 88 | var options = {}; 89 | var middleware = module(options); 90 | var req = { 91 | headers: {}, 92 | connection: {remoteAddress: false}, 93 | }; 94 | var res = { 95 | setHeader: sinon.stub(), 96 | on: sinon.stub() 97 | }; 98 | var next = sinon.spy(); 99 | middleware(req, res, next); 100 | expect(res.on.called).to.be.true; 101 | 102 | var uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; 103 | sinon.assert.calledWithMatch(loggerStub.child, { 104 | reqId: sinon.match(uuidRegex), 105 | conversationId: sinon.match(uuidRegex) 106 | }); 107 | expect(res.setHeader.calledTwice).to.be.true; 108 | expect(next.called).to.be.true; 109 | }); 110 | 111 | }); 112 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var fs = require('fs'); 4 | var _ = require('lodash'); 5 | var cfgDefaults = require('./config'); 6 | var https = require('https'); 7 | var http = require('http'); 8 | var createApp = require('./lib/app'); 9 | var gracefulShutdown = require('./lib/graceful-shutdown'); 10 | 11 | module.exports = function startServer(appInitCb, options) { 12 | // Initialize the express app 13 | options = _.merge({}, cfgDefaults, options); 14 | var app = createApp(appInitCb, options); 15 | 16 | // Create a secure or insecure server 17 | var server; 18 | if (!options.isBehindProxy && options.isSslEnabled) { 19 | // Secure https server 20 | var sslOptions = { 21 | key: fs.readFileSync(options.sslKeyFile), 22 | cert: fs.readFileSync(options.sslCertFile) 23 | }; 24 | server = https.createServer(sslOptions, app); 25 | } else { 26 | // Insecure http server 27 | server = http.createServer(app); 28 | } 29 | 30 | // Limits maximum incoming headers count 31 | server.maxHeadersCount = options.maxHeadersCount; 32 | 33 | // Inactivity before a socket is presumed to have timed out 34 | server.timeout = options.serverTimeout; 35 | 36 | // Start the server on port 37 | server.listen(options.port, function listenCb() { 38 | var host = server.address().address; 39 | var port = server.address().port; 40 | var scheme = (server instanceof https.Server) ? 'https' : 'http'; 41 | console.info('api server listening at %s://%s:%s', scheme, host, port); 42 | if (options.isGracefulShutdownEnabled) { 43 | gracefulShutdown(server); 44 | } 45 | }); 46 | 47 | return server; 48 | }; 49 | --------------------------------------------------------------------------------