@whook/whook
Version:
Build strong and efficient REST web services.
377 lines • 14.6 kB
JavaScript
import { noop } from 'common-services';
import { YError } from 'yerror';
import { YHTTPError } from 'yhttperror';
import Stream from 'node:stream';
import { ensureResolvedObject, isValidOpenAPIPath, pathItemToOperationMap, } from 'ya-open-api-types';
import { parseArrayOfBooleans, parseArrayOfNumbers, parseArrayOfStrings, parseBoolean, parseNumber, } from './coercion.js';
import { identity } from './utils.js';
export async function prepareBodyValidator({ API, schemaValidators, }, operation) {
if (!('requestBody' in operation) || !operation.requestBody) {
return rejectAnyRequestBody;
}
const requestBodyObject = await ensureResolvedObject(API, operation.requestBody);
if (!requestBodyObject.content) {
return rejectAnyRequestBody;
}
const bodyValidators = {};
for (const mediaType in requestBodyObject.content) {
const mediaTypeObject = requestBodyObject.content[mediaType];
if (!('schema' in mediaTypeObject) || !mediaTypeObject.schema) {
continue;
}
const schema = (await ensureResolvedObject(API, mediaTypeObject.schema));
const isBinaryContent = typeof schema === 'object' &&
schema &&
'type' in schema &&
schema.type === 'string' &&
schema.format === 'binary';
if (isBinaryContent) {
continue;
}
try {
bodyValidators[mediaType] = schemaValidators(mediaTypeObject.schema);
}
catch (err) {
throw YError.wrap(err, 'E_BAD_BODY_SCHEMA', [
operation.operationId,
mediaType,
]);
}
}
return validateRequestBody.bind(null, bodyValidators, !!requestBodyObject.required);
}
function validateRequestBody(bodyValidators, required, operation, contentType, body) {
if ('undefined' === typeof body) {
if (required) {
throw new YHTTPError(400, 'E_REQUIRED_REQUEST_BODY', [
operation.operationId,
typeof body,
body,
]);
}
return;
}
// Streamed contents, let it pass
if (!bodyValidators[contentType]) {
return;
}
if (!bodyValidators[contentType](body)) {
throw new YHTTPError(400, 'E_BAD_REQUEST_BODY', [
operation.operationId,
typeof body,
body instanceof Stream ? 'Stream' : body,
bodyValidators[contentType].errors,
]);
}
}
function rejectAnyRequestBody(operation, _contentType, body) {
if ('undefined' !== typeof body) {
throw new YHTTPError(400, 'E_NO_REQUEST_BODY', [
operation.operationId,
typeof body,
body instanceof Stream ? 'Stream' : body,
]);
}
}
// Supporting only mainstream schemes
// https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml
const SUPPORTED_HTTP_SCHEMES = ['basic', 'bearer', 'digest'];
export function extractSecuritySchemesNames(requirements) {
return [
...new Set(requirements.map((requirement) => Object.keys(requirement)).flat()),
].sort();
}
export async function extractAPISchemesKeysCombinations({ API, }) {
const schemesKeysCombinations = [];
if (API.paths) {
for (const path in API.paths) {
if (!isValidOpenAPIPath(path)) {
throw new YError('E_BAD_PATH', [path]);
}
const pathItem = API.paths[path];
for (const [method, operation] of Object.entries(pathItemToOperationMap(pathItem))) {
const operationId = operation.operationId;
if (!operationId) {
throw new YError('E_NO_OPERATION_ID', [path, method]);
}
const schemesKeysCombination = await extractOperationSchemesKeysCombination({ API }, operation);
if (schemesKeysCombinations.every((aSchemesKeysCombination) => aSchemesKeysCombination.join('-') !==
schemesKeysCombination.join('-'))) {
schemesKeysCombinations.push(schemesKeysCombination);
}
}
}
}
return schemesKeysCombinations;
}
export async function extractOperationSchemesKeysCombination({ API, }, operation) {
return [
...new Set([
...(API.security ? extractSecuritySchemesNames(API.security) : []),
...(operation.security
? extractSecuritySchemesNames(operation.security)
: []),
]),
].sort();
}
export async function extractParametersFromSchemesKeysCombination({ API }, schemesKeysCombination) {
if (!schemesKeysCombination.length) {
return [];
}
const securitySchemes = (API.components && API.components.securitySchemes) || {};
const combinationSecuritySchemes = [];
for (const schemeKey of schemesKeysCombination) {
if (!securitySchemes[schemeKey]) {
throw new YError('E_UNDECLARED_SECURITY_SCHEME', [schemeKey]);
}
combinationSecuritySchemes.push(await ensureResolvedObject(API, securitySchemes[schemeKey]));
}
return extractParametersFromSecuritySchemes(combinationSecuritySchemes);
}
export async function extractOperationSecurityParameters({ API }, operation) {
const schemesKeysCombination = await extractOperationSchemesKeysCombination({ API }, operation);
return extractParametersFromSchemesKeysCombination({ API }, schemesKeysCombination);
}
export async function extractAPISecurityParametersSchemas({ API, }) {
const schemesKeysCombinations = await extractAPISchemesKeysCombinations({
API,
});
return (await Promise.all(schemesKeysCombinations.map((schemesKeysCombination) => extractParametersFromSchemesKeysCombination({ API }, schemesKeysCombination))))
.flat()
.map((parameter) => parameter.schema);
}
export function extractParametersFromSecuritySchemes(securitySchemes) {
const hasOAuth = securitySchemes.some((securityScheme) => ['oauth2', 'openIdConnect'].includes(securityScheme.type));
const httpSchemes = [
...new Set([
...securitySchemes
.filter((securityScheme) => securityScheme.type === 'http')
.map((securityScheme) => {
if (!SUPPORTED_HTTP_SCHEMES.includes(securityScheme.scheme)) {
throw new YError('E_UNSUPPORTED_HTTP_SCHEME', [
securityScheme.scheme,
]);
}
return securityScheme.scheme;
}),
...(hasOAuth ? ['bearer'] : []),
]),
];
const hasBearerAuth = httpSchemes.includes('bearer');
let hasAuthorizationApiKey = false;
let hasAccessTokenApiKey = false;
const securityParameters = securitySchemes
.filter((securityScheme) => securityScheme.type === 'apiKey')
.map((securityScheme) => {
if (securityScheme.in === 'cookie') {
throw new YError('E_UNSUPPORTED_API_KEY_SOURCE', [
'cookie',
securityScheme.name,
]);
}
// This overlaps with OAuth/HTTP schemes
if (securityScheme.in === 'header' &&
securityScheme.name.toLowerCase() === 'authorization') {
hasAuthorizationApiKey = true;
}
// This overlaps with OAuth and BearerAuth schemes
if (securityScheme.in === 'query' &&
securityScheme.name === 'access_token') {
hasAccessTokenApiKey = true;
}
return {
in: securityScheme.in,
name: securityScheme.in === 'header'
? securityScheme.name.toLowerCase()
: securityScheme.name,
schema: {
type: 'string',
},
};
})
.concat(httpSchemes.length && !hasAuthorizationApiKey
? [
{
in: 'header',
name: 'authorization',
schema: {
type: 'string',
pattern: `(${httpSchemes
.map((httpScheme) => `(${httpScheme[0]}|${httpScheme[0].toUpperCase()})${httpScheme.slice(1)}`)
.join('|')}) .*`,
},
},
]
: [])
.concat(hasBearerAuth && !hasAccessTokenApiKey
? [
{
in: 'query',
name: 'access_token',
schema: {
type: 'string',
},
},
]
: []);
return securityParameters;
}
export async function resolveParameters({ API, log = noop, }, parameters) {
const resolvedParameters = [];
for (const parameter of parameters) {
const resolvedParameter = await ensureResolvedObject(API, parameter);
if ('style' in resolvedParameter) {
log('warning', '⚠️ - Only defaults styles are supported currently!');
log('debug', JSON.stringify(resolvedParameter));
}
if ('string' !== typeof resolvedParameter.name) {
throw new YError('E_BAD_PARAMETER_NAME', [resolvedParameter]);
}
if (resolvedParameter.in === 'querystring') {
throw new YError('E_UNSUPPORTED_PARAMETER_LOCATION', [
resolvedParameter.name,
'querystring',
]);
}
if ('content' in resolvedParameter) {
throw new YError('E_UNSUPPORTED_PARAMETER_DEFINITION', [
resolvedParameter.name,
'content',
]);
}
if ('style' in resolvedParameter && 'simple' !== resolvedParameter.style) {
throw new YError('E_UNSUPPORTED_PARAMETER_DEFINITION', [
resolvedParameter.name,
'style',
resolvedParameter.style,
]);
}
if (!['query', 'header', 'path'].includes(resolvedParameter.in)) {
throw new YError('E_UNSUPPORTED_PARAMETER_DEFINITION', [
resolvedParameter.name,
'in',
resolvedParameter.in,
]);
}
if (!resolvedParameter.schema) {
throw new YError('E_PARAMETER_WITHOUT_SCHEMA', [resolvedParameter.name]);
}
resolvedParameters.push(resolvedParameter);
}
return resolvedParameters;
}
export async function getCasterForSchema({ API, COERCION_OPTIONS, }, schema) {
const resolvedSchema = (await ensureResolvedObject(API, schema));
if (!('type' in resolvedSchema && resolvedSchema.type)) {
throw new YError('E_UNSUPPORTED_SCHEMA', [resolvedSchema]);
}
if (resolvedSchema.type === 'number') {
return parseNumber.bind(null, COERCION_OPTIONS);
}
else if (resolvedSchema.type === 'boolean') {
return parseBoolean;
}
else if (resolvedSchema.type === 'string') {
return identity;
}
else if (resolvedSchema.type === 'array') {
if (!('items' in resolvedSchema && resolvedSchema.items) ||
'prefixItems' in resolvedSchema) {
throw new YError('E_UNSUPPORTED_SCHEMA', [resolvedSchema]);
}
const itemSchema = (await ensureResolvedObject(API, resolvedSchema.items));
if (!('type' in itemSchema && itemSchema.type)) {
throw new YError('E_UNSUPPORTED_PARAMETER_SCHEMA', [resolvedSchema]);
}
if (itemSchema.type === 'string') {
return parseArrayOfStrings;
}
else if (itemSchema.type === 'number') {
return parseArrayOfNumbers.bind(null, COERCION_OPTIONS);
}
else if (itemSchema.type === 'boolean') {
return parseArrayOfBooleans;
}
}
throw new YError('E_UNSUPPORTED_PARAMETER_SCHEMA', [resolvedSchema]);
}
export async function createParameterValidator({ API, COERCION_OPTIONS, schemaValidators, }, parameter) {
let validator;
let caster = undefined;
const schema = (await ensureResolvedObject(API, parameter.schema));
if (!('type' in schema && schema.type)) {
throw new YError('E_UNSUPPORTED_PARAMETER_SCHEMA', [parameter]);
}
if (schema.type === 'number') {
caster = parseNumber.bind(null, COERCION_OPTIONS);
}
else if (schema.type === 'boolean') {
caster = parseBoolean;
}
else if (schema.type === 'array') {
if (!('items' in schema && schema.items) || 'prefixItems' in schema) {
throw new YError('E_UNSUPPORTED_PARAMETER_SCHEMA', [parameter]);
}
const itemSchema = (await ensureResolvedObject(API, schema.items));
if (!('type' in itemSchema && itemSchema.type)) {
throw new YError('E_UNSUPPORTED_PARAMETER_SCHEMA', [parameter]);
}
if (itemSchema.type === 'string') {
caster = parseArrayOfStrings;
}
else if (itemSchema.type === 'number') {
caster = parseArrayOfNumbers.bind(null, COERCION_OPTIONS);
}
else if (itemSchema.type === 'boolean') {
caster = parseArrayOfBooleans;
}
}
try {
validator = schemaValidators(parameter.schema);
}
catch (err) {
throw YError.wrap(err, 'E_BAD_PARAMETER_SCHEMA', [parameter.name]);
}
return validateParameter.bind(null, parameter, caster, validator);
}
export async function createParametersValidators({ API, COERCION_OPTIONS, schemaValidators, }, parameters) {
const parameterValidators = {
query: {},
header: {},
path: {},
cookie: {},
querystring: {},
};
for (const parameter of parameters) {
parameterValidators[parameter.in][parameter.name] =
await createParameterValidator({
API,
COERCION_OPTIONS,
schemaValidators,
}, parameter);
}
return parameterValidators;
}
export function validateParameter(parameter, caster, validator, str) {
if ('undefined' === typeof str) {
if (parameter.required) {
throw new YHTTPError(400, 'E_REQUIRED_PARAMETER', [
parameter.name,
typeof str,
str,
]);
}
return undefined;
}
const value = caster ? caster(str) : str;
if (!validator(value)) {
throw new YHTTPError(400, 'E_BAD_PARAMETER', [
parameter.name,
typeof value,
value,
validator.errors,
]);
}
return value;
}
//# sourceMappingURL=validation.js.map