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
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
```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
#### 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 format = require('util').format;
var logToConsole = require('./logger');
var sendJson = require('./send-json');
var negotiateContentProducer = require('./content-negotiation');
function noop() {
}
@ -52,11 +52,7 @@ exports = module.exports = function createStrongErrorHandler(options) {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.statusCode = data.statusCode;
// TODO: negotiate the content-type, take into account options.defaultType
// For now, we always return JSON. See
// - 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);
var sendResponse = negotiateContentProducer(req, options);
sendResponse(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"
},
"dependencies": {
"accepts": "^1.3.3",
"debug": "^2.2.0",
"ejs": "^2.4.2",
"http-status": "^0.2.2"
},
"devDependencies": {
"chai": "^2.1.1",
"eslint": "^2.5.3",
"eslint-config-loopback": "^3.0.0",
"express": "^4.13.4",
"mocha": "^2.1.0",
"supertest": "^1.1.0"
},
"browser": {
"strong-error-handler": false
}
}

View File

@ -8,7 +8,7 @@
var cloneAllProperties = require('../lib/clone.js');
var debug = require('debug')('test');
var expect = require('chai').expect;
var http = require('http');
var express = require('express');
var strongErrorHandler = require('..');
var supertest = require('supertest');
var util = require('util');
@ -361,9 +361,156 @@ describe('strong-error-handler', function() {
.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);
});
var _httpServer, _requestHandler, request;
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 app, _requestHandler, request;
function resetRequestHandler() {
_requestHandler = null;
}
@ -387,7 +534,8 @@ function givenErrorHandlerForError(error, options) {
}
function setupHttpServerAndClient(done) {
_httpServer = http.createServer(function(req, res) {
app = express();
app.use(function(req, res, next) {
if (!_requestHandler) {
var msg = 'Error handler middleware was not setup in this test';
console.error(msg);
@ -396,8 +544,22 @@ function setupHttpServerAndClient(done) {
res.end(msg);
return;
}
_requestHandler(req, res, warnUnhandledError);
});
_requestHandler(req, res, 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);
done(err);
});
}
function warnUnhandledError(err) {
console.log('unexpected: strong-error-handler called next with '
(err && (err.stack || err)) || 'no error');
res.statusCode = 500;
@ -405,22 +567,6 @@ function setupHttpServerAndClient(done) {
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) {
debug('Cannot setup HTTP server: %s', err.stack);
done(err);
});
_httpServer.once('listening', function() {
var url = 'http://127.0.0.1:' + this.address().port;
debug('Test server listening on %s', url);
request = supertest(url);
done();
});
_httpServer.listen(0, '127.0.0.1');
}
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;
}