Skip to content

Commit

Permalink
feat(html): Reply with html
Browse files Browse the repository at this point in the history
  • Loading branch information
jmendiara committed Apr 29, 2017
1 parent 603955c commit 0b95256
Show file tree
Hide file tree
Showing 5 changed files with 218 additions and 69 deletions.
73 changes: 33 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

therror-express implements a connect/express error handler middleware for [Therror.ServerError](https://github.com/therror/therror)

Logs all errors (by default) and replies with an error payload with only the error relevant information. Currently supports [content negotiation](https://en.wikipedia.org/wiki/Content_negotiation) for `text/plain` and `application/json`.
Logs all errors (by default) and replies with an error payload with only the error relevant information. Currently supports [content negotiation](https://en.wikipedia.org/wiki/Content_negotiation) for `text/html`, `text/plain` and `application/json`.

It's written in ES6, for node >= 4

Expand All @@ -22,45 +22,23 @@ const connect = require('connect');

let app = connect();

// The last one middleware added to your express app
app.use(errorHandler({
log: true, // use the `log` method in the ServerError to log it (default: true)
development: process.env.NODE_ENV === 'development' // return stack traces and causes in the payload (default: false),
unexpectedClass: Therror.ServerError.InternalServerError // When a strange thing reaches this middleware trying to behave as an error (such a Number, String, obj..), this error class will be instantiated, logged, and returned to the client.
}));
// The last one middleware added to your app
app.use(errorHandler()); // Some options can be provided. See below
```

### Full Example
### Customize html with express
```js
const Therror = require('therror'),
errorHandler = require('therror-connect');

Therror.Loggable.logger = require('logops');

app.use(
function(req, res, next) {
user = { id: 12, email: '[email protected]' };
next(new Therror.ServerError.Unauthorized('User ${id} not authorized', user));
},
errorHandler()
);
/* Writes log:
UnauthorizedError: User 12 not authorized
UnauthorizedError: User 12 not authorized { id: 12, email: '[email protected]' }
at Object.<anonymous> (/Users/javier/Documents/Proyectos/logops/deleteme.js:17:11)
at Module._compile (module.js:409:26)
at Object.Module._extensions..js (module.js:416:10)
at Module.load (module.js:343:32)
at Function.Module._load (module.js:300:12)
at Function.Module.runMain (module.js:441:10)
at startup (node.js:139:18)
at node.js:968:3
Replies:
401
{ error: 'UnauthorizedError',
message: 'User 12 not authorized' }
*/
const express = require('express');
const app = express();

app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');

app.use(errorHandler({
render: function(data, req, res, cb) {
res.render(`/errors/${data.statusCode}`, data, cb);
}
}));
```

### API
Expand All @@ -71,22 +49,37 @@ let errorHandler = require('therror-connect');
Creates the middleware configured with the provided `options` object

**`options.log`** `[Boolean]` can be
* `true`: logs the error using the `error.log` method. _default_
* `true`: logs the error using the `error.log({req, res})` method. _default_
* `false`: does nothing.

**`options.development`** `[Boolean]` can be
* `false`: Dont add stack traces and development info to the payload. _default_
* `true`: Add development info to the payload.
* `true`: Add development info to the responses.

**`options.unexpectedClass`** `[class]` The `Therror.ServerError` class to instantiate when an unmanegeable error reaches the middleware. _defaults to `Therror.ServerError.InternalServerError`_

**`options.render`** `Function` to customize the sent html sent.
```js
function render(data, req, res, cb) {
// data.error: the error instance.
// data.name: error name. Eg: UnauthorizedError
// data.message: error message. Eg: User 12 not authorized
// data.statusCode: associated statusCode to the message. Eg: 401
// data.stack: looong string with the stacktrace (if options.development === true; else '')

// req: Incoming http request
// res: Outgoing http response. Warning! don't send the html, give it to the callback
// cb: function(err, html) callback to call with the html
}
```

## Peer Projects
* [therror](https://github.com/therror/therror): The Therror library, easy errors for nodejs
* [serr](https://github.com/therror/serr): Error serializer to Objects and Strings

## LICENSE

Copyright 2016 [Telefónica I+D](http://www.tid.es)
Copyright 2016,2017 [Telefónica I+D](http://www.tid.es)

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down
81 changes: 69 additions & 12 deletions lib/therror-connect.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2016 Telefónica I+D
* Copyright 2016,2017 Telefónica I+D
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -17,15 +17,18 @@

'use strict';

const accepts = require('accepts'),
serr = require('serr'),
_ = require('lodash'),
stringify = require('json-stringify-safe'),
Therror = require('therror'),
util = require('util');
const accepts = require('accepts');
const serr = require('serr');
const _ = require('lodash');
const stringify = require('json-stringify-safe');
const Therror = require('therror');
const util = require('util');
const escapeHtml = require('escape-html');

module.exports = errorHandler;

class HtmlRenderError extends Therror.ServerError.InternalServerError {}

function errorHandler(options) {

let opts = options || /* istanbul ignore next */ {};
Expand All @@ -36,13 +39,19 @@ function errorHandler(options) {

let UnexpectedErrorClass = opts.unexpectedClass || Therror.ServerError.InternalServerError;

let render = opts.render || renderTherrorConnect;

if (!isServerTherror(UnexpectedErrorClass.prototype)) {
throw new Therror('You must provide a ServerError error');
}

return function errorHandlerMiddleware(err, req, res, next) {

if (!err.isTherror || !isServerTherror(err)) {
if (err instanceof HtmlRenderError) {
// An error in userland was raised when rendering using the provided options.render
// use our safe one
render = renderTherrorConnect;
} else if (!err.isTherror || !isServerTherror(err)) {
err = new UnexpectedErrorClass(err);
}

Expand All @@ -61,6 +70,7 @@ function errorHandler(options) {
res.setHeader('Content-Security-Policy', 'default-src \'self\'');

// respect err.statusCode
// TODO check it's a number
res.statusCode = err.statusCode;

if (req.method === 'HEAD') {
Expand All @@ -72,7 +82,7 @@ function errorHandler(options) {
// negotiate
let accept = accepts(req);
// the order of this list is significant; should be server preferred order
switch (accept.type(['json'])) {
switch (accept.type(['json', 'html'])) {
case 'json':
response = err.toPayload();
if (development) {
Expand All @@ -82,8 +92,37 @@ function errorHandler(options) {
// Split stack in lines for better developer readability
response.$$delevelopmentInfo.stack = response.$$delevelopmentInfo.stack.split('\n');
}
res.setHeader('Content-Type', 'application/json; charset=utf-8');
response = stringify(response);

res.setHeader('Content-Type', 'application/json; charset=utf-8');
end(res, response);
break;

case 'html':
let payload = err.toPayload();
let data = {
error: err,
name: escapeHtml(payload.error),
message: escapeHtml(payload.message),
statusCode: err.statusCode,
stack: development ? escapeHtml(serr(err).toString(true)) : ''
};

try {
render(data, req, res, htmlCb);
} catch (err) {
return htmlCb(err);
}

function htmlCb(err, html) {
if (err) {
let error = new HtmlRenderError(err, 'Cannot render with provided renderer');
return errorHandlerMiddleware(error, req, res);
}
res.setHeader('Content-Type', 'text/html; charset=utf-8');
end(res, html);
}

break;

default:
Expand All @@ -94,13 +133,17 @@ function errorHandler(options) {
}

res.setHeader('Content-Type', 'text/plain; charset=utf-8');
end(res, response);
break;
}
res.setHeader('Content-Length', Buffer.byteLength(response, 'utf8'));
res.end(response);
};
}

function end(res, response) {
res.setHeader('Content-Length', Buffer.byteLength(response, 'utf8'));
res.end(response);
}

// node 6 adds the stacktrace when toStringing an error
function errToString(err) {
let payload = err.toPayload();
Expand All @@ -111,3 +154,17 @@ function isServerTherror(obj) {
return _.isFunction(obj.toPayload) && !_.isUndefined(obj.statusCode);
}

function renderTherrorConnect(data, req, res, cb) {
return cb(null, `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Error ${data.statusCode}</title>
</head>
<body>
<h1>${data.name} <i>(${data.statusCode})</i></h1>
<h2>${data.message}</h2>
<pre>${data.stack}</pre>
</body>
</html>`);
}
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@
"test": "mocha -R spec test/environment.js 'test/**/*.spec.js'"
},
"devDependencies": {
"chai": "^3.0.0",
"chai": "^3.5.0",
"coveralls": "^2.11.2",
"eslint": "^3.19.0",
"istanbul": "^0.4.2",
"jscs": "^3.0.3",
"mocha": "^3.3.0",
"release-it": "^2.4.0",
"sinon": "^2.1.0",
"sinon-chai": "^2.8.0",
"sinon-chai": "^2.9.0",
"supertest": "^3.0.0",
"therror": "^3.0.0"
},
Expand All @@ -50,8 +50,10 @@
},
"dependencies": {
"accepts": "^1.3.3",
"escape-html": "^1.0.3",
"json-stringify-safe": "^5.0.1",
"lodash": "^4.1.0",
"serr": "^1.0.0"
"serr": "^1.0.0",
"var": "^0.2.0"
}
}
97 changes: 97 additions & 0 deletions test/therror-connect.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,90 @@ describe('errorHandler()', function() {
}), done);
});
});

describe('when client accepts text/html', function() {
it('should return default html', function(done) {
var error = new Therror.ServerError.NotFound('boom!');
var server = createServer(error);
request(server)
.get('/')
.set('Accept', 'text/html')
.expect('Content-Type', /text\/html/)
.expect(404, `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Error 404</title>
</head>
<body>
<h1>NotFound <i>(404)</i></h1>
<h2>boom!</h2>
<pre></pre>
</body>
</html>`, done);
});

it('should return user defined html', function(done) {
var error = new Therror.ServerError.NotFound('boom!');
var server = createServer(error, {
render(data, req, res, next) {
next(null, '<hello>')
}
});
request(server)
.get('/')
.set('Accept', 'text/html')
.expect('Content-Type', /text\/html/)
.expect(404, '<hello>', done);
});

it('should pass precomputed arguments to the render function', function(done) {
var error = new Therror.ServerError.NotFound('<p>boom!</p>');
var server = createServer(error, {
render(data, req, res, next) {
expect(data.error).to.be.eql(error);
expect(data.name).to.be.eql('NotFound');
expect(data.message).to.be.eql('&lt;p&gt;boom!&lt;/p&gt;');
expect(data.statusCode).to.be.eql(404);
expect(data.stack).to.be.eql('');
next(null, '<hello>');
}
});
request(server)
.get('/')
.set('Accept', 'text/html')
.expect('Content-Type', /text\/html/, done);
});

it('should return default html when the user render fails async', function(done) {
var error = new Therror.ServerError.NotFound('boom!');
var server = createServer(error, {
render(data, req, res, next) {
next(new Error('RenderError'));
}
});
request(server)
.get('/')
.set('Accept', 'text/html')
.expect('Content-Type', /text\/html/)
.expect(500, /<!DOCTYPE html>/, done);
});

it('should return default html when the user render fails sync', function(done) {
var error = new Therror.ServerError.NotFound('boom!');
var server = createServer(error, {
render(data, req, res, next) {
throw new Error('RenderError');
}
});
request(server)
.get('/')
.set('Accept', 'text/html')
.expect('Content-Type', /text\/html/)
.expect(500, /<!DOCTYPE html>/, done);
});

});
});

describe('in "development" environment', function() {
Expand Down Expand Up @@ -168,6 +252,19 @@ describe('errorHandler()', function() {
}), done);
});
});

describe('when client accepts text/html', function() {
it('should return default html with dev info ', function(done) {
var error = new Therror.ServerError.NotFound('boom!');
var server = createServer(error, { development: true });
request(server)
.get('/')
.set('Accept', 'text/html')
.expect('Content-Type', /text\/html/)
//.expect(404, /.*<pre>(.+)<\/pre>/, done);
.expect(404, /<pre>(.|\n)+<\/pre>/, done);
});
})
});

describe('logging', function() {
Expand Down
Loading

0 comments on commit 0b95256

Please sign in to comment.