Skip to content

Commit

Permalink
feat: custom schema applied on errors (#2691)
Browse files Browse the repository at this point in the history
* feat: custom schema applied on errors

* fix apply response schema when needed

* fix old lint

* fix hook test

* fix duplicated code

* Update test/hooks.test.js

* docs: how to use default handler with schema

* Update docs/Reply.md

Co-authored-by: James Sumners <james@sumners.email>

* docs: add jsdoc

* docs: add functions description

* Update lib/reply.js

Co-authored-by: James Sumners <james@sumners.email>

Co-authored-by: James Sumners <james@sumners.email>
  • Loading branch information
Eomm and jsumners committed Nov 29, 2020
1 parent 61c8a7b commit 43ea177
Show file tree
Hide file tree
Showing 8 changed files with 156 additions and 36 deletions.
32 changes: 32 additions & 0 deletions docs/Reply.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ fastify.get('/streams', function (request, reply) {
<a name="errors"></a>
#### Errors
If you pass to *send* an object that is an instance of *Error*, Fastify will automatically create an error structured as the following:

```js
{
error: String // the http error message
Expand All @@ -307,6 +308,7 @@ If you pass to *send* an object that is an instance of *Error*, Fastify will aut
statusCode: Number // the http status code
}
```

You can add some custom property to the Error object, such as `headers`, that will be used to enhance the http response.<br>
*Note: If you are passing an error to `send` and the statusCode is less than 400, Fastify will automatically set it at 500.*

Expand All @@ -318,6 +320,36 @@ fastify.get('/', function (request, reply) {
})
```

To customize the JSON error output you can do it by:

- setting a response JSON schema for the status code you need
- add the additional properties to the `Error` instance

Notice that if the returned status code is not in the response schema list, the default behaviour will be applied.

```js
fastify.get('/', {
schema: {
response: {
501: {
type: 'object',
properties: {
statusCode: { type: 'number' },
code: { type: 'string' },
error: { type: 'string' },
message: { type: 'string' },
time: { type: 'string' }
}
}
}
}
}, function (request, reply) {
const error = new Error('This endpoint has not been implemented')
error.time = 'it will be implemented in two weeks'
reply.code(501).send(error)
})
```

If you want to completely customize the error handling, checkout [`setErrorHandler`](Server.md#seterrorhandler) API.<br>
*Note: you are responsibile for logging when customizing the error handler*

Expand Down
66 changes: 58 additions & 8 deletions lib/reply.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const statusCodes = require('http').STATUS_CODES
const flatstr = require('flatstr')
const FJS = require('fast-json-stringify')
const {
kSchemaResponse,
kFourOhFourContext,
kReplyErrorHandlerCalled,
kReplySent,
Expand All @@ -19,8 +20,6 @@ const {
kDisableRequestLogging
} = require('./symbols.js')
const { hookRunner, hookIterator, onSendHookRunner } = require('./hooks')
const validation = require('./validation')
const serialize = validation.serialize

const internals = require('./handleRequest')[Symbol.for('internals')]
const loggerUtils = require('./logger')
Expand Down Expand Up @@ -532,12 +531,20 @@ function handleError (reply, error, cb) {
return
}

const payload = serializeError({
error: statusCodes[statusCode + ''],
code: error.code,
message: error.message || '',
statusCode: statusCode
})
const serializerFn = getSchemaSerializer(reply.context, statusCode)
const payload = (serializerFn === false)
? serializeError({
error: statusCodes[statusCode + ''],
code: error.code,
message: error.message || '',
statusCode: statusCode
})
: serializerFn(Object.create(error, {
error: { value: statusCodes[statusCode + ''] },
message: { value: error.message || '' },
statusCode: { value: statusCode }
}))

flatstr(payload)
reply[kReplyHeaders]['content-type'] = CONTENT_TYPE.JSON
reply[kReplyHeaders]['content-length'] = '' + Buffer.byteLength(payload)
Expand Down Expand Up @@ -647,6 +654,49 @@ function notFound (reply) {
}
}

/**
* This function runs when a payload that is not a string|buffer|stream or null
* should be serialized to be streamed to the response.
* This is the default serializer that can be customized by the user using the replySerializer
*
* @param {object} context the request context
* @param {object} data the JSON payload to serialize
* @param {number} statusCode the http status code
* @returns {string} the serialized payload
*/
function serialize (context, data, statusCode) {
const fnSerialize = getSchemaSerializer(context, statusCode)
if (fnSerialize) {
return fnSerialize(data)
}
return JSON.stringify(data)
}

/**
* Search for the right JSON schema compiled function in the request context
* setup by the route configuration `schema.response`.
* It will look for the exact match (eg 200) or generic (eg 2xx)
*
* @param {object} context the request context
* @param {number} statusCode the http status code
* @returns {function|boolean} the right JSON Schema function to serialize
* the reply or false if it is not set
*/
function getSchemaSerializer (context, statusCode) {
const responseSchemaDef = context[kSchemaResponse]
if (!responseSchemaDef) {
return false
}
if (responseSchemaDef[statusCode]) {
return responseSchemaDef[statusCode]
}
const fallbackStatusCode = (statusCode + '')[0] + 'xx'
if (responseSchemaDef[fallbackStatusCode]) {
return responseSchemaDef[fallbackStatusCode]
}
return false
}

function noop () { }

module.exports = Reply
Expand Down
3 changes: 1 addition & 2 deletions lib/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ const Context = require('./context')
const handleRequest = require('./handleRequest')
const { hookRunner, hookIterator, lifecycleHooks } = require('./hooks')
const supportedMethods = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT', 'OPTIONS']
const validation = require('./validation')
const { normalizeSchema } = require('./schemas')
const {
ValidatorSelector,
Expand All @@ -16,7 +15,7 @@ const warning = require('./warnings')
const {
compileSchemasForValidation,
compileSchemasForSerialization
} = validation
} = require('./validation')

const {
FST_ERR_SCH_VALIDATION_BUILD,
Expand Down
5 changes: 5 additions & 0 deletions lib/symbols.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ const keys = {
kHooks: Symbol('fastify.hooks'),
kHooksDeprecatedPreParsing: Symbol('fastify.hooks.DeprecatedPreParsing'),
kSchemas: Symbol('fastify.schemas'),
kSchemaHeaders: Symbol('headers-schema'),
kSchemaParams: Symbol('params-schema'),
kSchemaQuerystring: Symbol('querystring-schema'),
kSchemaBody: Symbol('body-schema'),
kSchemaResponse: Symbol('response-schema'),
kValidatorCompiler: Symbol('fastify.validatorCompiler'),
kSchemaErrorFormatter: Symbol('fastify.schemaErrorFormatter'),
kSerializerCompiler: Symbol('fastify.serializerCompiler'),
Expand Down
31 changes: 8 additions & 23 deletions lib/validation.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
'use strict'

const headersSchema = Symbol('headers-schema')
const paramsSchema = Symbol('params-schema')
const querystringSchema = Symbol('querystring-schema')
const bodySchema = Symbol('body-schema')

const responseSchema = Symbol('response-schema')
const {
kSchemaHeaders: headersSchema,
kSchemaParams: paramsSchema,
kSchemaQuerystring: querystringSchema,
kSchemaBody: bodySchema,
kSchemaResponse: responseSchema
} = require('./symbols')

function compileSchemasForSerialization (context, compile) {
if (!context.schema || !context.schema.response) {
Expand Down Expand Up @@ -107,25 +108,9 @@ function wrapValidationError (result, dataVar, schemaErrorFormatter) {
return error
}

function serialize (context, data, statusCode) {
const responseSchemaDef = context[responseSchema]
if (!responseSchemaDef) {
return JSON.stringify(data)
}
if (responseSchemaDef[statusCode]) {
return responseSchemaDef[statusCode](data)
}
const fallbackStatusCode = (statusCode + '')[0] + 'xx'
if (responseSchemaDef[fallbackStatusCode]) {
return responseSchemaDef[fallbackStatusCode](data)
}
return JSON.stringify(data)
}

module.exports = {
symbols: { bodySchema, querystringSchema, responseSchema, paramsSchema, headersSchema },
compileSchemasForValidation,
compileSchemasForSerialization,
validate,
serialize
validate
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"types": "fastify.d.ts",
"scripts": {
"lint": "npm run lint:standard && npm run lint:typescript",
"lint:fix": "standard --fix",
"lint:standard": "standard --verbose | snazzy",
"lint:typescript": "eslint -c types/.eslintrc.json types/**/*.d.ts test/types/**/*.test-d.ts",
"unit": "tap -J test/*.test.js test/*/*.test.js",
Expand Down
12 changes: 9 additions & 3 deletions test/hooks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2716,18 +2716,24 @@ test('preSerialization hook should run before serialization and be able to modif
})
})

test('preSerialization hook should be able to throw errors which are not validated against schema response', t => {
test('preSerialization hook should be able to throw errors which are validated against schema response', t => {
const fastify = Fastify()

fastify.addHook('preSerialization', function (req, reply, payload, done) {
done(new Error('preSerialization aborted'))
})

fastify.setErrorHandler((err, request, reply) => {
t.equals(err.message, 'preSerialization aborted')
err.world = 'error'
reply.send(err)
})

fastify.route({
method: 'GET',
url: '/first',
handler: function (req, reply) {
reply.send({ hello: 'world' })
reply.send({ world: 'hello' })
},
schema: {
response: {
Expand Down Expand Up @@ -2756,7 +2762,7 @@ test('preSerialization hook should be able to throw errors which are not validat
t.error(err)
t.strictEqual(response.statusCode, 500)
t.strictEqual(response.headers['content-length'], '' + body.length)
t.deepEqual(JSON.parse(body), { error: 'Internal Server Error', message: 'preSerialization aborted', statusCode: 500 })
t.deepEqual(JSON.parse(body), { world: 'error' })
t.end()
})
})
Expand Down
42 changes: 42 additions & 0 deletions test/schema-serialization.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -456,3 +456,45 @@ test('The schema compiler recreate itself if needed', t => {

fastify.ready(err => { t.error(err) })
})

test('The schema changes the default error handler output', async t => {
t.plan(4)
const fastify = Fastify()

fastify.get('/:code', {
schema: {
response: {
'2xx': { hello: { type: 'string' } },
501: {
type: 'object',
properties: {
message: { type: 'string' }
}
},
'5xx': {
type: 'object',
properties: {
customId: { type: 'number' },
error: { type: 'string' },
message: { type: 'string' }
}
}
}
}
}, (request, reply) => {
if (request.params.code === '501') {
return reply.code(501).send(new Error('501 message'))
}
const error = new Error('500 message')
error.customId = 42
reply.send(error)
})

let res = await fastify.inject('/501')
t.equals(res.statusCode, 501)
t.deepEquals(res.json(), { message: '501 message' })

res = await fastify.inject('/500')
t.equals(res.statusCode, 500)
t.deepEquals(res.json(), { error: 'Internal Server Error', message: '500 message', customId: 42 })
})

0 comments on commit 43ea177

Please sign in to comment.