From e6127f0b3486c86e7304bb90d6e6d20527e1f75c Mon Sep 17 00:00:00 2001 From: jannyHou Date: Mon, 28 May 2018 23:43:11 -0400 Subject: [PATCH] feat: add type coercion --- packages/rest/package.json | 1 + packages/rest/src/coerce/coerce-param.ts | 55 +++++++++++++ packages/rest/src/parser.ts | 11 ++- .../coercion/coercion.acceptance.ts | 78 +++++++++++++++++++ .../coercion/paramStringToBoolean.unit.ts | 28 +++++++ .../unit/coercion/paramStringToBuffer.unit.ts | 25 ++++++ .../unit/coercion/paramStringToDate.unit.ts | 23 ++++++ .../unit/coercion/paramStringToNumber.unit.ts | 38 +++++++++ packages/rest/test/unit/coercion/utils.ts | 63 +++++++++++++++ 9 files changed, 319 insertions(+), 3 deletions(-) create mode 100644 packages/rest/src/coerce/coerce-param.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/package.json b/packages/rest/package.json index 43d9ac3e097d..c5957e78a863 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -31,6 +31,7 @@ "@types/express": "^4.11.1", "@types/http-errors": "^1.6.1", "body": "^5.1.0", + "caller-id": "^0.1.0", "cors": "^2.8.4", "debug": "^3.1.0", "express": "^4.16.3", diff --git a/packages/rest/src/coerce/coerce-param.ts b/packages/rest/src/coerce/coerce-param.ts new file mode 100644 index 000000000000..18ecccbb47d2 --- /dev/null +++ b/packages/rest/src/coerce/coerce-param.ts @@ -0,0 +1,55 @@ +import { + SchemaObject, + ReferenceObject, + isReferenceObject, +} from '@loopback/openapi-v3-types'; + +import * as HttpErrors from 'http-errors'; +type HttpError = HttpErrors.HttpError; + +export function paramCoerce(data: string, schema?: SchemaObject | ReferenceObject): any { + // ignore reference schema + if (!schema || isReferenceObject(schema)) return data; + + let coercedResult; + coercedResult = data; + const type = schema.type; + const format = schema.format; + + switch(type) { + case 'string': + if (format === 'byte') { + coercedResult = Buffer.from(data, 'base64'); + } else if (format === 'date') { + coercedResult = new Date(data); + } else { + coercedResult = data + } + break; + case 'number': + if (format === 'float' || 'double') { + coercedResult = parseFloat(data); + } else { + throw new HttpErrors.NotImplemented('Type number with format ' + format + ' is not valid') + } + break; + case 'integer': + if (format === 'int32') { + coercedResult = parseInt(data) + } else if(format === 'int64') { + coercedResult = Number(data) + } else { + throw new HttpErrors.NotImplemented('Type integer with format ' + format + ' is not valid'); + } + break; + case 'boolean': + coercedResult = isTrue(data)? true: false; + default: break; + } + return coercedResult; +} + +function isTrue(data: string): boolean { + const isTrueSet = ['true', '1', true, 1]; + return isTrueSet.includes(data); +} diff --git a/packages/rest/src/parser.ts b/packages/rest/src/parser.ts index bb7da42c8f93..b206859a4a7c 100644 --- a/packages/rest/src/parser.ts +++ b/packages/rest/src/parser.ts @@ -8,12 +8,15 @@ import * as HttpErrors from 'http-errors'; import { OperationObject, ParameterObject, + SchemaObject, + ReferenceObject, isReferenceObject, } from '@loopback/openapi-v3-types'; 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 {paramCoerce} from './coerce/coerce-param'; type HttpError = HttpErrors.HttpError; // tslint:disable-next-line:no-any @@ -103,13 +106,14 @@ function buildOperationArguments( const spec = paramSpec as ParameterObject; switch (spec.in) { case 'query': - paramArgs.push(request.query[spec.name]); + paramArgs.push(paramCoerce(request.query[spec.name], spec.schema)); break; case 'path': - paramArgs.push(pathParams[spec.name]); + paramArgs.push(paramCoerce(pathParams[spec.name], spec.schema)); break; case 'header': - paramArgs.push(request.headers[spec.name.toLowerCase()]); + // @jannyhou TBD: check edge cases + paramArgs.push(paramCoerce(request.headers[spec.name.toLowerCase()] as string, spec.schema)); break; // TODO(jannyhou) to support `cookie`, // see issue https://github.com/strongloop/loopback-next/issues/997 @@ -122,3 +126,4 @@ function buildOperationArguments( if (requestBodyIndex > -1) paramArgs.splice(requestBodyIndex, 0, body); return paramArgs; } + 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..7e4b7d7249c0 --- /dev/null +++ b/packages/rest/test/acceptance/coercion/coercion.acceptance.ts @@ -0,0 +1,78 @@ +import {supertest, createClientForHandler, sinon} from '@loopback/testlab'; +import { + RestApplication, + RestServer, + get, + param, + post, + requestBody, + RestBindings, + InvokeMethod, +} from '../../..'; + +describe('Coercion', () => { + let app: RestApplication; + let server: RestServer; + let client: supertest.SuperTest; + let invokeMethod: InvokeMethod; + + 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/13').expect(200); + sinon.assert.calledWithExactly(spy, 13); + sinon.assert.neverCalledWith(spy, '13'); + }); + + 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: 13}); + sinon.assert.calledWithExactly(spy, 13); + sinon.assert.neverCalledWith(spy, '13'); + }); + + 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: 13}).expect(200); + sinon.assert.calledWithExactly(spy, 13); + sinon.assert.neverCalledWith(spy, '13'); + }); + + 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..780884eb856d --- /dev/null +++ b/packages/rest/test/unit/coercion/paramStringToBoolean.unit.ts @@ -0,0 +1,28 @@ +// 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} from '@loopback/openapi-v3-types'; + +import { + expect +} from '@loopback/testlab'; + +import { + parseOperationArgs, +} from '../../..'; + +import {givenOperationWithParameters, givenRequest, givenResolvedRoute, testCoercion} from './utils' + +describe('coerce param from string to boolean', () => { + it('false - \'false\'', async () => { + const caller = new Error().stack!.split(/\n/)[1]; + await testCoercion({type: 'boolean'}, 'false', false, caller); + }) + + it('true - \'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..514320180143 --- /dev/null +++ b/packages/rest/test/unit/coercion/paramStringToBuffer.unit.ts @@ -0,0 +1,25 @@ +// 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} from '@loopback/openapi-v3-types'; + +import { + expect +} from '@loopback/testlab'; + +import { + parseOperationArgs, +} from '../../..'; + +import {givenOperationWithParameters, givenRequest, givenResolvedRoute, 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..f14aa510ef57 --- /dev/null +++ b/packages/rest/test/unit/coercion/paramStringToDate.unit.ts @@ -0,0 +1,23 @@ +// 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} from '@loopback/openapi-v3-types'; + +import { + expect +} from '@loopback/testlab'; + +import { + parseOperationArgs, +} from '../../..'; + +import {givenOperationWithParameters, givenRequest, givenResolvedRoute, testCoercion} from './utils' + +describe('coerce param from string to date', () => { + it('string to 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..0957fc8aec2f --- /dev/null +++ b/packages/rest/test/unit/coercion/paramStringToNumber.unit.ts @@ -0,0 +1,38 @@ +// 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} from '@loopback/openapi-v3-types'; + +import { + expect +} from '@loopback/testlab'; + +import { + parseOperationArgs, +} from '../../..'; + +import {givenOperationWithParameters, givenRequest, givenResolvedRoute, 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..39fccabbb3d9 --- /dev/null +++ b/packages/rest/test/unit/coercion/utils.ts @@ -0,0 +1,63 @@ + +import { + OperationObject, + ParameterObject, + RequestBodyObject, + 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}`); + } +} \ No newline at end of file