-
Notifications
You must be signed in to change notification settings - Fork 1.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: add type coercion #1370
feat: add type coercion #1370
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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'; | ||
} |
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.', | ||
); | ||
} | ||
} |
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I thought we were using AJV for the validation portion of the epic. Is this catered for interweaving coercion and validation together? I think I may get a better understanding if I was to see how the code here would be used. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My initial thought: Can we leave Validation out of scope of this initial pull request please? As I understand our plan, the scope of #750 is only coercion. Validation will be covered by #118. Even if we think that #750 should cover basic validation, I would prefer to keep this initial pull request as small as possible so that we can land it sooner. On the second though, it's difficult to implement coercion without basic validation, because we need some way to handle string values that cannot be converted to the target type. Would it make sense to (temporarily?) simplify this part and let As for Thoughts? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A code snippet to illustrate what I meant. Now: case 'number':
validator.validateParamBeforeCoercion('number', data);
coercedResult = data ? Number(data) : undefined;
validator.validateParamAfterCoercion('number', coercedResult); My proposal: // handle empty string for all types at the top
case 'number':
// we can assume "data" is not an empty string here
coercedResult = Number(data);
if (isNaN(coercedResult)) {
// report coercion error - data is not a number
} There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @bajtos I think the problem is it's not possible to ignore the requirement check if I add all edge cases for a type.
That's the tricky part...
IMO, requirement check should be part of the basic validation. // handle empty string for all types at the top
case 'number':
// we can assume "data" is not an empty string here
coercedResult = Number(data);
if (isNaN(coercedResult)) {
// report coercion error - data is not a number
} ^ That looks good to me within this PR 👍While I still want to apply those checking functions through a validator class. The reason why I define the validator(does basic validation) as a class is
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Good point about AJV capabilities. Even if we don't use AJV to validate parameter spec, my opinion is that validation is out of scope of #750.
I have a different view. When the spec says that a parameter is of type number, I consider Having said that, I have re-read the Parameter Object from OpenAPI-Specification and the way how to spec is laid out, I agree with you it makes sense to handle |
||
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; | ||
} | ||
} |
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}); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The parameter here should be in string if I understand how http headers work correctly There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe the HTTP client we are using (supertest/superagent) has to convert the value to string during transport, so it should not really matter whether we use a number or a string here. Having said that, I don't mind either way. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yep, see this edge case for type number, there is a conversion done by http client before the original query data reaches our paramParser, and that's why whatever data type you provide in the query/path/header, the paramParser always receive it as a string. I use a |
||
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}) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same as above |
||
.expect(200); | ||
sinon.assert.calledWithExactly(spy, 100); | ||
}); | ||
|
||
async function givenAClient() { | ||
app = new RestApplication(); | ||
app.controller(MyController); | ||
await app.start(); | ||
client = await createClientForHandler(app.requestHandler); | ||
} | ||
}); |
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')); | ||
}); |
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); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
typo:
serizlize
->serialize
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@b-admike You may review a wrong commit? In the latest code it's already fixed, actually it was fixed long time(1-2 weeks) ago lol