Merge pull request #15 from strongloop/html-response
Html response and content negotiation
This commit is contained in:
commit
4770af97ae
13
README.md
13
README.md
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
||||
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() {
|
||||
_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,31 +544,29 @@ function setupHttpServerAndClient(done) {
|
|||
res.end(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
_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');
|
||||
});
|
||||
_requestHandler(req, res, warnUnhandledError);
|
||||
});
|
||||
|
||||
_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);
|
||||
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 warnUnhandledError(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');
|
||||
}
|
||||
|
||||
function ErrorWithProps(props) {
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
Loading…
Reference in New Issue