diff --git a/README.md b/README.md index ba80cb2..eba021b 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ The following plugin options allow you to customize the default behavior of `hap - **showErrors**: `(boolean)`, default: `false` — by default, the plugin is disabled and keeps hapi's default error handling behavior - **template**: `(string)`, no default — provide the template name that you want to render with `h.view(template, errorData)` - **toTerminal**: `(boolean)`, default: `true` — print pretty errors to the terminal as well (enabled by default) +- **links**: `(array)`, defaults to Google and Stack Overflow icons that are linked with the error message as the search term (enabled by default). Pass an empty array `[]` to disable the default links ```js await server.register({ @@ -110,7 +111,13 @@ await server.register({ options: { showErrors: process.env.NODE_ENV !== 'production', template: 'my-error-view', - toTerminal: true + toTerminal: true, + links: [ (error) => { + return ` + Search Youch on GitHub + ` + } + ] } }) diff --git a/examples/default.js b/examples/default.js index 82e396d..a440b77 100644 --- a/examples/default.js +++ b/examples/default.js @@ -21,7 +21,7 @@ async function launchIt () { method: 'GET', path: '/{path*}', handler: (request, h) => { - h.notAvailable() + return h.notAvailable() } }) diff --git a/examples/with-links.js b/examples/with-links.js new file mode 100644 index 0000000..6a95739 --- /dev/null +++ b/examples/with-links.js @@ -0,0 +1,44 @@ +'use strict' + +const Hapi = require('hapi') + +// create new server instance +// add server’s connection information +const server = new Hapi.Server({ + host: 'localhost', + port: 3000 +}) + +async function launchIt () { + await server.register({ + plugin: require('../lib'), + options: { + showErrors: process.env.NODE_ENV !== 'production', + toTerminal: false, + links: [ + (error) => { + return ` + Search Youch on GitHub + ` + } + ] + } + }) + + server.route({ + method: 'GET', + path: '/{path*}', + handler: (request, h) => { + h.notAvailable() + } + }) + + try { + await server.start() + console.log('Server running at: ' + server.info.uri) + } catch (err) { + throw err + } +} + +launchIt() diff --git a/lib/index.js b/lib/index.js index 0cb607c..fe9d272 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,6 +1,5 @@ 'use strict' -const Hoek = require('hoek') const Youch = require('youch') const ForTerminal = require('youch-terminal') @@ -14,19 +13,26 @@ const ForTerminal = require('youch-terminal') * * @returns {Object} */ -function createYouch (request, error) { - // assign the url’s path to "url" property of request directly - // hapi uses a URL object and Youch wants the path directly +function createYouch ({ request, error, links = [] }) { + /** + * hapi’s request and error objects don’t match the + * expected structure in Youch. We need to adjust + * properties to display them correctly. + */ request.url = request.path - - // assign httpVersion -> same as with request.url request.httpVersion = request.raw.req.httpVersion - - // let Youch show the error’s status code error.status = error.output.statusCode - // pretty error printing on terminal or web view - return new Youch(error, request) + try { + const youch = new Youch(error, request) + + links.forEach(link => youch.addLink(link)) + + return youch + } catch (error) { + console.error(error) + throw error + } } /** @@ -55,6 +61,44 @@ function matches (str, regex) { return str && str.match(regex) } +/** + * Returns a link to Google that includes + * the error message as the search + * term. The link is an SVG icon. + * + * @param {Object} error + * + * @returns {String} + */ +function googleIcon (error) { + return ` + + + + + + ` +} + +/** + * Returns a link to Stack Overflow that + * includes the error message as the + * search term. The link is an SVG icon. + * + * @param {Object} error + * + * @returns {String} + */ +function stackOverflowIcon (error) { + return ` + + + + + + ` +} + /** * Render better error views during development. * @@ -64,7 +108,11 @@ function matches (str, regex) { async function register (server, options) { const defaults = { showErrors: false, - toTerminal: true + toTerminal: true, + links: [ + (error) => googleIcon(error), + (error) => stackOverflowIcon(error) + ] } const config = Object.assign({}, defaults, options) @@ -82,10 +130,15 @@ async function register (server, options) { server.dependency(['vision']) } + // Make sure the `links` are an array + if (!Array.isArray(config.links)) { + config.links = [config.links] + } + // extend the request lifecycle at `onPreResponse` // to change the default error handling behavior (if enabled) server.ext('onPreResponse', async (request, h) => { - const error = Hoek.clone(request.response) + const error = request.response // only show `bad implementation` developer errors (status code 500) if (error.isBoom && error.output.statusCode === 500) { @@ -104,7 +157,7 @@ async function register (server, options) { stacktrace: error.stack } - const youch = createYouch(request, error) + const youch = createYouch({ request, error, links: config.links }) // print a pretty error to terminal as well if (config.toTerminal) { diff --git a/media/hapi-dev-errors-default-youch-view.png b/media/hapi-dev-errors-default-youch-view.png index 08088b9..d6b4068 100644 Binary files a/media/hapi-dev-errors-default-youch-view.png and b/media/hapi-dev-errors-default-youch-view.png differ diff --git a/package.json b/package.json index af335c6..12484d7 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,7 @@ "url": "https://github.com/fs-opensource/hapi-dev-errors/issues" }, "dependencies": { - "hoek": "~5.0.3", - "youch": "~2.0.8", + "youch": "~2.0.10", "youch-terminal": "~1.0.0" }, "devDependencies": { @@ -21,10 +20,10 @@ "eslint-plugin-promise": "~3.8.0", "eslint-plugin-standard": "~3.1.0", "hapi": "~17.6.0", - "husky": "~1.0.1", - "joi": "~13.6.0", + "husky": "~1.1.1", + "joi": "~13.7.0", "lab": "~15.5.0", - "sinon": "~6.3.4", + "sinon": "~6.3.5", "vision": "~5.4.0" }, "engines": { diff --git a/test/plugin-falls-back-to-json.js b/test/plugin-falls-back-to-json.js index eb7e35c..ae26972 100644 --- a/test/plugin-falls-back-to-json.js +++ b/test/plugin-falls-back-to-json.js @@ -32,7 +32,7 @@ experiment('hapi-dev-error falls back to json', () => { server.route(routeOptions) }) - test('test if the plugin responses json with json accept header', async () => { + test('test if the plugin responds json with json accept header', async () => { const response = await server.inject({ url: '/error', method: 'GET', @@ -46,7 +46,7 @@ experiment('hapi-dev-error falls back to json', () => { Code.expect(payload).to.startWith('{') }) - test('test if the plugin responses json with curl user-agent', async () => { + test('test if the plugin responds json with curl user-agent', async () => { const response = await server.inject({ url: '/error', method: 'GET', diff --git a/test/plugin-uses-links.js b/test/plugin-uses-links.js new file mode 100644 index 0000000..141468d --- /dev/null +++ b/test/plugin-uses-links.js @@ -0,0 +1,100 @@ +'use strict' + +const Lab = require('lab') +const Code = require('code') +const Hapi = require('hapi') +const Sinon = require('sinon') + +const { experiment, test, beforeEach, afterEach } = (exports.lab = Lab.script()) + +experiment('hapi-dev-error handles custom user links', () => { + async function createServer (options) { + const server = new Hapi.Server() + + await server.register({ + plugin: require('../lib/index'), + options: { + showErrors: true, + toTerminal: false, + ...options + } + }) + + const routeOptions = { + path: '/', + method: 'GET', + handler: () => new Error('Somethinng bad happened') + } + + server.route(routeOptions) + + return server + } + + beforeEach(() => { + Sinon.stub(console, 'error') + }) + + afterEach(() => { + console.error.restore() + }) + + test('that the plugin works fine with empty links', async () => { + const server = await createServer({ links: [] }) + + const response = await server.inject({ + url: '/', + method: 'GET' + }) + + Sinon.assert.notCalled(console.error) + + Code.expect(response.statusCode).to.equal(500) + Code.expect(response.payload).to.startWith('<') + }) + + test('that the plugin throws if the links are strings', async () => { + const server = await createServer({ links: [ 'error' ] }) + + const response = await server.inject({ + url: '/', + method: 'GET' + }) + + Sinon.assert.called(console.error) + + Code.expect(response.statusCode).to.equal(500) + Code.expect(response.payload).to.startWith('{') + Code.expect(response.payload).to.include('Internal Server Error') + }) + + test('that the plugin throws if the links is not an array of functions', async () => { + const server = await createServer({ links: 'error' }) + + const response = await server.inject({ + url: '/', + method: 'GET' + }) + + Sinon.assert.called(console.error) + + Code.expect(response.statusCode).to.equal(500) + Code.expect(response.payload).to.startWith('{') + Code.expect(response.payload).to.include('Internal Server Error') + }) + + test('that the plugin works fine with a link function', async () => { + const server = await createServer({ links: () => `link` }) + + const response = await server.inject({ + url: '/', + method: 'GET', + headers: { accept: 'application/json' } + }) + + Sinon.assert.notCalled(console.error) + + Code.expect(response.statusCode).to.equal(500) + Code.expect(response.payload).to.startWith('{') + }) +})