Skip to content

Commit fb34d1c

Browse files
author
Shogun
committed
feat: Improve response validation.
1 parent e9588c6 commit fb34d1c

File tree

8 files changed

+150
-64
lines changed

8 files changed

+150
-64
lines changed

lib/index.js

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ var __importStar = (this && this.__importStar) || function (mod) {
1010
return result;
1111
};
1212
Object.defineProperty(exports, "__esModule", { value: true });
13+
const ajv_1 = __importDefault(require("ajv"));
1314
const fastify_plugin_1 = __importDefault(require("fastify-plugin"));
1415
const http_errors_1 = __importStar(require("http-errors"));
1516
const http_status_codes_1 = require("http-status-codes");
17+
const lodash_upperfirst_1 = __importDefault(require("lodash.upperfirst"));
1618
const statuses_1 = __importDefault(require("statuses"));
1719
const properties_1 = require("./properties");
1820
const validation_1 = require("./validation");
@@ -21,7 +23,6 @@ exports.addAdditionalProperties = properties_2.addAdditionalProperties;
2123
var validation_2 = require("./validation");
2224
exports.convertValidationErrors = validation_2.convertValidationErrors;
2325
exports.niceJoin = validation_2.niceJoin;
24-
exports.validationMessages = validation_2.validationMessages;
2526
exports.validationMessagesFormatters = validation_2.validationMessagesFormatters;
2627
function handleNotFoundError(request, reply) {
2728
handleErrors(new http_errors_1.NotFound('Not found.'), request, reply);
@@ -46,9 +47,10 @@ exports.handleValidationError = handleValidationError;
4647
function handleErrors(error, request, reply) {
4748
var _a, _b;
4849
// It is a generic error, handle it
50+
const code = error.code;
4951
if (!('statusCode' in error)) {
50-
// If it is a validation error, convert errors to human friendly format
5152
if ('validation' in error && ((_a = request.errorProperties) === null || _a === void 0 ? void 0 : _a.convertValidationErrors)) {
53+
// If it is a validation error, convert errors to human friendly format
5254
error = handleValidationError(error, request);
5355
}
5456
else if ((_b = request.errorProperties) === null || _b === void 0 ? void 0 : _b.hideUnhandledErrors) {
@@ -62,6 +64,15 @@ function handleErrors(error, request, reply) {
6264
Object.defineProperty(error, 'stack', { enumerable: true });
6365
}
6466
}
67+
else if (code === 'INVALID_CONTENT_TYPE' || code === 'FST_ERR_CTP_INVALID_MEDIA_TYPE') {
68+
error = http_errors_1.default(http_status_codes_1.UNSUPPORTED_MEDIA_TYPE, lodash_upperfirst_1.default(validation_1.validationMessagesFormatters.contentType()));
69+
}
70+
else if (code === 'FST_ERR_CTP_EMPTY_JSON_BODY') {
71+
error = http_errors_1.default(http_status_codes_1.BAD_REQUEST, lodash_upperfirst_1.default(validation_1.validationMessagesFormatters.jsonEmpty()));
72+
}
73+
else if (code === 'MALFORMED_JSON' || error.message === 'Invalid JSON' || error.stack.includes('at JSON.parse')) {
74+
error = http_errors_1.default(http_status_codes_1.BAD_REQUEST, lodash_upperfirst_1.default(validation_1.validationMessagesFormatters.json()));
75+
}
6576
// Get the status code
6677
let { statusCode, headers } = error;
6778
// Code outside HTTP range
@@ -85,11 +96,24 @@ function handleErrors(error, request, reply) {
8596
}
8697
exports.handleErrors = handleErrors;
8798
exports.default = fastify_plugin_1.default(function (instance, options, done) {
88-
var _a, _b;
89-
const hideUnhandledErrors = (_a = options.hideUnhandledErrors, (_a !== null && _a !== void 0 ? _a : process.env.NODE_ENV === 'production'));
99+
var _a, _b, _c;
100+
const isProduction = process.env.NODE_ENV === 'production';
101+
const hideUnhandledErrors = (_a = options.hideUnhandledErrors, (_a !== null && _a !== void 0 ? _a : isProduction));
90102
const convertValidationErrors = (_b = options.convertValidationErrors, (_b !== null && _b !== void 0 ? _b : true));
103+
const convertResponsesValidationErrors = (_c = options.convertResponsesValidationErrors, (_c !== null && _c !== void 0 ? _c : !isProduction));
91104
instance.decorateRequest('errorProperties', { hideUnhandledErrors, convertValidationErrors });
92105
instance.setErrorHandler(handleErrors);
93106
instance.setNotFoundHandler(handleNotFoundError);
107+
if (convertResponsesValidationErrors) {
108+
instance.decorate('responseValidatorSchemaCompiler', new ajv_1.default({
109+
// The fastify defaults, with the exception of removeAdditional and coerceTypes, which have been reversed
110+
removeAdditional: false,
111+
useDefaults: true,
112+
coerceTypes: false,
113+
allErrors: true,
114+
nullable: true
115+
}));
116+
instance.addHook('onRoute', validation_1.addResponseValidation);
117+
}
94118
done();
95119
}, { name: 'fastify-errors-properties' });

lib/interfaces.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
"use strict";
2+
Object.defineProperty(exports, "__esModule", { value: true });

lib/validation.js

Lines changed: 78 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
"use strict";
2+
var __importDefault = (this && this.__importDefault) || function (mod) {
3+
return (mod && mod.__esModule) ? mod : { "default": mod };
4+
};
25
Object.defineProperty(exports, "__esModule", { value: true });
6+
const http_errors_1 = __importDefault(require("http-errors"));
7+
const http_status_codes_1 = require("http-status-codes");
38
const get = require("lodash.get");
49
function niceJoin(array, lastSeparator = ' and ', separator = ', ') {
510
switch (array.length) {
@@ -15,6 +20,35 @@ function niceJoin(array, lastSeparator = ' and ', separator = ', ') {
1520
}
1621
exports.niceJoin = niceJoin;
1722
exports.validationMessagesFormatters = {
23+
contentType: () => 'only JSON payloads are accepted. Please set the "Content-Type" header to start with "application/json"',
24+
json: () => 'the body payload is not a valid JSON',
25+
jsonEmpty: () => 'the JSON body payload cannot be empty if the "Content-Type" header is set',
26+
missing: () => 'must be present',
27+
unknown: () => 'is not a valid property',
28+
uuid: () => 'must be a valid GUID (UUID v4)',
29+
timestamp: () => 'must be a valid ISO 8601 / RFC 3339 timestamp (example: 2018-07-06T12:34:56Z)',
30+
date: () => 'must be a valid ISO 8601 / RFC 3339 date (example: 2018-07-06)',
31+
time: () => 'must be a valid ISO 8601 / RFC 3339 time (example: 12:34:56)',
32+
hostname: () => 'must be a valid hostname',
33+
ipv4: () => 'must be a valid IPv4',
34+
ipv6: () => 'must be a valid IPv6',
35+
paramType: (type) => {
36+
switch (type) {
37+
case 'integer':
38+
return 'must be a valid integer number';
39+
case 'number':
40+
return 'must be a valid number';
41+
case 'boolean':
42+
return 'must be a valid boolean (true or false)';
43+
case 'object':
44+
return 'must be a object';
45+
case 'array':
46+
return 'must be an array';
47+
default:
48+
return 'must be a string';
49+
}
50+
},
51+
presentString: () => 'must be a non empty string',
1852
minimum: (min) => `must be a number greater than or equal to ${min}`,
1953
maximum: (max) => `must be a number less than or equal to ${max}`,
2054
minimumProperties(min) {
@@ -32,30 +66,8 @@ exports.validationMessagesFormatters = {
3266
enum: (values) => `must be one of the following values: ${niceJoin(values.map((f) => `"${f}"`), ' or ')}`,
3367
pattern: (pattern) => `must match pattern "${pattern.replace(/\(\?\:/g, '(')}"`,
3468
invalidResponseCode: (code) => `This endpoint cannot respond with HTTP status ${code}.`,
35-
invalidResponse: (code) => `The response returned from the endpoint violates its specification for the HTTP status ${code}.`
36-
};
37-
exports.validationMessages = {
38-
contentType: 'only JSON payloads are accepted. Please set the "Content-Type" header to start with "application/json"',
39-
json: 'the body payload is not a valid JSON',
40-
jsonEmpty: 'the JSON body payload cannot be empty if the "Content-Type" header is set',
41-
missing: 'must be present',
42-
unknown: 'is not a valid property',
43-
emptyObject: 'cannot be a empty object',
44-
uuid: 'must be a valid GUID (UUID v4)',
45-
timestamp: 'must be a valid ISO 8601 / RFC 3339 timestamp (example: 2018-07-06T12:34:56Z)',
46-
date: 'must be a valid ISO 8601 / RFC 3339 date (example: 2018-07-06)',
47-
time: 'must be a valid ISO 8601 / RFC 3339 time (example: 12:34:56)',
48-
hostname: 'must be a valid hostname',
49-
ip: 'must be a valid IPv4 or IPv6',
50-
ipv4: 'must be a valid IPv4',
51-
ipv6: 'must be a valid IPv6',
52-
integer: 'must be a valid integer number',
53-
number: 'must be a valid number',
54-
boolean: 'must be a valid boolean (true or false)',
55-
object: 'must be a object',
56-
array: 'must be an array',
57-
string: 'must be a string',
58-
presentString: 'must be a non empty string'
69+
invalidResponse: (code) => `The response returned from the endpoint violates its specification for the HTTP status ${code}.`,
70+
invalidFormat: (format) => `must match format "${format}" (format)`
5971
};
6072
function convertValidationErrors(section, data, validationErrors) {
6173
const errors = {};
@@ -78,14 +90,14 @@ function convertValidationErrors(section, data, validationErrors) {
7890
case 'required':
7991
case 'dependencies':
8092
key = e.params.missingProperty;
81-
message = exports.validationMessages.missing;
93+
message = exports.validationMessagesFormatters.missing();
8294
break;
8395
case 'additionalProperties':
8496
key = e.params.additionalProperty;
85-
message = exports.validationMessages.unknown;
97+
message = exports.validationMessagesFormatters.unknown();
8698
break;
8799
case 'type':
88-
message = exports.validationMessages[e.params.type];
100+
message = exports.validationMessagesFormatters.paramType(e.params.type);
89101
break;
90102
case 'minProperties':
91103
message = exports.validationMessagesFormatters.minimumProperties(e.params.limit);
@@ -112,7 +124,7 @@ function convertValidationErrors(section, data, validationErrors) {
112124
const pattern = e.params.pattern;
113125
const value = get(data, key);
114126
if (pattern === '.+' && !value) {
115-
message = exports.validationMessages.presentString;
127+
message = exports.validationMessagesFormatters.presentString();
116128
}
117129
else {
118130
message = exports.validationMessagesFormatters.pattern(e.params.pattern);
@@ -124,9 +136,7 @@ function convertValidationErrors(section, data, validationErrors) {
124136
if (reason === 'date-time') {
125137
reason = 'timestamp';
126138
}
127-
message = exports.validationMessagesFormatters[reason]
128-
? exports.validationMessagesFormatters[reason](reason)
129-
: exports.validationMessages[reason];
139+
message = (exports.validationMessagesFormatters[reason] || exports.validationMessagesFormatters.invalidFormat)(reason);
130140
break;
131141
}
132142
// No custom message was found, default to input one replacing the starting verb and adding some path info
@@ -137,14 +147,48 @@ function convertValidationErrors(section, data, validationErrors) {
137147
let property = key
138148
.replace(/\[(\d+)\]/g, '.$1') // Array path
139149
.replace(/\[([^\]]+)\]/g, '.$1'); // Object path
140-
if (!property) {
141-
property = '$root';
142-
}
150+
// Remove useless quotes
143151
if (property.match(/(?:^['"])(?:[^\.]+)(?:['"]$)/)) {
144152
property = property.substring(1, property.length - 1);
145153
}
154+
// Fix empty properties
155+
if (!property) {
156+
property = '$root';
157+
}
146158
errors[property] = message;
147159
}
148160
return { [section]: errors };
149161
}
150162
exports.convertValidationErrors = convertValidationErrors;
163+
function addResponseValidation(route) {
164+
var _a;
165+
if (!((_a = route.schema) === null || _a === void 0 ? void 0 : _a.response)) {
166+
return;
167+
}
168+
const validators = Object.entries(route.schema.response).reduce((accu, [code, schema]) => {
169+
accu[code] = this.responseValidatorSchemaCompiler.compile(schema);
170+
return accu;
171+
}, {});
172+
// Note that this hook is not called for non JSON payloads therefore validation is not possible in such cases
173+
route.preSerialization = async function (_request, reply, payload) {
174+
const statusCode = reply.res.statusCode;
175+
// Never validate error 500
176+
if (statusCode === http_status_codes_1.INTERNAL_SERVER_ERROR) {
177+
return payload;
178+
}
179+
// No validator, it means the HTTP status is not allowed
180+
const validator = validators[statusCode];
181+
if (!validator) {
182+
throw http_errors_1.default(http_status_codes_1.INTERNAL_SERVER_ERROR, exports.validationMessagesFormatters.invalidResponseCode(statusCode));
183+
}
184+
// Now validate the payload
185+
const valid = validator(payload);
186+
if (!valid) {
187+
throw http_errors_1.default(http_status_codes_1.INTERNAL_SERVER_ERROR, exports.validationMessagesFormatters.invalidResponse(statusCode), {
188+
failedValidations: convertValidationErrors('response', payload, validator.errors)
189+
});
190+
}
191+
return payload;
192+
};
193+
}
194+
exports.addResponseValidation = addResponseValidation;

tsconfig.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
"noImplicitAny": true,
1414
"noUnusedLocals": true,
1515
"noUnusedParameters": true,
16-
"keyofStringsOnly": true,
1716
"strictNullChecks": true,
1817
"declaration": true
1918
},

types/index.d.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
11
/// <reference types="node" />
22
import { FastifyError, FastifyInstance, FastifyReply, FastifyRequest, RegisterOptions } from 'fastify';
33
import { IncomingMessage, Server, ServerResponse } from 'http';
4-
export { addAdditionalProperties, GenericObject } from './properties';
5-
export { convertValidationErrors, niceJoin, validationMessages, validationMessagesFormatters } from './validation';
6-
export interface FastifyDecoratedRequest extends FastifyRequest {
7-
errorProperties?: {
8-
hideUnhandledErrors?: boolean;
9-
convertValidationErrors?: boolean;
10-
};
11-
}
4+
import { FastifyDecoratedRequest } from './interfaces';
5+
export * from './interfaces';
6+
export { addAdditionalProperties } from './properties';
7+
export { convertValidationErrors, niceJoin, validationMessagesFormatters } from './validation';
128
export declare function handleNotFoundError(request: FastifyRequest, reply: FastifyReply<unknown>): void;
139
export declare function handleValidationError(error: FastifyError, request: FastifyRequest): FastifyError;
1410
export declare function handleErrors(error: FastifyError, request: FastifyDecoratedRequest, reply: FastifyReply<unknown>): void;

types/interfaces.d.ts

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

types/properties.d.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
/// <reference types="node" />
2-
export declare type GenericObject = {
3-
[key: string]: any;
4-
};
5-
export declare type NodeError = NodeJS.ErrnoException;
1+
import { GenericObject } from './interfaces';
62
export declare function addAdditionalProperties(target: GenericObject, source: GenericObject): void;
73
export declare function serializeError(error: Error): GenericObject;

types/validation.d.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,10 @@
1-
import { ValidationResult } from 'fastify';
2-
export declare type RequestSection = 'params' | 'query' | 'querystring' | 'headers' | 'body';
3-
export interface Validations {
4-
[key: string]: {
5-
[key: string]: string;
6-
};
7-
}
8-
export declare type validationFormatter = (...args: Array<any>) => string;
1+
import { RouteOptions, ValidationResult } from 'fastify';
2+
import { FastifyDecoratedInstance, RequestSection, ValidationFormatter, Validations } from './interfaces';
93
export declare function niceJoin(array: Array<string>, lastSeparator?: string, separator?: string): string;
104
export declare const validationMessagesFormatters: {
11-
[key: string]: validationFormatter;
12-
};
13-
export declare const validationMessages: {
14-
[key: string]: string;
5+
[key: string]: ValidationFormatter;
156
};
167
export declare function convertValidationErrors(section: RequestSection, data: {
178
[key: string]: unknown;
189
}, validationErrors: Array<ValidationResult>): Validations;
10+
export declare function addResponseValidation(this: FastifyDecoratedInstance, route: RouteOptions): void;

0 commit comments

Comments
 (0)