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'
212import 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-
1414export 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
7784export 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