fastify-http-errors-enhanced
Version:
A error handling plugin for Fastify that uses enhanced HTTP errors.
255 lines (254 loc) • 10.5 kB
JavaScript
import { Ajv } from 'ajv';
import addFormats from 'ajv-formats';
import { INTERNAL_SERVER_ERROR, InternalServerError } from 'http-errors-enhanced';
import { kHttpErrorsEnhancedConfiguration, kHttpErrorsEnhancedResponseValidations } from './interfaces.js';
import { get } from './utils.js';
/* c8 ignore next 15 */ /*
The fastify defaults, with the following modifications:
* coerceTypes is set to false
* removeAdditional is set to false
* allErrors is set to true
* uriResolver has been removed
*/ export const defaultAjvOptions = {
coerceTypes: false,
useDefaults: true,
removeAdditional: false,
addUsedSchema: false,
allErrors: true
};
function buildAjv(options, plugins) {
// Create the instance
const compiler = new Ajv({
...defaultAjvOptions,
...options
});
// Add plugins
let formatPluginAdded = false;
for (const pluginSpec of plugins ?? []){
const [plugin, pluginOpts] = Array.isArray(pluginSpec) ? pluginSpec : [
pluginSpec,
undefined
];
if (plugin.name === 'formatsPlugin') {
formatPluginAdded = true;
}
plugin(compiler, pluginOpts);
}
if (!formatPluginAdded) {
// @ts-expect-error Wrong typing
addFormats(compiler);
}
return compiler;
}
export function niceJoin(array, lastSeparator = ' and ', separator = ', ') {
switch(array.length){
case 0:
return '';
case 1:
return array[0];
case 2:
return array.join(lastSeparator);
default:
return array.slice(0, -1).join(separator) + lastSeparator + array.at(-1);
}
}
export const validationMessagesFormatters = {
contentType: ()=>'only JSON payloads are accepted. Please set the "Content-Type" header to start with "application/json"',
json: ()=>'the body payload is not a valid JSON',
jsonEmpty: ()=>'the JSON body payload cannot be empty if the "Content-Type" header is set',
missing: ()=>'must be present',
unknown: ()=>'is not a valid property',
uuid: ()=>'must be a valid GUID (UUID v4)',
timestamp: ()=>'must be a valid ISO 8601 / RFC 3339 timestamp (example: 2018-07-06T12:34:56Z)',
date: ()=>'must be a valid ISO 8601 / RFC 3339 date (example: 2018-07-06)',
time: ()=>'must be a valid ISO 8601 / RFC 3339 time (example: 12:34:56)',
uri: ()=>'must be a valid URI',
hostname: ()=>'must be a valid hostname',
ipv4: ()=>'must be a valid IPv4',
ipv6: ()=>'must be a valid IPv6',
paramType: (type)=>{
switch(type){
case 'integer':
return 'must be a valid integer number';
case 'number':
return 'must be a valid number';
case 'boolean':
return 'must be a valid boolean (true or false)';
case 'object':
return 'must be a object';
case 'array':
return 'must be an array';
default:
return 'must be a string';
}
},
presentString: ()=>'must be a non empty string',
minimum: (min)=>`must be a number greater than or equal to ${min}`,
maximum: (max)=>`must be a number less than or equal to ${max}`,
minimumProperties (min) {
return min === 1 ? 'cannot be a empty object' : `must be a object with at least ${min} properties`;
},
maximumProperties (max) {
return max === 0 ? 'must be a empty object' : `must be a object with at most ${max} properties`;
},
minimumItems (min) {
return min === 1 ? 'cannot be a empty array' : `must be an array with at least ${min} items`;
},
maximumItems (max) {
return max === 0 ? 'must be a empty array' : `must be an array with at most ${max} items`;
},
enum: (values)=>`must be one of the following values: ${niceJoin(values.map((f)=>`"${f}"`), ' or ')}`,
pattern: (pattern)=>`must match pattern "${pattern.replaceAll('(?:', '(')}"`,
invalidResponseCode: (code)=>`This endpoint cannot respond with HTTP status ${code}.`,
invalidResponse: (code)=>`The response returned from the endpoint violates its specification for the HTTP status ${code}.`,
invalidFormat: (format)=>`must match format "${format}" (format)`
};
export function convertValidationErrors(section, data, validationErrors) {
/* c8 ignore next 2 */ const errors = {};
if (section === 'querystring') {
section = 'query';
}
// For each error
for (const e of validationErrors){
let message = '';
let pattern;
let value;
let reason;
// Normalize the key
let key = e.dataPath ?? e.instancePath /* c8 ignore next */ ?? '';
if (/^[./]/.test(key)) {
key = key.slice(1);
}
// Remove useless quotes
/* c8 ignore next 3 */ if (key.startsWith('[') && key.endsWith(']')) {
key = key.slice(1, -1);
}
// Depending on the type
switch(e.keyword){
case 'required':
case 'dependencies':
key = e.params.missingProperty;
message = validationMessagesFormatters.missing();
break;
case 'additionalProperties':
key = e.params.additionalProperty;
message = validationMessagesFormatters.unknown();
break;
case 'type':
message = validationMessagesFormatters.paramType(e.params.type);
break;
case 'minProperties':
message = validationMessagesFormatters.minimumProperties(e.params.limit);
break;
case 'maxProperties':
message = validationMessagesFormatters.maximumProperties(e.params.limit);
break;
case 'minItems':
message = validationMessagesFormatters.minimumItems(e.params.limit);
break;
case 'maxItems':
message = validationMessagesFormatters.maximumItems(e.params.limit);
break;
case 'minimum':
message = validationMessagesFormatters.minimum(e.params.limit);
break;
case 'maximum':
message = validationMessagesFormatters.maximum(e.params.limit);
break;
case 'enum':
message = validationMessagesFormatters.enum(e.params.allowedValues);
break;
case 'pattern':
pattern = e.params.pattern;
value = get(data, key);
message = pattern === '.+' && !value ? validationMessagesFormatters.presentString() : validationMessagesFormatters.pattern(e.params.pattern);
break;
case 'format':
reason = e.params.format;
// Normalize the key
if (reason === 'date-time') {
reason = 'timestamp';
}
message = (validationMessagesFormatters[reason] || validationMessagesFormatters.invalidFormat)(reason);
break;
}
// No custom message was found, default to input one replacing the starting verb and adding some path info
/* c8 ignore next 3 */ if (!message.length) {
message = `${e.message?.replace(/^should/, 'must')} (${e.keyword})`;
}
// Remove useless quotes
/* c8 ignore next 3 */ if (/^["'][^.]+["']$/.test(key)) {
key = key.slice(1, -1);
}
// Fix empty properties
if (!key) {
key = '$root';
}
key = key.replace(/^\//, '');
errors[key] = message;
}
return {
[section]: errors
};
}
export function addResponseValidation(route) {
if (!route.schema?.response) {
return;
}
const validators = {};
/*
Add these validators to the list of the one to compile once the server is started.
This makes possible to handle shared schemas.
*/ this[kHttpErrorsEnhancedResponseValidations].push([
this,
validators,
Object.entries(route.schema.response)
]);
// Note that this hook is not called for non JSON payloads therefore validation is not possible in such cases
route.preSerialization = function(request, reply, payload, done) {
const statusCode = reply.raw.statusCode;
// Never validate error 500
if (statusCode === INTERNAL_SERVER_ERROR) {
done(null, payload);
return;
}
// No validator, it means the HTTP status is not allowed
const validator = validators[statusCode];
if (!validator) {
if (request[kHttpErrorsEnhancedConfiguration].allowUndeclaredResponses) {
done(null, payload);
return;
}
done(new InternalServerError(validationMessagesFormatters.invalidResponseCode(statusCode)));
return;
}
// Now validate the payload
const valid = validator(payload);
if (!valid) {
done(new InternalServerError(validationMessagesFormatters.invalidResponse(statusCode), {
failedValidations: convertValidationErrors('response', payload, validator.errors)
}));
return;
}
done(null, payload);
};
}
export function compileResponseValidationSchema(configuration) {
/* c8 ignore next 3 */ const hasCustomizer = typeof configuration.responseValidatorCustomizer === 'function';
// This is hackish, but it is the only way to get the options from fastify at the moment.
const kOptions = Object.getOwnPropertySymbols(this).find((s)=>s.description === 'fastify.options');
for (const [instance, validators, schemas] of this[kHttpErrorsEnhancedResponseValidations]){
// Create the compiler using exactly the same options as fastify
const ajvOptions = instance[kOptions]?.ajv ?? {};
const compiler = buildAjv(ajvOptions.customOptions, ajvOptions.plugins);
// Add instance schemas
compiler.addSchema(Object.values(instance.getSchemas()));
// Customize if required to
if (hasCustomizer) {
configuration.responseValidatorCustomizer(compiler);
}
for (const [code, schema] of schemas){
validators[code] = compiler.compile(schema);
}
}
}