@whook/whook
Version:
Build strong and efficient REST web services.
268 lines (237 loc) • 7.36 kB
text/typescript
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;
}