;
19 |
20 | export = hapiError;
21 |
22 | declare module 'hapi' {
23 | interface Request {
24 | handleError: hapiError.handleError;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/example/server_example.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | var server = require('./server.js');
4 | var Hoek = require('@hapi/hoek');
5 |
6 | module.exports = async () => {
7 | try {
8 | await server.register(require('@hapi/vision'));
9 | await server.register(require('../lib/index.js'));
10 | server.views({
11 | engines: {
12 | html: require('handlebars')
13 | },
14 | path: require('path').resolve(__dirname, './')
15 | });
16 | await server.start();
17 | server.log('info', 'Visit: ' + server.info.uri);
18 | Hoek.assert('no errors starting server');
19 | return server;
20 | } catch(e) {
21 | throw e;
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 |
6 | # Runtime data
7 | pids
8 | *.pid
9 | *.seed
10 |
11 | # Directory for instrumented libs generated by jscoverage/JSCover
12 | lib-cov
13 |
14 | # Coverage directory used by tools like istanbul
15 | coverage
16 | coverage.lcov
17 |
18 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
19 | .grunt
20 |
21 | # node-waf configuration
22 | .lock-wscript
23 |
24 | # Compiled binary addons (https://nodejs.org/api/addons.html)
25 | build/Release
26 |
27 | # Dependency directory
28 | node_modules
29 |
30 | # Optional npm cache directory
31 | .npm
32 |
33 | # Optional REPL history
34 | .node_repl_history
35 | .DS_Store
36 |
37 | // currently required for Goodparts
38 | .eslintrc.js
39 |
40 | .nyc_output
41 | package-lock.json
42 | yarn.lock
--------------------------------------------------------------------------------
/test/message_server_example.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | require('decache')('../example/server.js');
4 | // ensure we have a fresh module
5 | var server = require('../example/server.js');
6 | var Hoek = require('@hapi/hoek');
7 | var Path = require('path');
8 | var Handlebars = require('handlebars');
9 |
10 | var config = {
11 | 404: { // if the statusCode is 401 redirect to /login page/endpoint
12 | message: function () {
13 | return 'robots in disguise';
14 | }
15 | },
16 | 500: {
17 | message: function (msg, request) {
18 | return 'User agent: ' + request.headers['user-agent'];
19 | }
20 | }
21 | };
22 |
23 | module.exports = async () => {
24 | try {
25 | await server.register({ plugin: require('../lib/index.js'), options: config });
26 | Hoek.assert('no errors registering plugins');
27 | return server;
28 | } catch (e){
29 | throw e;
30 | }
31 | };
32 |
--------------------------------------------------------------------------------
/example/error_template.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{errorTitle}}
6 |
7 |
29 |
30 |
31 |
32 |
◕ ︵ ◕
33 |
{{errorMessage}}
34 |
{{statusCode}}
35 |
{{email}}
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/.github/workflows/npm-publish.yml:
--------------------------------------------------------------------------------
1 | name: npm-publish
2 | on:
3 | push:
4 | branches:
5 | - master # Change this to your default branch
6 | jobs:
7 | npm-publish:
8 | name: npm-publish
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Checkout repository
12 | uses: actions/checkout@v2
13 | - name: Publish if version has been updated
14 | uses: pascalgn/npm-publish-action@1.3.9
15 | with: # All of theses inputs are optional
16 | tag_name: "v%s"
17 | tag_message: "v%s"
18 | create_tag: "true"
19 | commit_pattern: "^Release (\\S+)"
20 | workspace: "."
21 | publish_command: "yarn"
22 | publish_args: "--non-interactive"
23 | env: # More info about the environment variables in the README
24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Leave this as is, it's automatically generated
25 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} # You need to set this in your repo settings
26 |
--------------------------------------------------------------------------------
/test/uncaught_exception_server.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | var Hapi = require('@hapi/hapi');
4 | var Hoek = require('@hapi/hoek');
5 |
6 | var server = new Hapi.Server();
7 |
8 | module.exports = async () => {
9 | try {
10 | await server.register({
11 | plugin: require('@hapi/good'),
12 | options: require('./good_options'),
13 | });
14 | await server.register(require('../lib/index.js'));
15 | await server.register(require('@hapi/vision'));
16 | await server.views({
17 | engines: {
18 | html: require('handlebars')
19 | },
20 | path: require('path').resolve(__dirname, '../example')
21 | });
22 | server.route([
23 | {
24 | method: 'GET',
25 | path: '/throw',
26 | handler: function (request, reply) {
27 | throw new Error('AAAAA!');
28 | }
29 | }
30 | ]);
31 | Hoek.assert('no errors registering plugins');
32 | return server;
33 | } catch (e) {
34 | throw e;
35 | }
36 | };
37 | ;
38 |
--------------------------------------------------------------------------------
/test/redirect_server_example.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | require('decache')('../example/server.js'); // ensure we have a fresh module
4 | var server = require('../example/server.js');
5 | var Hoek = require('@hapi/hoek');
6 |
7 | var config = {
8 | "401": { // if the statusCode is 401 redirect to /login page/endpoint
9 | "redirect": "/login"
10 | },
11 | "403": {
12 | "redirect": function (request) {
13 | var redirectString = request.url.pathname + request.url.search;
14 | return "/login?redirect=" + redirectString
15 | }
16 | }
17 | };
18 |
19 | module.exports = async () => {
20 | try {
21 | await server.register(require('@hapi/vision'));
22 | await server.register({
23 | plugin: require('../lib/index.js'),
24 | options: config // pass in your redirect configuration in options
25 | });
26 | await server.views({
27 | engines: {
28 | html: require('handlebars')
29 | },
30 | path: require('path').resolve(__dirname, '../example')
31 | });
32 | Hoek.assert('no errors registering plugins');
33 | return server;
34 | } catch (e) {
35 | throw e;
36 | }
37 | };
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2022, Dwyl and Contributors
4 | All rights reserved.
5 |
6 | Redistribution and use in source and binary forms, with or without
7 | modification, are permitted provided that the following conditions are met:
8 |
9 | 1. Redistributions of source code must retain the above copyright notice, this
10 | list of conditions and the following disclaimer.
11 |
12 | 2. Redistributions in binary form must reproduce the above copyright notice,
13 | this list of conditions and the following disclaimer in the documentation
14 | and/or other materials provided with the distribution.
15 |
16 | 3. Neither the name of the copyright holder nor the names of its
17 | contributors may be used to endorse or promote products derived from
18 | this software without specific prior written permission.
19 |
20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30 |
--------------------------------------------------------------------------------
/example/server.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | var Hapi = require('@hapi/hapi');
4 | var Boom = require('@hapi/boom');
5 | var Hoek = require('@hapi/hoek');
6 | var Joi = require('@hapi/validate');
7 |
8 | var server = new Hapi.Server({ port: process.env.PORT });
9 |
10 | server.route([
11 | {
12 | method: 'GET',
13 | path: '/',
14 | config: {
15 | handler: function (request, reply) {
16 | var err = null;
17 | request.handleError(err);
18 | return 'hello';
19 | }
20 | }
21 | },
22 | {
23 | method: 'GET',
24 | path: '/error',
25 | config: {
26 | handler: function (request, reply) {
27 | throw new Error('500');
28 | }
29 | }
30 | },
31 | {
32 | method: 'GET',
33 | path: '/admin',
34 | config: {
35 | handler: function (request, reply) {
36 | throw Boom.unauthorized('Anauthorised');
37 | }
38 | }
39 | },
40 | {
41 | method: 'GET',
42 | path: '/management',
43 | config: {
44 | handler: function (request, reply) {
45 | throw Boom.forbidden('forbidden');
46 | }
47 | }
48 | },
49 | {
50 | method: 'GET',
51 | path: '/register/{param*}',
52 | config: {
53 | validate: {
54 | params: Joi.object({ param: Joi.string().min(4).max(160).alphanum() }),
55 | },
56 | handler: function (request, reply) {
57 | if(request.params.param.indexOf('script') > -1) { // more validation
58 | throw Boom.notFound('hapi-error intercepts this');
59 | } else {
60 | return 'Hello ' + request.params.param + '!';
61 | }
62 | }
63 | }
64 | },
65 | {
66 | method: 'GET',
67 | path: '/login',
68 | config: {
69 | handler: function (request, reply) {
70 | return 'please login';
71 | }
72 | }
73 | }
74 | ]);
75 |
76 | module.exports = server;
77 |
--------------------------------------------------------------------------------
/test/jwt_server_example.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | process.env.JWT_SECRET = 'supersecret'; // github.com/dwyl/hapi-auth-jwt2#generating-your-secret-key
4 | var Hapi = require('@hapi/hapi');
5 | var path = require('path');
6 | var Hoek = require('@hapi/hoek');
7 | var assert = require('assert');
8 | var server = new Hapi.Server({ port: 8765, debug: false });
9 |
10 |
11 | var db = {
12 | '123': { allowed: true, name: 'Charlie', email: 'charlie@mail.co' },
13 | '321': { allowed: false, name: 'Old Gregg'}
14 | };
15 |
16 | // for a more real-world validate function, see: https://git.io/vPZmr
17 | var validate = function (decoded, request, callback) {
18 | if (db[decoded.id].allowed) {
19 | return callback(null, true);
20 | }
21 | else {
22 | return callback(null, false);
23 | }
24 | };
25 |
26 | // server.start(function (err) {
27 | // assert(!err);
28 | // server.log('info', 'Visit: ' + server.info.uri);
29 | // });
30 |
31 | module.exports = async () => {
32 | try {
33 | await server.register(require('../lib/index.js'));
34 | await server.register(require('@hapi/vision'));
35 | await server.register(require('hapi-auth-jwt2'));
36 | await server.views({
37 | engines: {
38 | html: require('handlebars')
39 | },
40 | path: path.resolve(__dirname, '../example')
41 | });
42 | await server.auth.strategy('jwt', 'jwt', {
43 | key: process.env.JWT_SECRET,
44 | validate: validate
45 | });
46 | await server.route([
47 | { method: 'GET', path: '/throwerror', config: { auth: 'jwt' },
48 | handler: function throwerror (request, reply) {
49 | var err = true; // deliberately throw an error for https://git.io/vPZ4A
50 | return request.handleError(err, { errorMessage: 'Sorry, we haz fail.'});
51 | }
52 | }]);
53 | Hoek.assert('no errors registering plugins');
54 | return server;
55 | } catch (e) {
56 | throw e;
57 | }
58 | };
59 | ;
60 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "hapi-error",
3 | "version": "3.0.0",
4 | "description": "catch errors in your hapi application and display the appropriate error message/page",
5 | "main": "lib/index.js",
6 | "scripts": {
7 | "test": "nyc tape ./test/*.test.js | tap-nyc",
8 | "nocov": "tape ./test/*.test.js",
9 | "dev": "PORT=8000 ./node_modules/.bin/nodemon example/server_example.js",
10 | "start": "node example/server_example.js",
11 | "check-coverage": "npm run test && nyc check-coverage --statements 100 --functions 100 --lines 100 --branches 100",
12 | "lint": "node_modules/.bin/goodparts ./lib"
13 | },
14 | "engines": {
15 | "node": ">=14.0.0"
16 | },
17 | "repository": {
18 | "type": "git",
19 | "url": "git+https://github.com/dwyl/hapi-error.git"
20 | },
21 | "license": "BSD-3-Clause",
22 | "bugs": {
23 | "url": "https://github.com/dwyl/hapi-error/issues"
24 | },
25 | "homepage": "https://github.com/dwyl/hapi-error#readme",
26 | "dependencies": {
27 | "@hapi/hoek": "^10.0.0"
28 | },
29 | "devDependencies": {
30 | "@hapi/boom": "^10.0.0",
31 | "@hapi/good": "^9.0.1",
32 | "@hapi/hapi": "^20.2.2",
33 | "@hapi/validate": "^2.0.0",
34 | "@hapi/vision": "^6.1.0",
35 | "@types/hapi": "^18.0.7",
36 | "decache": "^4.6.1",
37 | "handlebars": "^4.7.7",
38 | "hapi-auth-jwt2": "^10.2.0",
39 | "jsonwebtoken": "^8.5.1",
40 | "nodemon": "^2.0.18",
41 | "nyc": "15.1.0",
42 | "pre-commit": "^1.2.2",
43 | "tap-nyc": "1.0.3",
44 | "tape": "5.5.3",
45 | "tape-async": "2.3.0"
46 | },
47 | "pre-commit": [
48 | "check-coverage"
49 | ],
50 | "keywords": [
51 | "custom",
52 | "customise",
53 | "error",
54 | "friendly",
55 | "hapi",
56 | "hapijs",
57 | "hapi.js",
58 | "helpful",
59 | "html",
60 | "human",
61 | "json",
62 | "message",
63 | "page",
64 | "useful",
65 | "user friendly",
66 | "UX"
67 | ],
68 | "author": "dwyl & co",
69 | "nyc": {
70 | "exclude": [
71 | "example/*.js",
72 | "coverage/*",
73 | "test/*.js"
74 | ],
75 | "report-dir": "./coverage",
76 | "reporter": [
77 | "lcov",
78 | "text"
79 | ],
80 | "cache": false,
81 | "all": true
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/lib/index.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | var Hoek = require('@hapi/hoek');
4 | var pkg = require('../package.json'); // require package.json for attributes
5 |
6 | /**
7 | * isFunction checks if a given value is a function.
8 | * @param {Object} functionToCheck - the object we want to confirm is a function
9 | * @returns {Boolean} true|false
10 | */
11 | function isFunction(functionToCheck) {
12 | const toString = Object.prototype.toString;
13 | return functionToCheck
14 | && toString.call(functionToCheck) === '[object Function]'
15 | || toString.call(functionToCheck) === '[object AsyncFunction]';
16 | }
17 |
18 | /**
19 | * Merges in custom options to teh default config for each status code
20 | * @param {Object} config - the custom option object with status codes as keys
21 | * and objects with settings as values
22 | * @returns {Object} config to be used in plugin with defaults overwritten
23 | * and or added to
24 | */
25 | function createConfig (config) {
26 | var mergedConfig = {
27 | templateName: 'error_template',
28 | statusCodes: {
29 | 401: { message: 'Please Login to view that page' },
30 | 400: { message: 'Sorry, we do not have that page.' },
31 | 404: { message: 'Sorry, that page is not available.' }
32 | }
33 | };
34 |
35 | // Target status code configuration objects.
36 | var statusCodes = config.statusCodes || config; // Backwards compatibility.
37 |
38 | // Configure error template name.
39 | mergedConfig.templateName = config.templateName || mergedConfig.templateName;
40 |
41 | Object.keys(statusCodes).forEach(function (statusCode) {
42 | if (!mergedConfig.statusCodes[statusCode]) {
43 | mergedConfig.statusCodes[statusCode] = {};
44 | }
45 | // Configure status code settings.
46 | Object.keys(statusCodes[statusCode]).forEach(function (setting) {
47 | mergedConfig.statusCodes[statusCode][setting]
48 | = statusCodes[statusCode][setting];
49 | });
50 | });
51 |
52 | return mergedConfig;
53 | };
54 |
55 |
56 | /**
57 | * Takes an error Object and Message and throws Hoek Error if not null
58 | * @param {String} error - error Object or null
59 | * @param {String|Object} [errorMessage] - Optional error message String/Object
60 | * @returns {Boolean} false.
61 | */
62 | function handleError (error, errorMessage) {
63 | if (errorMessage) {
64 | return Hoek.assert(!error, errorMessage);
65 | }
66 |
67 | return Hoek.assert(!error, error);
68 | };
69 | // export for use in files that do not have access to the request object
70 | exports.handleError = handleError; // e.g. database-specific getters/setters
71 |
72 | /**
73 | * register defines our errorHandler plugin following the standard hapi plugin
74 | * @param {Object} server - the server instance where the plugin is being used
75 | * @param {Object} options - any configuration options passed into the plugin
76 | * @returns {Function} reply.continue is called when the plugin is finished
77 | */
78 | exports.plugin = {
79 | pkg: pkg,
80 | register: async function (server, options) {
81 | // creates config for handler to be used in 'onPreResponse' function
82 | var config = createConfig(options);
83 |
84 | // make handleError available on request
85 | server.ext('onRequest', function (request, reply) {
86 | request.handleError = handleError; // github.com/dwyl/hapi-error/issues/23
87 |
88 | return reply.continue;
89 | });
90 |
91 | // onPreResponse intercepts ALL errors
92 | server.ext('onPreResponse', function (request, reply) {
93 | var res = request.response;
94 | var req = request.raw.req;
95 | var msg = 'Sorry, something went wrong, please retrace your steps.';
96 | var statusCode = 200; // default to "success"
97 | var accept = request.raw.req.headers.accept;
98 | var debug; // defined here to keep JSLint Happy.
99 |
100 | if (res.isBoom) {
101 | statusCode = res.output.payload.statusCode;
102 |
103 | debug = {
104 | method: req.method, // e.g GET/POST
105 | url: request.url.path, // the path the person requested
106 | headers: request.raw.req.headers, // all HTTP Headers
107 | info: request.info, // all additional request info (useful to debug)
108 | auth: request.auth, // any authentication details e.g. decoded JWT
109 | payload: request.payload, // the complete request payload received
110 | response: res.output.payload, // response before error intercepted
111 | stackTrace: res.stack // the stack trace of the error
112 | };
113 | // ALWAYS Log the error
114 | server.log('error', debug); // github.com/dwyl/hapi-error/issues/22
115 |
116 | // Header check, should take priority
117 | if (accept && accept.match(/json/)) { // support REST/JSON requests
118 | return reply.response(res.output.payload).code(statusCode);
119 | }
120 | // custom redirect https://github.com/dwyl/hapi-error/issues/5
121 | var currentCodeConfig = config.statusCodes[statusCode];
122 | if (currentCodeConfig && currentCodeConfig.redirect) {
123 | // if redirect is function invoke it with the request object
124 | if (isFunction(currentCodeConfig.redirect)) {
125 | const url = currentCodeConfig.redirect(request)
126 | return reply.redirect(url);
127 | }
128 | else {
129 | // if parameter is string, append redirect query
130 | var redirectString = request.url.pathname + request.url.search;
131 | return reply.redirect(currentCodeConfig.redirect + '?redirect=' + redirectString);
132 | }
133 | }
134 |
135 | if (currentCodeConfig && currentCodeConfig.message) {
136 | msg = isFunction(currentCodeConfig.message)
137 | ? currentCodeConfig.message(msg, request)
138 | : currentCodeConfig.message
139 | ;
140 | }
141 |
142 | res = Object.assign(debug, {
143 | errorTitle: res.output.payload.error,
144 | statusCode: statusCode,
145 | errorMessage: msg
146 | });
147 |
148 | // next avoids TypeError if view rendering is not used in app e.g API!
149 | // see: https://github.com/dwyl/hapi-error/issues/49
150 | if (!reply.view) {
151 | return reply.response(res).code(statusCode);
152 | }
153 |
154 | return reply.view(config.templateName, res).code(statusCode); // e.g 401
155 | }; // end if (res.isBoom)
156 | return reply.continue; // continue processing the request
157 | });
158 | }
159 | };
160 |
--------------------------------------------------------------------------------
/test/index.test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const test = require('tape-async');
4 | const JWT = require('jsonwebtoken');
5 | const decache = require('decache');
6 |
7 | /************************* handleError method test ***************************/
8 |
9 | const handleError = require('../lib').handleError;
10 |
11 | test("handleError no error is thrown when error = null", function (t) {
12 | const error = null;
13 | t.equal(handleError(error), undefined, 'No error thrown');
14 | t.end();
15 | });
16 |
17 | test("handleError don't throw error even if errorMessage is set", function (t) {
18 | const error = null;
19 | t.equal(handleError(error, 'this will not throw!'), undefined, 'No error thrown');
20 | t.end();
21 | });
22 |
23 | /************************* REDIRECT TEST ***************************/
24 | const redirectServerExampleLocation = './redirect_server_example';
25 |
26 | test("GET /admin?hello=world should re-direct to /login?redirect=/admin?hello=world", async function (t) {
27 | decache(redirectServerExampleLocation);
28 | const redirectServer = await require(redirectServerExampleLocation)();
29 |
30 | const urlWithQuery = '/admin?hello=world';
31 | const combinedUrl = '/login?redirect=/admin?hello=world';
32 |
33 | const options = {
34 | method: 'GET',
35 | url: urlWithQuery // this will re-direct to /login
36 | };
37 |
38 | const res = await redirectServer.inject(options);
39 | t.equal(res.statusCode, 302, 'statusCode: + ' + res.statusCode + ' (as expected)');
40 | t.equal(res.headers.location, combinedUrl, 'Successfully redirected to: ' + combinedUrl);
41 | t.end( await redirectServer.stop() );
42 | });
43 |
44 | test("GET /management?hello=world should re-direct to /login?redirect=/management?hello=world", async function (t) {
45 | decache(redirectServerExampleLocation);
46 | const redirectServer = await require(redirectServerExampleLocation)();
47 |
48 | const urlWithQuery = '/management?hello=world';
49 | const combinedUrl = '/login?redirect=/management?hello=world';
50 |
51 | const options = {
52 | method: 'GET',
53 | url: urlWithQuery // this will re-direct to /login
54 | };
55 |
56 | const res = await redirectServer.inject(options);
57 | t.equal(res.statusCode, 302, 'statusCode: + ' + res.statusCode + ' (as expected)');
58 | t.equal(res.headers.location, combinedUrl, 'Successfully redirected to: ' + combinedUrl);
59 | t.end( await redirectServer.stop() );
60 | });
61 |
62 | /************************* Message TEST ***************************/
63 | test('Initializing message_server_example', async function (t) {
64 | try {
65 | decache('../example/server.js');
66 | const messageServer = await require('./message_server_example')();
67 | test('example of overriding the', async function (t) {
68 | const options = {
69 | method: 'GET',
70 | url: '/notfound'
71 | };
72 |
73 | const res = await messageServer.inject(options);
74 | t.ok(res.payload.includes('robots in disguise'), '404 gets transformed');
75 | t.equal(res.statusCode, 404, 'statusCode give back ok');
76 | t.end();
77 | });
78 |
79 | test('example of adding a new message transform which uses req',async function (t) {
80 | const options = {
81 | method: 'GET',
82 | url: '/error'
83 | };
84 |
85 | const res = await messageServer.inject(options);
86 | t.ok(res.payload.includes('User agent: shot'), 'Internal Server Error');
87 | t.equal(res.statusCode, 500, 'statusCode 500');
88 | t.end();
89 | });
90 |
91 | test('close messageServer', async function (t) {
92 | await messageServer.stop();
93 | t.end()
94 | });
95 | } catch (e) {
96 | throw e;
97 | }
98 | });
99 |
100 | // /************************* Regular TESTS ***************************/
101 | test('Initializing server_example', async function (t) {
102 | decache('../example/server.js');
103 | const server = await require('../example/server_example')();
104 |
105 | test("GET / returns 200",async function (t) {
106 | const options = {
107 | method: 'GET',
108 | url: '/',
109 | headers: { accept: 'application/json' }
110 | };
111 |
112 | const res = await server.inject(options);
113 | t.ok(res.payload.includes('hello'), 'No Errors');
114 | t.equal(res.statusCode, 200, 'statusCode 200');
115 | t.end();
116 | });
117 |
118 | test("GET /login ",async function (t) {
119 | const options = {
120 | method: 'GET',
121 | url: '/login',
122 | headers: { accept: 'application/json' }
123 | };
124 | const res = await server.inject(options);
125 | t.equal(res.statusCode, 200, 'statusCode 200');
126 | t.ok(res.payload.includes('please login'), 'Please Login');
127 | t.end();
128 | });
129 |
130 | test("GET /notfound returns 404",async function (t) {
131 | const options = {
132 | method: 'GET',
133 | url: '/notfound'
134 | };
135 | const res = await server.inject(options);
136 | t.ok(res.payload.includes('not available'), 'page not available');
137 | t.equal(res.statusCode, 404, 'statusCode 404');
138 | t.end();
139 | });
140 |
141 | test("GET /admin expect to see 401 unauthorized error",async function (t) {
142 | const options = {
143 | method: 'GET',
144 | url: '/admin'
145 | };
146 | const res = await server.inject(options);
147 | t.ok(res.payload.includes('Please Login'), 'Please login to see /admin');
148 | t.equal(res.statusCode, 401, 'statusCode 401');
149 | t.end();
150 | });
151 |
152 | test("GET /error returns 500 Error HTML Page",async function (t) {
153 | const options = {
154 | method: 'GET',
155 | url: '/error'
156 | };
157 | const res = await server.inject(options);
158 | t.ok(res.payload.includes('500'), 'Internal Server Error');
159 | t.equal(res.statusCode, 500, 'statusCode 500');
160 | t.end();
161 | });
162 |
163 | test("GET /error returns JSON when headers.accept 'application/json'",async function (t) {
164 | const options = {
165 | method: 'GET',
166 | url: '/error',
167 | headers: { accept: 'application/json' }
168 | };
169 | const res = await server.inject(options);
170 | t.ok(res.payload.includes('Internal Server Error'), '500 Server Error');
171 | t.equal(res.statusCode, 500, 'Got statusCode 500 (as expected)');
172 | t.end();
173 | });
174 |
175 | test("GET /register/username passes validation",async function (t) {
176 | const options = {
177 | method: 'GET',
178 | url: '/register/username'
179 | };
180 | const res = await server.inject(options);
181 | t.ok(res.payload.includes('Hello username'), 'Passes validation');
182 | t.equal(res.statusCode, 200, 'statusCode 200');
183 | t.end();
184 | });
185 |
186 | test("GET /register/22%3A%5B%22black%22%5D%7D%22%3E%3C%7%203cript fails Joi validation",async function (t) {
187 | const options = {
188 | method: 'GET',
189 | url: '/register/22%3A%5B%22black%22%5D%7D%22%3E%3C%7%203cript%3Ealert%281%29%3C%2fscript%3E'
190 | };
191 | const res = await server.inject(options);
192 | t.ok(res.payload.includes('Sorry'), 'Fails Joi validation');
193 | t.equal(res.statusCode, 400, 'intercepted error > 400');
194 | t.end();
195 | });
196 |
197 | test("GET /register/myscript fails additional (CUSTOM) validation",async function (t) {
198 | const options = {
199 | method: 'GET',
200 | url: '/register/myscript?hello=world'
201 | };
202 | const res = await server.inject(options);
203 | t.ok(res.payload.includes('Sorry, that page is not available.'), 'Got Friendly 404 Page');
204 | t.equal(res.statusCode, 404, 'Got 404');
205 | t.end();
206 | });
207 |
208 | server.stop()
209 | });
210 |
211 | // /************************* 'email' prop Available in Error Template/View ***************/
212 | test('Initializing server', async function (t) {
213 | decache('./jwt_server_example');
214 | const jwtserver = await require('./jwt_server_example')();
215 |
216 | test("GET /error should display an error page containing the current person's email address",async function (t) {
217 | decache('../lib/index.js'); // ensure we have a fresh module
218 | const person = { id: 123, email: 'charlie@mail.me' }
219 | const token = JWT.sign(person, process.env.JWT_SECRET);
220 |
221 | const options = {
222 | method: 'GET',
223 | url: '/throwerror',
224 | headers: { authorization: "Bearer " + token }
225 | };
226 |
227 | const res = await jwtserver.inject(options);
228 | t.equal(res.statusCode, 500, 'statusCode: + ' + res.statusCode + ' (as expected)');
229 | jwtserver.stop();
230 | t.end();
231 | });
232 | });
233 |
234 | // /************************* API (no vision) Tests ***************************/
235 | test('Initializing api_server', async function (t) {
236 | decache('./api_server.js');
237 | const apiServer = await require('./api_server.js')();
238 |
239 | test('regression test for #49 (when no vison views configured)', async function (t) {
240 | const options = { url: '/error' };
241 |
242 | const res = await apiServer.inject(options);
243 | t.equal(res.statusCode, 404, 'statusCode give back ok');
244 | apiServer.stop();
245 | t.end();
246 | });
247 | });
248 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # `hapi-error`
4 |
5 | Intercept errors in your Hapi web app/api
6 | and send a *useful* message to the client.
7 |
8 | 
9 |
10 | [](https://snyk.io/test/github/dwyl/hapi-error?targetFile=package.json)
11 | [](https://travis-ci.org/dwyl/hapi-error)
12 | [](https://codecov.io/github/dwyl/hapi-error?branch=master)
13 | [](https://hapijs.com)
14 | [](https://nodejs.org/download/)
15 | [](https://david-dm.org/dwyl/hapi-error)
16 | [](https://david-dm.org/dwyl/hapi-error?type=dev)
17 | [](https://github.com/dwyl/hapi-error/issues)
18 | [](https://hits.dwyl.io/dwyl/hapi-error)
19 | [](https://www.npmjs.com/package/hapi-error)
20 |
21 |
22 |
23 |
24 | ## *Why*?
25 |
26 | > #### Seeing an (_unhelpful/unfriendly_) error message is _by far_ the _most frustrating_ part of the "**User _Experience_**" (**UX**) of your web app/site.
27 |
28 | Most _non-technical_ people (_"average" web users_) have _no clue_
29 | what a `401` error is. And if you/we the developer(s) do not _communicate_ with them, it can quickly lead to confusion and
30 | [_abandonment_](https://en.wikipedia.org/wiki/Abandonment_rate)!
31 | If instead of simply displaying **`401`** we _inform_ people:
32 | `"Please login to see that page."` we _**instantly improve**_
33 | the **UX** and thus make that person's day/life better. :heart:
34 |
35 | > _The "**Number 1 Rule**" is to make sure your **error messages**
36 | sound like they’ve been **written for/by humans**_.
37 | [~ _The **Four H**'s of Writing Error Messages_](https://uxmas.com/2012/the-4-hs-of-writing-error-messages)
38 |
39 | ## *What*?
40 |
41 | By `default`, `Hapi` does _not_ give people *friendly* error messages.
42 |
43 | `hapi-error` is a plugin that lets your Hapi app display _consistent_, _**human-friendly**_ & *useful*
44 | error messages so the _people_ using your app
45 | [_don't panic_](https://en.wikipedia.org/wiki/Phrases_from_The_Hitchhiker%27s_Guide_to_the_Galaxy#Don.27t_Panic).
46 |
47 | > Try it: https://hapi-error.herokuapp.com/panacea
48 |
49 |
50 | Under the hood, Hapi uses
51 | [`Boom`](https://github.com/dwyl/learn-hapi#error-handling-with-boom)
52 | to handle errors. These errors are returned as `JSON`. e.g:
53 |
54 | If a URL/Endpoint does not exist a `404` error is returned:
55 | 
56 |
57 | When a person/client attempts to access a "*restricted*" endpoint without
58 | the proper authentication/authorisation a `401` error is shown:
59 |
60 | 
61 |
62 | And if an *unknown* error occurs on the server, a `500` error is *thrown*:
63 |
64 | 
65 |
66 | The `hapi-error` plugin *re-purposes* the `Boom` errors (*both the standard Hapi errors and your custom ones*) and instead display human-friendly error *page*:
67 |
68 | 
69 |
70 | > ***Note***: *super basic error page example is just what we came up with in a few minutes, you have full control over what your error page looks like, so use your imagination*!
71 |
72 | > ***Note***: if the client expects a JSON response simply define
73 | that in the `headers.accept` and it will still receive the JSON error messages.
74 |
75 | ## *v3.0.0 Changes*
76 | 1. Support for Hapi.js v20
77 | 2. Not backward compatible with Hapi.js < v18
78 | 3. Requires NodeJS v14 and above
79 |
80 | ## *How*?
81 |
82 | > **Note**: If you (_or anyone on your team_) are _unfamiliar_ with **Hapi.js** we have a
83 | quick guide/tutorial to help get you started: [https://github.com/dwyl/**learn-hapi**](https://github.com/dwyl/learn-hapi)
84 |
85 | Error handling in 3 *easy* steps:
86 |
87 | ### 1. Install the [plugin](https://www.npmjs.com/package/hapi-error) from NPM:
88 |
89 | ```sh
90 | npm install hapi-error --save
91 | ```
92 |
93 | ### 2. Include the plugin in your Hapi project
94 |
95 | Include the plugin when you `register` your `server`:
96 |
97 | ```js
98 | var Hapi = require('@hapi/hapi');
99 | var Path = require('path');
100 | var server = new Hapi.Server({ port: process.env.PORT || 8000 });
101 |
102 | server.route([
103 | {
104 | method: 'GET',
105 | path: '/',
106 | config: {
107 | handler: function (request, reply) {
108 | reply('hello world');
109 | }
110 | }
111 | },
112 | {
113 | method: 'GET',
114 | path: '/error',
115 | config: {
116 | handler: function (request, reply) {
117 | reply(new Error('500'));
118 | }
119 | }
120 | }
121 | ]);
122 |
123 | // this is where we include the hapi-error plugin:
124 | module.exports = async () => {
125 | try {
126 | await server.register(require('hapi-error'));
127 | await server.register(require('vision'));
128 | server.views({
129 | engines: {
130 | html: require('handlebars') // or Jade or Riot or React etc.
131 | },
132 | path: Path.resolve(__dirname, '/your/view/directory')
133 | });
134 | await server.start();
135 | return server;
136 | } catch (e) {
137 | throw e;
138 | }
139 | };
140 | ```
141 |
142 | > See: [/example/server_example.js](https://github.com/dwyl/hapi-error/blob/master/example/server_example.js) for simple example
143 |
144 | ### 3. Create an Error View Template
145 |
146 | The default template name is `error_template` and is expected to exist, but can be configured in the options:
147 |
148 | ```js
149 | const config = {
150 | templateName: 'my-error-template'
151 | };
152 | ```
153 |
154 | > Note: `hapi-error` plugin *expects* you are using [`Vision`](https://github.com/hapijs/vision) (*the standard view rendering library for Hapi apps*)
155 | which allows you to use Handlebars, Jade, [**Riot**](https://github.com/dwyl/hapi-riot), React, etc. for your templates.
156 |
157 | Your `templateName` (*or `error_template.ext` `error_template.tag` `error_template.jsx`*) should make use of the 3 variables it will be passed:
158 |
159 | + `errorTitle` - *the error tile generated by Hapi*
160 | + `statusCode` - *HTTP statusCode sent to the client *e.g: `404`* (*not found*)
161 | + `errorMessage` - the *human-friendly error message*
162 |
163 | > for an example see: [`/example/error_template.html`](https://github.com/dwyl/hapi-error/blob/master/example/error_template.html)
164 |
165 | ### 4. *Optional* Add `statusCodes` config object to transform messages or redirect for certain status codes
166 |
167 | Each status code can be given two properties `message` and `redirect`.
168 |
169 | The default config object for status codes:
170 | ```
171 | const config = {
172 | statusCodes: {
173 | 401: { message: 'Please Login to view that page' },
174 | 400: { message: 'Sorry, we do not have that page.' },
175 | 404: { message: 'Sorry, that page is not available.' }
176 | }
177 | };
178 | ```
179 | We want to provide useful error messages that are pleasant for the user. If you think there are better defaults for messages or other codes then do let us know via [issue](https://github.com/dwyl/hapi-error/issues).
180 |
181 | Any of the above can be overwritten and new status codes can be added.
182 |
183 | #### `message` Parse/replace the error message
184 |
185 | This parameter can be of the form `function(message, request)` or just simply a `'string'` to replace the message.
186 |
187 | An example of a use case would be handling errors form joi validation.
188 |
189 | Or erroring in different languages.
190 | ```js
191 | const config = {
192 | statusCodes: {
193 | "401": {
194 | "message": function(msg, req) {
195 | var lang = findLang(req);
196 |
197 | return translate(lang, message);
198 | }
199 | }
200 | }
201 | };
202 | ```
203 |
204 | Or providing nice error messages like in the default config above.
205 |
206 | #### `redirect` *Redirecting* to another endpoint
207 |
208 | Sometimes you don't _want_ to show an error page;
209 | _instead_ you want to re-direct to another page.
210 | For example, when your route/page requires the person
211 | to be authenticated (_logged in_), but they have
212 | not supplied a valid session/token to view the route/page.
213 |
214 | In this situation the default Hapi behaviour is to return a `401` (_unauthorized_) error,
215 | however this is not very _useful_ to the _person_ using your application.
216 |
217 | Redirecting to a specific url is _easy_ with `hapi-error`:
218 |
219 | ```js
220 | const config = {
221 | statusCodes: {
222 | "401": { // if the statusCode is 401
223 | "redirect": "/login" // redirect to /login page/endpoint
224 | },
225 | "403": { // if the statusCode is 403
226 | "redirect": function (request) {
227 | return "/login?redirect=" + request.url.pathname
228 | }
229 | }
230 | }
231 | }
232 | (async () => {
233 | await server.register({
234 | plugin: require('hapi-error'),
235 | options: config // pass in your redirect configuration in options
236 | });
237 | await server.register(require('vision'));
238 | })();
239 | ```
240 |
241 | This in both cases will `redirect` the client/browser to the `/login` endpoint
242 | and will append a query parameter with the url the person was _trying_ to visit.
243 | With the use of function instead of simple string you can further manipulate the resulted url.
244 | Should the parameter be a function and return false it will be ignored.
245 |
246 | e.g: GET /admin --> 401 unauthorized --> redirect to /login?redirect=/admin
247 |
248 | > Redirect Example: [/redirect_server_example.js](https://github.com/dwyl/hapi-error/blob/master/test/redirect_server_example.js)
249 |
250 |
251 | ## *That's it*!
252 |
253 | *Want more...?* [*ask*!](https://github.com/dwyl/hapi-error/issues)
254 |
255 | ## *Custom* Error Messages using `request.handleError`
256 |
257 | When you `register` the `hapi-error` plugin a _useful_ `handleError` method
258 | becomes available in every request handler which allows you to (_safely_)
259 | "handle" any "*thrown*" errors using just one line of code.
260 |
261 | Consider the following Hapi route handler code that is fetching data from a generic Database:
262 |
263 | ```js
264 | function handler (request, reply) {
265 | db.get('yourkey', function (err, data) {
266 | if (err) {
267 | return reply('error_template', { msg: 'A database error occurred'});
268 | } else {
269 | return reply('amazing_app_view', {data: data});
270 | }
271 | });
272 | }
273 | ```
274 | This can be re-written (*simplified*) using `request.handleError` method:
275 |
276 | ```js
277 | function handler (request, reply) {
278 | db.get('yourkey', function (err, data) { // much simpler, right?
279 | request.handleError(err, 'A database error occurred');
280 | return reply('amazing_app_view', {data: data});
281 | }); // this has *exactly* the same effect in much less code.
282 | }
283 | ```
284 | Output:
285 |
286 | 
287 |
288 | #### Explanation:
289 |
290 | Under the hood, `request.handleError` is using `Hoek.assert` which
291 | will `assert` that there is ***no error*** e.g:
292 |
293 | `Hoek.assert(!err, 'A database error occurred');`
294 |
295 | Which means that if there *is* an error, it will be "*thrown*"
296 | with the message you define in the *second argument*.
297 |
298 |
299 |
300 | ### `handleError` _everywhere_
301 |
302 | > Need to call `handleError` _outside_ of the context of the `request` ?
303 |
304 | Sometimes we create handlers that perform a task _outside_ of the context of
305 | a route/handler (_e.g accessing a database or API_) in this context
306 | we still want to use `handleError` to simplify error handling.
307 |
308 | This is easy with `hapi-error`, here's an example:
309 |
310 | ```js
311 | var handleError = require('hapi-error').handleError;
312 |
313 | db.get(key, function (error, result) {
314 | handleError(error, 'Error retrieving ' + key + ' from DB :-( ');
315 | return callback(err, result);
316 | });
317 | ```
318 | or in a file operation (_uploading a file to AWS S3_):
319 |
320 | ```js
321 | var handleError = require('hapi-error').handleError;
322 |
323 | s3Bucket.upload(params, function (err, data) {
324 | handleError(error, 'Error retrieving ' + key + ' from DB :-( ');
325 | return callback(err, result);
326 | }
327 | ```
328 |
329 | Provided the `handleError` is called from a function/helper
330 | that is being _run_ by a Hapi server any errors will be _intercepted_
331 | and _logged_ and displayed (_nicely_) to people using your app.
332 |
333 | ### _custom_ data in error pages
334 |
335 | > Want/need to pass some more/custom data to display in your `error_template` view?
336 |
337 | All you have to do is pass an object to `request.handleError` with an
338 | errorMessage property and any other template properties you want!
339 |
340 | For example:
341 | ```js
342 | request.handleError(!error, {errorMessage: 'Oops - there has been an error',
343 | email: 'example@mail.co', color:'blue'});
344 | ```
345 | You will then be able to use {{email}} and {{color}} in your `error_template.html`
346 |
347 | ### logging
348 |
349 | As with _all_ hapi apps/APIs the recommended approach to logging
350 | is to use [`good`](https://github.com/dwyl/learn-hapi#logging-with-good)
351 |
352 | `hapi-error` logs all errors using `server.log` (_the standard way of logging in Hapi apps_) so once you enable `good` in your app you will _see_ any errors in your logs.
353 |
354 | e.g:
355 | 
356 |
357 | ### Debugging
358 |
359 | If you need more debugging in your error template, `hapi-error` exposes _several_
360 | useful properties which you can use.
361 |
362 | ```js
363 | {
364 | "method":"GET",
365 | "url":"/your-endpoint",
366 | "headers":{
367 | "authorization":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTIzLCJlbWFpbCI6ImhhaUBtYWlsLm1lIiwiaWF0IjoxNDc1Njc0MDQ2fQ.Xc6nCPQW4ZSf9jnIIs8wYsM4bGtvpe8peAxp6rq4y0g",
368 | "user-agent":"shot",
369 | "host":"http://yourserver:3001"
370 | },
371 | "info":{
372 | "received":1475674046045,
373 | "responded":0,
374 | "remoteAddress":"127.0.0.1",
375 | "remotePort":"",
376 | "referrer":"",
377 | "host":"http://yourserver:3001",
378 | "acceptEncoding":"identity",
379 | "hostname":"http://yourserver:3001"
380 | },
381 | "auth":{
382 | "isAuthenticated":true,
383 | "credentials":{
384 | "id":123,
385 | "email":"hai@mail.me",
386 | "iat":1475674046
387 | },
388 | "strategy":"jwt",
389 | "mode":"required",
390 | "error":null,
391 | "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTIzLCJlbWFpbCI6ImhhaUBtYWlsLm1lIiwiaWF0IjoxNDc1Njc0MDQ2fQ.Xc6nCPQW4ZSf9jnIIs8wYsM4bGtvpe8peAxp6rq4y0g"
392 | },
393 | "email":"hai@mail.me",
394 | "payload":null,
395 | "response":{
396 | "statusCode":500,
397 | "error":"Internal Server Error",
398 | "message":"An internal server error occurred"
399 | }
400 | }
401 | ```
402 |
403 | All the properties which are logged by `hapi-error` are available in
404 | your error template.
405 |
406 | ### Are Query Parameters Preserved?
407 |
408 | ***Yes***! e.g: if the original url is `/admin?sort=desc`
409 | the redirect url will be: `/login?redirect=/admin?sort=desc`
410 | Such that after the person has logged in they will be re-directed
411 | back to to `/admin?sort=desc` _as desired_.
412 |
413 | And it's valid to have multiple question marks in the URL see:
414 | https://stackoverflow.com/questions/2924160/is-it-valid-to-have-more-than-one-question-mark-in-a-url
415 | so the query is preserved and can be used to send the person
416 | to the _exact_ url they requested _after_ they have successfully logged in.
417 |
418 |
419 |
420 | ### Under the Hood (_Implementation Detail_):
421 |
422 | When there is an error in the request/response cycle,
423 | the Hapi `request` Object has *useful* error object we can use.
424 |
425 | Try logging the `request.response` in one of your Hapi route handlers:
426 |
427 | ```js
428 | console.log(request.response);
429 | ```
430 | A typical `Boom` error has the format:
431 | ```js
432 | { [Error: 500]
433 | isBoom: true,
434 | isServer: true,
435 | data: null,
436 | output:
437 | { statusCode: 500,
438 | payload:
439 | { statusCode: 500,
440 | error: 'Internal Server Error',
441 | message: 'An internal server error occurred' },
442 | headers: {} },
443 | reformat: [Function] }
444 | ```
445 |
446 | The way to *intercept* this error is with a plugin that gets invoked
447 | *before* the response is returned to the client.
448 |
449 | See: [lib/index.js](https://github.com/dwyl/hapi-error/blob/master/lib/index.js)
450 | for details on how the plugin is implemented.
451 |
452 | If you have _any_ questions, just [*ask*!](https://github.com/dwyl/hapi-error/issues)
453 |
454 |
455 | ## Background Reading & Research
456 |
457 | + Writing *useful* / *friendly* error messages:
458 | https://medium.com/@thomasfuchs/how-to-write-an-error-message-883718173322
459 |
--------------------------------------------------------------------------------