Skip to content

Commit

Permalink
feat: coerce query object with schema
Browse files Browse the repository at this point in the history
  • Loading branch information
jannyHou committed Jun 30, 2020
1 parent 8f644ce commit ccea25f
Show file tree
Hide file tree
Showing 5 changed files with 149 additions and 32 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,21 @@ describe('Coercion', () => {
if (spy) spy.restore();
});

const filterSchema = {
type: 'object',
title: 'filter',
properties: {
where: {
type: 'object',
properties: {
id: {type: 'number'},
name: {type: 'string'},
active: {type: 'boolean'},
},
},
},
};

class MyController {
@get('/create-number-from-path/{num}')
createNumberFromPath(@param.path.number('num') num: number) {
Expand All @@ -49,7 +64,14 @@ describe('Coercion', () => {
}

@get('/object-from-query')
getObjectFromQuery(@param.query.object('filter') filter: object) {
getObjectFromQuery(
@param.query.object('filter', filterSchema) filter: object,
) {
return filter;
}

@get('/random-object-from-query')
getRandomObjectFromQuery(@param.query.object('filter') filter: object) {
return filter;
}
}
Expand Down Expand Up @@ -84,6 +106,8 @@ describe('Coercion', () => {
});

it('coerces parameter in query from nested keys to object', async () => {
// Notice that numeric and boolean values are coerced to their own types
// because the schema is provided.
spy = sinon.spy(MyController.prototype, 'getObjectFromQuery');
await client
.get('/object-from-query')
Expand All @@ -94,9 +118,28 @@ describe('Coercion', () => {
})
.expect(200);
sinon.assert.calledWithExactly(spy, {
// Notice that numeric and boolean values are converted to strings.
// This is because all values are encoded as strings on URL queries
// and we did not specify any schema in @param.query.object() decorator.
where: {
id: 1,
name: 'Pen',
active: true,
},
});
});

it('coerces parameter in query from nested keys to object - no schema', async () => {
// Notice that numeric and boolean values are converted to strings.
// This is because all values are encoded as strings on URL queries
// and we did not specify any schema in @param.query.object() decorator.
spy = sinon.spy(MyController.prototype, 'getRandomObjectFromQuery');
await client
.get('/random-object-from-query')
.query({
'filter[where][id]': 1,
'filter[where][name]': 'Pen',
'filter[where][active]': true,
})
.expect(200);
sinon.assert.calledWithExactly(spy, {
where: {
id: '1',
name: 'Pen',
Expand Down
70 changes: 55 additions & 15 deletions packages/rest/src/coercion/coerce-parameter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,19 @@
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {isReferenceObject, ParameterObject} from '@loopback/openapi-v3';
import {
isReferenceObject,
ParameterObject,
ReferenceObject,
SchemaObject,
} from '@loopback/openapi-v3';
import debugModule from 'debug';
import {RestHttpErrors} from '../';
import {
RequestBodyValidationOptions,
RestHttpErrors,
validateValueAgainstSchema,
ValueValidationOptions,
} from '../';
import {parseJson} from '../parse-json';
import {
DateCoercionOptions,
Expand All @@ -27,18 +37,14 @@ const debug = debugModule('loopback:rest:coercion');
*
* @param data - The raw data get from http request
* @param schema - The parameter's schema defined in OpenAPI specification
* @param options - The ajv validation options
*/
export function coerceParameter(
export async function coerceParameter(
data: string | undefined | object,
spec: ParameterObject,
options?: ValueValidationOptions,
) {
let schema = spec.schema;

// If a query parameter is a url encoded Json object, the schema is defined under content['application/json']
if (!schema && spec.in === 'query' && spec.content) {
const jsonSpec = spec.content['application/json'];
schema = jsonSpec.schema;
}
const schema = extractSchemaFromSpec(spec);

if (!schema || isReferenceObject(schema)) {
debug(
Expand Down Expand Up @@ -72,7 +78,7 @@ export function coerceParameter(
case 'boolean':
return coerceBoolean(data, spec);
case 'object':
return coerceObject(data, spec);
return coerceObject(data, spec, options);
case 'string':
case 'password':
return coerceString(data, spec);
Expand Down Expand Up @@ -158,21 +164,55 @@ function coerceBoolean(data: string | object, spec: ParameterObject) {
throw RestHttpErrors.invalidData(data, spec.name);
}

function coerceObject(input: string | object, spec: ParameterObject) {
async function coerceObject(
input: string | object,
spec: ParameterObject,
options?: RequestBodyValidationOptions,
) {
const data = parseJsonIfNeeded(input, spec);

if (data === undefined) {
if (data == null) {
// Skip any further checks and coercions, nothing we can do with `undefined`
return undefined;
return data;
}

if (typeof data !== 'object' || Array.isArray(data))
throw RestHttpErrors.invalidData(input, spec.name);

// TODO(bajtos) apply coercion based on properties defined by spec.schema
const schema = extractSchemaFromSpec(spec);
if (schema) {
// Apply coercion based on properties defined by spec.schema
await validateValueAgainstSchema(
data,
schema,
{},
{...options, coerceTypes: true, source: 'parameter'},
);
}

return data;
}

/**
* Extract the schema from an OpenAPI parameter specification. If the root level
* one not found, search from media type 'application/json'.
*
* @param spec The parameter specification
*/
function extractSchemaFromSpec(
spec: ParameterObject,
): SchemaObject | ReferenceObject | undefined {
let schema = spec.schema;

// If a query parameter is a url encoded Json object,
// the schema is defined under content['application/json']
if (!schema && spec.in === 'query') {
schema = spec.content?.['application/json']?.schema;
}

return schema;
}

function parseJsonIfNeeded(
data: string | object,
spec: ParameterObject,
Expand Down
2 changes: 1 addition & 1 deletion packages/rest/src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ async function buildOperationArguments(
}
const spec = paramSpec as ParameterObject;
const rawValue = getParamFromRequest(spec, request, pathParams);
const coercedValue = coerceParameter(rawValue, spec);
const coercedValue = await coerceParameter(rawValue, spec);
paramArgs.push(coercedValue);
}

Expand Down
11 changes: 11 additions & 0 deletions packages/rest/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,17 @@ export type AjvKeyword = KeywordDefinition & {name: string};
*/
export type AjvFormat = FormatDefinition & {name: string};

/**
* Options for any value validation using AJV
*/
export interface ValueValidationOptions extends RequestBodyValidationOptions {
/**
* Where the data comes from. It can be 'body', 'path', 'header',
* 'query', 'cookie', etc...
*/
source?: string;
}

/**
* Options for request body validation using AJV
*/
Expand Down
47 changes: 35 additions & 12 deletions packages/rest/src/validation/request-body.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import debugModule from 'debug';
import _ from 'lodash';
import util from 'util';
import {HttpErrors, RequestBody, RestHttpErrors} from '..';
import {RequestBodyValidationOptions, SchemaValidatorCache} from '../types';
import {
RequestBodyValidationOptions,
SchemaValidatorCache,
ValueValidationOptions,
} from '../types';
import {AjvFactoryProvider} from './ajv-factory.provider';

const toJsonSchema = require('@openapi-contrib/openapi-schema-to-json-schema');
Expand Down Expand Up @@ -66,7 +70,10 @@ export async function validateRequestBody(
if (!schema) return;

options = {coerceTypes: !!body.coercionRequired, ...options};
await validateValueAgainstSchema(body.value, schema, globalSchemas, options);
await validateValueAgainstSchema(body.value, schema, globalSchemas, {
...options,
source: 'body',
});
}

/**
Expand Down Expand Up @@ -115,12 +122,12 @@ function getKeyForOptions(options: RequestBodyValidationOptions) {
* @param globalSchemas - Schema references.
* @param options - Request body validation options.
*/
async function validateValueAgainstSchema(
export async function validateValueAgainstSchema(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
body: any,
value: any,
schema: SchemaObject | ReferenceObject,
globalSchemas: SchemasObject = {},
options: RequestBodyValidationOptions = {},
options: ValueValidationOptions = {},
) {
let validate: ajv.ValidateFunction | undefined;

Expand All @@ -145,10 +152,10 @@ async function validateValueAgainstSchema(

let validationErrors: ajv.ErrorObject[] = [];
try {
const validationResult = await validate(body);
// When body is optional & values is empty / null, ajv returns null
const validationResult = await validate(value);
// When value is optional & values is empty / null, ajv returns null
if (validationResult || validationResult === null) {
debug('Request body passed AJV validation.');
debug(`Value from ${options.source} passed AJV validation.`);
return;
}
} catch (error) {
Expand All @@ -158,8 +165,8 @@ async function validateValueAgainstSchema(
/* istanbul ignore if */
if (debug.enabled) {
debug(
'Invalid request body: %s. Errors: %s',
util.inspect(body, {depth: null}),
'Invalid value: %s. Errors: %s',
util.inspect(value, {depth: null}),
util.inspect(validationErrors),
);
}
Expand All @@ -168,7 +175,24 @@ async function validateValueAgainstSchema(
validationErrors = options.ajvErrorTransformer(validationErrors);
}

const error = RestHttpErrors.invalidRequestBody();
// Throw invalid request body error
if (options.source === 'body') {
const error = RestHttpErrors.invalidRequestBody();
addErrorDetails(error, validationErrors);
throw error;
}

// Throw invalid value error
const error = new HttpErrors.BadRequest('Invalid value.');
addErrorDetails(error, validationErrors);
throw error;
}

function addErrorDetails(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: any,
validationErrors: ajv.ErrorObject[],
) {
error.details = _.map(validationErrors, e => {
return {
path: e.dataPath,
Expand All @@ -177,7 +201,6 @@ async function validateValueAgainstSchema(
info: e.params,
};
});
throw error;
}

/**
Expand Down

0 comments on commit ccea25f

Please sign in to comment.