UNPKG

@whook/whook

Version:

Build strong and efficient REST web services.

505 lines (487 loc) 15.5 kB
import { initializer, location } from 'knifecycle'; import { DEFAULT_DEBUG_NODE_ENVS, DEFAULT_STRINGIFIERS, type WhookStringifiers, } from '../libs/constants.js'; // @ts-expect-error no types for this module import miniquery from 'miniquery'; import { printStackTrace, YError } from 'yerror'; import { type YHTTPError } from 'yhttperror'; import { type AppEnvVars } from 'application-services'; import { type WhookResponseBodySpec } from '../libs/body.js'; import { type JSONSchema } from 'ya-json-schema-types'; import { JsonValue } from 'type-fest'; /* Architecture Note #2.13: Error handler Whook provides a default error handler that allows you to use the [`yerror`](https://github.com/nfroidure/yerror) and [`yhttperror`](https://github.com/nfroidure/yhttperror) modules to throw errors with some parameters allowing you to give some debugging context with raised errors. Depending on the `NODE_ENV` it adds extra information to HTTP response, beware to avoid activating it in production. */ /* Architecture Note #2.13.1: Errors descriptors Errors descriptors allows you to change the error handler behavior selectively for each error : - change error codes to proper error messages templated with params, - add URIs to help for debugging API errors - override the error status with the `status` property. - decide to pass some error parameters through with the `transmittedParams` so that your end users can have some more context on the error root cause. */ export interface WhookErrorDescriptor { code: string; description?: string; uri?: string; help?: string; status?: number; transmittedParams?: number[]; } export type WhookErrorsDescriptors = Record<string, WhookErrorDescriptor>; export const DEFAULT_ERROR_URI = 'https://stackoverflow.com/search?q=%5Bwhook%5D+$code'; export const DEFAULT_HELP_URI = 'https://stackoverflow.com/questions/ask?tags=whook&title=How+to+debug+$code+whook+error+code'; export const DEFAULT_DEFAULT_ERROR_CODE = 'E_UNEXPECTED'; export const DEFAULT_ERRORS_DESCRIPTORS = { [DEFAULT_DEFAULT_ERROR_CODE]: { code: 'error', description: 'Got an unexpected error', status: 500, help: DEFAULT_HELP_URI, }, E_TRANSACTION_TIMEOUT: { code: 'error', description: 'The request processing timed out (timeout: "$0")', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_STRINGIFYER_LACK: { code: 'router_misconfiguration', description: 'The router has no stringifyer declared for the response content type "$0" with status "$1.status"', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_UNDECLARED_PATH_PARAMETER: { code: 'router_misconfiguration', description: 'The no path parameter declared for the node "$0" on the path "$1"', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_BAD_PATH: { code: 'router_misconfiguration', description: 'The path "$0" must start with a slash', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_NO_OPERATION_ID: { code: 'router_misconfiguration', description: 'No operation id for the path "$0" and the "$1"', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_NO_HANDLER: { code: 'router_misconfiguration', description: 'No handler declared for the operation id "$0"', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_BAD_OPEN_API: { code: 'router_misconfiguration', description: 'The Open API provided to the router seems invalid', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_UNDECLARED_SECURITY_SCHEME: { code: 'router_misconfiguration', description: 'An operation ("$1") mentions an undeclared security scheme ("$0")', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_BAD_BODY_SCHEMA: { code: 'router_misconfiguration', description: 'An operation ("$0") has an invalid body schema ("$1")', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_UNSUPPORTED_HTTP_SCHEME: { code: 'router_misconfiguration', description: 'This router do not support this security schema yet ("$0")', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_UNSUPPORTED_API_KEY_SOURCE: { code: 'router_misconfiguration', description: 'This router do not support this API key source yet ("$0")', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_BAD_PARAMETER_NAME: { code: 'router_misconfiguration', description: 'Bad name for the parameter at index "$1" for the operation "$0"', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_UNSUPPORTED_PARAMETER_DEFINITION: { code: 'router_misconfiguration', description: 'This router does not support the parameter "$1" defined in "$0"', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_BAD_PARAMETER_SCHEMA: { code: 'router_misconfiguration', description: 'The router where unable to compile the parameter "$1" defined in "$0"', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_NO_RESPONSE_PROMISE: { code: 'bad_handler', description: 'The handler for the operation id "$0" seems to not return a promise', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_NO_RESPONSE: { code: 'bad_handler', description: 'The handler for the operation id "$0" seems to not return any response', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_NO_RESPONSE_STATUS: { code: 'bad_handler', description: 'The handler for the operation id "$0" seems to not return any status', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_NON_NUMERIC_STATUS: { code: 'bad_handler', description: 'The handler for the operation id "$0" seems to not be a number', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_NOT_FOUND: { code: 'bad_handler', description: 'No endpoint found at that path ("$0", "$2")', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_REQUEST_CONTENT_TOO_LARGE: { code: 'bad_request', description: 'The request content length ("$0") exceeded the maximum allowed ("$1")', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_BAD_BODY: { code: 'bad_request', description: 'The request body could not be parsed', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, transmittedParams: [0], }, E_BAD_BODY_LENGTH: { code: 'bad_request', description: 'The request body length ("$0") is different than the length defined in the headers ("$1")', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_UNSUPPORTED_CHARSET: { code: 'bad_request', description: 'The body charset "$0" is not supported (supported char sets "$1")', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_BAD_CONTENT_TYPE: { code: 'bad_request', description: 'The content type provided is weird "$0"', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_REQUEST_FAILURE: { code: 'bad_request', description: 'Failed to read the request body input stream', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_UNSUPPORTED_MEDIA_TYPE: { code: 'bad_request', description: 'The body media type "$0" is not supported (supported media types "$1")', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_UNACCEPTABLE_CHARSET: { code: 'bad_request', description: 'The asked response charset "$0" is not satisfiable (allowed char sets "$2")', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_UNACCEPTABLE_MEDIA_TYPE: { code: 'bad_request', description: 'The asked response media type "$0" is not satisfiable (allowed media types "$1")', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_REQUIRED_REQUEST_BODY: { code: 'bad_request', description: 'This endpoint requires a request body to be sent', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_BAD_REQUEST_BODY: { code: 'bad_request', description: 'The request body does not match the schema', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, transmittedParams: [2, 3], }, E_NO_REQUEST_BODY: { code: 'bad_request', description: 'This endpoint do not declare a request body but one were sent', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_REQUIRED_PARAMETER: { code: 'bad_request', description: 'This endpoint requires a value for the parameter "$0"', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_BAD_PARAMETER: { code: 'bad_request', description: 'The value provided for the parameter "$0" is not valid', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, transmittedParams: [2, 3], }, E_NON_REENTRANT_NUMBER: { code: 'bad_request', description: 'The value provided for the parameter "$0" is not reentrant', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, transmittedParams: [2, 3], }, E_BAD_BOOLEAN: { code: 'bad_request', description: 'The value provided ("$0") is not a boolean', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, E_TRANSACTION_ID_NOT_UNIQUE: { code: 'bad_request', description: 'The transaction id of the request "$0" is colliding with another one.', uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, } satisfies WhookErrorsDescriptors; export interface WhookErrorHandlerConfig { DEBUG_NODE_ENVS: string[]; ERRORS_DESCRIPTORS: WhookErrorsDescriptors; DEFAULT_ERROR_CODE?: string; } export type WhookErrorHandlerDependencies = WhookErrorHandlerConfig & { ENV: AppEnvVars; STRINGIFIERS?: WhookStringifiers; }; export type WhookErrorHandler = ( transactionId: string, responseSpec: WhookResponseBodySpec, err: Error, ) => Promise<WhookErrorResponse>; export interface WhookErrorResponse { status: number; headers: { 'cache-control': 'private'; 'content-type': string; [name: string]: string | string[]; }; body: { error: string; error_description?: string; error_uri?: string; error_help_uri?: string; error_params?: JsonValue[]; error_debug_data: { guruMeditation: string; code?: string; stack?: string; params?: JsonValue[]; }; }; } export const whookErrorSchema: JSONSchema = { type: 'object', additionalProperties: false, properties: { error: { type: 'string' }, error_description: { type: 'string' }, error_uri: { type: 'string' }, error_debug_data: { type: 'object', additionalProperties: true, properties: { code: { type: 'string' }, stack: { type: 'string' }, // TODO: find a way to specify params or // maybe standardize it (only one object in params?) }, }, }, }; export default location( initializer( { name: 'errorHandler', type: 'service', inject: [ 'ENV', '?DEBUG_NODE_ENVS', '?STRINGIFIERS', '?ERRORS_DESCRIPTORS', '?DEFAULT_ERROR_CODE', ], }, initErrorHandler, ), import.meta.url, ); /** * Initialize an error handler for the * HTTP router * @param {Object} services * The service dependencies * @param {Object} services.ENV * The app ENV * @param {Array} [services.DEBUG_NODE_ENVS] * The environnement that activate debugging * (prints stack trace in HTTP errors responses) * @param {Object} [services.STRINGIFIERS] * The synchronous body stringifiers * @param {Object} [services.ERRORS_DESCRIPTORS] * An hash of the various error descriptors * @param {Object} [services.DEFAULT_ERROR_CODE] * A string giving the default error code * @return {Promise} * A promise of a function to handle errors */ async function initErrorHandler({ ENV, DEBUG_NODE_ENVS = DEFAULT_DEBUG_NODE_ENVS, STRINGIFIERS = DEFAULT_STRINGIFIERS, ERRORS_DESCRIPTORS = DEFAULT_ERRORS_DESCRIPTORS, DEFAULT_ERROR_CODE = DEFAULT_DEFAULT_ERROR_CODE, }: WhookErrorHandlerDependencies): Promise<WhookErrorHandler> { // Ensure we have required error descriptors ERRORS_DESCRIPTORS = { ...DEFAULT_ERRORS_DESCRIPTORS, ...ERRORS_DESCRIPTORS, }; return errorHandler; /** * Handle an HTTP transaction error and * map it to a serializable response * @param {String} transactionId * A raw NodeJS HTTP incoming message * @param {Object} responseSpec * The response specification * @param {YHTTPError} err * The encountered error * @return {Promise} * A promise resolving when the operation * completes */ async function errorHandler( transactionId: string, responseSpec: WhookResponseBodySpec, err: Error | YError | YHTTPError, ) { const errorCode = (err as YError).code || DEFAULT_ERROR_CODE; const errorDescriptor = ERRORS_DESCRIPTORS[errorCode] || ERRORS_DESCRIPTORS[DEFAULT_DEFAULT_ERROR_CODE]; const response: WhookErrorResponse = { status: (errorDescriptor !== ERRORS_DESCRIPTORS[DEFAULT_DEFAULT_ERROR_CODE] && errorDescriptor.status ? errorDescriptor.status : undefined) || (err as YHTTPError).httpCode || ERRORS_DESCRIPTORS[DEFAULT_DEFAULT_ERROR_CODE].status || 500, headers: Object.assign({}, (err as YHTTPError).headers || {}, { // Avoid caching errors 'cache-control': 'private' as const, // Fallback to the default stringifyer to always be // able to display errors 'content-type': responseSpec && responseSpec.contentTypes[0] && STRINGIFIERS[responseSpec.contentTypes[0]] ? responseSpec.contentTypes[0] : Object.keys(STRINGIFIERS)[0], }), // Here we do not respect the camelCase convention to // have OAuth2 compatible errors 🤷 body: { error: errorDescriptor.code, error_description: replaceTemplatedValues( err as YError, errorDescriptor.description as string, ), error_uri: replaceTemplatedValues( err as YError, errorDescriptor.uri as string, ), error_help_uri: replaceTemplatedValues( err as YError, errorDescriptor.help as string, ), error_params: errorDescriptor.transmittedParams ? errorDescriptor.transmittedParams.map( (paramIndex) => (((err as YError).debug as JsonValue[]) || [])[paramIndex], ) : undefined, error_debug_data: { // Enjoy nerdy stuff: // https://en.wikipedia.org/wiki/Guru_Meditation guruMeditation: transactionId, }, }, }; if (DEBUG_NODE_ENVS.includes(ENV.NODE_ENV)) { response.body.error_debug_data.code = errorCode; response.body.error_debug_data.stack = printStackTrace(err); response.body.error_debug_data.params = (err as YError) .debug as JsonValue[]; } return response; } } function replaceTemplatedValues(err: YError, str: string): string | undefined { return str ? str .replace(/\$([0-9](:?\.[a-zA-Z0-9#@*]+)*)/g, (_, path) => miniquery(path, err.debug ? [err.debug] : []).join(', '), ) .replace(/\$code/, (err as YError).code) : undefined; }