@whook/whook
Version:
Build strong and efficient REST web services.
388 lines (383 loc) • 17.8 kB
JavaScript
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