UNPKG

@whook/whook

Version:

Build strong and efficient REST web services.

388 lines (383 loc) 17.8 kB
import bytes from 'bytes'; import Stream from 'node:stream'; import { initializer, location } from 'knifecycle'; import { YHTTPError } from 'yhttperror'; import { printStackTrace, YError } from 'yerror'; import { Siso } from 'siso'; import { pickFirstHeaderValue } from '../libs/headers.js'; import { prepareBodyValidator, extractOperationSecurityParameters, resolveParameters, createParametersValidators, } from '../libs/validation.js'; import { DEFAULT_COERCION_OPTIONS, } from '../libs/coercion.js'; import { extractBodySpec, extractResponseSpec, checkResponseCharset, checkResponseMediaType, executeHandler, extractProduceableMediaTypes, extractConsumableMediaTypes, } from '../libs/router.js'; import { getBody, sendBody } from '../libs/body.js'; import { DEFAULT_BUFFER_LIMIT, DEFAULT_PARSERS, DEFAULT_STRINGIFIERS, DEFAULT_DECODERS, DEFAULT_ENCODERS, } from '../libs/constants.js'; import { pathItemToOperationMap, ensureResolvedObject, } from 'ya-open-api-types'; export const SEARCH_SEPARATOR = '?'; export const PATH_SEPARATOR = '/'; function noop() { return undefined; } function identity(x) { return x; } /* Architecture Note #2.11: HTTP Router The Whook's `httpRouter` service is responsible for wiring routes definitions to their actual implementation while filtering inputs and ensuring good outputs. This is the default implementation of the Framework but it can be replaced or customized by setting your own configurations to replace the default ones (see the [API section](#API)). The `httpRouter` service is responsible for handling the request, validating it and wiring the routes response to the actual HTTP response. It is very opinionated and clearly diverges from the current standards based on a middlewares/plugins approach. Here, the single source of truth is your API definition. No documentation, no route. */ export default location(initializer({ name: 'httpRouter', type: 'provider', inject: [ 'ENV', '?DEBUG_NODE_ENVS', '?BUFFER_LIMIT', '?BASE_PATH', 'ROUTES_HANDLERS', 'API', 'DEFINITIONS', '?PARSERS', '?STRINGIFIERS', '?DECODERS', '?ENCODERS', '?COERCION_OPTIONS', '?log', 'schemaValidators', 'httpTransaction', 'errorHandler', 'queryParserBuilder', ], }, initHTTPRouter), import.meta.url); /** * Initialize an HTTP router * @param {Object} services * The services the server depends on * @param {String} [services.BUFFER_LIMIT] * The maximum bufferisation before parsing the * request body * @param {String} [services.BASE_PATH] * API base path * @param {Object} services.ROUTES_HANDLERS * The handlers for the operations decribe * by the OpenAPI API definition * @param {Object} services.API * The OpenAPI definition of the API * @param {Object} [services.PARSERS] * The synchronous body parsers (for operations * that defines a request body schema) * @param {Object} [services.STRINGIFIERS] * The synchronous body stringifiers (for * operations that defines a response body * schema) * @param {Object} [services.ENCODERS] * A map of encoder stream constructors * @param {Object} [services.DECODERS] * A map of decoder stream constructors * @param {Object} [services.queryParserBuilder] * A query parser builder from OpenAPI parameters * @param {Object} [services.COERCION_OPTIONS] * Options for type coercion of parameters values * @param {Function} [services.log=noop] * A logging function * @param {Function} services.httpTransaction * A function to create a new HTTP transaction * @return {Promise} * A promise of a function to handle HTTP requests. */ async function initHTTPRouter({ BUFFER_LIMIT = DEFAULT_BUFFER_LIMIT, BASE_PATH = '', ROUTES_HANDLERS, API, DEFINITIONS, PARSERS = DEFAULT_PARSERS, STRINGIFIERS = DEFAULT_STRINGIFIERS, DECODERS = DEFAULT_DECODERS, ENCODERS = DEFAULT_ENCODERS, COERCION_OPTIONS = DEFAULT_COERCION_OPTIONS, queryParserBuilder, schemaValidators, log = noop, httpTransaction, errorHandler, }) { const bufferLimit = bytes.parse(BUFFER_LIMIT) || bytes.parse(DEFAULT_BUFFER_LIMIT); const consumableCharsets = Object.keys(DECODERS); const produceableCharsets = Object.keys(ENCODERS); const defaultResponseSpec = { contentTypes: Object.keys(STRINGIFIERS), charsets: produceableCharsets, }; const routers = await _createRouters({ API, DEFINITIONS, COERCION_OPTIONS, ROUTES_HANDLERS, BASE_PATH, queryParserBuilder, schemaValidators, log, }); let handleFatalError; const fatalErrorPromise = new Promise((_resolve, reject) => { handleFatalError = reject; }); log('debug', '🚦 - HTTP Router initialized.'); return { service: httpRouter, fatalErrorPromise, }; /** * Handle an HTTP incoming message * @param {HTTPRequest} req * A raw NodeJS HTTP incoming message * @param {HTTPResponse} res * A raw NodeJS HTTP response * @return {Promise} * A promise resolving when the operation * completes */ async function httpRouter(req, res) { try { let operation; let responseSpec = defaultResponseSpec; const { request, transaction } = await httpTransaction(req, res); await transaction .start(async () => { const method = request.method; const path = request.url.split(SEARCH_SEPARATOR)[0]; const parts = path.split(PATH_SEPARATOR).filter(identity); let [result, pathNodesValues] = routers[method] ? routers[method].find(parts) : []; // Second chance for HEAD calls if (!result && 'head' === method) { [result, pathNodesValues] = routers.get ? routers.get.find(parts) : []; } if (!result || !result.handler) { log('debug', '❌ - No handler found for: ', method, parts); throw new YHTTPError(404, 'E_NOT_FOUND', method, parts, PATH_SEPARATOR + parts.join(PATH_SEPARATOR)); } const { handler, operation: _operation_, config, queryParser, parametersValidators, consumableMediaTypes, produceableMediaTypes, bodyValidator, } = result; operation = _operation_; const search = request.url.substr(request.url.split(SEARCH_SEPARATOR)[0].length); const parametersValues = { query: {}, headers: {}, path: {}, cookies: {}, body: undefined, }; try { const queryValues = queryParser(search); for (const location of Object.keys(parametersValidators)) { if (location === 'query') { for (const [name, validator] of Object.entries(parametersValidators.query)) { parametersValues.query[name] = validator(typeof queryValues[name] !== 'undefined' ? queryValues[name]?.toString() : undefined); } } else if (location === 'header') { for (const [name, validator] of Object.entries(parametersValidators.header)) { // header names are case insensitive const canonicalName = name.toLowerCase(); parametersValues.headers[name] = validator(typeof request.headers[canonicalName] !== 'undefined' ? request.headers[canonicalName].toString() : undefined); } } else if (location === 'path') { for (const [name, validator] of Object.entries(parametersValidators.path)) { parametersValues.path[name] = validator(pathNodesValues && typeof pathNodesValues[name] !== 'undefined' ? pathNodesValues[name].toString() : undefined); } } } const bodySpec = extractBodySpec(request, consumableMediaTypes || [], consumableCharsets); responseSpec = extractResponseSpec(operation, request, produceableMediaTypes || [], produceableCharsets); const body = await getBody({ API, DECODERS, PARSERS, bufferLimit, }, operation, request.body, bodySpec); bodyValidator(operation, bodySpec.contentType, body); if (typeof body !== 'undefined') { parametersValues.body = body; } } catch (err) { throw YHTTPError.cast(err, 400); } const response = await executeHandler({ path, method, config, operation: operation, }, handler, parametersValues); response.headers = response.headers || {}; if (response.body) { response.headers['content-type'] = response.headers['content-type'] || responseSpec.contentTypes[0]; } const responseContentType = pickFirstHeaderValue('content-type', response.headers || {}) || 'text/plain'; const responseObject = operation.responses && operation.responses[response.status] ? await ensureResolvedObject(API, operation.responses[response.status]) : undefined; const responseSchema = responseObject && responseObject.content && responseObject.content?.[responseContentType] && 'schema' in responseObject.content[responseContentType] && (await ensureResolvedObject(API, responseObject.content?.[responseContentType].schema)); // Check the stringifyer only when a schema is // specified and it is not a binary one const responseHasSchema = typeof responseSchema === 'boolean' || (typeof responseSchema === 'object' && responseSchema && !('type' in responseSchema && responseSchema.type === 'string' && responseSchema.format === 'binary')); if (responseHasSchema && !STRINGIFIERS[responseContentType]) { throw new YHTTPError(500, 'E_STRINGIFYER_LACK', response.headers['content-type'], response); } if (response.body) { checkResponseMediaType(request, responseSpec, produceableMediaTypes || []); checkResponseCharset(request, responseSpec, produceableCharsets); } return response; }) .catch(transaction.catch) .catch(errorHandler.bind(null, transaction.id, responseSpec)) .then(async (response) => { // We can safely assume response is not void since // error handler is supposed to rethrow any catched // error let castedResponse = response; if (castedResponse.body && 'head' === request.method) { log('debug', '💇 - Body stripped:', castedResponse.body instanceof Stream ? 'Stream' : castedResponse.body); castedResponse = { ...castedResponse, body: undefined, }; } await transaction.end(await sendBody({ ENCODERS, STRINGIFIERS, }, castedResponse), operation ? operation.operationId : 'none'); }); } catch (err) { log('error', '☢️ - Unrecovable router error...'); log('error-stack', printStackTrace(err)); handleFatalError(err); } } } async function buildPathNodes({ API, schemaValidators, }, path, parameters) { const pathNodes = []; for (const node of path.split(PATH_SEPARATOR).filter(identity)) { const matches = /^{([a-z0-9]+)}$/i.exec(node); if (!matches) { pathNodes.push(node); continue; } const parameter = (parameters || []).find((aParameter) => aParameter.in === 'path' && aParameter.name === matches[1]); if (!parameter) { throw new YError('E_UNDECLARED_PATH_PARAMETER', path, node); } if (!('schema' in parameter) || typeof parameter.schema !== 'object') { throw new YError('E_UNSUPPORTED_PATH_PARAMETER', path, node); } const schema = (await ensureResolvedObject(API, parameter.schema)); if (!('type' in schema) || typeof schema.type !== 'string' || !['number', 'string', 'boolean'].includes(schema.type)) { throw new YError('E_UNSUPPORTED_PATH_PARAMETER', path, node); } pathNodes.push({ type: schema.type, name: parameter.name, validate: schemaValidators(parameter.schema), }); } return pathNodes; } async function _createRouters({ API, DEFINITIONS, ROUTES_HANDLERS, BASE_PATH = '', COERCION_OPTIONS, queryParserBuilder, schemaValidators, log, }) { const routers = {}; for (const [path, pathItem] of Object.entries(API.paths || {})) { const pathItemParameters = await resolveParameters({ API, log }, pathItem.parameters || []); const pathItemValidators = await createParametersValidators({ API, COERCION_OPTIONS, schemaValidators, }, pathItemParameters); for (const [method, operation] of Object.entries(pathItemToOperationMap(pathItem))) { const operationId = operation.operationId; if (!path.startsWith(PATH_SEPARATOR)) { throw new YError('E_BAD_PATH', path); } if (!operationId) { throw new YError('E_NO_OPERATION_ID', path, method); } const targetHandler = DEFINITIONS.configs?.[operationId]?.type === 'route' && DEFINITIONS.configs[operationId].config?.targetHandler ? DEFINITIONS.configs[operationId].config.targetHandler : operationId; const handler = ROUTES_HANDLERS[targetHandler]; if (!handler) { throw new YError('E_NO_HANDLER', targetHandler); } const operationParameters = await resolveParameters({ API, log }, operation.parameters || []); const ammendedParameters = await resolveParameters({ API, log }, await extractOperationSecurityParameters({ API }, operation)); const operationValidators = await createParametersValidators({ API, COERCION_OPTIONS, schemaValidators, }, operationParameters.concat(ammendedParameters)); const bodyValidator = await prepareBodyValidator({ API, schemaValidators }, operation); const consumableMediaTypes = await extractConsumableMediaTypes(API, operation); const produceableMediaTypes = await extractProduceableMediaTypes(API, operation); const parameters = pathItemParameters .concat(ammendedParameters) .filter((parameter) => operationParameters.every((aParameter) => aParameter.in !== parameter.in || aParameter.name !== parameter.name)) .concat(operationParameters); const queryParser = await queryParserBuilder(parameters); routers[method] = routers[method] || new Siso(); routers[method].register(await buildPathNodes({ API, schemaValidators }, BASE_PATH + path, pathItemParameters.concat(operationParameters)), { handler, operation, config: DEFINITIONS.configs?.[operationId]?.config || {}, queryParser, parametersValidators: { path: { ...pathItemValidators.path, ...operationValidators.path, }, query: { ...pathItemValidators.query, ...operationValidators.query, }, header: { ...pathItemValidators.header, ...operationValidators.header, }, cookie: { ...pathItemValidators.cookie, ...operationValidators.cookie, }, }, consumableMediaTypes, produceableMediaTypes, bodyValidator, }); } } return routers; } //# sourceMappingURL=httpRouter.js.map