@loopback/rest
Version:
Expose controllers as REST endpoints and route REST API requests to controller methods
235 lines (210 loc) • 7.16 kB
text/typescript
// Copyright IBM Corp. and LoopBack contributors 2018,2020. All Rights Reserved.
// Node module: @loopback/rest
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT
import {
isReferenceObject,
ReferenceObject,
RequestBodyObject,
SchemaObject,
SchemasObject,
} from '@loopback/openapi-v3';
import util from 'node:util';
import debugFactory from 'debug';
import Ajv, {AsyncSchema, AsyncValidateFunction, ErrorObject} from 'ajv';
import {AnyValidateFunction} from 'ajv/dist/types';
import {HttpErrors, RequestBody, RestHttpErrors} from '..';
import {
SchemaValidatorCache,
ValidationOptions,
ValueValidationOptions,
} from '../types';
import {
AjvFactoryProvider,
DEFAULT_AJV_VALIDATION_OPTIONS,
} from './ajv-factory.provider';
const {
openapiSchemaToJsonSchema: toJsonSchema,
} = require('@openapi-contrib/openapi-schema-to-json-schema');
const debug = debugFactory('loopback:rest:validation');
/**
* Check whether the request body is valid according to the provided OpenAPI schema.
* The JSON schema is generated from the OpenAPI schema which is typically defined
* by `@requestBody()`.
* The validation leverages AJV schema validator.
* @param body - The request body parsed from an HTTP request.
* @param requestBodySpec - The OpenAPI requestBody specification defined in `@requestBody()`.
* @param globalSchemas - The referenced schemas generated from `OpenAPISpec.components.schemas`.
* @param options - Request body validation options for AJV
*/
export async function validateRequestBody(
body: RequestBody,
requestBodySpec?: RequestBodyObject,
globalSchemas: SchemasObject = {},
options: ValidationOptions = DEFAULT_AJV_VALIDATION_OPTIONS,
) {
const required = requestBodySpec?.required;
if (required && body.value == null) {
throw Object.assign(new HttpErrors.BadRequest('Request body is required'), {
code: 'MISSING_REQUIRED_PARAMETER',
parameterName: 'request body',
});
}
if (!required && !body.value) return;
const schema = body.schema;
/* istanbul ignore if */
if (debug.enabled) {
debug('Request body schema:', util.inspect(schema, {depth: null}));
if (
schema &&
isReferenceObject(schema) &&
schema.$ref.startsWith('#/components/schemas/')
) {
const ref = schema.$ref.slice('#/components/schemas/'.length);
debug(' referencing:', util.inspect(globalSchemas[ref], {depth: null}));
}
}
if (!schema) return;
options = {coerceTypes: !!body.coercionRequired, ...options};
await validateValueAgainstSchema(body.value, schema, globalSchemas, {
...options,
source: 'body',
});
}
/**
* Convert an OpenAPI schema to the corresponding JSON schema.
* @param openapiSchema - The OpenAPI schema to convert.
*/
function convertToJsonSchema(openapiSchema: SchemaObject) {
const jsonSchema = toJsonSchema(openapiSchema);
delete jsonSchema['$schema'];
/* istanbul ignore if */
if (debug.enabled) {
debug(
'Converted OpenAPI schema to JSON schema: %s',
util.inspect(jsonSchema, {depth: null}),
);
}
return jsonSchema;
}
/**
* Built-in cache for complied schemas by AJV
*/
const DEFAULT_COMPILED_SCHEMA_CACHE: SchemaValidatorCache = new WeakMap();
/**
* Build a cache key for AJV options
* @param options - Request body validation options
*/
function getKeyForOptions(
options: ValidationOptions = DEFAULT_AJV_VALIDATION_OPTIONS,
) {
const ajvOptions: Record<string, unknown> = {};
// Sort keys for options
const keys = Object.keys(options).sort() as (keyof ValidationOptions)[];
for (const k of keys) {
if (k === 'compiledSchemaCache') continue;
ajvOptions[k] = options[k];
}
return JSON.stringify(ajvOptions);
}
/**
* Validate the value against JSON schema.
* @param value - The data value.
* @param schema - The JSON schema used to perform the validation.
* @param globalSchemas - Schema references.
* @param options - Value validation options.
*/
export async function validateValueAgainstSchema(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any,
schema: SchemaObject | ReferenceObject,
globalSchemas: SchemasObject = {},
options: ValueValidationOptions = {},
) {
let validate: AnyValidateFunction | undefined;
const cache = options.compiledSchemaCache ?? DEFAULT_COMPILED_SCHEMA_CACHE;
const key = getKeyForOptions(options);
let validatorMap: Map<string, AnyValidateFunction> | undefined;
if (cache.has(schema)) {
validatorMap = cache.get(schema)!;
validate = validatorMap.get(key);
}
if (!validate) {
const ajvFactory =
options.ajvFactory ?? new AjvFactoryProvider(options).value();
const ajvInst = ajvFactory(options);
validate = createValidator(schema, globalSchemas, ajvInst);
validatorMap = validatorMap ?? new Map();
validatorMap.set(key, validate);
cache.set(schema, validatorMap);
}
let validationErrors: ErrorObject[] = [];
try {
const validationResult = validate(value);
debug(
`Value from ${options.source} passed AJV validation.`,
validationResult,
);
return await validationResult;
} catch (error) {
validationErrors = error.errors;
}
/* istanbul ignore if */
if (debug.enabled) {
debug(
'Invalid value: %s. Errors: %s',
util.inspect(value, {depth: null}),
util.inspect(validationErrors),
);
}
if (typeof options.ajvErrorTransformer === 'function') {
validationErrors = options.ajvErrorTransformer(validationErrors);
}
// Throw invalid request body error
if (options.source === 'body') {
throw RestHttpErrors.invalidRequestBody(
buildErrorDetails(validationErrors),
);
}
// Throw invalid value error
throw RestHttpErrors.invalidData(value, options.name ?? '(unknown)', {
details: buildErrorDetails(validationErrors),
});
}
function buildErrorDetails(
validationErrors: ErrorObject[],
): RestHttpErrors.ValidationErrorDetails[] {
return validationErrors.map(
(e: ErrorObject): RestHttpErrors.ValidationErrorDetails => {
return {
path: e.instancePath,
code: e.keyword,
message: e.message ?? `must pass validation rule ${e.keyword}`,
info: e.params,
};
},
);
}
/**
* Create a validate function for the given schema
* @param schema - JSON schema for the target
* @param globalSchemas - Global schemas
* @param ajvInst - An instance of Ajv
*/
function createValidator(
schema: SchemaObject,
globalSchemas: SchemasObject = {},
ajvInst: Ajv,
): AsyncValidateFunction {
const jsonSchema = convertToJsonSchema(schema);
// Clone global schemas to set `$async: true` flag
const schemas: SchemasObject = {};
for (const name in globalSchemas) {
// See https://github.com/loopbackio/loopback-next/issues/4939
schemas[name] = {...globalSchemas[name], $async: true};
}
const schemaWithRef: AsyncSchema = {components: {schemas}, ...jsonSchema};
// See https://js.org/#asynchronous-validation for async validation
schemaWithRef.$async = true;
return ajvInst.compile(schemaWithRef);
}