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 May 31, 2018
1 parent 7d8345c commit e6127f0
Show file tree
Hide file tree
Showing 9 changed files with 319 additions and 3 deletions.
1 change: 1 addition & 0 deletions packages/rest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
55 changes: 55 additions & 0 deletions packages/rest/src/coerce/coerce-param.ts
Original file line number Diff line number Diff line change
@@ -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);
}
11 changes: 8 additions & 3 deletions packages/rest/src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -122,3 +126,4 @@ function buildOperationArguments(
if (requestBodyIndex > -1) paramArgs.splice(requestBodyIndex, 0, body);
return paramArgs;
}

78 changes: 78 additions & 0 deletions packages/rest/test/acceptance/coercion/coercion.acceptance.ts
Original file line number Diff line number Diff line change
@@ -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<supertest.Test>;
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);
}
});
28 changes: 28 additions & 0 deletions packages/rest/test/unit/coercion/paramStringToBoolean.unit.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>({type: 'boolean'}, 'false', false, caller);
})

it('true - \'true\'', async () => {
const caller = new Error().stack!.split(/\n/)[1];
await testCoercion<boolean>({type: 'boolean'}, 'true', true, caller);
})
});
25 changes: 25 additions & 0 deletions packages/rest/test/unit/coercion/paramStringToBuffer.unit.ts
Original file line number Diff line number Diff line change
@@ -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<Buffer>({type: 'string', format: 'byte'}, base64, buffer, caller);
})
});
23 changes: 23 additions & 0 deletions packages/rest/test/unit/coercion/paramStringToDate.unit.ts
Original file line number Diff line number Diff line change
@@ -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<Date>({ type: 'string', format: 'date'}, '2015-03-01', new Date('2015-03-01'), caller);
})
});
38 changes: 38 additions & 0 deletions packages/rest/test/unit/coercion/paramStringToNumber.unit.ts
Original file line number Diff line number Diff line change
@@ -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<number>({type: 'number', format: 'float'}, '3.333333', 3.333333, caller);
})

it('string to double', async () => {
const caller = new Error().stack!.split(/\n/)[1];
await testCoercion<number>({type: 'number', format: 'double'}, '3.333333333', 3.333333333, caller);
})

it('string to integer', async() => {
const caller = new Error().stack!.split(/\n/)[1];
await testCoercion<number>({type: 'integer', format: 'int32'}, '100', 100, caller);
})

it('string to long', async()=> {
const caller = new Error().stack!.split(/\n/)[1];
await testCoercion<number>({type: 'integer', format: 'int64'}, '9223372036854775807', 9223372036854775807, caller);
})
});
63 changes: 63 additions & 0 deletions packages/rest/test/unit/coercion/utils.ts
Original file line number Diff line number Diff line change
@@ -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 <OperationObject>{
'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<T>(
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}`);
}
}

0 comments on commit e6127f0

Please sign in to comment.