UNPKG

@whook/whook

Version:

Build strong and efficient REST web services.

377 lines 14.6 kB
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