Merge pull request #15 from strongloop/html-response

Html response and content negotiation
This commit is contained in:
David Cheung 2016-06-14 12:33:15 -04:00 committed by GitHub
commit 4770af97ae
8 changed files with 373 additions and 33 deletions

View File

@ -8,10 +8,6 @@ Error handler for use in development (debug) and production environments.
- When in debug mode, detailed information such as stack traces - When in debug mode, detailed information such as stack traces
are returned in the HTTP responses. are returned in the HTTP responses.
JSON is the only supported response format at this time.
*There are plans to support other formats such as Text, HTML, and XML.*
## Install ## Install
```bash ```bash
@ -47,6 +43,15 @@ In LoopBack applications, add the following entry to your
} }
``` ```
## Content Type
Depending on the request header's `Accepts`, response will be returned in
the corresponding content-type, current supported types include:
- JSON (`json`/`application/json`)
- HTML (`html`/`text/html`)
*There are plans to support other formats such as Text and XML.*
## Options ## Options
#### debug #### debug

View File

@ -0,0 +1,85 @@
// Copyright IBM Corp. 2016. All Rights Reserved.
// Node module: strong-error-handler
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT
var accepts = require('accepts');
var debug = require('debug')('strong-error-handler:http-response');
var sendJson = require('./send-json');
var sendHtml = require('./send-html');
var util = require('util');
module.exports = negotiateContentProducer;
/**
* Handles req.accepts and req.query._format and options.defaultType
* to resolve the correct operation
*
* @param req request object
* @param {Object} options options of strong-error-handler
* @returns {Function} Opeartion function with signature `fn(res, data)`
*/
function negotiateContentProducer(req, options) {
var SUPPORTED_TYPES = [
'application/json', 'json',
'text/html', 'html',
];
options = options || {};
var defaultType = 'json';
// checking if user provided defaultType is supported
if (options.defaultType) {
if (SUPPORTED_TYPES.indexOf(options.defaultType) > -1) {
debug('Accepting options.defaultType `%s`', options.defaultType);
defaultType = options.defaultType;
} else {
debug('defaultType: `%s` is not supported, ' +
'falling back to defaultType: `%s`', options.defaultType, defaultType);
}
}
// decide to use resolvedType or defaultType
// Please note that accepts assumes the order of content-type is provided
// in the priority returned
// example
// Accepts: text/html, */*, application/json ---> will resolve as text/html
// Accepts: application/json, */*, text/html ---> will resolve as application/json
// Accepts: */*, application/json, text/html ---> will resolve as application/json
// eg. Chrome accepts defaults to `text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*`
// In this case `resolvedContentType` will result as: `text/html` due to the order given
var resolvedContentType = accepts(req).types(SUPPORTED_TYPES);
debug('Resolved content-type', resolvedContentType);
var contentType = resolvedContentType || defaultType;
// to receive _format from user's url param to overide the content type
// req.query (eg /api/Users/1?_format=json will overide content negotiation
// https://github.com/strongloop/strong-remoting/blob/ac3093dcfbb787977ca0229b0f672703859e52e1/lib/http-context.js#L643-L645
var query = req.query || {};
if (query._format) {
if (SUPPORTED_TYPES.indexOf(query._format) > -1) {
contentType = query._format;
} else {
// format passed through query but not supported
var msg = util.format('Response _format "%s" is not supported' +
'used "%s" instead"', query._format, defaultType);
res.header('X-Warning', msg);
debug(msg);
}
}
debug('Content-negotiation: req.headers.accept: `%s` Resolved as: `%s`',
req.headers.accept, contentType);
return resolveOperation(contentType);
}
function resolveOperation(contentType) {
switch (contentType) {
case 'application/json':
case 'json':
return sendJson;
case 'text/html':
case 'html':
return sendHtml;
}
}

View File

@ -9,7 +9,7 @@ var buildResponseData = require('./data-builder');
var debug = require('debug')('strong-error-handler'); var debug = require('debug')('strong-error-handler');
var format = require('util').format; var format = require('util').format;
var logToConsole = require('./logger'); var logToConsole = require('./logger');
var sendJson = require('./send-json'); var negotiateContentProducer = require('./content-negotiation');
function noop() { function noop() {
} }
@ -52,11 +52,7 @@ exports = module.exports = function createStrongErrorHandler(options) {
res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Content-Type-Options', 'nosniff');
res.statusCode = data.statusCode; res.statusCode = data.statusCode;
// TODO: negotiate the content-type, take into account options.defaultType var sendResponse = negotiateContentProducer(req, options);
// For now, we always return JSON. See sendResponse(res, data);
// - https://github.com/strongloop/strong-error-handler/issues/4
// - https://github.com/strongloop/strong-error-handler/issues/5
// - https://github.com/strongloop/strong-error-handler/issues/6
sendJson(res, data);
}; };
}; };

46
lib/send-html.js Normal file
View File

@ -0,0 +1,46 @@
// Copyright IBM Corp. 2016. All Rights Reserved.
// Node module: strong-error-handler
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT
var ejs = require('ejs');
var fs = require('fs');
var path = require('path');
var assetDir = path.resolve(__dirname, '../views');
var compiledTemplates = {
// loading default template and stylesheet
default: loadDefaultTemplates(),
};
module.exports = sendHtml;
function sendHtml(res, data, options) {
var toRender = {options: {}, data: data};
// TODO: ability to call non-default template functions from options
var body = compiledTemplates.default(toRender);
sendReponse(res, body);
}
/**
* Compile and cache the file with the `filename` key in options
*
* @param filepath (description)
* @returns {Function} render function with signature fn(data);
*/
function compileTemplate(filepath) {
var options = {cache: true, filename: filepath};
fileContent = fs.readFileSync(filepath, 'utf8');
return ejs.compile(fileContent, options);
}
// loads and cache default error templates
function loadDefaultTemplates() {
var defaultTemplate = path.resolve(assetDir, 'default-error.ejs');
return compileTemplate(defaultTemplate);
}
function sendReponse(res, body) {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(body);
}

View File

@ -14,14 +14,20 @@
"posttest": "npm run lint" "posttest": "npm run lint"
}, },
"dependencies": { "dependencies": {
"accepts": "^1.3.3",
"debug": "^2.2.0", "debug": "^2.2.0",
"ejs": "^2.4.2",
"http-status": "^0.2.2" "http-status": "^0.2.2"
}, },
"devDependencies": { "devDependencies": {
"chai": "^2.1.1", "chai": "^2.1.1",
"eslint": "^2.5.3", "eslint": "^2.5.3",
"eslint-config-loopback": "^3.0.0", "eslint-config-loopback": "^3.0.0",
"express": "^4.13.4",
"mocha": "^2.1.0", "mocha": "^2.1.0",
"supertest": "^1.1.0" "supertest": "^1.1.0"
},
"browser": {
"strong-error-handler": false
} }
} }

View File

@ -8,7 +8,7 @@
var cloneAllProperties = require('../lib/clone.js'); var cloneAllProperties = require('../lib/clone.js');
var debug = require('debug')('test'); var debug = require('debug')('test');
var expect = require('chai').expect; var expect = require('chai').expect;
var http = require('http'); var express = require('express');
var strongErrorHandler = require('..'); var strongErrorHandler = require('..');
var supertest = require('supertest'); var supertest = require('supertest');
var util = require('util'); var util = require('util');
@ -361,9 +361,156 @@ describe('strong-error-handler', function() {
.expect('Content-Type', /^application\/json/); .expect('Content-Type', /^application\/json/);
} }
}); });
context('HTML response', function() {
it('contains all error properties when debug=true', function(done) {
var error = new ErrorWithProps({
message: 'a test error message',
details: 'some details',
extra: 'sensitive data',
});
error.statusCode = 500;
givenErrorHandlerForError(error, {debug: true});
requestHTML()
.expect(500)
.expect(/<title>ErrorWithProps<\/title>/)
.expect(/500(.*?)a test error message/)
.expect(/extra(.*?)sensitive data/)
.expect(/details(.*?)some details/)
.expect(/id="stacktrace"(.*?)ErrorWithProps: a test error message/,
done);
});
it('contains subset of properties when status=4xx', function(done) {
var error = new ErrorWithProps({
name: 'ValidationError',
message: 'The model instance is not valid.',
statusCode: 422,
details: 'some details',
extra: 'sensitive data',
});
givenErrorHandlerForError(error, {debug: false});
requestHTML()
.end(function(err, res) {
expect(res.statusCode).to.eql(422);
var body = res.error.text;
expect(body).to.match(/some details/);
expect(body).to.not.match(/sensitive data/);
expect(body).to.match(/<title>ValidationError<\/title>/);
expect(body).to.match(/422(.*?)The model instance is not valid./);
done();
});
});
it('contains only safe info when status=5xx', function(done) {
// Mock an error reported by fs.readFile
var error = new ErrorWithProps({
name: 'Error',
message: 'ENOENT: no such file or directory, open "/etc/passwd"',
errno: -2,
code: 'ENOENT',
syscall: 'open',
path: '/etc/password',
});
givenErrorHandlerForError(error);
requestHTML()
.end(function(err, res) {
expect(res.statusCode).to.eql(500);
var body = res.error.text;
expect(body).to.not.match(/\/etc\/password/);
expect(body).to.not.match(/-2/);
expect(body).to.not.match(/ENOENT/);
// only have the following
expect(body).to.match(/<title>Internal Server Error<\/title>/);
expect(body).to.match(/500(.*?)Internal Server Error/);
done();
});
});
function requestHTML(url) {
return request.get(url || '/')
.set('Accept', 'text/html')
.expect('Content-Type', /^text\/html/);
}
});
context('Content Negotiation', function() {
it('defaults to json without options', function(done) {
givenErrorHandlerForError(new Error('Some error'), {});
request.get('/')
.set('Accept', '*/*')
.expect('Content-Type', /^application\/json/, done);
});
it('honors accepted content-type', function(done) {
givenErrorHandlerForError(new Error('Some error'), {
defaultType: 'application/json',
});
request.get('/')
.set('Accept', 'text/html')
.expect('Content-Type', /^text\/html/, done);
});
it('honors order of accepted content-type', function(done) {
givenErrorHandlerForError(new Error('Some error'), {
defaultType: 'text/html',
});
request.get('/')
// `application/json` will be used because its provided first
.set('Accept', 'application/json, text/html')
.expect('Content-Type', /^application\/json/, done);
});
it('honors order of accepted content-types of text/html', function(done) {
givenErrorHandlerForError(new Error('Some error'), {
defaultType: 'application/json',
});
request.get('/')
// text/html will be used because its provided first
.set('Accept', 'text/html, application/json')
.expect('Content-Type', /^text\/html/, done);
});
it('picks first supported type upon multiple accepted', function(done) {
givenErrorHandlerForError(new Error('Some error'), {
defaultType: 'application/json',
});
request.get('/')
.set('Accept', '*/*, not-supported, text/html, application/json')
.expect('Content-Type', /^text\/html/, done);
});
it('falls back for unsupported option.defaultType', function(done) {
givenErrorHandlerForError(new Error('Some error'), {
defaultType: 'unsupported',
});
request.get('/')
.set('Accept', '*/*')
.expect('Content-Type', /^application\/json/, done);
});
it('returns defaultType for unsupported type', function(done) {
givenErrorHandlerForError(new Error('Some error'), {
defaultType: 'text/html',
});
request.get('/')
.set('Accept', 'unsupported/type')
.expect('Content-Type', /^text\/html/, done);
});
it('supports query _format', function(done) {
givenErrorHandlerForError(new Error('Some error'), {
defaultType: 'text/html',
});
request.get('/?_format=html')
.set('Accept', 'application/json')
.expect('Content-Type', /^text\/html/, done);
});
});
}); });
var _httpServer, _requestHandler, request; var app, _requestHandler, request;
function resetRequestHandler() { function resetRequestHandler() {
_requestHandler = null; _requestHandler = null;
} }
@ -387,7 +534,8 @@ function givenErrorHandlerForError(error, options) {
} }
function setupHttpServerAndClient(done) { function setupHttpServerAndClient(done) {
_httpServer = http.createServer(function(req, res) { app = express();
app.use(function(req, res, next) {
if (!_requestHandler) { if (!_requestHandler) {
var msg = 'Error handler middleware was not setup in this test'; var msg = 'Error handler middleware was not setup in this test';
console.error(msg); console.error(msg);
@ -396,31 +544,29 @@ function setupHttpServerAndClient(done) {
res.end(msg); res.end(msg);
return; return;
} }
_requestHandler(req, res, warnUnhandledError);
_requestHandler(req, res, function(err) {
console.log('unexpected: strong-error-handler called next with '
(err && (err.stack || err)) || 'no error');
res.statusCode = 500;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end(err ?
'Unhandled strong-error-handler error:\n' + (err.stack || err) :
'The error was silently discared by strong-error-handler');
});
}); });
_httpServer.once('error', function(err) { app.listen(0, function() {
var url = 'http://127.0.0.1:' + this.address().port;
debug('Test server listening on %s', url);
request = supertest(app);
done();
})
.once('error', function(err) {
debug('Cannot setup HTTP server: %s', err.stack); debug('Cannot setup HTTP server: %s', err.stack);
done(err); done(err);
}); });
}
_httpServer.once('listening', function() { function warnUnhandledError(err) {
var url = 'http://127.0.0.1:' + this.address().port; console.log('unexpected: strong-error-handler called next with '
debug('Test server listening on %s', url); (err && (err.stack || err)) || 'no error');
request = supertest(url); res.statusCode = 500;
done(); res.setHeader('Content-Type', 'text/plain; charset=utf-8');
}); res.end(err ?
'Unhandled strong-error-handler error:\n' + (err.stack || err) :
_httpServer.listen(0, '127.0.0.1'); 'The error was silently discared by strong-error-handler');
} }
function ErrorWithProps(props) { function ErrorWithProps(props) {

25
views/default-error.ejs Normal file
View File

@ -0,0 +1,25 @@
<html>
<head>
<meta charset='utf-8'>
<title><%- data.name || data.message %></title>
<style><%- include style.css %></style>
</head>
<body>
<div id="wrapper">
<h1><%- data.name %></h1>
<h2><em><%- data.statusCode %></em> <%- data.message %></h2>
<%
// display all the non-standard properties
var standardProps = ['name', 'statusCode', 'message', 'stack'];
for (var prop in data) {
if (standardProps.indexOf(prop) == -1 && data[prop]) { %>
<div><b><%- prop %></b>: <%- data[prop] %></div>
<% }
}
if (data.stack) { %>
<pre id="stacktrace"><%- data.stack %></pre>
<% }
%>
</div>
</body>
</html>

31
views/style.css Normal file
View File

@ -0,0 +1,31 @@
* {
margin: 0;
padding: 0;
outline: 0;
}
body {
padding: 80px 100px;
font: 13px "Helvetica Neue", "Lucida Grande", "Arial";
background: #ECE9E9 -webkit-gradient(linear, 0% 0%, 0% 100%, from(#fff), to(#ECE9E9));
background: #ECE9E9 -moz-linear-gradient(top, #fff, #ECE9E9);
background-repeat: no-repeat;
color: #555;
-webkit-font-smoothing: antialiased;
}
h1, h2 {
font-size: 22px;
color: #343434;
}
h1 em, h2 em {
padding: 0 5px;
font-weight: normal;
}
h1 {
font-size: 60px;
}
h2 {
margin: 10px 0;
}
ul li {
list-style: none;
}