From 71987b00664686eddd130cc00f055612385e964a Mon Sep 17 00:00:00 2001 From: jannyHou Date: Mon, 28 May 2018 23:43:11 -0400 Subject: [PATCH] feat: add type coercion --- .../rest/src/coercion/coerce-parameter.ts | 121 ++++++++++++++++++ packages/rest/src/parser.ts | 42 +++--- .../coercion/coercion.acceptance.ts | 78 +++++++++++ .../coercion/paramStringToBoolean.unit.ts | 18 +++ .../unit/coercion/paramStringToBuffer.unit.ts | 20 +++ .../unit/coercion/paramStringToDate.unit.ts | 18 +++ .../unit/coercion/paramStringToNumber.unit.ts | 48 +++++++ packages/rest/test/unit/coercion/utils.ts | 63 +++++++++ 8 files changed, 392 insertions(+), 16 deletions(-) create mode 100644 packages/rest/src/coercion/coerce-parameter.ts create mode 100644 packages/rest/test/acceptance/coercion/coercion.acceptance.ts create mode 100644 packages/rest/test/unit/coercion/paramStringToBoolean.unit.ts create mode 100644 packages/rest/test/unit/coercion/paramStringToBuffer.unit.ts create mode 100644 packages/rest/test/unit/coercion/paramStringToDate.unit.ts create mode 100644 packages/rest/test/unit/coercion/paramStringToNumber.unit.ts create mode 100644 packages/rest/test/unit/coercion/utils.ts diff --git a/packages/rest/src/coercion/coerce-parameter.ts b/packages/rest/src/coercion/coerce-parameter.ts new file mode 100644 index 000000000000..bcf58c3e0f1e --- /dev/null +++ b/packages/rest/src/coercion/coerce-parameter.ts @@ -0,0 +1,121 @@ +// 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 { + SchemaObject, + ReferenceObject, + isReferenceObject, +} from '@loopback/openapi-v3-types'; + +import * as HttpErrors from 'http-errors'; + +/** + * 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, + schema?: SchemaObject | ReferenceObject, +) { + // ignore reference schema + if (!schema || isReferenceObject(schema)) return data; + + let coercedResult; + coercedResult = data; + + const OAIType = getOAIPrimitiveType(schema.type, schema.format); + + switch (OAIType) { + case 'byte': + coercedResult = Buffer.from(data, 'base64'); + break; + case 'date': + coercedResult = new Date(data); + break; + case 'float': + case 'double': + coercedResult = parseFloat(data); + break; + case 'number': + case 'long': + coercedResult = Number(data); + break; + case 'integer': + coercedResult = parseInt(data); + break; + case 'boolean': + coercedResult = isTrue(data) ? true : false; + case 'string': + case 'password': + // serizlize will be supported in next PR + case 'serialize': + break; + case 'unknownType': + default: + throw new HttpErrors.NotImplemented( + `Type ${schema.type} with format ${ + schema.format + } is not a valid OpenAPI schema`, + ); + } + return coercedResult; +} + +/** + * A set of truthy values. A data in this set will be coerced to `true`, + * otherwise coerced to `false`. + * + * @param data The raw data get from http request + * @returns The corresponding coerced boolean type + */ + +function isTrue(data: string): boolean { + const isTrueSet = ['true', '1', true, 1]; + return isTrueSet.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) { + let OAIType: string = 'unknownType'; + // serizlize will be supported in next PR + if (type === 'object' || type === 'array') OAIType = 'serialize'; + if (type === 'string') { + switch (format) { + case 'byte': + OAIType = 'byte'; + break; + case 'binary': + OAIType = 'binary'; + break; + case 'date': + OAIType = 'date'; + break; + case 'date-time': + OAIType = 'date-time'; + break; + case 'password': + OAIType = 'password'; + break; + default: + OAIType = 'string'; + break; + } + } + if (type === 'boolean') OAIType = 'boolean'; + if (type === 'number') + OAIType = + format === 'float' ? 'float' : format === 'double' ? 'double' : 'number'; + if (type === 'integer') OAIType = format === 'int64' ? 'long' : 'integer'; + return OAIType; +} diff --git a/packages/rest/src/parser.ts b/packages/rest/src/parser.ts index bb7da42c8f93..6ff28c0348d3 100644 --- a/packages/rest/src/parser.ts +++ b/packages/rest/src/parser.ts @@ -14,6 +14,7 @@ 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'; type HttpError = HttpErrors.HttpError; // tslint:disable-next-line:no-any @@ -101,22 +102,31 @@ 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(); + const coercedValue = coerceParameter(rawValue, spec.schema); + paramArgs.push(coercedValue); + + function getParamFromRequest() { + 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 new HttpErrors.NotImplemented( + 'Parameters with "in: ' + spec.in + '" are not supported yet.', + ); + } + return result; } } if (requestBodyIndex > -1) paramArgs.splice(requestBodyIndex, 0, body); diff --git a/packages/rest/test/acceptance/coercion/coercion.acceptance.ts b/packages/rest/test/acceptance/coercion/coercion.acceptance.ts new file mode 100644 index 000000000000..77f241fdb822 --- /dev/null +++ b/packages/rest/test/acceptance/coercion/coercion.acceptance.ts @@ -0,0 +1,78 @@ +import { + supertest, + createClientForHandler, + sinon, + SinonSpy, +} from '@loopback/testlab'; +import {RestApplication, RestServer, get, param} from '../../..'; + +describe('Coercion', () => { + let app: RestApplication; + let server: RestServer; + let client: supertest.SuperTest; + + before(givenAnApplication); + before(givenAServer); + 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); + assertCoercion(spy); + }); + + 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}); + assertCoercion(spy); + }); + + 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); + assertCoercion(spy); + }); + + function assertCoercion(spy: SinonSpy) { + sinon.assert.calledWithExactly(spy, 100); + sinon.assert.neverCalledWith(spy, '100'); + } + + async function givenAnApplication() { + app = new RestApplication(); + app.controller(MyController); + await app.start(); + } + + async function givenAServer() { + server = await app.getServer(RestServer); + } + + async function givenAClient() { + client = await createClientForHandler(server.requestHandler); + } +}); diff --git a/packages/rest/test/unit/coercion/paramStringToBoolean.unit.ts b/packages/rest/test/unit/coercion/paramStringToBoolean.unit.ts new file mode 100644 index 000000000000..6861a2fc90a6 --- /dev/null +++ b/packages/rest/test/unit/coercion/paramStringToBoolean.unit.ts @@ -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 {testCoercion} from './utils'; + +describe('coerce param from string to boolean', () => { + it("value 'false' is coerced to false", async () => { + const caller = new Error().stack!.split(/\n/)[1]; + await testCoercion({type: 'boolean'}, 'false', false, caller); + }); + + it("value 'true' is coerced to true", async () => { + const caller = new Error().stack!.split(/\n/)[1]; + await testCoercion({type: 'boolean'}, 'true', true, caller); + }); +}); diff --git a/packages/rest/test/unit/coercion/paramStringToBuffer.unit.ts b/packages/rest/test/unit/coercion/paramStringToBuffer.unit.ts new file mode 100644 index 000000000000..44f7abd8ec48 --- /dev/null +++ b/packages/rest/test/unit/coercion/paramStringToBuffer.unit.ts @@ -0,0 +1,20 @@ +// 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 {testCoercion} from './utils'; + +describe('coerce param from string to buffer', () => { + it('base64', async () => { + const base64 = Buffer.from('Hello World').toString('base64'); + const buffer = Buffer.from(base64, 'base64'); + const caller = new Error().stack!.split(/\n/)[1]; + await testCoercion( + {type: 'string', format: 'byte'}, + base64, + buffer, + caller, + ); + }); +}); diff --git a/packages/rest/test/unit/coercion/paramStringToDate.unit.ts b/packages/rest/test/unit/coercion/paramStringToDate.unit.ts new file mode 100644 index 000000000000..4d9819c14e29 --- /dev/null +++ b/packages/rest/test/unit/coercion/paramStringToDate.unit.ts @@ -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 {testCoercion} from './utils'; + +describe('coerce param from string to date', () => { + it('simple date', async () => { + const caller = new Error().stack!.split(/\n/)[1]; + await testCoercion( + {type: 'string', format: 'date'}, + '2015-03-01', + new Date('2015-03-01'), + caller, + ); + }); +}); diff --git a/packages/rest/test/unit/coercion/paramStringToNumber.unit.ts b/packages/rest/test/unit/coercion/paramStringToNumber.unit.ts new file mode 100644 index 000000000000..44e548fe58d4 --- /dev/null +++ b/packages/rest/test/unit/coercion/paramStringToNumber.unit.ts @@ -0,0 +1,48 @@ +// 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 {testCoercion} from './utils'; + +describe('coerce param from string to number', () => { + it('string to float', async () => { + const caller = new Error().stack!.split(/\n/)[1]; + await testCoercion( + {type: 'number', format: 'float'}, + '3.333333', + 3.333333, + caller, + ); + }); + + it('string to double', async () => { + const caller = new Error().stack!.split(/\n/)[1]; + await testCoercion( + {type: 'number', format: 'double'}, + '3.333333333', + 3.333333333, + caller, + ); + }); + + it('string to integer', async () => { + const caller = new Error().stack!.split(/\n/)[1]; + await testCoercion( + {type: 'integer', format: 'int32'}, + '100', + 100, + caller, + ); + }); + + it('string to long', async () => { + const caller = new Error().stack!.split(/\n/)[1]; + await testCoercion( + {type: 'integer', format: 'int64'}, + '9223372036854775807', + 9223372036854775807, + caller, + ); + }); +}); diff --git a/packages/rest/test/unit/coercion/utils.ts b/packages/rest/test/unit/coercion/utils.ts new file mode 100644 index 000000000000..5a709c7ab2c8 --- /dev/null +++ b/packages/rest/test/unit/coercion/utils.ts @@ -0,0 +1,63 @@ +import { + OperationObject, + ParameterObject, + SchemaObject, +} from '@loopback/openapi-v3-types'; + +import { + ShotRequestOptions, + expect, + stubExpressContext, +} from '@loopback/testlab'; + +import { + PathParameterValues, + Request, + Route, + createResolvedRoute, + parseOperationArgs, + ResolvedRoute, +} from '../../..'; + +export function givenOperationWithParameters(params?: ParameterObject[]) { + return { + 'x-operation-name': 'testOp', + parameters: params, + responses: {}, + }; +} + +export function givenRequest(options?: ShotRequestOptions): Request { + return stubExpressContext(options).request; +} + +export function givenResolvedRoute( + spec: OperationObject, + pathParams: PathParameterValues = {}, +): ResolvedRoute { + const route = new Route('get', '/', spec, () => {}); + return createResolvedRoute(route, pathParams); +} + +export async function testCoercion( + schemaSpec: SchemaObject, + valueFromReq: string, + expectedResult: T, + caller: string, +) { + try { + const req = givenRequest(); + const spec = givenOperationWithParameters([ + { + name: 'aparameter', + in: 'path', + schema: schemaSpec, + }, + ]); + const route = givenResolvedRoute(spec, {aparameter: valueFromReq}); + const args = await parseOperationArgs(req, route); + expect(args).to.eql([expectedResult]); + } catch (err) { + throw new Error(`${err} \n Failed ${caller}`); + } +}