Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/rest/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export * from './rest.application';
export * from './rest.component';
export * from './rest.server';
export * from './sequence';
export * from './coercion/rest-http-error';
export * from './rest-http-error';

// export all errors from external http-errors package
import * as HttpErrors from 'http-errors';
Expand Down
75 changes: 43 additions & 32 deletions packages/rest/src/validation/request-body.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,26 @@ import {
RequestBodyObject,
SchemaObject,
SchemasObject,
isReferenceObject,
} from '@loopback/openapi-v3-types';
import * as AJV from 'ajv';
import * as debugModule from 'debug';
import * as util from 'util';
import {HttpErrors} from '..';
import {RestHttpErrors} from '../coercion/rest-http-error';
import {RestHttpErrors} from '..';
import {AnyObject} from '@loopback/repository';

const toJsonSchema = require('openapi-schema-to-json-schema');

const debug = debugModule('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 AJS shema validator.
* @param body The body data from an HTTP request.
* @param requestBodySpec The OpenAPI requestBody specification defined in `@requestBody()`.
* @param globalSchemas The referenced schemas generated from `OpenAPISpec.components.schemas`.
*/
export function validateRequestBody(
// tslint:disable-next-line:no-any
body: any,
Expand All @@ -28,47 +36,33 @@ export function validateRequestBody(
if (requestBodySpec && requestBodySpec.required && body == undefined)
throw new HttpErrors.BadRequest('Request body is required');

const schema = getRequestBodySchema(requestBodySpec, globalSchemas || {});
const schema = getRequestBodySchema(requestBodySpec);
debug('Request body schema: %j', util.inspect(schema, {depth: null}));
if (!schema) return;

const jsonSchema = convertToJsonSchema(schema);
validateValueAgainstJsonSchema(body, jsonSchema);
validateValueAgainstJsonSchema(body, jsonSchema, globalSchemas);
}

/**
* Get the schema from requestBody specification.
* @param requestBodySpec The requestBody specification defined in `@requestBody()`.
*/
function getRequestBodySchema(
requestBodySpec: RequestBodyObject | undefined,
globalSchemas: SchemasObject,
): SchemaObject | undefined {
if (!requestBodySpec) return;

const content = requestBodySpec.content;
// FIXME(bajtos) we need to find the entry matching the content-type
// header from the incoming request (e.g. "application/json").
const schema = content[Object.keys(content)[0]].schema;
if (!schema || !isReferenceObject(schema)) {
return schema;
}

return resolveSchemaReference(schema.$ref, globalSchemas);
}

function resolveSchemaReference(ref: string, schemas: SchemasObject) {
// A temporary solution for resolving schema references produced
// by @loopback/repository-json-schema. In the future, we should
// support arbitrary references anywhere in the OpenAPI spec.
// See https://github.com/strongloop/loopback-next/issues/435
const match = ref.match(/^#\/components\/schemas\/([^\/]+)$/);
if (!match) throw new Error(`Unsupported schema reference format: ${ref}`);
const schemaId = match[1];

debug(`Resolving schema reference ${ref} (schema id ${schemaId}).`);
if (!(schemaId in schemas)) {
throw new Error(`Invalid reference ${ref} - schema ${schemaId} not found.`);
}
return schemas[schemaId];
return content[Object.keys(content)[0]].schema;
}

/**
* 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'];
Expand All @@ -79,11 +73,28 @@ function convertToJsonSchema(openapiSchema: SchemaObject) {
return jsonSchema;
}

// tslint:disable-next-line:no-any
function validateValueAgainstJsonSchema(body: any, schema: any) {
const ajv = new AJV({allErrors: true});
/**
* Validate the request body data against JSON schema.
* @param body The request body data.
* @param schema The JSON schema used to perform the validation.
* @param globalSchemas Schema references.
*/
function validateValueAgainstJsonSchema(
// tslint:disable-next-line:no-any
body: any,
jsonSchema: AnyObject,
globalSchemas?: SchemasObject,
) {
const schemaWithRef = Object.assign({}, jsonSchema);
schemaWithRef.components = {
schemas: globalSchemas,
};

const ajv = new AJV({
allErrors: true,
});
try {
if (ajv.validate(schema, body)) {
if (ajv.validate(schemaWithRef, body)) {
debug('Request body passed AJV validation.');
return;
}
Expand Down
113 changes: 110 additions & 3 deletions packages/rest/test/unit/request-body.validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
import {expect} from '@loopback/testlab';
import {validateRequestBody} from '../../src/validation/request-body.validator';
import {aBodySpec} from '../helpers';
import {RequestBodyObject, SchemasObject} from '@loopback/openapi-v3-types';
import {
RequestBodyObject,
SchemaObject,
SchemasObject,
} from '@loopback/openapi-v3-types';

const TODO_SCHEMA = {
title: 'Todo',
Expand All @@ -18,6 +22,24 @@ const TODO_SCHEMA = {
required: ['title'],
};

// a schema that contains a property with referenced schema
const ACCOUNT_SCHEMA = {
title: 'Account',
properties: {
title: {type: 'string'},
address: {$ref: '#/components/schemas/Address'},
},
};

const ADDRESS_SCHEMA = {
title: 'Address',
properties: {
city: {type: 'string'},
unit: {type: 'number'},
isOwner: {type: 'boolean'},
},
};

describe('validateRequestBody', () => {
it('accepts valid data omitting optional properties', () => {
validateRequestBody({title: 'work'}, aBodySpec(TODO_SCHEMA));
Expand Down Expand Up @@ -66,7 +88,7 @@ describe('validateRequestBody', () => {

it('rejects empty values when body is required', () => {
verifyValidationRejectsInputWithError(
/body is required/i,
/body is required/,
null,
aBodySpec(TODO_SCHEMA, {required: true}),
);
Expand All @@ -77,7 +99,7 @@ describe('validateRequestBody', () => {
});

it('rejects invalid values for number properties', () => {
const schema: SchemasObject = {
const schema: SchemaObject = {
properties: {
count: {type: 'number'},
},
Expand All @@ -88,6 +110,91 @@ describe('validateRequestBody', () => {
aBodySpec(schema),
);
});

context('rejects array of data with wrong type - ', () => {
it('primitive types', () => {
const schema: SchemaObject = {
type: 'object',
properties: {
orders: {
type: 'array',
items: {
type: 'string',
},
},
},
};
verifyValidationRejectsInputWithError(
/orders\[1\] should be string/,
{orders: ['order1', 1]},
aBodySpec(schema),
);
});

it('first level $ref', () => {
const schema: SchemaObject = {
type: 'array',
items: {
$ref: '#/components/schemas/Todo',
},
};
verifyValidationRejectsInputWithError(
/required property/,
[{title: 'a good todo'}, {description: 'a todo item missing title'}],
aBodySpec(schema),
{Todo: TODO_SCHEMA},
);
});

it('nested $ref in schema', () => {
const schema: SchemaObject = {
type: 'object',
properties: {
todos: {
type: 'array',
items: {
$ref: '#/components/schemas/Todo',
},
},
},
};
verifyValidationRejectsInputWithError(
/todos\[1\] should have required property \'title\'/,
{
todos: [
{title: 'a good todo'},
{description: 'a todo item missing title'},
],
},
aBodySpec(schema),
{Todo: TODO_SCHEMA},
);
});

it('nested $ref in reference', () => {
const schema: SchemaObject = {
type: 'object',
properties: {
accounts: {
type: 'array',
items: {
$ref: '#/components/schemas/Account',
},
},
},
};
verifyValidationRejectsInputWithError(
/accounts\[0\]\.address\.city should be string/,
{
accounts: [
{title: 'an account with invalid address', address: {city: 1}},
],
},
aBodySpec(schema),
{Account: ACCOUNT_SCHEMA, Address: ADDRESS_SCHEMA},
);
});
});
});

// ----- HELPERS ----- /
Expand Down