Skip to content

Commit d1d38ec

Browse files
author
Shogun
committed
feat: Added response validation.
1 parent 4276ef7 commit d1d38ec

File tree

8 files changed

+491
-106
lines changed

8 files changed

+491
-106
lines changed

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
},
1515
"author": "Shogun <shogun@cowtech.it>",
1616
"license": "ISC",
17-
"private": false,
1817
"files": [
1918
"lib",
2019
"types",
@@ -35,22 +34,25 @@
3534
"postpublish": "git push origin && git push origin -f --tags"
3635
},
3736
"dependencies": {
37+
"ajv": "^6.10.2",
3838
"fastify-plugin": "^1.6.0",
3939
"http-errors": "^1.7.3",
4040
"http-status-codes": "^1.4.0",
4141
"lodash.get": "^4.4.2",
42+
"lodash.upperfirst": "^4.3.1",
4243
"statuses": "^1.5.0"
4344
},
4445
"devDependencies": {
4546
"@cowtech/tslint-config": "^5.13.0",
4647
"@types/http-errors": "^1.6.2",
4748
"@types/jest": "^24.0.23",
4849
"@types/lodash.get": "^4.4.6",
50+
"@types/lodash.upperfirst": "^4.3.6",
4951
"@types/node": "^12.12.8",
5052
"@types/statuses": "^1.5.0",
51-
"ajv": "^6.10.2",
5253
"fastify": "^2.10.0",
5354
"jest": "^24.9.0",
55+
"jest-additional-expectations": "^0.1.0",
5456
"prettier": "^1.19.1",
5557
"ts-jest": "^24.1.0",
5658
"tslint": "^5.20.1",

src/index.ts

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,18 @@
1+
import Ajv from 'ajv'
12
import { FastifyError, FastifyInstance, FastifyReply, FastifyRequest, RegisterOptions } from 'fastify'
23
import fastifyPlugin from 'fastify-plugin'
34
import { IncomingMessage, Server, ServerResponse } from 'http'
45
import createError, { HttpError, InternalServerError, NotFound } from 'http-errors'
5-
import { BAD_REQUEST, INTERNAL_SERVER_ERROR } from 'http-status-codes'
6+
import { BAD_REQUEST, INTERNAL_SERVER_ERROR, UNSUPPORTED_MEDIA_TYPE } from 'http-status-codes'
7+
import upperFirst from 'lodash.upperfirst'
68
import statuses from 'statuses'
7-
import { addAdditionalProperties, GenericObject, NodeError, serializeError } from './properties'
8-
import { convertValidationErrors, RequestSection } from './validation'
9+
import { FastifyDecoratedRequest, GenericObject, NodeError, RequestSection } from './interfaces'
10+
import { addAdditionalProperties, serializeError } from './properties'
11+
import { addResponseValidation, convertValidationErrors, validationMessagesFormatters } from './validation'
912

10-
export { addAdditionalProperties, GenericObject } from './properties'
11-
export { convertValidationErrors, niceJoin, validationMessages, validationMessagesFormatters } from './validation'
12-
13-
export interface FastifyDecoratedRequest extends FastifyRequest {
14-
errorProperties?: {
15-
hideUnhandledErrors?: boolean
16-
convertValidationErrors?: boolean
17-
}
18-
}
13+
export * from './interfaces'
14+
export { addAdditionalProperties } from './properties'
15+
export { convertValidationErrors, niceJoin, validationMessagesFormatters } from './validation'
1916

2017
export function handleNotFoundError(request: FastifyRequest, reply: FastifyReply<unknown>): void {
2118
handleErrors(new NotFound('Not found.'), request, reply)
@@ -44,9 +41,11 @@ export function handleErrors(
4441
reply: FastifyReply<unknown>
4542
): void {
4643
// It is a generic error, handle it
44+
const code = (error as NodeError).code
45+
4746
if (!('statusCode' in (error as HttpError))) {
48-
// If it is a validation error, convert errors to human friendly format
4947
if ('validation' in error && request.errorProperties?.convertValidationErrors) {
48+
// If it is a validation error, convert errors to human friendly format
5049
error = handleValidationError(error, request)
5150
} else if (request.errorProperties?.hideUnhandledErrors) {
5251
// It is requested to hide the error, just log it and then create a generic one
@@ -57,6 +56,12 @@ export function handleErrors(
5756
error = Object.assign(new InternalServerError(error.message), serializeError(error))
5857
Object.defineProperty(error, 'stack', { enumerable: true })
5958
}
59+
} else if (code === 'INVALID_CONTENT_TYPE' || code === 'FST_ERR_CTP_INVALID_MEDIA_TYPE') {
60+
error = createError(UNSUPPORTED_MEDIA_TYPE, upperFirst(validationMessagesFormatters.contentType()))
61+
} else if (code === 'FST_ERR_CTP_EMPTY_JSON_BODY') {
62+
error = createError(BAD_REQUEST, upperFirst(validationMessagesFormatters.jsonEmpty()))
63+
} else if (code === 'MALFORMED_JSON' || error.message === 'Invalid JSON' || error.stack!.includes('at JSON.parse')) {
64+
error = createError(BAD_REQUEST, upperFirst(validationMessagesFormatters.json()))
6065
}
6166

6267
// Get the status code
@@ -91,13 +96,31 @@ export default fastifyPlugin(
9196
options: RegisterOptions<S, I, R>,
9297
done: () => void
9398
): void {
94-
const hideUnhandledErrors = options.hideUnhandledErrors ?? process.env.NODE_ENV === 'production'
99+
const isProduction = process.env.NODE_ENV === 'production'
100+
const hideUnhandledErrors = options.hideUnhandledErrors ?? isProduction
95101
const convertValidationErrors = options.convertValidationErrors ?? true
102+
const convertResponsesValidationErrors = options.convertResponsesValidationErrors ?? !isProduction
96103

97104
instance.decorateRequest('errorProperties', { hideUnhandledErrors, convertValidationErrors })
98105
instance.setErrorHandler(handleErrors)
99106
instance.setNotFoundHandler(handleNotFoundError)
100107

108+
if (convertResponsesValidationErrors) {
109+
instance.decorate(
110+
'responseValidatorSchemaCompiler',
111+
new Ajv({
112+
// The fastify defaults, with the exception of removeAdditional and coerceTypes, which have been reversed
113+
removeAdditional: false,
114+
useDefaults: true,
115+
coerceTypes: false,
116+
allErrors: true,
117+
nullable: true
118+
})
119+
)
120+
121+
instance.addHook('onRoute', addResponseValidation)
122+
}
123+
101124
done()
102125
},
103126
{ name: 'fastify-errors-properties' }

src/interfaces.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Ajv, ValidateFunction } from 'ajv'
2+
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
3+
import { ServerResponse } from 'http'
4+
5+
export type GenericObject = { [key: string]: any }
6+
export type NodeError = NodeJS.ErrnoException
7+
8+
export type RequestSection = 'params' | 'query' | 'querystring' | 'headers' | 'body' | 'response'
9+
10+
export interface ResponseSchemas {
11+
[key: string]: ValidateFunction
12+
}
13+
14+
export interface FastifyDecoratedInstance extends FastifyInstance {
15+
responseValidatorSchemaCompiler: Ajv
16+
}
17+
18+
export interface FastifyDecoratedRequest extends FastifyRequest {
19+
errorProperties?: {
20+
hideUnhandledErrors?: boolean
21+
convertValidationErrors?: boolean
22+
}
23+
}
24+
25+
export interface FastifyDecoratedReply extends FastifyReply<ServerResponse> {
26+
originalResponse?: {
27+
statusCode: number
28+
payload: any
29+
}
30+
}
31+
export interface Validations {
32+
[key: string]: {
33+
[key: string]: string
34+
}
35+
}
36+
37+
export type ValidationFormatter = (...args: Array<any>) => string

src/properties.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
const processRoot = process.cwd()
1+
import { GenericObject, NodeError } from './interfaces'
22

3-
export type GenericObject = { [key: string]: any }
4-
export type NodeError = NodeJS.ErrnoException
3+
const processRoot = process.cwd()
54

65
export function addAdditionalProperties(target: GenericObject, source: GenericObject): void {
76
for (const v in source) {

src/validation.ts

Lines changed: 97 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
import { ValidationResult } from 'fastify'
1+
import { FastifyInstance, FastifyRequest, RouteOptions, ValidationResult } from 'fastify'
2+
import createError from 'http-errors'
3+
import { INTERNAL_SERVER_ERROR } from 'http-status-codes'
4+
import {
5+
FastifyDecoratedInstance,
6+
FastifyDecoratedReply,
7+
RequestSection,
8+
ResponseSchemas,
9+
ValidationFormatter,
10+
Validations
11+
} from './interfaces'
212
import get = require('lodash.get')
313

4-
export type RequestSection = 'params' | 'query' | 'querystring' | 'headers' | 'body'
5-
6-
export interface Validations {
7-
[key: string]: {
8-
[key: string]: string
9-
}
10-
}
11-
12-
export type validationFormatter = (...args: Array<any>) => string
13-
1414
export function niceJoin(array: Array<string>, lastSeparator: string = ' and ', separator: string = ', '): string {
1515
switch (array.length) {
1616
case 0:
@@ -24,7 +24,37 @@ export function niceJoin(array: Array<string>, lastSeparator: string = ' and ',
2424
}
2525
}
2626

27-
export const validationMessagesFormatters: { [key: string]: validationFormatter } = {
27+
export const validationMessagesFormatters: { [key: string]: ValidationFormatter } = {
28+
contentType: () =>
29+
'only JSON payloads are accepted. Please set the "Content-Type" header to start with "application/json"',
30+
json: () => 'the body payload is not a valid JSON',
31+
jsonEmpty: () => 'the JSON body payload cannot be empty if the "Content-Type" header is set',
32+
missing: () => 'must be present',
33+
unknown: () => 'is not a valid property',
34+
uuid: () => 'must be a valid GUID (UUID v4)',
35+
timestamp: () => 'must be a valid ISO 8601 / RFC 3339 timestamp (example: 2018-07-06T12:34:56Z)',
36+
date: () => 'must be a valid ISO 8601 / RFC 3339 date (example: 2018-07-06)',
37+
time: () => 'must be a valid ISO 8601 / RFC 3339 time (example: 12:34:56)',
38+
hostname: () => 'must be a valid hostname',
39+
ipv4: () => 'must be a valid IPv4',
40+
ipv6: () => 'must be a valid IPv6',
41+
paramType: (type: string) => {
42+
switch (type) {
43+
case 'integer':
44+
return 'must be a valid integer number'
45+
case 'number':
46+
return 'must be a valid number'
47+
case 'boolean':
48+
return 'must be a valid boolean (true or false)'
49+
case 'object':
50+
return 'must be a object'
51+
case 'array':
52+
return 'must be an array'
53+
default:
54+
return 'must be a string'
55+
}
56+
},
57+
presentString: () => 'must be a non empty string',
2858
minimum: (min: number) => `must be a number greater than or equal to ${min}`,
2959
maximum: (max: number) => `must be a number less than or equal to ${max}`,
3060
minimumProperties(min: number): string {
@@ -47,31 +77,8 @@ export const validationMessagesFormatters: { [key: string]: validationFormatter
4777
pattern: (pattern: string) => `must match pattern "${pattern.replace(/\(\?\:/g, '(')}"`,
4878
invalidResponseCode: (code: number) => `This endpoint cannot respond with HTTP status ${code}.`,
4979
invalidResponse: (code: number) =>
50-
`The response returned from the endpoint violates its specification for the HTTP status ${code}.`
51-
}
52-
53-
export const validationMessages: { [key: string]: string } = {
54-
contentType: 'only JSON payloads are accepted. Please set the "Content-Type" header to start with "application/json"',
55-
json: 'the body payload is not a valid JSON',
56-
jsonEmpty: 'the JSON body payload cannot be empty if the "Content-Type" header is set',
57-
missing: 'must be present',
58-
unknown: 'is not a valid property',
59-
emptyObject: 'cannot be a empty object',
60-
uuid: 'must be a valid GUID (UUID v4)',
61-
timestamp: 'must be a valid ISO 8601 / RFC 3339 timestamp (example: 2018-07-06T12:34:56Z)',
62-
date: 'must be a valid ISO 8601 / RFC 3339 date (example: 2018-07-06)',
63-
time: 'must be a valid ISO 8601 / RFC 3339 time (example: 12:34:56)',
64-
hostname: 'must be a valid hostname',
65-
ip: 'must be a valid IPv4 or IPv6',
66-
ipv4: 'must be a valid IPv4',
67-
ipv6: 'must be a valid IPv6',
68-
integer: 'must be a valid integer number',
69-
number: 'must be a valid number',
70-
boolean: 'must be a valid boolean (true or false)',
71-
object: 'must be a object',
72-
array: 'must be an array',
73-
string: 'must be a string',
74-
presentString: 'must be a non empty string'
80+
`The response returned from the endpoint violates its specification for the HTTP status ${code}.`,
81+
invalidFormat: (format: string) => `must match format "${format}" (format)`
7582
}
7683

7784
export function convertValidationErrors(
@@ -105,15 +112,15 @@ export function convertValidationErrors(
105112
case 'required':
106113
case 'dependencies':
107114
key = e.params.missingProperty
108-
message = validationMessages.missing
115+
message = validationMessagesFormatters.missing()
109116
break
110117
case 'additionalProperties':
111118
key = e.params.additionalProperty
112119

113-
message = validationMessages.unknown
120+
message = validationMessagesFormatters.unknown()
114121
break
115122
case 'type':
116-
message = validationMessages[e.params.type]
123+
message = validationMessagesFormatters.paramType(e.params.type)
117124
break
118125
case 'minProperties':
119126
message = validationMessagesFormatters.minimumProperties(e.params.limit)
@@ -141,7 +148,7 @@ export function convertValidationErrors(
141148
const value = get(data, key) as string
142149

143150
if (pattern === '.+' && !value) {
144-
message = validationMessages.presentString
151+
message = validationMessagesFormatters.presentString()
145152
} else {
146153
message = validationMessagesFormatters.pattern(e.params.pattern)
147154
}
@@ -155,9 +162,7 @@ export function convertValidationErrors(
155162
reason = 'timestamp'
156163
}
157164

158-
message = validationMessagesFormatters[reason]
159-
? validationMessagesFormatters[reason](reason)
160-
: validationMessages[reason]
165+
message = (validationMessagesFormatters[reason] || validationMessagesFormatters.invalidFormat)(reason)
161166

162167
break
163168
}
@@ -187,3 +192,51 @@ export function convertValidationErrors(
187192

188193
return { [section]: errors }
189194
}
195+
196+
export function addResponseValidation(this: FastifyDecoratedInstance, route: RouteOptions): void {
197+
if (!route.schema?.response) {
198+
return
199+
}
200+
201+
const validators = Object.entries(route.schema.response).reduce<ResponseSchemas>(
202+
(accu: ResponseSchemas, [code, schema]: [string, object]) => {
203+
accu[code] = this.responseValidatorSchemaCompiler.compile(schema)
204+
205+
return accu
206+
},
207+
{} as ResponseSchemas
208+
)
209+
210+
// Note that this hook is not called for non JSON payloads therefore validation is not possible in such cases
211+
route.preSerialization = async function(
212+
this: FastifyInstance,
213+
_request: FastifyRequest,
214+
reply: FastifyDecoratedReply,
215+
payload: any
216+
): Promise<any> {
217+
const statusCode = reply.res.statusCode
218+
219+
// Never validate error 500
220+
if (statusCode === 500) {
221+
return payload
222+
}
223+
224+
// No validator, it means the HTTP status is not allowed
225+
const validator = validators[statusCode]
226+
227+
if (!validator) {
228+
throw createError(INTERNAL_SERVER_ERROR, validationMessagesFormatters.invalidResponseCode(statusCode))
229+
}
230+
231+
// Now validate the payload
232+
const valid = validator(payload)
233+
234+
if (!valid) {
235+
throw createError(INTERNAL_SERVER_ERROR, validationMessagesFormatters.invalidResponse(statusCode), {
236+
failedValidations: convertValidationErrors('response', payload, validator.errors as Array<ValidationResult>)
237+
})
238+
}
239+
240+
return payload
241+
}
242+
}

0 commit comments

Comments
 (0)