Skip to content

Commit

Permalink
feat: add type coercion
Browse files Browse the repository at this point in the history
  • Loading branch information
jannyHou committed Jun 12, 2018
1 parent 140ca5f commit c770417
Show file tree
Hide file tree
Showing 14 changed files with 588 additions and 29 deletions.
12 changes: 0 additions & 12 deletions examples/todo/src/controllers/todo.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,6 @@ export class TodoController {
@param.path.number('id') id: number,
@requestBody() todo: Todo,
): Promise<boolean> {
// REST adapter does not coerce parameter values coming from string sources
// like path & query. As a workaround, we have to cast the value to a number
// ourselves.
// See https://github.com/strongloop/loopback-next/issues/750
id = +id;

return await this.todoRepo.replaceById(id, todo);
}

Expand All @@ -62,12 +56,6 @@ export class TodoController {
@param.path.number('id') id: number,
@requestBody() todo: Todo,
): Promise<boolean> {
// REST adapter does not coerce parameter values coming from string sources
// like path & query. As a workaround, we have to cast the value to a number
// ourselves.
// See https://github.com/strongloop/loopback-next/issues/750
id = +id;

return await this.todoRepo.updateById(id, todo);
}

Expand Down
115 changes: 115 additions & 0 deletions packages/rest/src/coercion/coerce-parameter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Copyright IBM Corp. 2018. 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 {ParameterObject, isReferenceObject} from '@loopback/openapi-v3-types';
import {Validator} from './validator';
import * as debugModule from 'debug';
import {RestHttpErrors} from '../';

const debug = debugModule('loopback:rest:coercion');

/**
* Coerce the http raw data to a JavaScript type data of a parameter
* according to its OpenAPI schema specification.
*
* @param data The raw data get from http request
* @param schema The parameter's schema defined in OpenAPI specification
*/
export function coerceParameter(data: string, spec: ParameterObject) {
const schema = spec.schema;
if (!schema || isReferenceObject(schema)) {
debug(
'The parameter with schema %s is not coerced since schema' +
'dereference is not supported yet.',
schema,
);
return data;
}
const OAIType = getOAIPrimitiveType(schema.type, schema.format);
const validator = new Validator({parameterSpec: spec});

validator.validateParamBeforeCoercion(data);

switch (OAIType) {
case 'byte':
return Buffer.from(data, 'base64');
case 'date':
return new Date(data);
case 'float':
case 'double':
return parseFloat(data);
case 'number':
const coercedData = data ? Number(data) : undefined;
if (coercedData === undefined) return;
if (isNaN(coercedData)) throw RestHttpErrors.invalidData(data, spec.name);
return coercedData;
case 'long':
return Number(data);
case 'integer':
return parseInt(data);
case 'boolean':
return isTrue(data) ? true : isFalse(data) ? false : undefined;
case 'string':
case 'password':
// serialize will be supported in next PR
case 'serialize':
default:
return data;
}
}

/**
* A set of truthy values. A data in this set will be coerced to `true`.
*
* @param data The raw data get from http request
* @returns The corresponding coerced boolean type
*/
function isTrue(data: string): boolean {
return ['true', '1'].includes(data);
}

/**
* A set of falsy values. A data in this set will be coerced to `false`.
* @param data The raw data get from http request
* @returns The corresponding coerced boolean type
*/
function isFalse(data: string): boolean {
return ['false', '0'].includes(data);
}

/**
* Return the corresponding OpenAPI data type given an OpenAPI schema
*
* @param type The type in an OpenAPI schema specification
* @param format The format in an OpenAPI schema specification
*/
function getOAIPrimitiveType(type?: string, format?: string) {
// serizlize will be supported in next PR
if (type === 'object' || type === 'array') return 'serialize';
if (type === 'string') {
switch (format) {
case 'byte':
return 'byte';
case 'binary':
return 'binary';
case 'date':
return 'date';
case 'date-time':
return 'date-time';
case 'password':
return 'password';
default:
return 'string';
}
}
if (type === 'boolean') return 'boolean';
if (type === 'number')
return format === 'float'
? 'float'
: format === 'double'
? 'double'
: 'number';
if (type === 'integer') return format === 'int64' ? 'long' : 'integer';
}
16 changes: 16 additions & 0 deletions packages/rest/src/coercion/rest-http-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
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}!`;
return new HttpErrors.BadRequest(msg);
}
export function missingRequired(name: string): HttpErrors.HttpError {
const msg = `Required parameter ${name} is missing!`;
return new HttpErrors.BadRequest(msg);
}
export function invalidParamLocation(location: string): HttpErrors.HttpError {
return new HttpErrors.NotImplemented(
'Parameters with "in: ' + location + '" are not supported yet.',
);
}
}
68 changes: 68 additions & 0 deletions packages/rest/src/coercion/validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright IBM Corp. 2018. 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 {ParameterObject} from '@loopback/openapi-v3-types';
import {RestHttpErrors} from '../';

/**
* A set of options to pass into the validator functions
*/
export type ValidationOptions = {
required?: boolean;
};

/**
* The context information that a validator needs
*/
export type ValidationContext = {
parameterSpec: ParameterObject;
};

/**
* Validator class provides a bunch of functions that perform
* validations on the request parameters and request body.
*/
export class Validator {
constructor(public ctx: ValidationContext) {}

/**
* The validation executed before type coercion. Like
* checking absence.
*
* @param type A parameter's type.
* @param value A parameter's raw value from http request.
* @param opts options
*/
validateParamBeforeCoercion(
value: string | object | undefined,
opts?: ValidationOptions,
) {
if (this.isAbsent(value) && this.isRequired(opts)) {
const name = this.ctx.parameterSpec.name;
throw RestHttpErrors.missingRequired(name);
}
}

/**
* Check is a parameter required or not.
*
* @param opts
*/
isRequired(opts?: ValidationOptions) {
if (this.ctx.parameterSpec.required) return true;
if (opts && opts.required) return true;
return false;
}

/**
* Return `true` if the value is empty, return `false` otherwise.
*
* @param value
*/
// tslint:disable-next-line:no-any
isAbsent(value: any) {
return value === '' || value === undefined;
}
}
1 change: 1 addition & 0 deletions packages/rest/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ export * from './rest.component';
export * from './rest.server';
export * from './sequence';
export * from '@loopback/openapi-v3';
export * from './coercion/rest-http-error';
47 changes: 30 additions & 17 deletions packages/rest/src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {REQUEST_BODY_INDEX} from '@loopback/openapi-v3';
import {promisify} from 'util';
import {OperationArgs, Request, PathParameterValues} from './types';
import {ResolvedRoute} from './router/routing-table';
import {coerceParameter} from './coercion/coerce-parameter';
import {RestHttpErrors} from './index';
type HttpError = HttpErrors.HttpError;

// tslint:disable-next-line:no-any
Expand Down Expand Up @@ -101,24 +103,35 @@ function buildOperationArguments(
throw new Error('$ref parameters are not supported yet.');
}
const spec = paramSpec as ParameterObject;
switch (spec.in) {
case 'query':
paramArgs.push(request.query[spec.name]);
break;
case 'path':
paramArgs.push(pathParams[spec.name]);
break;
case 'header':
paramArgs.push(request.headers[spec.name.toLowerCase()]);
break;
// TODO(jannyhou) to support `cookie`,
// see issue https://github.com/strongloop/loopback-next/issues/997
default:
throw new HttpErrors.NotImplemented(
'Parameters with "in: ' + spec.in + '" are not supported yet.',
);
}
const rawValue = getParamFromRequest(spec, request, pathParams);
const coercedValue = coerceParameter(rawValue, spec);
paramArgs.push(coercedValue);
}
if (requestBodyIndex > -1) paramArgs.splice(requestBodyIndex, 0, body);
return paramArgs;
}

function getParamFromRequest(
spec: ParameterObject,
request: Request,
pathParams: PathParameterValues,
) {
let result;
switch (spec.in) {
case 'query':
result = request.query[spec.name];
break;
case 'path':
result = pathParams[spec.name];
break;
case 'header':
// @jannyhou TBD: check edge cases
result = request.headers[spec.name.toLowerCase()];
break;
// TODO(jannyhou) to support `cookie`,
// see issue https://github.com/strongloop/loopback-next/issues/997
default:
throw RestHttpErrors.invalidParamLocation(spec.in);
}
return result;
}
58 changes: 58 additions & 0 deletions packages/rest/test/acceptance/coercion/coercion.acceptance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {supertest, createClientForHandler, sinon} from '@loopback/testlab';
import {RestApplication, get, param} from '../../..';

describe('Coercion', () => {
let app: RestApplication;
let client: supertest.SuperTest<supertest.Test>;

before(givenAClient);

after(async () => {
await app.stop();
});

class MyController {
@get('/create-number-from-path/{num}')
createNumberFromPath(@param.path.number('num') num: number) {
return num;
}

@get('/create-number-from-query')
createNumberFromQuery(@param.query.number('num') num: number) {
return num;
}

@get('/create-number-from-header')
createNumberFromHeader(@param.header.number('num') num: number) {
return num;
}
}

it('coerces parameter in path from string to number', async () => {
const spy = sinon.spy(MyController.prototype, 'createNumberFromPath');
await client.get('/create-number-from-path/100').expect(200);
sinon.assert.calledWithExactly(spy, 100);
});

it('coerces parameter in header from string to number', async () => {
const spy = sinon.spy(MyController.prototype, 'createNumberFromHeader');
await client.get('/create-number-from-header').set({num: 100});
sinon.assert.calledWithExactly(spy, 100);
});

it('coerces parameter in query from string to number', async () => {
const spy = sinon.spy(MyController.prototype, 'createNumberFromQuery');
await client
.get('/create-number-from-query')
.query({num: 100})
.expect(200);
sinon.assert.calledWithExactly(spy, 100);
});

async function givenAClient() {
app = new RestApplication();
app.controller(MyController);
await app.start();
client = await createClientForHandler(app.requestHandler);
}
});
18 changes: 18 additions & 0 deletions packages/rest/test/unit/coercion/invalid-spec.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright IBM Corp. 2018. 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 {test} from './utils';
import {RestHttpErrors} from '../../../';
import {ParameterLocation} from '@loopback/openapi-v3-types';

const INVALID_PARAM = {
in: <ParameterLocation>'unknown',
name: 'aparameter',
schema: {type: 'unknown'},
};

describe('throws error for invalid parameter spec', () => {
test(INVALID_PARAM, '', RestHttpErrors.invalidParamLocation('unknown'));
});
19 changes: 19 additions & 0 deletions packages/rest/test/unit/coercion/paramStringToBoolean.unit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright IBM Corp. 2018. 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 {test} from './utils';
import {ParameterLocation} from '@loopback/openapi-v3-types';

const BOOLEAN_PARAM = {
in: <ParameterLocation>'path',
name: 'aparameter',
schema: {type: 'boolean'},
};

describe('coerce param from string to boolean', () => {
test(BOOLEAN_PARAM, 'false', false);
test(BOOLEAN_PARAM, 'true', true);
test(BOOLEAN_PARAM, undefined, undefined);
});
Loading

0 comments on commit c770417

Please sign in to comment.