Improve API error handling

close #2757, refs #5286

- moves error formatting from api/index into errors lib
- moves error handling from api/index into its own middleware
- adds extra middleware for method not allowed which captures all unsupported routes
This commit is contained in:
Hannah Wolfe 2015-06-14 20:07:52 +01:00
parent b15f1daf5a
commit 254e0f0597
10 changed files with 141 additions and 69 deletions

View File

@ -21,10 +21,8 @@ var _ = require('lodash'),
authentication = require('./authentication'),
uploads = require('./upload'),
dataExport = require('../data/export'),
errors = require('../errors'),
http,
formatHttpErrors,
addHeaders,
cacheInvalidationHeader,
locationHeader,
@ -144,37 +142,6 @@ contentDispositionHeader = function contentDispositionHeader() {
});
};
/**
* ### Format HTTP Errors
* Converts the error response from the API into a format which can be returned over HTTP
*
* @private
* @param {Array} error
* @return {{errors: Array, statusCode: number}}
*/
formatHttpErrors = function formatHttpErrors(error) {
var statusCode = 500,
errors = [];
if (!_.isArray(error)) {
error = [].concat(error);
}
_.each(error, function each(errorItem) {
var errorContent = {};
// TODO: add logic to set the correct status code
statusCode = errorItem.code || 500;
errorContent.message = _.isString(errorItem) ? errorItem :
(_.isObject(errorItem) ? errorItem.message : 'Unknown API Error');
errorContent.errorType = errorItem.errorType || 'InternalServerError';
errors.push(errorContent);
});
return {errors: errors, statusCode: statusCode};
};
addHeaders = function addHeaders(apiMethod, req, res, result) {
var cacheInvalidation,
location,
@ -221,7 +188,7 @@ addHeaders = function addHeaders(apiMethod, req, res, result) {
* @return {Function} middleware format function to be called by the route when a matching request is made
*/
http = function http(apiMethod) {
return function apiHandler(req, res) {
return function apiHandler(req, res, next) {
// We define 2 properties for using as arguments in API calls:
var object = req.body,
options = _.extend({}, req.files, req.query, req.params, {
@ -243,11 +210,9 @@ http = function http(apiMethod) {
}).then(function then(response) {
// Send a properly formatting HTTP response containing the data with correct headers
res.json(response || {});
}).catch(function onError(error) {
errors.logError(error);
var httpErrors = formatHttpErrors(error);
// Send a properly formatted HTTP response containing the errors
res.status(httpErrors.statusCode).json({errors: httpErrors.errors});
}).catch(function onAPIError(error) {
// To be handled by the API middleware
next(error);
});
};
};

View File

@ -175,6 +175,37 @@ errors = {
};
},
/**
* ### Format HTTP Errors
* Converts the error response from the API into a format which can be returned over HTTP
*
* @private
* @param {Array} error
* @return {{errors: Array, statusCode: number}}
*/
formatHttpErrors: function formatHttpErrors(error) {
var statusCode = 500,
errors = [];
if (!_.isArray(error)) {
error = [].concat(error);
}
_.each(error, function each(errorItem) {
var errorContent = {};
// TODO: add logic to set the correct status code
statusCode = errorItem.code || 500;
errorContent.message = _.isString(errorItem) ? errorItem :
(_.isObject(errorItem) ? errorItem.message : 'Unknown API Error');
errorContent.errorType = errorItem.errorType || 'InternalServerError';
errors.push(errorContent);
});
return {errors: errors, statusCode: statusCode};
},
handleAPIError: function (error, permsMessage) {
if (!error) {
return this.rejectError(
@ -342,6 +373,7 @@ _.each([
'logErrorAndExit',
'logErrorWithRedirect',
'handleAPIError',
'formatHttpErrors',
'renderErrorPage',
'error404',
'error500'

View File

@ -5,7 +5,7 @@ function MethodNotAllowedError(message) {
this.message = message;
this.stack = new Error().stack;
this.code = 405;
this.type = this.name;
this.errorType = this.name;
}
MethodNotAllowedError.prototype = Object.create(Error.prototype);

View File

@ -0,0 +1,13 @@
var errors = require('../errors');
module.exports.methodNotAllowed = function methodNotAllowed(req, res, next) {
next(new errors.MethodNotAllowedError('Unknown method: ' + req.path));
};
module.exports.errorHandler = function errorHandler(err, req, res, next) {
/*jshint unused:false */
var httpErrors = errors.formatHttpErrors(err);
errors.logError(err);
// Send a properly formatted HTTP response containing the errors
res.status(httpErrors.statusCode).json({errors: httpErrors.errors});
};

View File

@ -217,8 +217,7 @@ function serveSharedFile(file, type, maxAge) {
setupMiddleware = function setupMiddleware(blogAppInstance, adminApp) {
var logging = config.logging,
corePath = config.paths.corePath,
oauthServer = oauth2orize.createServer(),
apiRouter;
oauthServer = oauth2orize.createServer();
// silence JSHint without disabling unused check for the whole file
authStrategies = authStrategies;
@ -226,7 +225,7 @@ setupMiddleware = function setupMiddleware(blogAppInstance, adminApp) {
// Cache express server instance
blogApp = blogAppInstance;
middleware.cacheBlogApp(blogApp);
middleware.cacheOauthServer(oauthServer);
middleware.api.cacheOauthServer(oauthServer);
oauth.init(oauthServer, middleware.spamPrevention.resetCounter);
// Make sure 'req.secure' is valid for proxied requests
@ -311,19 +310,7 @@ setupMiddleware = function setupMiddleware(blogAppInstance, adminApp) {
// ### Routing
// Set up API routes
apiRouter = routes.api(middleware);
blogApp.use(routes.apiBaseUri, apiRouter);
// ### Invalid method call on valid route
apiRouter.use(function (req, res, next) {
apiRouter.stack.forEach(function (item) {
if (item.regexp.test(req.path) && item.route !== undefined) {
return next(new errors.MethodNotAllowedError('Method not allowed'));
}
});
// Didn't match any path -> 404
res.status(404).json({errors: {type: 'NotFoundError', message: 'Unknown API endpoint.'}});
});
blogApp.use(routes.apiBaseUri, routes.api(middleware));
// Mount admin express app to /ghost and set up routes
adminApp.use(middleware.redirectToSetup);

View File

@ -18,8 +18,9 @@ var _ = require('lodash'),
busboy = require('./ghost-busboy'),
cacheControl = require('./cache-control'),
spamPrevention = require('./spam-prevention'),
clientAuth = require('./client-auth'),
spamPrevention = require('./spam-prevention'),
clientAuth = require('./client-auth'),
apiErrorHandlers = require('./api-error-handlers'),
middleware,
blogApp;
@ -306,10 +307,14 @@ middleware = {
module.exports = middleware;
module.exports.cacheBlogApp = cacheBlogApp;
module.exports.addClientSecret = clientAuth.addClientSecret;
module.exports.cacheOauthServer = clientAuth.cacheOauthServer;
module.exports.authenticateClient = clientAuth.authenticateClient;
module.exports.generateAccessToken = clientAuth.generateAccessToken;
module.exports.api = {
addClientSecret: clientAuth.addClientSecret,
cacheOauthServer: clientAuth.cacheOauthServer,
authenticateClient: clientAuth.authenticateClient,
generateAccessToken: clientAuth.generateAccessToken,
methodNotAllowed: apiErrorHandlers.methodNotAllowed,
errorHandler: apiErrorHandlers.errorHandler
};
// SSL helper functions are exported primarily for unity testing.
module.exports.isSSLrequired = isSSLrequired;

View File

@ -3,7 +3,7 @@ var express = require('express'),
api = require('../api'),
apiRoutes;
apiRoutes = function (middleware) {
apiRoutes = function apiRoutes(middleware) {
var router = express.Router();
// alias delete with del
router.del = router.delete;
@ -79,15 +79,20 @@ apiRoutes = function (middleware) {
router.get('/authentication/setup', api.http(api.authentication.isSetup));
router.post('/authentication/token',
middleware.spamPrevention.signin,
middleware.addClientSecret,
middleware.authenticateClient,
middleware.generateAccessToken
middleware.api.addClientSecret,
middleware.api.authenticateClient,
middleware.api.generateAccessToken
);
router.post('/authentication/revoke', api.http(api.authentication.revoke));
// ## Uploads
router.post('/uploads', middleware.busboy, api.http(api.uploads.add));
// API Router middleware
router.use(middleware.api.methodNotAllowed);
router.use(middleware.api.errorHandler);
return router;
};

View File

@ -0,0 +1,61 @@
/*globals describe, beforeEach, afterEach, it*/
/*jshint expr:true*/
var should = require('should'),
sinon = require('sinon'),
middleware = require('../../../server/middleware').middleware,
errors = require('../../../server/errors');
// To stop jshint complaining
should.equal(true, true);
describe('Middleware: API Error Handlers', function () {
var sandbox, req, res, next;
beforeEach(function () {
sandbox = sinon.sandbox.create();
req = {};
res = {};
res.json = sandbox.spy();
res.status = sandbox.stub().returns(res);
next = sandbox.spy();
});
afterEach(function () {
sandbox.restore();
});
describe('methodNotAllowed', function () {
it('calls next with an error', function () {
req.path = 'test';
middleware.api.methodNotAllowed(req, res, next);
next.calledOnce.should.be.true;
next.firstCall.args[0].code.should.equal(405);
next.firstCall.args[0].errorType.should.equal('MethodNotAllowedError');
next.firstCall.args[0].message.should.match(/test$/);
});
});
describe('errorHandler', function () {
it('sends a JSON error response', function () {
errors.logError = sandbox.spy(errors, 'logError');
errors.formatHttpErrors = sandbox.spy(errors, 'formatHttpErrors');
var msg = 'Something got lost',
err = new errors.NotFoundError(msg);
middleware.api.errorHandler(err, req, res, next);
next.called.should.be.false;
errors.logError.calledOnce.should.be.true;
errors.formatHttpErrors.calledOnce.should.be.true;
res.status.calledWith(404).should.be.true;
res.json.calledOnce.should.be.true;
res.json.firstCall.args[0].errors[0].message.should.eql(msg);
res.json.firstCall.args[0].errors[0].errorType.should.eql('NotFoundError');
});
});
});

View File

@ -20,7 +20,7 @@ describe('Middleware: Client Auth', function () {
req.body = requestBody;
middleware.addClientSecret(req, res, next);
middleware.api.addClientSecret(req, res, next);
next.called.should.be.true;
should(req.body).have.property('client_secret');
@ -34,7 +34,7 @@ describe('Middleware: Client Auth', function () {
req.body = requestBody;
middleware.addClientSecret(req, res, next);
middleware.api.addClientSecret(req, res, next);
next.called.should.be.true;
should(req.body).have.property('client_secret');

View File

@ -1,8 +1,12 @@
/*globals describe, beforeEach, afterEach, it*/
/*jshint expr:true*/
var sinon = require('sinon'),
var should = require('should'),
sinon = require('sinon'),
decideIsAdmin = require('../../../server/middleware/decide-is-admin');
// To stop jshint complaining
should.equal(true, true);
describe('Middleware: decideIsAdmin', function () {
var sandbox,
res,