Skip to content

Commit

Permalink
feat: localize error in details
Browse files Browse the repository at this point in the history
  • Loading branch information
jannyHou committed Jul 12, 2018
1 parent 503a66e commit 6066291
Show file tree
Hide file tree
Showing 3 changed files with 159 additions and 22 deletions.
31 changes: 29 additions & 2 deletions packages/rest/src/rest-http-error.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as HttpErrors from 'http-errors';

export namespace RestHttpErrors {
export function invalidData<T>(data: T, name: string) {
const msg = `Invalid data ${JSON.stringify(data)} for parameter ${name}!`;
Expand All @@ -12,7 +13,33 @@ export namespace RestHttpErrors {
const msg = `Parameters with "in: ${location}" are not supported yet.`;
return new HttpErrors.NotImplemented(msg);
}
export function invalidRequestBody(msg: string): HttpErrors.HttpError {
return new HttpErrors.UnprocessableEntity(msg);
export const INVALID_REQUEST_BODY_MESSAGE =
'The request body is invalid. See error object `details` property for more info.';
export function invalidRequestBody(): HttpErrors.HttpError {
return new HttpErrors.UnprocessableEntity(INVALID_REQUEST_BODY_MESSAGE);
}
/**
* An invalid request body error contains a `details` property as the machine-readable error.
* Each entry in `error.details` contains 4 attributes: `path`, `code`, `info` and `message`.
* `ErrorDetails` defines the type of each entry, which is an object.
* The type of `error.details` is `ErrorDetails[]`.
*/
export interface ErrorDetails {
/**
* A path to the invalid field.
*/
path: string;
/**
* A single word code represents the error's type.
*/
code: string;
/**
* Some additional details that the 3 attributes above don't cover.
*/
info: object;
/**
* A humnan readable description of the error.
*/
message?: string;
}
}
21 changes: 14 additions & 7 deletions packages/rest/src/validation/request-body.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import * as debugModule from 'debug';
import * as util from 'util';
import {HttpErrors} from '..';
import {RestHttpErrors} from '..';
import {AnyObject} from '@loopback/repository';
import * as _ from 'lodash';

const toJsonSchema = require('openapi-schema-to-json-schema');
const debug = debugModule('loopback:rest:validation');
Expand Down Expand Up @@ -82,10 +82,10 @@ function convertToJsonSchema(openapiSchema: SchemaObject) {
function validateValueAgainstJsonSchema(
// tslint:disable-next-line:no-any
body: any,
jsonSchema: AnyObject,
jsonSchema: object,
globalSchemas?: SchemasObject,
) {
const schemaWithRef = Object.assign({}, jsonSchema);
const schemaWithRef = Object.assign({components: {}}, jsonSchema);
schemaWithRef.components = {
schemas: globalSchemas,
};
Expand All @@ -103,8 +103,15 @@ function validateValueAgainstJsonSchema(
}

debug('Invalid request body: %s', util.inspect(ajv.errors));
const message = ajv.errorsText(ajv.errors, {dataVar: body});
// FIXME add `err.details` object containing machine-readable information
// see LB 3.x ValidationError for inspiration
throw RestHttpErrors.invalidRequestBody(message);

const error = RestHttpErrors.invalidRequestBody();
error.details = _.map(ajv.errors, e => {
return {
path: e.dataPath,
code: e.keyword,
message: e.message,
info: e.params,
};
});
throw error;
}
129 changes: 116 additions & 13 deletions packages/rest/test/unit/request-body.validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@

import {expect} from '@loopback/testlab';
import {validateRequestBody} from '../../src/validation/request-body.validator';
import {RestHttpErrors} from '../../';
import {aBodySpec} from '../helpers';
import {
RequestBodyObject,
SchemaObject,
SchemasObject,
} from '@loopback/openapi-v3-types';

const INVALID_MSG = RestHttpErrors.INVALID_REQUEST_BODY_MESSAGE;

const TODO_SCHEMA = {
title: 'Todo',
properties: {
Expand Down Expand Up @@ -46,8 +49,17 @@ describe('validateRequestBody', () => {
});

it('rejects data missing a required property', () => {
const details: RestHttpErrors.ErrorDetails[] = [
{
path: '',
code: 'required',
message: "should have required property 'title'",
info: {missingProperty: 'title'},
},
];
verifyValidationRejectsInputWithError(
/required property 'title'/,
INVALID_MSG,
details,
{
description: 'missing required "title"',
},
Expand All @@ -56,8 +68,17 @@ describe('validateRequestBody', () => {
});

it('rejects data containing values of a wrong type', () => {
const details: RestHttpErrors.ErrorDetails[] = [
{
path: '.isComplete',
code: 'type',
message: 'should be boolean',
info: {type: 'boolean'},
},
];
verifyValidationRejectsInputWithError(
/isComplete should be boolean/,
INVALID_MSG,
details,
{
title: 'todo with a string value of "isComplete"',
isComplete: 'a string value',
Expand All @@ -67,8 +88,23 @@ describe('validateRequestBody', () => {
});

it('reports all validation errors', () => {
const details: RestHttpErrors.ErrorDetails[] = [
{
path: '',
code: 'required',
message: "should have required property 'title'",
info: {missingProperty: 'title'},
},
{
path: '.isComplete',
code: 'type',
message: 'should be boolean',
info: {type: 'boolean'},
},
];
verifyValidationRejectsInputWithError(
/required property 'title'.*isComplete should be boolean/,
INVALID_MSG,
details,
{
description: 'missing title and a string value of "isComplete"',
isComplete: 'a string value',
Expand All @@ -78,8 +114,17 @@ describe('validateRequestBody', () => {
});

it('resolves schema references', () => {
const details: RestHttpErrors.ErrorDetails[] = [
{
path: '',
code: 'required',
message: "should have required property 'title'",
info: {missingProperty: 'title'},
},
];
verifyValidationRejectsInputWithError(
/required property/,
INVALID_MSG,
details,
{description: 'missing title'},
aBodySpec({$ref: '#/components/schemas/Todo'}),
{Todo: TODO_SCHEMA},
Expand All @@ -88,7 +133,8 @@ describe('validateRequestBody', () => {

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

it('rejects invalid values for number properties', () => {
const details: RestHttpErrors.ErrorDetails[] = [
{
path: '.count',
code: 'type',
message: 'should be number',
info: {type: 'number'},
},
];
const schema: SchemaObject = {
properties: {
count: {type: 'number'},
},
};
verifyValidationRejectsInputWithError(
/count should be number/,
INVALID_MSG,
details,
{count: 'string value'},
aBodySpec(schema),
);
});

context('rejects array of data with wrong type - ', () => {
it('primitive types', () => {
const details: RestHttpErrors.ErrorDetails[] = [
{
path: '.orders[1]',
code: 'type',
message: 'should be string',
info: {type: 'string'},
},
];
const schema: SchemaObject = {
type: 'object',
properties: {
Expand All @@ -125,28 +188,52 @@ describe('validateRequestBody', () => {
},
};
verifyValidationRejectsInputWithError(
/orders\[1\] should be string/,
INVALID_MSG,
details,
{orders: ['order1', 1]},
aBodySpec(schema),
);
});

it('first level $ref', () => {
const details: RestHttpErrors.ErrorDetails[] = [
{
path: '[1]',
code: 'required',
message: "should have required property 'title'",
info: {missingProperty: 'title'},
},
];
const schema: SchemaObject = {
type: 'array',
items: {
$ref: '#/components/schemas/Todo',
},
};
verifyValidationRejectsInputWithError(
/required property/,
INVALID_MSG,
details,
[{title: 'a good todo'}, {description: 'a todo item missing title'}],
aBodySpec(schema),
{Todo: TODO_SCHEMA},
);
});

it('nested $ref in schema', () => {
const details: RestHttpErrors.ErrorDetails[] = [
{
path: '.todos[1]',
code: 'required',
message: "should have required property 'title'",
info: {missingProperty: 'title'},
},
{
path: '.todos[2].title',
code: 'type',
message: 'should be string',
info: {type: 'string'},
},
];
const schema: SchemaObject = {
type: 'object',
properties: {
Expand All @@ -159,11 +246,13 @@ describe('validateRequestBody', () => {
},
};
verifyValidationRejectsInputWithError(
/todos\[1\] should have required property \'title\'/,
INVALID_MSG,
details,
{
todos: [
{title: 'a good todo'},
{description: 'a todo item missing title'},
{description: 'a todo with wrong type of title', title: 2},
],
},
aBodySpec(schema),
Expand All @@ -172,6 +261,14 @@ describe('validateRequestBody', () => {
});

it('nested $ref in reference', () => {
const details: RestHttpErrors.ErrorDetails[] = [
{
path: '.accounts[0].address.city',
code: 'type',
message: 'should be string',
info: {type: 'string'},
},
];
const schema: SchemaObject = {
type: 'object',
properties: {
Expand All @@ -184,7 +281,8 @@ describe('validateRequestBody', () => {
},
};
verifyValidationRejectsInputWithError(
/accounts\[0\]\.address\.city should be string/,
INVALID_MSG,
details,
{
accounts: [
{title: 'an account with invalid address', address: {city: 1}},
Expand All @@ -201,13 +299,18 @@ describe('validateRequestBody', () => {

function verifyValidationRejectsInputWithError(
errorMatcher: Error | RegExp | string,
details: RestHttpErrors.ErrorDetails[] | undefined,
body: object | null,
spec: RequestBodyObject | undefined,
schemas?: SchemasObject,
) {
// workaround for Function.prototype.bind not preserving argument types
function validateRequestBodyWithBoundArgs() {
try {
validateRequestBody(body, spec, schemas);
throw new Error(
"expected Function { name: 'validateRequestBody' } to throw exception",
);
} catch (err) {
expect(err.message).to.equal(errorMatcher);
expect(err.details).to.deepEqual(details);
}
expect(validateRequestBodyWithBoundArgs).to.throw(errorMatcher);
}

0 comments on commit 6066291

Please sign in to comment.