diff --git a/lib/application.js b/lib/application.js index 838b882aaae..5d0c4e25f51 100644 --- a/lib/application.js +++ b/lib/application.js @@ -21,6 +21,8 @@ var methods = require('./utils').methods; var compileETag = require('./utils').compileETag; var compileQueryParser = require('./utils').compileQueryParser; var compileTrust = require('./utils').compileTrust; +var errors = require('./errors'); +var codes = errors.codes; var resolve = require('node:path').resolve; var once = require('once') var Router = require('router'); @@ -210,7 +212,10 @@ app.use = function use(fn) { var fns = flatten.call(slice.call(arguments, offset), Infinity); if (fns.length === 0) { - throw new TypeError('app.use() requires a middleware function') + throw errors.createTypeError( + codes.ERR_MIDDLEWARE_REQUIRED, + 'app.use() requires a middleware function' + ); } // get router @@ -293,7 +298,10 @@ app.route = function route(path) { app.engine = function engine(ext, fn) { if (typeof fn !== 'function') { - throw new Error('callback function required'); + throw errors.createError( + codes.ERR_ENGINE_CALLBACK_REQUIRED, + 'callback function required' + ); } // get file extension diff --git a/lib/errors.js b/lib/errors.js new file mode 100644 index 00000000000..a99d6ad6989 --- /dev/null +++ b/lib/errors.js @@ -0,0 +1,111 @@ +/*! + * express + * Copyright(c) 2009-2013 TJ Holowaychuk + * Copyright(c) 2013 Roman Shtylman + * Copyright(c) 2014-2015 Douglas Christopher Wilson + * MIT Licensed + */ + +"use strict"; + +/** + * Error codes for Express errors. + * + * These codes provide a stable contract for error handling, + * allowing developers to rely on error codes rather than error messages. + * + * @public + */ + +var codes = { + // Response errors + ERR_INVALID_STATUS_CODE: "ERR_INVALID_STATUS_CODE", + ERR_STATUS_CODE_OUT_OF_RANGE: "ERR_STATUS_CODE_OUT_OF_RANGE", + ERR_SENDFILE_PATH_REQUIRED: "ERR_SENDFILE_PATH_REQUIRED", + ERR_SENDFILE_PATH_NOT_STRING: "ERR_SENDFILE_PATH_NOT_STRING", + ERR_SENDFILE_PATH_NOT_ABSOLUTE: "ERR_SENDFILE_PATH_NOT_ABSOLUTE", + ERR_CONTENT_TYPE_ARRAY: "ERR_CONTENT_TYPE_ARRAY", + ERR_COOKIE_SECRET_REQUIRED: "ERR_COOKIE_SECRET_REQUIRED", + + // Application/middleware errors + ERR_MIDDLEWARE_REQUIRED: "ERR_MIDDLEWARE_REQUIRED", + ERR_ENGINE_CALLBACK_REQUIRED: "ERR_ENGINE_CALLBACK_REQUIRED", + + // Request errors + ERR_HEADER_NAME_REQUIRED: "ERR_HEADER_NAME_REQUIRED", + ERR_HEADER_NAME_NOT_STRING: "ERR_HEADER_NAME_NOT_STRING", + + // View errors + ERR_NO_DEFAULT_ENGINE: "ERR_NO_DEFAULT_ENGINE", + ERR_VIEW_ENGINE_NOT_FOUND: "ERR_VIEW_ENGINE_NOT_FOUND", + + // Configuration errors + ERR_INVALID_ETAG_OPTION: "ERR_INVALID_ETAG_OPTION", + ERR_INVALID_QUERY_PARSER_OPTION: "ERR_INVALID_QUERY_PARSER_OPTION", +}; + +/** + * Create a TypeError with an error code. + * + * @param {string} code - The error code + * @param {string} message - The error message + * @return {TypeError} + * @private + */ + +function createTypeError(code, message) { + var error = new TypeError(message); + error.code = code; + if (Error.captureStackTrace) { + Error.captureStackTrace(error, createTypeError); + } + return error; +} + +/** + * Create a RangeError with an error code. + * + * @param {string} code - The error code + * @param {string} message - The error message + * @return {RangeError} + * @private + */ + +function createRangeError(code, message) { + var error = new RangeError(message); + error.code = code; + if (Error.captureStackTrace) { + Error.captureStackTrace(error, createRangeError); + } + return error; +} + +/** + * Create an Error with an error code. + * + * @param {string} code - The error code + * @param {string} message - The error message + * @return {Error} + * @private + */ + +function createError(code, message) { + var error = new Error(message); + error.code = code; + if (Error.captureStackTrace) { + Error.captureStackTrace(error, createError); + } + return error; +} + +/** + * Module exports. + * @public + */ + +module.exports = { + codes: Object.freeze(codes), + createTypeError: createTypeError, + createRangeError: createRangeError, + createError: createError, +}; diff --git a/lib/express.js b/lib/express.js index 2d502eb54e4..076a1be06ef 100644 --- a/lib/express.js +++ b/lib/express.js @@ -79,3 +79,9 @@ exports.raw = bodyParser.raw exports.static = require('serve-static'); exports.text = bodyParser.text exports.urlencoded = bodyParser.urlencoded + +/** + * Expose error codes for programmatic error handling. + */ + +exports.errorCodes = require('./errors').codes diff --git a/lib/request.js b/lib/request.js index 9f517721c3e..9a61b702055 100644 --- a/lib/request.js +++ b/lib/request.js @@ -21,6 +21,8 @@ var fresh = require('fresh'); var parseRange = require('range-parser'); var parse = require('parseurl'); var proxyaddr = require('proxy-addr'); +var errors = require('./errors'); +var codes = errors.codes; /** * Request prototype. @@ -63,11 +65,17 @@ module.exports = req req.get = req.header = function header(name) { if (!name) { - throw new TypeError('name argument is required to req.get'); + throw errors.createTypeError( + codes.ERR_HEADER_NAME_REQUIRED, + 'name argument is required to req.get' + ); } if (typeof name !== 'string') { - throw new TypeError('name must be a string to req.get'); + throw errors.createTypeError( + codes.ERR_HEADER_NAME_NOT_STRING, + 'name must be a string to req.get' + ); } var lc = name.toLowerCase(); diff --git a/lib/response.js b/lib/response.js index 7a2f0ecce56..b3bb1006b5d 100644 --- a/lib/response.js +++ b/lib/response.js @@ -27,6 +27,8 @@ var sign = require('cookie-signature').sign; var normalizeType = require('./utils').normalizeType; var normalizeTypes = require('./utils').normalizeTypes; var setCharset = require('./utils').setCharset; +var errors = require('./errors'); +var codes = errors.codes; var cookie = require('cookie'); var send = require('send'); var extname = path.extname; @@ -64,11 +66,17 @@ module.exports = res res.status = function status(code) { // Check if the status code is not an integer if (!Number.isInteger(code)) { - throw new TypeError(`Invalid status code: ${JSON.stringify(code)}. Status code must be an integer.`); + throw errors.createTypeError( + codes.ERR_INVALID_STATUS_CODE, + `Invalid status code: ${JSON.stringify(code)}. Status code must be an integer.` + ); } // Check if the status code is outside of Node's valid range if (code < 100 || code > 999) { - throw new RangeError(`Invalid status code: ${JSON.stringify(code)}. Status code must be greater than 99 and less than 1000.`); + throw errors.createRangeError( + codes.ERR_STATUS_CODE_OUT_OF_RANGE, + `Invalid status code: ${JSON.stringify(code)}. Status code must be greater than 99 and less than 1000.` + ); } this.statusCode = code; @@ -383,11 +391,17 @@ res.sendFile = function sendFile(path, options, callback) { var opts = options || {}; if (!path) { - throw new TypeError('path argument is required to res.sendFile'); + throw errors.createTypeError( + codes.ERR_SENDFILE_PATH_REQUIRED, + 'path argument is required to res.sendFile' + ); } if (typeof path !== 'string') { - throw new TypeError('path must be a string to res.sendFile') + throw errors.createTypeError( + codes.ERR_SENDFILE_PATH_NOT_STRING, + 'path must be a string to res.sendFile' + ); } // support function as second arg @@ -397,7 +411,10 @@ res.sendFile = function sendFile(path, options, callback) { } if (!opts.root && !pathIsAbsolute(path)) { - throw new TypeError('path must be absolute or specify root to res.sendFile'); + throw errors.createTypeError( + codes.ERR_SENDFILE_PATH_NOT_ABSOLUTE, + 'path must be absolute or specify root to res.sendFile' + ); } // create file stream @@ -678,7 +695,10 @@ res.header = function header(field, val) { // add charset to content-type if (field.toLowerCase() === 'content-type') { if (Array.isArray(value)) { - throw new TypeError('Content-Type cannot be set to an Array'); + throw errors.createTypeError( + codes.ERR_CONTENT_TYPE_ARRAY, + 'Content-Type cannot be set to an Array' + ); } value = mime.contentType(value) } @@ -752,7 +772,10 @@ res.cookie = function (name, value, options) { var signed = opts.signed; if (signed && !secret) { - throw new Error('cookieParser("secret") required for signed cookies'); + throw errors.createError( + codes.ERR_COOKIE_SECRET_REQUIRED, + 'cookieParser("secret") required for signed cookies' + ); } var val = typeof value === 'object' diff --git a/lib/utils.js b/lib/utils.js index 4f21e7ef1e3..0edeebe8f73 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -20,6 +20,8 @@ var proxyaddr = require('proxy-addr'); var qs = require('qs'); var querystring = require('node:querystring'); const { Buffer } = require('node:buffer'); +var errors = require('./errors'); +var codes = errors.codes; /** @@ -145,7 +147,10 @@ exports.compileETag = function(val) { fn = exports.etag; break; default: - throw new TypeError('unknown value for etag function: ' + val); + throw errors.createTypeError( + codes.ERR_INVALID_ETAG_OPTION, + 'unknown value for etag function: ' + val + ); } return fn; @@ -177,7 +182,10 @@ exports.compileQueryParser = function compileQueryParser(val) { fn = parseExtendedQueryString; break; default: - throw new TypeError('unknown value for query parser function: ' + val); + throw errors.createTypeError( + codes.ERR_INVALID_QUERY_PARSER_OPTION, + 'unknown value for query parser function: ' + val + ); } return fn; diff --git a/lib/view.js b/lib/view.js index d66b4a2d89c..b0f45c0db66 100644 --- a/lib/view.js +++ b/lib/view.js @@ -16,6 +16,8 @@ var debug = require('debug')('express:view'); var path = require('node:path'); var fs = require('node:fs'); +var errors = require('./errors'); +var codes = errors.codes; /** * Module variables. @@ -58,7 +60,10 @@ function View(name, options) { this.root = opts.root; if (!this.ext && !this.defaultEngine) { - throw new Error('No default engine was specified and no extension was provided.'); + throw errors.createError( + codes.ERR_NO_DEFAULT_ENGINE, + 'No default engine was specified and no extension was provided.' + ); } var fileName = name; @@ -81,7 +86,10 @@ function View(name, options) { var fn = require(mod).__express if (typeof fn !== 'function') { - throw new Error('Module "' + mod + '" does not provide a view engine.') + throw errors.createError( + codes.ERR_VIEW_ENGINE_NOT_FOUND, + 'Module "' + mod + '" does not provide a view engine.' + ); } opts.engines[this.ext] = fn diff --git a/test/error-codes.js b/test/error-codes.js new file mode 100644 index 00000000000..3382c50b745 --- /dev/null +++ b/test/error-codes.js @@ -0,0 +1,302 @@ +'use strict' + +var assert = require('node:assert') +var express = require('../') +var request = require('supertest') + +describe('error codes', function () { + describe('exports', function () { + it('should expose errorCodes object', function () { + assert.strictEqual(typeof express.errorCodes, 'object') + }) + + it('should expose frozen errorCodes object', function () { + assert.strictEqual(Object.isFrozen(express.errorCodes), true) + }) + + it('should expose all error codes', function () { + var codes = express.errorCodes + // Response errors + assert.strictEqual(codes.ERR_INVALID_STATUS_CODE, 'ERR_INVALID_STATUS_CODE') + assert.strictEqual(codes.ERR_STATUS_CODE_OUT_OF_RANGE, 'ERR_STATUS_CODE_OUT_OF_RANGE') + assert.strictEqual(codes.ERR_SENDFILE_PATH_REQUIRED, 'ERR_SENDFILE_PATH_REQUIRED') + assert.strictEqual(codes.ERR_SENDFILE_PATH_NOT_STRING, 'ERR_SENDFILE_PATH_NOT_STRING') + assert.strictEqual(codes.ERR_SENDFILE_PATH_NOT_ABSOLUTE, 'ERR_SENDFILE_PATH_NOT_ABSOLUTE') + assert.strictEqual(codes.ERR_CONTENT_TYPE_ARRAY, 'ERR_CONTENT_TYPE_ARRAY') + assert.strictEqual(codes.ERR_COOKIE_SECRET_REQUIRED, 'ERR_COOKIE_SECRET_REQUIRED') + // Application/middleware errors + assert.strictEqual(codes.ERR_MIDDLEWARE_REQUIRED, 'ERR_MIDDLEWARE_REQUIRED') + assert.strictEqual(codes.ERR_ENGINE_CALLBACK_REQUIRED, 'ERR_ENGINE_CALLBACK_REQUIRED') + // Request errors + assert.strictEqual(codes.ERR_HEADER_NAME_REQUIRED, 'ERR_HEADER_NAME_REQUIRED') + assert.strictEqual(codes.ERR_HEADER_NAME_NOT_STRING, 'ERR_HEADER_NAME_NOT_STRING') + // View errors + assert.strictEqual(codes.ERR_NO_DEFAULT_ENGINE, 'ERR_NO_DEFAULT_ENGINE') + assert.strictEqual(codes.ERR_VIEW_ENGINE_NOT_FOUND, 'ERR_VIEW_ENGINE_NOT_FOUND') + // Configuration errors + assert.strictEqual(codes.ERR_INVALID_ETAG_OPTION, 'ERR_INVALID_ETAG_OPTION') + assert.strictEqual(codes.ERR_INVALID_QUERY_PARSER_OPTION, 'ERR_INVALID_QUERY_PARSER_OPTION') + }) + }) + + describe('res.status()', function () { + it('should have ERR_INVALID_STATUS_CODE for non-integer', function (done) { + var app = express() + + app.use(function (req, res) { + try { + res.status('200') + } catch (err) { + res.status(500).json({ code: err.code, name: err.name }) + } + }) + + request(app) + .get('/') + .expect(500) + .expect(function (res) { + assert.strictEqual(res.body.code, 'ERR_INVALID_STATUS_CODE') + assert.strictEqual(res.body.name, 'TypeError') + }) + .end(done) + }) + + it('should have ERR_STATUS_CODE_OUT_OF_RANGE for invalid range', function (done) { + var app = express() + + app.use(function (req, res) { + try { + res.status(1000) + } catch (err) { + res.status(500).json({ code: err.code, name: err.name }) + } + }) + + request(app) + .get('/') + .expect(500) + .expect(function (res) { + assert.strictEqual(res.body.code, 'ERR_STATUS_CODE_OUT_OF_RANGE') + assert.strictEqual(res.body.name, 'RangeError') + }) + .end(done) + }) + }) + + describe('res.sendFile()', function () { + it('should have ERR_SENDFILE_PATH_REQUIRED when path is missing', function (done) { + var app = express() + + app.use(function (req, res) { + try { + res.sendFile() + } catch (err) { + res.status(500).json({ code: err.code, name: err.name }) + } + }) + + request(app) + .get('/') + .expect(500) + .expect(function (res) { + assert.strictEqual(res.body.code, 'ERR_SENDFILE_PATH_REQUIRED') + assert.strictEqual(res.body.name, 'TypeError') + }) + .end(done) + }) + + it('should have ERR_SENDFILE_PATH_NOT_STRING when path is not a string', function (done) { + var app = express() + + app.use(function (req, res) { + try { + res.sendFile(42) + } catch (err) { + res.status(500).json({ code: err.code, name: err.name }) + } + }) + + request(app) + .get('/') + .expect(500) + .expect(function (res) { + assert.strictEqual(res.body.code, 'ERR_SENDFILE_PATH_NOT_STRING') + assert.strictEqual(res.body.name, 'TypeError') + }) + .end(done) + }) + + it('should have ERR_SENDFILE_PATH_NOT_ABSOLUTE when path is relative without root', function (done) { + var app = express() + + app.use(function (req, res) { + try { + res.sendFile('relative/path.txt') + } catch (err) { + res.status(500).json({ code: err.code, name: err.name }) + } + }) + + request(app) + .get('/') + .expect(500) + .expect(function (res) { + assert.strictEqual(res.body.code, 'ERR_SENDFILE_PATH_NOT_ABSOLUTE') + assert.strictEqual(res.body.name, 'TypeError') + }) + .end(done) + }) + }) + + describe('res.set()', function () { + it('should have ERR_CONTENT_TYPE_ARRAY when setting Content-Type to array', function (done) { + var app = express() + + app.use(function (req, res) { + try { + res.set('Content-Type', ['text/html', 'text/plain']) + } catch (err) { + res.status(500).json({ code: err.code, name: err.name }) + } + }) + + request(app) + .get('/') + .expect(500) + .expect(function (res) { + assert.strictEqual(res.body.code, 'ERR_CONTENT_TYPE_ARRAY') + assert.strictEqual(res.body.name, 'TypeError') + }) + .end(done) + }) + }) + + describe('res.cookie()', function () { + it('should have ERR_COOKIE_SECRET_REQUIRED for signed cookie without secret', function (done) { + var app = express() + + app.use(function (req, res) { + try { + res.cookie('name', 'value', { signed: true }) + } catch (err) { + res.status(500).json({ code: err.code, name: err.name }) + } + }) + + request(app) + .get('/') + .expect(500) + .expect(function (res) { + assert.strictEqual(res.body.code, 'ERR_COOKIE_SECRET_REQUIRED') + assert.strictEqual(res.body.name, 'Error') + }) + .end(done) + }) + }) + + describe('req.get()', function () { + it('should have ERR_HEADER_NAME_REQUIRED when name is missing', function (done) { + var app = express() + + app.use(function (req, res) { + try { + req.get() + } catch (err) { + res.status(500).json({ code: err.code, name: err.name }) + } + }) + + request(app) + .get('/') + .expect(500) + .expect(function (res) { + assert.strictEqual(res.body.code, 'ERR_HEADER_NAME_REQUIRED') + assert.strictEqual(res.body.name, 'TypeError') + }) + .end(done) + }) + + it('should have ERR_HEADER_NAME_NOT_STRING when name is not a string', function (done) { + var app = express() + + app.use(function (req, res) { + try { + req.get(42) + } catch (err) { + res.status(500).json({ code: err.code, name: err.name }) + } + }) + + request(app) + .get('/') + .expect(500) + .expect(function (res) { + assert.strictEqual(res.body.code, 'ERR_HEADER_NAME_NOT_STRING') + assert.strictEqual(res.body.name, 'TypeError') + }) + .end(done) + }) + }) + + describe('app.use()', function () { + it('should have ERR_MIDDLEWARE_REQUIRED when no middleware provided', function () { + var app = express() + + try { + app.use('/') + } catch (err) { + assert.strictEqual(err.code, 'ERR_MIDDLEWARE_REQUIRED') + assert.strictEqual(err.name, 'TypeError') + return + } + + assert.fail('Expected error to be thrown') + }) + }) + + describe('app.engine()', function () { + it('should have ERR_ENGINE_CALLBACK_REQUIRED when callback is not a function', function () { + var app = express() + + try { + app.engine('html', 'not a function') + } catch (err) { + assert.strictEqual(err.code, 'ERR_ENGINE_CALLBACK_REQUIRED') + assert.strictEqual(err.name, 'Error') + return + } + + assert.fail('Expected error to be thrown') + }) + }) + + describe('app.set() configuration errors', function () { + it('should have ERR_INVALID_ETAG_OPTION for invalid etag value', function () { + var app = express() + + try { + app.set('etag', 'invalid') + } catch (err) { + assert.strictEqual(err.code, 'ERR_INVALID_ETAG_OPTION') + assert.strictEqual(err.name, 'TypeError') + return + } + + assert.fail('Expected error to be thrown') + }) + + it('should have ERR_INVALID_QUERY_PARSER_OPTION for invalid query parser value', function () { + var app = express() + + try { + app.set('query parser', 'invalid') + } catch (err) { + assert.strictEqual(err.code, 'ERR_INVALID_QUERY_PARSER_OPTION') + assert.strictEqual(err.name, 'TypeError') + return + } + + assert.fail('Expected error to be thrown') + }) + }) +})