Skip to content

Commit 87e9af4

Browse files
author
Shogun
committed
feat: Added validation errors handling.
1 parent d27ff99 commit 87e9af4

File tree

10 files changed

+940
-136
lines changed

10 files changed

+940
-136
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ npm install fastify-errors-properties --save
2222
Register as a plugin, optional providing any of the following options:
2323

2424
- `hideUnhandledErrors`: If to hide unhandled server errors or returning to the client including stack information. Default is to hide errors when `NODE_ENV` environment variable is `production`.
25+
- `convertValidationErrors`: Convert validation errors to a structured human readable object. Default is `true`.
2526

2627
Once registered, the server will use the plugin handlers for all errors (basically, both `setErrorHandler` and `setNotFoundHandler` are called).
2728

lib/index.js

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,57 @@
22
var __importDefault = (this && this.__importDefault) || function (mod) {
33
return (mod && mod.__esModule) ? mod : { "default": mod };
44
};
5+
var __importStar = (this && this.__importStar) || function (mod) {
6+
if (mod && mod.__esModule) return mod;
7+
var result = {};
8+
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
9+
result["default"] = mod;
10+
return result;
11+
};
512
Object.defineProperty(exports, "__esModule", { value: true });
613
const fastify_plugin_1 = __importDefault(require("fastify-plugin"));
7-
const http_errors_1 = require("http-errors");
14+
const http_errors_1 = __importStar(require("http-errors"));
815
const http_status_codes_1 = require("http-status-codes");
916
const statuses_1 = __importDefault(require("statuses"));
1017
const properties_1 = require("./properties");
18+
const validation_1 = require("./validation");
1119
var properties_2 = require("./properties");
1220
exports.addAdditionalProperties = properties_2.addAdditionalProperties;
21+
var validation_2 = require("./validation");
22+
exports.convertValidationErrors = validation_2.convertValidationErrors;
23+
exports.niceJoin = validation_2.niceJoin;
24+
exports.validationMessages = validation_2.validationMessages;
25+
exports.validationMessagesFormatters = validation_2.validationMessagesFormatters;
1326
function handleNotFoundError(request, reply) {
1427
handleErrors(new http_errors_1.NotFound('Not found.'), request, reply);
1528
}
1629
exports.handleNotFoundError = handleNotFoundError;
30+
function handleValidationError(error, request) {
31+
/*
32+
As seen in
33+
https://github.com/fastify/fastify/blob/master/lib/validation.js#L96
34+
and
35+
https://github.com/fastify/fastify/blob/master/lib/validation.js#L156,
36+
37+
the error.message will always start with the relative section (params, querystring, headers, body)
38+
and fastify throws on first failing section.
39+
*/
40+
const section = error.message.match(/^\w+/)[0];
41+
return http_errors_1.default(http_status_codes_1.BAD_REQUEST, 'One or more validations failed trying to process your request.', {
42+
failedValidations: validation_1.convertValidationErrors(section, Reflect.get(request, section), error.validation)
43+
});
44+
}
45+
exports.handleValidationError = handleValidationError;
1746
function handleErrors(error, request, reply) {
18-
var _a;
47+
var _a, _b;
1948
// It is a generic error, handle it
2049
if (!('statusCode' in error)) {
21-
// It is requested to hide the error, just log it and then create a generic one
22-
if ((_a = request.errorProperties) === null || _a === void 0 ? void 0 : _a.hideUnhandledErrors) {
50+
// If it is a validation error, convert errors to human friendly format
51+
if ('validation' in error && ((_a = request.errorProperties) === null || _a === void 0 ? void 0 : _a.convertValidationErrors)) {
52+
error = handleValidationError(error, request);
53+
}
54+
else if ((_b = request.errorProperties) === null || _b === void 0 ? void 0 : _b.hideUnhandledErrors) {
55+
// It is requested to hide the error, just log it and then create a generic one
2356
request.log.error({ error: properties_1.serializeError(error) });
2457
error = new http_errors_1.InternalServerError('An error occurred trying to process your request.');
2558
}
@@ -52,9 +85,10 @@ function handleErrors(error, request, reply) {
5285
}
5386
exports.handleErrors = handleErrors;
5487
exports.default = fastify_plugin_1.default(function (instance, options, done) {
55-
var _a;
88+
var _a, _b;
5689
const hideUnhandledErrors = (_a = options.hideUnhandledErrors, (_a !== null && _a !== void 0 ? _a : process.env.NODE_ENV === 'production'));
57-
instance.decorateRequest('errorProperties', { hideUnhandledErrors });
90+
const convertValidationErrors = (_b = options.convertValidationErrors, (_b !== null && _b !== void 0 ? _b : true));
91+
instance.decorateRequest('errorProperties', { hideUnhandledErrors, convertValidationErrors });
5892
instance.setErrorHandler(handleErrors);
5993
instance.setNotFoundHandler(handleNotFoundError);
6094
done();

lib/validation.js

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"use strict";
2+
Object.defineProperty(exports, "__esModule", { value: true });
3+
const get = require("lodash.get");
4+
function niceJoin(array, lastSeparator = ' and ', separator = ', ') {
5+
switch (array.length) {
6+
case 0:
7+
return '';
8+
case 1:
9+
return array[0];
10+
case 2:
11+
return array.join(lastSeparator);
12+
default:
13+
return array.slice(0, array.length - 1).join(separator) + lastSeparator + array[array.length - 1];
14+
}
15+
}
16+
exports.niceJoin = niceJoin;
17+
exports.validationMessagesFormatters = {
18+
minimum: (min) => `must be a number greater than or equal to ${min}`,
19+
maximum: (max) => `must be a number less than or equal to ${max}`,
20+
minimumProperties(min) {
21+
return min === 1 ? 'cannot be a empty object' : `must be a object with at least ${min} properties`;
22+
},
23+
maximumProperties(max) {
24+
return max === 0 ? 'must be a empty object' : `must be a object with at most ${max} properties`;
25+
},
26+
minimumItems(min) {
27+
return min === 1 ? 'cannot be a empty array' : `must be an array with at least ${min} items`;
28+
},
29+
maximumItems(max) {
30+
return max === 0 ? 'must be a empty array' : `must be an array with at most ${max} items`;
31+
},
32+
enum: (values) => `must be one of the following values: ${niceJoin(values.map((f) => `"${f}"`), ' or ')}`,
33+
pattern: (pattern) => `must match pattern "${pattern.replace(/\(\?\:/g, '(')}"`,
34+
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'
59+
};
60+
function convertValidationErrors(section, data, validationErrors) {
61+
const errors = {};
62+
if (section === 'querystring') {
63+
section = 'query';
64+
}
65+
for (const e of validationErrors) {
66+
// For each error
67+
let baseKey = e.dataPath.substring(e.dataPath.startsWith('.') ? 1 : 0);
68+
let key = baseKey;
69+
let message = '';
70+
// Depending on the type
71+
switch (e.keyword) {
72+
case 'required':
73+
case 'dependencies':
74+
key = e.params.missingProperty;
75+
message = exports.validationMessages.missing;
76+
break;
77+
case 'additionalProperties':
78+
key = e.params.additionalProperty;
79+
message = exports.validationMessages.unknown;
80+
break;
81+
case 'type':
82+
message = exports.validationMessages[e.params.type];
83+
break;
84+
case 'minProperties':
85+
message = exports.validationMessagesFormatters.minimumProperties(e.params.limit);
86+
break;
87+
case 'maxProperties':
88+
message = exports.validationMessagesFormatters.maximumProperties(e.params.limit);
89+
break;
90+
case 'minItems':
91+
message = exports.validationMessagesFormatters.minimumItems(e.params.limit);
92+
break;
93+
case 'maxItems':
94+
message = exports.validationMessagesFormatters.maximumItems(e.params.limit);
95+
break;
96+
case 'minimum':
97+
message = exports.validationMessagesFormatters.minimum(e.params.limit);
98+
break;
99+
case 'maximum':
100+
message = exports.validationMessagesFormatters.maximum(e.params.limit);
101+
break;
102+
case 'enum':
103+
message = exports.validationMessagesFormatters.enum(e.params.allowedValues);
104+
break;
105+
case 'pattern':
106+
const pattern = e.params.pattern;
107+
const value = get(data, key);
108+
if (pattern === '.+' && !value) {
109+
message = exports.validationMessages.presentString;
110+
}
111+
else {
112+
message = exports.validationMessagesFormatters.pattern(e.params.pattern);
113+
}
114+
break;
115+
case 'format':
116+
let reason = e.params.format;
117+
// Normalize the key
118+
if (reason === 'date-time') {
119+
reason = 'timestamp';
120+
}
121+
message = exports.validationMessagesFormatters[reason]
122+
? exports.validationMessagesFormatters[reason](reason)
123+
: exports.validationMessages[reason];
124+
break;
125+
}
126+
// No custom message was found, default to input one replacing the starting verb and adding some path info
127+
if (!message) {
128+
message = `${e.message.replace(/^should/, 'must')} (${e.keyword})`;
129+
}
130+
// Find the property to add
131+
let property = Array.from(new Set([baseKey, key].filter((p) => p)))
132+
.join('.')
133+
.replace(/\[(\d+)\]/g, '.$1');
134+
if (!property) {
135+
property = '$root';
136+
}
137+
errors[property] = message;
138+
}
139+
return { [section]: errors };
140+
}
141+
exports.convertValidationErrors = convertValidationErrors;

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,17 @@
3939
"fastify-plugin": "^1.6.0",
4040
"http-errors": "^1.7.3",
4141
"http-status-codes": "^1.4.0",
42+
"lodash.get": "^4.4.2",
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",
49+
"@types/lodash.get": "^4.4.6",
4850
"@types/node": "^12.12.8",
4951
"@types/statuses": "^1.5.0",
52+
"ajv": "^6.10.2",
5053
"fastify": "^2.10.0",
5154
"jest": "^24.9.0",
5255
"prettier": "^1.19.1",

src/index.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,55 @@
1-
import { FastifyInstance, FastifyReply, FastifyRequest, RegisterOptions } from 'fastify'
1+
import { FastifyError, FastifyInstance, FastifyReply, FastifyRequest, RegisterOptions } from 'fastify'
22
import fastifyPlugin from 'fastify-plugin'
33
import { IncomingMessage, Server, ServerResponse } from 'http'
4-
import { HttpError, InternalServerError, NotFound } from 'http-errors'
5-
import { INTERNAL_SERVER_ERROR } from 'http-status-codes'
4+
import createError, { HttpError, InternalServerError, NotFound } from 'http-errors'
5+
import { BAD_REQUEST, INTERNAL_SERVER_ERROR } from 'http-status-codes'
66
import statuses from 'statuses'
77
import { addAdditionalProperties, GenericObject, NodeError, serializeError } from './properties'
8+
import { convertValidationErrors, RequestSection } from './validation'
89

910
export { addAdditionalProperties, GenericObject } from './properties'
11+
export { convertValidationErrors, niceJoin, validationMessages, validationMessagesFormatters } from './validation'
1012

1113
export interface FastifyDecoratedRequest extends FastifyRequest {
1214
errorProperties?: {
1315
hideUnhandledErrors?: boolean
16+
convertValidationErrors?: boolean
1417
}
1518
}
1619

1720
export function handleNotFoundError(request: FastifyRequest, reply: FastifyReply<unknown>): void {
1821
handleErrors(new NotFound('Not found.'), request, reply)
1922
}
2023

21-
export function handleErrors(error: Error, request: FastifyDecoratedRequest, reply: FastifyReply<unknown>): void {
24+
export function handleValidationError(error: FastifyError, request: FastifyRequest): FastifyError {
25+
/*
26+
As seen in
27+
https://github.com/fastify/fastify/blob/master/lib/validation.js#L96
28+
and
29+
https://github.com/fastify/fastify/blob/master/lib/validation.js#L156,
30+
31+
the error.message will always start with the relative section (params, querystring, headers, body)
32+
and fastify throws on first failing section.
33+
*/
34+
const section = error.message.match(/^\w+/)![0] as RequestSection
35+
36+
return createError(BAD_REQUEST, 'One or more validations failed trying to process your request.', {
37+
failedValidations: convertValidationErrors(section, Reflect.get(request, section), error.validation!)
38+
})
39+
}
40+
41+
export function handleErrors(
42+
error: FastifyError,
43+
request: FastifyDecoratedRequest,
44+
reply: FastifyReply<unknown>
45+
): void {
2246
// It is a generic error, handle it
2347
if (!('statusCode' in (error as HttpError))) {
24-
// It is requested to hide the error, just log it and then create a generic one
25-
if (request.errorProperties?.hideUnhandledErrors) {
48+
// If it is a validation error, convert errors to human friendly format
49+
if ('validation' in error && request.errorProperties?.convertValidationErrors) {
50+
error = handleValidationError(error, request)
51+
} else if (request.errorProperties?.hideUnhandledErrors) {
52+
// It is requested to hide the error, just log it and then create a generic one
2653
request.log.error({ error: serializeError(error) })
2754
error = new InternalServerError('An error occurred trying to process your request.')
2855
} else {
@@ -65,8 +92,9 @@ export default fastifyPlugin(
6592
done: () => void
6693
): void {
6794
const hideUnhandledErrors = options.hideUnhandledErrors ?? process.env.NODE_ENV === 'production'
95+
const convertValidationErrors = options.convertValidationErrors ?? true
6896

69-
instance.decorateRequest('errorProperties', { hideUnhandledErrors })
97+
instance.decorateRequest('errorProperties', { hideUnhandledErrors, convertValidationErrors })
7098
instance.setErrorHandler(handleErrors)
7199
instance.setNotFoundHandler(handleNotFoundError)
72100

0 commit comments

Comments
 (0)