Skip to content

Commit 2b4b269

Browse files
jannyHouJanny
authored andcommitted
feat: coercion for more types
1 parent 5fa896d commit 2b4b269

11 files changed

+606
-84
lines changed

docs/site/todo-tutorial-controller.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,6 @@ export class TodoController {
143143
@param.path.number('id') id: number,
144144
@requestBody() todo: Todo,
145145
): Promise<boolean> {
146-
// REST adapter does not coerce parameter values coming from string sources
147-
// like path & query, so we cast the value to a number ourselves.
148-
id = +id;
149146
return await this.todoRepo.replaceById(id, todo);
150147
}
151148

@@ -154,7 +151,6 @@ export class TodoController {
154151
@param.path.number('id') id: number,
155152
@requestBody() todo: Todo,
156153
): Promise<boolean> {
157-
id = +id;
158154
return await this.todoRepo.updateById(id, todo);
159155
}
160156

packages/rest/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@
3838
"http-errors": "^1.6.3",
3939
"js-yaml": "^3.11.0",
4040
"lodash": "^4.17.5",
41-
"path-to-regexp": "^2.2.0"
41+
"path-to-regexp": "^2.2.0",
42+
"validator": "^10.4.0"
4243
},
4344
"devDependencies": {
4445
"@loopback/build": "^0.6.9",

packages/rest/src/coercion/coerce-parameter.ts

Lines changed: 86 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,17 @@ import {ParameterObject, isReferenceObject} from '@loopback/openapi-v3-types';
77
import {Validator} from './validator';
88
import * as debugModule from 'debug';
99
import {RestHttpErrors} from '../';
10-
10+
import {
11+
getOAIPrimitiveType,
12+
isEmpty,
13+
isFalse,
14+
isTrue,
15+
isValidDateTime,
16+
matchDateFormat,
17+
DateCoercionOptions,
18+
IntegerCoercionOptions,
19+
} from './utils';
20+
const isRFC3339 = require('validator/lib/isRFC3339');
1121
const debug = debugModule('loopback:rest:coercion');
1222

1323
/**
@@ -17,7 +27,10 @@ const debug = debugModule('loopback:rest:coercion');
1727
* @param data The raw data get from http request
1828
* @param schema The parameter's schema defined in OpenAPI specification
1929
*/
20-
export function coerceParameter(data: string, spec: ParameterObject) {
30+
export function coerceParameter(
31+
data: string | undefined | object,
32+
spec: ParameterObject,
33+
) {
2134
const schema = spec.schema;
2235
if (!schema || isReferenceObject(schema)) {
2336
debug(
@@ -31,26 +44,25 @@ export function coerceParameter(data: string, spec: ParameterObject) {
3144
const validator = new Validator({parameterSpec: spec});
3245

3346
validator.validateParamBeforeCoercion(data);
47+
if (data === undefined) return data;
3448

3549
switch (OAIType) {
3650
case 'byte':
37-
return Buffer.from(data, 'base64');
51+
return coerceBuffer(data, spec);
3852
case 'date':
39-
return new Date(data);
53+
return coerceDatetime(data, spec, {dateOnly: true});
54+
case 'date-time':
55+
return coerceDatetime(data, spec);
4056
case 'float':
4157
case 'double':
42-
return parseFloat(data);
4358
case 'number':
44-
const coercedData = data ? Number(data) : undefined;
45-
if (coercedData === undefined) return;
46-
if (isNaN(coercedData)) throw RestHttpErrors.invalidData(data, spec.name);
47-
return coercedData;
59+
return coerceNumber(data, spec);
4860
case 'long':
49-
return Number(data);
61+
return coerceInteger(data, spec, {isLong: true});
5062
case 'integer':
51-
return parseInt(data);
63+
return coerceInteger(data, spec);
5264
case 'boolean':
53-
return isTrue(data) ? true : isFalse(data) ? false : undefined;
65+
return coerceBoolean(data, spec);
5466
case 'string':
5567
case 'password':
5668
// serialize will be supported in next PR
@@ -60,56 +72,71 @@ export function coerceParameter(data: string, spec: ParameterObject) {
6072
}
6173
}
6274

63-
/**
64-
* A set of truthy values. A data in this set will be coerced to `true`.
65-
*
66-
* @param data The raw data get from http request
67-
* @returns The corresponding coerced boolean type
68-
*/
69-
function isTrue(data: string): boolean {
70-
return ['true', '1'].includes(data);
75+
function coerceBuffer(data: string | object, spec: ParameterObject) {
76+
if (typeof data === 'object')
77+
throw RestHttpErrors.invalidData(data, spec.name);
78+
return Buffer.from(data, 'base64');
7179
}
7280

73-
/**
74-
* A set of falsy values. A data in this set will be coerced to `false`.
75-
* @param data The raw data get from http request
76-
* @returns The corresponding coerced boolean type
77-
*/
78-
function isFalse(data: string): boolean {
79-
return ['false', '0'].includes(data);
81+
function coerceDatetime(
82+
data: string | object,
83+
spec: ParameterObject,
84+
options?: DateCoercionOptions,
85+
) {
86+
if (typeof data === 'object' || isEmpty(data))
87+
throw RestHttpErrors.invalidData(data, spec.name);
88+
89+
if (options && options.dateOnly) {
90+
if (!matchDateFormat(data))
91+
throw RestHttpErrors.invalidData(data, spec.name);
92+
} else {
93+
if (!isRFC3339(data)) throw RestHttpErrors.invalidData(data, spec.name);
94+
}
95+
96+
const coercedDate = new Date(data);
97+
if (!isValidDateTime(coercedDate))
98+
throw RestHttpErrors.invalidData(data, spec.name);
99+
return coercedDate;
80100
}
81101

82-
/**
83-
* Return the corresponding OpenAPI data type given an OpenAPI schema
84-
*
85-
* @param type The type in an OpenAPI schema specification
86-
* @param format The format in an OpenAPI schema specification
87-
*/
88-
function getOAIPrimitiveType(type?: string, format?: string) {
89-
// serizlize will be supported in next PR
90-
if (type === 'object' || type === 'array') return 'serialize';
91-
if (type === 'string') {
92-
switch (format) {
93-
case 'byte':
94-
return 'byte';
95-
case 'binary':
96-
return 'binary';
97-
case 'date':
98-
return 'date';
99-
case 'date-time':
100-
return 'date-time';
101-
case 'password':
102-
return 'password';
103-
default:
104-
return 'string';
105-
}
102+
function coerceNumber(data: string | object, spec: ParameterObject) {
103+
if (typeof data === 'object' || isEmpty(data))
104+
throw RestHttpErrors.invalidData(data, spec.name);
105+
106+
const coercedNum = Number(data);
107+
if (isNaN(coercedNum)) throw RestHttpErrors.invalidData(data, spec.name);
108+
109+
debug('data of type number is coerced to %s', coercedNum);
110+
return coercedNum;
111+
}
112+
113+
function coerceInteger(
114+
data: string | object,
115+
spec: ParameterObject,
116+
options?: IntegerCoercionOptions,
117+
) {
118+
if (typeof data === 'object' || isEmpty(data))
119+
throw RestHttpErrors.invalidData(data, spec.name);
120+
121+
const coercedInt = Number(data);
122+
if (isNaN(coercedInt!)) throw RestHttpErrors.invalidData(data, spec.name);
123+
124+
if (options && options.isLong) {
125+
if (!Number.isInteger(coercedInt))
126+
throw RestHttpErrors.invalidData(data, spec.name);
127+
} else {
128+
if (!Number.isSafeInteger(coercedInt))
129+
throw RestHttpErrors.invalidData(data, spec.name);
106130
}
107-
if (type === 'boolean') return 'boolean';
108-
if (type === 'number')
109-
return format === 'float'
110-
? 'float'
111-
: format === 'double'
112-
? 'double'
113-
: 'number';
114-
if (type === 'integer') return format === 'int64' ? 'long' : 'integer';
131+
132+
debug('data of type integer is coerced to %s', coercedInt);
133+
return coercedInt;
134+
}
135+
136+
function coerceBoolean(data: string | object, spec: ParameterObject) {
137+
if (typeof data === 'object' || isEmpty(data))
138+
throw RestHttpErrors.invalidData(data, spec.name);
139+
if (isTrue(data)) return true;
140+
if (isFalse(data)) return false;
141+
throw RestHttpErrors.invalidData(data, spec.name);
115142
}

packages/rest/src/coercion/rest-http-error.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@ export namespace RestHttpErrors {
99
return new HttpErrors.BadRequest(msg);
1010
}
1111
export function invalidParamLocation(location: string): HttpErrors.HttpError {
12-
return new HttpErrors.NotImplemented(
13-
'Parameters with "in: ' + location + '" are not supported yet.',
14-
);
12+
const msg = `Parameters with "in: ${location}" are not supported yet.`;
13+
return new HttpErrors.NotImplemented(msg);
1514
}
1615
}

packages/rest/src/coercion/utils.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright IBM Corp. 2018. All Rights Reserved.
2+
// Node module: @loopback/rest
3+
// This file is licensed under the MIT License.
4+
// License text available at https://opensource.org/licenses/MIT
5+
6+
import * as debugModule from 'debug';
7+
const debug = debugModule('loopback:rest:coercion');
8+
9+
/**
10+
* Options for function coerceDatetime
11+
*/
12+
export type DateCoercionOptions = {
13+
dateOnly?: boolean;
14+
};
15+
16+
/**
17+
* Options for function coerceInteger
18+
*/
19+
export type IntegerCoercionOptions = {
20+
isLong?: boolean;
21+
};
22+
23+
export function isEmpty(data: string) {
24+
debug('isEmpty %s', data);
25+
return data === '';
26+
}
27+
/**
28+
* A set of truthy values. A data in this set will be coerced to `true`.
29+
*
30+
* @param data The raw data get from http request
31+
* @returns The corresponding coerced boolean type
32+
*/
33+
export function isTrue(data: string): boolean {
34+
return ['TRUE', '1'].includes(data.toUpperCase());
35+
}
36+
37+
/**
38+
* A set of falsy values. A data in this set will be coerced to `false`.
39+
* @param data The raw data get from http request
40+
* @returns The corresponding coerced boolean type
41+
*/
42+
export function isFalse(data: string): boolean {
43+
return ['FALSE', '0'].includes(data.toUpperCase());
44+
}
45+
46+
/**
47+
* Return false for invalid date
48+
*/
49+
export function isValidDateTime(data: Date) {
50+
return isNaN(data.getTime()) ? false : true;
51+
}
52+
53+
const REGEX_RFC3339_DATE = /^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])$/;
54+
55+
/**
56+
* Return true when a date follows the RFC3339 standard
57+
*
58+
* @param date The date to verify
59+
*/
60+
export function matchDateFormat(date: string) {
61+
const pattern = new RegExp(REGEX_RFC3339_DATE);
62+
debug('matchDateFormat: %s', pattern.test(date));
63+
return pattern.test(date);
64+
}
65+
66+
/**
67+
* Return the corresponding OpenAPI data type given an OpenAPI schema
68+
*
69+
* @param type The type in an OpenAPI schema specification
70+
* @param format The format in an OpenAPI schema specification
71+
*/
72+
export function getOAIPrimitiveType(type?: string, format?: string) {
73+
// serizlize will be supported in next PR
74+
if (type === 'object' || type === 'array') return 'serialize';
75+
if (type === 'string') {
76+
switch (format) {
77+
case 'byte':
78+
return 'byte';
79+
case 'binary':
80+
return 'binary';
81+
case 'date':
82+
return 'date';
83+
case 'date-time':
84+
return 'date-time';
85+
case 'password':
86+
return 'password';
87+
default:
88+
return 'string';
89+
}
90+
}
91+
if (type === 'boolean') return 'boolean';
92+
if (type === 'number')
93+
switch (format) {
94+
case 'float':
95+
return 'float';
96+
case 'double':
97+
return 'double';
98+
default:
99+
return 'number';
100+
}
101+
if (type === 'integer') return format === 'int64' ? 'long' : 'integer';
102+
}

0 commit comments

Comments
 (0)