UNPKG

fastify-http-errors-enhanced

Version:

A error handling plugin for Fastify that uses enhanced HTTP errors.

255 lines (254 loc) 10.5 kB
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); } } }