UNPKG

@whook/whook

Version:

Build strong and efficient REST web services.

268 lines (237 loc) 7.36 kB
import { YHTTPError } from 'yhttperror'; import { parse as parseContentType } from 'content-type'; // @ts-expect-error No types 🤷 import preferredCharsets from 'negotiator/lib/charset.js'; // @ts-expect-error No types 🤷 import preferredMediaType from 'negotiator/lib/encoding.js'; import { YError } from 'yerror'; import { pickFirstHeaderValue, pickAllHeaderValues } from './headers.js'; import { ensureResolvedObject } from 'ya-open-api-types'; import { type WhookOpenAPI, type WhookOpenAPIOperation, } from '../types/openapi.js'; import { type WhookRequest, type WhookResponse } from '../types/http.js'; import { type WhookRouteHandler, type WhookRouteDefinition, type WhookRouteHandlerParameters, } from '../types/routes.js'; import { type WhookRequestBodySpec, type WhookResponseBodySpec, } from './body.js'; export async function extractConsumableMediaTypes( API: WhookOpenAPI, operation: WhookOpenAPIOperation, ): Promise<string[]> { if (!('requestBody' in operation && operation.requestBody)) { return []; } const requestBody = await ensureResolvedObject(API, operation.requestBody); if (!('content' in requestBody && requestBody.content)) { return []; } // Per spec contents, the `content` property should always // be present so not checking before using it // https://swagger.io/specification/#requestBodyObject return Object.keys(requestBody.content); } export async function extractProduceableMediaTypes( API: WhookOpenAPI, operation: WhookOpenAPIOperation, ): Promise<string[]> { if (!('responses' in operation && operation.responses)) { return []; } const produceableMediaTypes: string[] = []; for (const response of Object.values(operation.responses)) { if (!(typeof response === 'object' && response)) { continue; } const resolvedResponse = await ensureResolvedObject(API, response); if (!('content' in resolvedResponse && resolvedResponse.content)) { continue; } produceableMediaTypes.push(...Object.keys(resolvedResponse.content || {})); } return [...new Set(produceableMediaTypes)]; } export function extractBodySpec( request: WhookRequest, consumableMediaTypes: string[], consumableCharsets: string[], ): WhookRequestBodySpec { const contentLengthValues = pickAllHeaderValues( 'content-length', request.headers, ); const bodySpec: WhookRequestBodySpec = { contentType: '', contentLength: contentLengthValues.length ? Number(contentLengthValues[0]) : 0, charset: 'utf-8', }; if (request.headers['content-type']) { const baseContentType = pickFirstHeaderValue( 'content-type', request.headers, ); if (typeof baseContentType === 'string') { const parsedContentType = parseContentType(baseContentType); // See https://stackoverflow.com/questions/25201083/regex-to-match-and-validate-internet-media-type if ( !parsedContentType.type.match( /(application|audio|font|example|image|message|model|multipart|text|video|x-(?:[0-9A-Za-z!#$%&'*+.^_`|~-]+))\/([0-9A-Za-z!#$%&'*+.^_`|~-]+)/, ) ) { throw new YHTTPError(400, 'E_BAD_CONTENT_TYPE', [ request.headers['content-type'], ]); } bodySpec.contentType = parsedContentType.type; if ( parsedContentType.parameters && parsedContentType.parameters.charset ) { if ( ['utf-8', 'utf8'].includes( parsedContentType.parameters.charset.toLowerCase(), ) ) { bodySpec.charset = 'utf-8'; } else { throw new YError('E_UNSUPPORTED_CHARSET', [ parsedContentType.parameters.charset, ]); } } if ( parsedContentType.parameters && parsedContentType.parameters.boundary ) { bodySpec.boundary = parsedContentType.parameters.boundary; } } else { throw new YError('E_EMPTY_CONTENT_TYPE'); } } checkBodyCharset(bodySpec, consumableCharsets); checkBodyMediaType(bodySpec, consumableMediaTypes); return bodySpec; } export function checkBodyCharset( bodySpec: WhookRequestBodySpec, consumableCharsets: string[], ): void { if ( bodySpec.contentLength && bodySpec.charset && !consumableCharsets.includes(bodySpec.charset) ) { throw new YHTTPError(406, 'E_UNSUPPORTED_CHARSET', [ bodySpec.charset, consumableCharsets, ]); } } export function checkBodyMediaType( bodySpec: WhookRequestBodySpec, consumableMediaTypes: string[], ): void { if ( bodySpec.contentLength && bodySpec.contentType && !consumableMediaTypes.includes(bodySpec.contentType) ) { throw new YHTTPError(415, 'E_UNSUPPORTED_MEDIA_TYPE', [ bodySpec.contentType, consumableMediaTypes, ]); } } export function extractResponseSpec( operation: WhookOpenAPIOperation, request: WhookRequest, supportedMediaTypes: string[], supportedCharsets: string[], ): WhookResponseBodySpec { const accept = pickFirstHeaderValue('accept', request.headers) || '*'; const responseSpec: WhookResponseBodySpec = { charsets: request.headers['accept-charset'] ? preferredCharsets( pickFirstHeaderValue('accept-charset', request.headers), supportedCharsets, ) : supportedCharsets, contentTypes: preferredMediaType( accept.replace(/(^|,)\*\/\*($|,|;)/g, '$1*$2'), supportedMediaTypes, ), }; return responseSpec; } export function checkResponseMediaType( request: WhookRequest, responseSpec: WhookResponseBodySpec, produceableMediaTypes: string[], ): void { if (0 === responseSpec.contentTypes.length) { throw new YHTTPError(406, 'E_UNACCEPTABLE_MEDIA_TYPE', [ request.headers.accept, produceableMediaTypes, ]); } } export function checkResponseCharset( request: WhookRequest, responseSpec: WhookResponseBodySpec, produceableCharsets: string[], ): void { if (0 === responseSpec.charsets.length) { throw new YHTTPError(406, 'E_UNACCEPTABLE_CHARSET', [ request.headers['accept-charset'], responseSpec.charsets, produceableCharsets, ]); } } export async function executeHandler( definition: WhookRouteDefinition, handler: WhookRouteHandler, parameters: WhookRouteHandlerParameters, ): Promise<WhookResponse> { const responsePromise = handler(parameters, definition); if (!(responsePromise && responsePromise.then)) { throw new YHTTPError(500, 'E_NO_RESPONSE_PROMISE', [ definition.operation.operationId, definition.method, definition.path, ]); } const response = await responsePromise; if (!response) { throw new YHTTPError(500, 'E_NO_RESPONSE', [ definition.operation.operationId, definition.method, definition.path, ]); } if ('undefined' === typeof response.status) { throw new YHTTPError(500, 'E_NO_RESPONSE_STATUS', [ definition.operation.operationId, definition.method, definition.path, ]); } if ('number' !== typeof response.status) { throw new YHTTPError(500, 'E_NON_NUMERIC_STATUS', [ definition.operation.operationId, definition.method, definition.path, ]); } response.headers = response.headers || {}; return response; }