diff --git a/.travis.yml b/.travis.yml index d2860a8..7e4c90d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,3 +10,4 @@ env: - PACKAGES=./packages/rest-accounts-password PACKAGE_DIRS=./packages/ - PACKAGES=./packages/rest-bearer-token-parser PACKAGE_DIRS=./packages/ - PACKAGES=./packages/authenticate-user-by-token PACKAGE_DIRS=./packages/ + - PACKAGES=./packages/rest-json-error-handler PACKAGE_DIRS=./packages/ diff --git a/packages/json-routes/.npm/package/npm-shrinkwrap.json b/packages/json-routes/.npm/package/npm-shrinkwrap.json index 3c08d69..3c608de 100644 --- a/packages/json-routes/.npm/package/npm-shrinkwrap.json +++ b/packages/json-routes/.npm/package/npm-shrinkwrap.json @@ -1,62 +1,228 @@ { "dependencies": { "connect": { - "version": "2.11.0", + "version": "2.30.2", "dependencies": { - "qs": { - "version": "0.6.5" + "basic-auth-connect": { + "version": "1.0.0" }, - "cookie-signature": { - "version": "1.0.1" + "body-parser": { + "version": "1.13.3", + "dependencies": { + "iconv-lite": { + "version": "0.4.11" + }, + "on-finished": { + "version": "2.3.0", + "dependencies": { + "ee-first": { + "version": "1.1.1" + } + } + }, + "raw-body": { + "version": "2.1.4", + "dependencies": { + "iconv-lite": { + "version": "0.4.12" + }, + "unpipe": { + "version": "1.0.0" + } + } + } + } }, - "buffer-crc32": { - "version": "0.2.1" + "bytes": { + "version": "2.1.0" }, "cookie": { - "version": "0.1.0" + "version": "0.1.3" }, - "send": { - "version": "0.1.4", + "cookie-parser": { + "version": "1.3.5" + }, + "cookie-signature": { + "version": "1.0.6" + }, + "compression": { + "version": "1.5.2", "dependencies": { - "mime": { - "version": "1.2.11" + "accepts": { + "version": "1.2.13", + "dependencies": { + "mime-types": { + "version": "2.1.7", + "dependencies": { + "mime-db": { + "version": "1.19.0" + } + } + }, + "negotiator": { + "version": "0.5.3" + } + } + }, + "compressible": { + "version": "2.0.6", + "dependencies": { + "mime-db": { + "version": "1.20.0" + } + } }, - "range-parser": { - "version": "0.0.4" + "vary": { + "version": "1.0.1" } } }, - "bytes": { - "version": "0.2.1" - }, - "fresh": { - "version": "0.2.0" + "connect-timeout": { + "version": "1.6.2", + "dependencies": { + "ms": { + "version": "0.7.1" + } + } }, - "pause": { - "version": "0.0.1" + "content-type": { + "version": "1.0.1" }, - "uid2": { - "version": "0.0.3" + "csurf": { + "version": "1.8.3", + "dependencies": { + "csrf": { + "version": "3.0.0", + "dependencies": { + "base64-url": { + "version": "1.2.1" + }, + "rndm": { + "version": "1.1.1" + }, + "scmp": { + "version": "1.0.0" + }, + "uid-safe": { + "version": "2.0.0" + } + } + } + } }, "debug": { - "version": "2.1.3", + "version": "2.2.0", "dependencies": { "ms": { - "version": "0.7.0" + "version": "0.7.1" + } + } + }, + "depd": { + "version": "1.0.1" + }, + "errorhandler": { + "version": "1.4.2", + "dependencies": { + "accepts": { + "version": "1.2.13", + "dependencies": { + "mime-types": { + "version": "2.1.7", + "dependencies": { + "mime-db": { + "version": "1.19.0" + } + } + }, + "negotiator": { + "version": "0.5.3" + } + } + }, + "escape-html": { + "version": "1.0.2" } } }, - "methods": { - "version": "0.0.1" + "express-session": { + "version": "1.11.3", + "dependencies": { + "crc": { + "version": "3.3.0" + }, + "uid-safe": { + "version": "2.0.0", + "dependencies": { + "base64-url": { + "version": "1.2.1" + } + } + } + } }, - "raw-body": { - "version": "0.0.3" + "finalhandler": { + "version": "0.4.0", + "dependencies": { + "escape-html": { + "version": "1.0.2" + }, + "on-finished": { + "version": "2.3.0", + "dependencies": { + "ee-first": { + "version": "1.1.1" + } + } + }, + "unpipe": { + "version": "1.0.0" + } + } }, - "negotiator": { + "fresh": { "version": "0.3.0" }, + "http-errors": { + "version": "1.3.1", + "dependencies": { + "inherits": { + "version": "2.0.1" + }, + "statuses": { + "version": "1.2.1" + } + } + }, + "method-override": { + "version": "2.3.5", + "dependencies": { + "methods": { + "version": "1.1.1" + }, + "vary": { + "version": "1.0.1" + } + } + }, + "morgan": { + "version": "1.6.1", + "dependencies": { + "basic-auth": { + "version": "1.0.3" + }, + "on-finished": { + "version": "2.3.0", + "dependencies": { + "ee-first": { + "version": "1.1.1" + } + } + } + } + }, "multiparty": { - "version": "2.2.0", + "version": "3.3.2", "dependencies": { "readable-stream": { "version": "1.1.13", @@ -79,6 +245,120 @@ "version": "0.2.0" } } + }, + "on-headers": { + "version": "1.0.1" + }, + "parseurl": { + "version": "1.3.0" + }, + "pause": { + "version": "0.1.0" + }, + "qs": { + "version": "4.0.0" + }, + "response-time": { + "version": "2.3.1" + }, + "serve-favicon": { + "version": "2.3.0", + "dependencies": { + "etag": { + "version": "1.7.0" + }, + "ms": { + "version": "0.7.1" + } + } + }, + "serve-index": { + "version": "1.7.2", + "dependencies": { + "accepts": { + "version": "1.2.13", + "dependencies": { + "negotiator": { + "version": "0.5.3" + } + } + }, + "batch": { + "version": "0.5.2" + }, + "escape-html": { + "version": "1.0.2" + }, + "mime-types": { + "version": "2.1.7", + "dependencies": { + "mime-db": { + "version": "1.19.0" + } + } + } + } + }, + "serve-static": { + "version": "1.10.0", + "dependencies": { + "escape-html": { + "version": "1.0.2" + }, + "send": { + "version": "0.13.0", + "dependencies": { + "destroy": { + "version": "1.0.3" + }, + "etag": { + "version": "1.7.0" + }, + "mime": { + "version": "1.3.4" + }, + "ms": { + "version": "0.7.1" + }, + "on-finished": { + "version": "2.3.0", + "dependencies": { + "ee-first": { + "version": "1.1.1" + } + } + }, + "range-parser": { + "version": "1.0.3" + }, + "statuses": { + "version": "1.2.1" + } + } + } + } + }, + "type-is": { + "version": "1.6.9", + "dependencies": { + "media-typer": { + "version": "0.3.0" + }, + "mime-types": { + "version": "2.1.7", + "dependencies": { + "mime-db": { + "version": "1.19.0" + } + } + } + } + }, + "utils-merge": { + "version": "1.0.0" + }, + "vhost": { + "version": "3.0.2" } } }, diff --git a/packages/json-routes/README.md b/packages/json-routes/README.md index e6d3ee1..75a6785 100644 --- a/packages/json-routes/README.md +++ b/packages/json-routes/README.md @@ -39,13 +39,22 @@ Return data fom a route. - `code` - Optional. The status code to send. `200` for OK, `500` for internal error, etc. Default is 200. - `data` - Optional. The data you want to send back. This is serialized to JSON with content type `application/json`. If `undefined`, there will be no response body. -### JsonRoutes.sendError(response, code, error) +### Errors -Return an error response from a route. +We recommend that you simply throw an Error or Meteor.Error from your handler function. You can then attach error handling middleware that converts those errors to JSON and sends the response. Here's how to do it with our default error middleware: -- `response` - Required. The Node response object you got as an argument to your handler function. -- `code` - Optional. The status code to send. Default is 500. -- `error` - Optional. An `Error` or `Meteor.Error` object. A JSON representation of the error details will be sent. You can set `error.data` or `error.sanitizedError.data` to some extra data to be serialized and sent with the response. +```js +JsonRoutes.ErrorMiddleware.use( + '/widgets', + RestMiddleware.handleErrorAsJson +); + +JsonRoutes.add('get', 'widgets', function () { + var error = new Meteor.Error('not-found', 'Not Found'); + error.statusCode = 404; + throw error; +}); +``` ### JsonRoutes.setResponseHeaders(headerObj) @@ -88,7 +97,7 @@ JsonRoutes.Middleware.someMiddlewareFunc = function (req, res, next) { }; ``` -## Change log +## Change Log #### 1.0.4 diff --git a/packages/json-routes/json-routes.js b/packages/json-routes/json-routes.js index 51f62dc..a8574f0 100644 --- a/packages/json-routes/json-routes.js +++ b/packages/json-routes/json-routes.js @@ -6,15 +6,15 @@ var connectRoute = Npm.require('connect-route'); JsonRoutes = {}; -WebApp.rawConnectHandlers.use(connect.urlencoded()); -WebApp.rawConnectHandlers.use(connect.json()); -WebApp.rawConnectHandlers.use(connect.query()); +WebApp.connectHandlers.use(connect.urlencoded()); +WebApp.connectHandlers.use(connect.json()); +WebApp.connectHandlers.use(connect.query()); // Handler for adding middleware before an endpoint (JsonRoutes.middleWare // is just for legacy reasons). Also serves as a namespace for middleware // packages to declare their middleware functions. JsonRoutes.Middleware = JsonRoutes.middleWare = connect(); -WebApp.rawConnectHandlers.use(JsonRoutes.Middleware); +WebApp.connectHandlers.use(JsonRoutes.Middleware); // List of all defined JSON API endpoints JsonRoutes.routes = []; @@ -23,10 +23,27 @@ JsonRoutes.routes = []; var connectRouter; // Register as a middleware -WebApp.rawConnectHandlers.use(connectRoute(function (router) { +WebApp.connectHandlers.use(connectRoute(function (router) { connectRouter = router; })); +// Error middleware must be added last, to catch errors from prior middleware. +// That's why we cache them and then add after startup. +var errorMiddlewares = []; +JsonRoutes.ErrorMiddleware = { + use: function () { + errorMiddlewares.push(arguments); + }, +}; + +Meteor.startup(function () { + _.each(errorMiddlewares, function (errorMiddleware) { + WebApp.connectHandlers.use.apply(WebApp.connectHandlers, errorMiddleware); + }); + + errorMiddlewares = []; +}); + JsonRoutes.add = function (method, path, handler) { // Make sure path starts with a slash if (path[0] !== '/') { @@ -40,11 +57,13 @@ JsonRoutes.add = function (method, path, handler) { }); connectRouter[method.toLowerCase()](path, function (req, res, next) { + // Set headers on response + setHeaders(res); Fiber(function () { try { handler(req, res, next); - } catch (err) { - JsonRoutes.sendError(res, getStatusCodeFromError(err), err); + } catch (error) { + next(error); } }).run(); }); @@ -59,27 +78,6 @@ JsonRoutes.setResponseHeaders = function (headers) { responseHeaders = headers; }; -/** - * Convert `Error` objects to plain response objects suitable - * for serialization. - * - * @param {Any} [error] Should be a Meteor.Error or Error object. If anything - * else is passed or this argument isn't provided, a generic - * "internal-server-error" object is returned - */ -JsonRoutes._errorToJson = function (error) { - if (error instanceof Meteor.Error) { - return buildErrorResponse(error); - } else if (error && error.sanitizedError instanceof Meteor.Error) { - return buildErrorResponse(error.sanitizedError); - } else { - return { - error: 'internal-server-error', - reason: 'Internal server error', - }; - } -}; - /** * Sets the response headers, status code, and body, and ends it. * The JSON response will be pretty printed if NODE_ENV is `development`. @@ -104,80 +102,12 @@ JsonRoutes.sendResult = function (res, code, data) { res.end(); }; -/** - * Sets the response headers, status code, and body, and ends it. - * The JSON response will be pretty printed if NODE_ENV is `development`. - * - * @param {Object} res Response object - * @param {Number} code The status code to send. Default is 500. - * @param {Error|Meteor.Error} error The error object to stringify as - * the response. A JSON representation of the error details will be - * sent. You can set `error.data` or `error.sanitizedError.data` to - * some extra data to be serialized and sent with the response. - */ -JsonRoutes.sendError = function (res, code, error) { - // Set headers on response - setHeaders(res); - - // If no error passed in, use the default empty error - error = error || new Error(); - - // Set status code on response - res.statusCode = code || 500; - - // Convert `Error` objects to JSON representations - var json = JsonRoutes._errorToJson(error); - - // Set response body - writeJsonToBody(res, json); - - // Send the response - res.end(); -}; - function setHeaders(res) { _.each(responseHeaders, function (value, key) { res.setHeader(key, value); }); } -function getStatusCodeFromError(error) { - // Bail out if no error passed in - if (!error) { - return 500; - } - - // If an error or sanitizedError has a `statusCode` property, we use that. - // This allows packages to check whether JsonRoutes package is used and if so, - // to include a specific error status code with the errors they throw. - if (error.sanitizedError && error.sanitizedError.statusCode) { - return error.sanitizedError.statusCode; - } - - if (error.statusCode) { - return error.statusCode; - } - - // At this point, we know the error doesn't have any attached error code - if (error instanceof Meteor.Error || - (error.sanitizedError instanceof Meteor.Error)) { - // If we at least put in some effort to throw a user-facing Meteor.Error, - // the default code should be less severe - return 400; - } - - // Most pessimistic case: internal server error 500 - return 500; -} - -function buildErrorResponse(errObj) { - // If an error has a `data` property, we - // send that. This allows packages to include - // extra client-safe data with the errors they throw. - var fields = ['error', 'reason', 'details', 'data']; - return _.pick(errObj, fields); -} - function writeJsonToBody(res, json) { if (json !== undefined) { var shouldPrettyPrint = (process.env.NODE_ENV === 'development'); diff --git a/packages/json-routes/middleware.js b/packages/json-routes/middleware.js new file mode 100644 index 0000000..c2da095 --- /dev/null +++ b/packages/json-routes/middleware.js @@ -0,0 +1,3 @@ +/* global RestMiddleware:true */ + +RestMiddleware = {}; diff --git a/packages/json-routes/package.js b/packages/json-routes/package.js index 180dd8b..cd31cac 100644 --- a/packages/json-routes/package.js +++ b/packages/json-routes/package.js @@ -14,7 +14,7 @@ Package.describe({ }); Npm.depends({ - connect: '2.11.0', + connect: '2.30.2', 'connect-route': '0.1.5', }); @@ -26,9 +26,15 @@ Package.onUse(function (api) { 'webapp', ], 'server'); - api.addFiles('json-routes.js', 'server'); + api.addFiles([ + 'json-routes.js', + 'middleware.js', + ], 'server'); - api.export('JsonRoutes', 'server'); + api.export([ + 'JsonRoutes', + 'RestMiddleware', + ], 'server'); }); Package.onTest(function (api) { diff --git a/packages/rest-json-error-handler/.jshintrc b/packages/rest-json-error-handler/.jshintrc new file mode 100644 index 0000000..f9de830 --- /dev/null +++ b/packages/rest-json-error-handler/.jshintrc @@ -0,0 +1,14 @@ +{ + "extends": "../../.jshintrc", + "globals": { + + }, + "predef": [ + "HTTP", + "JsonRoutes", + "Meteor", + "Package", + "RestMiddleware", + "testAsyncMulti" + ] +} diff --git a/packages/rest-json-error-handler/README.md b/packages/rest-json-error-handler/README.md new file mode 100644 index 0000000..ef0b066 --- /dev/null +++ b/packages/rest-json-error-handler/README.md @@ -0,0 +1,35 @@ +# simple:rest-json-error-handler + +SimpleRest error middleware for converting thrown Meteor.Errors to JSON and sending the response. + +## Usage + +Handle errors from all routes: + +```js +JsonRoutes.ErrorMiddleware.use(RestMiddleware.handleErrorAsJson); +``` + +Handle errors from one route: + +```js +JsonRoutes.ErrorMiddleware.use( + '/handle-error', + RestMiddleware.handleErrorAsJson +); +``` + +## Example + +```js +JsonRoutes.ErrorMiddleware.use( + '/handle-error', + RestMiddleware.handleErrorAsJson +); + +JsonRoutes.add('get', 'handle-error', function () { + var error = new Meteor.Error('not-found', 'Not Found'); + error.statusCode = 404; + throw error; +}); +``` diff --git a/packages/rest-json-error-handler/json_error_handler.js b/packages/rest-json-error-handler/json_error_handler.js new file mode 100644 index 0000000..f361417 --- /dev/null +++ b/packages/rest-json-error-handler/json_error_handler.js @@ -0,0 +1,48 @@ +/** + * Handle any connect errors with a standard JSON response + * + * Response looks like: + * { + * error: 'Error type', + * reason: 'Cause of error' + * } + * + * @middleware + */ +RestMiddleware.handleErrorAsJson = function (err, request, response, next) { // jshint ignore:line + // If we at least put in some effort to throw a user-facing Meteor.Error, + // the default code should be less severe + if (err.sanitizedError && err.sanitizedError.errorType === 'Meteor.Error') { + if (!err.sanitizedError.statusCode) { + err.sanitizedError.statusCode = err.statusCode || 400; + } + + err = err.sanitizedError; + } else if (err.errorType === 'Meteor.Error') { + if (!err.statusCode) err.statusCode = 400; + } else { + // Hide internal error details + // XXX could check node_env here and return full + // error details if development + var statusCode = err.statusCode; + err = new Error(); + err.statusCode = statusCode; + } + + // If an error has a `data` property, we + // send that. This allows packages to include + // extra client-safe data with the errors they throw. + var body = { + error: err.error || 'internal-server-error', + reason: err.reason || 'Internal server error', + details: err.details, + data: err.data, + }; + + body = JSON.stringify(body, null, 2); + + response.statusCode = err.statusCode || 500; + response.setHeader('Content-Type', 'application/json'); + response.write(body); + response.end(); +}; diff --git a/packages/rest-json-error-handler/json_error_handler_tests.js b/packages/rest-json-error-handler/json_error_handler_tests.js new file mode 100644 index 0000000..725e935 --- /dev/null +++ b/packages/rest-json-error-handler/json_error_handler_tests.js @@ -0,0 +1,24 @@ +if (Meteor.isServer) { + JsonRoutes.ErrorMiddleware.use( + '/handle-error', + RestMiddleware.handleErrorAsJson + ); + + JsonRoutes.add('get', 'handle-error', function () { + var error = new Meteor.Error('not-found', 'Not Found'); + error.statusCode = 404; + throw error; + }); +} else { // Meteor.isClient + testAsyncMulti('Middleware - JSON Error Handling - ' + + 'handle standard Connect error with JSON response', [ + function (test, waitFor) { + HTTP.get(Meteor.absoluteUrl('/handle-error'), + waitFor(function (err, resp) { + test.equal(resp.statusCode, 404); + test.equal(resp.data.error, 'not-found'); + test.equal(resp.data.reason, 'Not Found'); + })); + }, + ]); +} diff --git a/packages/rest-json-error-handler/package.js b/packages/rest-json-error-handler/package.js new file mode 100644 index 0000000..4f83a1b --- /dev/null +++ b/packages/rest-json-error-handler/package.js @@ -0,0 +1,32 @@ +Package.describe({ + name: 'simple:rest-json-error-handler', + version: '0.0.1', + + // Brief, one-line summary of the package. + summary: 'simple:rest middleware for handling standard Connect errors', + + // URL to the Git repository containing the source code for this package. + git: 'https://github.com/stubailo/meteor-rest', + + // By default, Meteor will default to using README.md for documentation. + // To avoid submitting documentation, set this field to null. + documentation: 'README.md', +}); + +Package.onUse(function (api) { + api.versionsFrom('1.0'); + api.use('simple:json-routes@1.0.3'); + api.addFiles('json_error_handler.js', 'server'); +}); + +Package.onTest(function (api) { + api.use([ + 'http', + 'simple:json-routes@1.0.3', + 'simple:rest-json-error-handler', + 'test-helpers', + 'tinytest', + ]); + + api.addFiles('json_error_handler_tests.js'); +}); diff --git a/packages/rest/.jshintrc b/packages/rest/.jshintrc index de8ecd4..d0baa10 100644 --- a/packages/rest/.jshintrc +++ b/packages/rest/.jshintrc @@ -16,6 +16,7 @@ "Npm", "Package", "process", + "RestMiddleware", "testAsyncMulti", "Tinytest", "window", diff --git a/packages/rest/README.md b/packages/rest/README.md index 83cbdb6..3be8304 100644 --- a/packages/rest/README.md +++ b/packages/rest/README.md @@ -272,6 +272,18 @@ The result looks like: > Note that this package also generates `OPTIONS` endpoints for all of your methods. This is to allow you to enable cross-origin requests if you choose to, by returning an `Access-Control-Allow-Origin` header. More on that below. +### Error Handling + +We recommend that you add our default error handler like so: + +```js +JsonRoutes.ErrorMiddleware.use(RestMiddleware.handleErrorAsJson); +``` + +This will convert any `Meteor.Error`s that your methods throw to useful JSON responses. + +You can set `error.statusCode` before you throw the error if you want a particular status code returned. + ### Cross-origin requests If you would like to use your API from the client side of a different app, you need to return a special header. You can do this by hooking into a method on the `simple:json-routes` package, like so: @@ -287,7 +299,7 @@ JsonRoutes.setResponseHeaders({ }); ``` -## Change log +## Change Log #### Unreleased diff --git a/packages/rest/package.js b/packages/rest/package.js index f1cb0f1..1b259de 100644 --- a/packages/rest/package.js +++ b/packages/rest/package.js @@ -51,6 +51,7 @@ Package.onTest(function (api) { 'simple:json-routes', 'simple:rest', 'simple:rest-accounts-password', + 'simple:rest-json-error-handler', 'test-helpers', 'tinytest', 'underscore', diff --git a/packages/rest/rest-tests.js b/packages/rest/rest-tests.js index 474c1de..4e4fd1d 100644 --- a/packages/rest/rest-tests.js +++ b/packages/rest/rest-tests.js @@ -3,12 +3,13 @@ if (Meteor.isServer) { JsonRoutes.Middleware.use( JsonRoutes.Middleware.authenticateMeteorUserByToken ); + JsonRoutes.ErrorMiddleware.use(RestMiddleware.handleErrorAsJson); -// SimpleRest.configure({ -// objectIdCollections: ['widgets'] -// }); -// -// var Widgets = new Mongo.Collection('widgets', {idGeneration: 'MONGO'}); + // SimpleRest.configure({ + // objectIdCollections: ['widgets'] + // }); + // + // var Widgets = new Mongo.Collection('widgets', {idGeneration: 'MONGO'}); var Widgets = new Mongo.Collection('widgets'); diff --git a/run-tests.sh b/run-tests.sh index c1d441c..25970c4 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -15,4 +15,5 @@ meteor test-packages \ "$DIR/packages/json-routes" \ "$DIR/packages/rest-bearer-token-parser" \ "$DIR/packages/authenticate-user-by-token" \ - "$DIR/packages/rest-accounts-password" \ No newline at end of file + "$DIR/packages/rest-accounts-password" \ + "$DIR/packages/rest-json-error-handler"