Skip to content

Commit d095693

Browse files
committed
feat: coerce object arguments from query strings
Introduce a new decorator `@param.query.object` allowing controller methods to describe a parameter as an object and optionally provide the schema for the accepted values. Improve `parseParams` action to correctly parse and coerce object values coming from query strings, supporting the following two flavours: "deepObject" encoding as described by OpenAPI Spec v3: GET /api/products?filter[where][name]=Pen&filter[limit]=10 JSON-based encoding for compatibility with LoopBack 3.x: GET /api/products?filter={"where":{"name":"Pen"},"limit":10}
1 parent 2d98873 commit d095693

File tree

15 files changed

+581
-62
lines changed

15 files changed

+581
-62
lines changed

docs/site/Parsing-requests.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,36 @@ async replaceTodo(
7878
}
7979
```
8080

81+
#### Object values
82+
83+
OpenAPI specification describes several ways how to encode object values into a
84+
string, see
85+
[Style Values](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#style-values)
86+
and
87+
[Style Examples](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#style-examples).
88+
89+
At the moment, LoopBack supports object values for parameters in query strings
90+
with `style: "deepObject"` only. Please note that this style does not preserve
91+
encoding of primitive types, numbers and booleans are always parsed as strings.
92+
93+
For example:
94+
95+
```
96+
GET /todos?filter[where][completed]=false
97+
// filter={where: {completed: 'false'}}
98+
```
99+
100+
As an extension to the deep-object encoding described by OpenAPI, when the
101+
parameter is specified with `style: "deepObject"`, we allow clients to provide
102+
the object value as a JSON-encoded string too.
103+
104+
For example:
105+
106+
```
107+
GET /todos?filter={"where":{"completed":false}}
108+
// filter={where: {completed: false}}
109+
```
110+
81111
### Validation
82112

83113
Validations are applied on the parameters and the request body data. They also
@@ -107,6 +137,7 @@ Here are our default validation rules for each type:
107137
[RFC3339](https://xml2rfc.tools.ietf.org/public/rfc/html/rfc3339.html#anchor14).
108138
- boolean: after converted to all upper case, should be one of the following
109139
values: `TRUE`, `1`, `FALSE` or `0`.
140+
- object: should be a plain data object, not an array.
110141

111142
#### Request Body
112143

packages/openapi-v3-types/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
},
88
"dependencies": {
99
"@loopback/dist-util": "^0.3.6",
10-
"openapi3-ts": "^0.11.0"
10+
"openapi3-ts": "^1.0.0"
1111
},
1212
"devDependencies": {
1313
"@loopback/build": "^0.7.1",

packages/openapi-v3/src/decorators/parameter.decorator.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,29 @@ export namespace param {
204204
* @param name Parameter name.
205205
*/
206206
password: createParamShortcut('query', builtinTypes.password),
207+
208+
/**
209+
* Define a parameter accepting an object value encoded
210+
* - as a JSON string, e.g. `filter={"where":{"id":1}}`); or
211+
* - in multiple nested keys, e.g. `filter[where][id]=1`
212+
*
213+
* @param name Parameter name
214+
* @param schema Optional OpenAPI Schema describing the object value.
215+
*/
216+
object: function(
217+
name: string,
218+
schema: SchemaObject | ReferenceObject = {
219+
type: 'object',
220+
additionalProperties: true,
221+
},
222+
) {
223+
return param({
224+
name,
225+
in: 'query',
226+
style: 'deepObject',
227+
schema,
228+
});
229+
},
207230
};
208231

209232
export const header = {

packages/openapi-v3/test/unit/decorators/param/param-query.decorator.unit.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import {get, param, getControllerSpec} from '../../../..';
77
import {expect} from '@loopback/testlab';
8+
import {ParameterObject} from '@loopback/openapi-v3-types';
89

910
describe('Routing metadata for parameters', () => {
1011
describe('@param.query.string', () => {
@@ -219,6 +220,54 @@ describe('Routing metadata for parameters', () => {
219220
expectSpecToBeEqual(MyController, expectedParamSpec);
220221
});
221222
});
223+
224+
describe('@param.query.object', () => {
225+
it('sets in:query style:deepObject and a default schema', () => {
226+
class MyController {
227+
@get('/greet')
228+
greet(@param.query.object('filter') filter: Object) {}
229+
}
230+
const expectedParamSpec = <ParameterObject>{
231+
name: 'filter',
232+
in: 'query',
233+
style: 'deepObject',
234+
schema: {
235+
type: 'object',
236+
additionalProperties: true,
237+
},
238+
};
239+
expectSpecToBeEqual(MyController, expectedParamSpec);
240+
});
241+
242+
it('supports user-defined schema', () => {
243+
class MyController {
244+
@get('/greet')
245+
greet(
246+
@param.query.object('filter', {
247+
type: 'object',
248+
properties: {
249+
where: {type: 'object', additionalProperties: true},
250+
limit: {type: 'number'},
251+
},
252+
})
253+
filter: Object,
254+
) {}
255+
}
256+
const expectedParamSpec: ParameterObject = {
257+
name: 'filter',
258+
in: 'query',
259+
style: 'deepObject',
260+
schema: {
261+
type: 'object',
262+
properties: {
263+
where: {type: 'object', additionalProperties: true},
264+
limit: {type: 'number'},
265+
},
266+
},
267+
};
268+
expectSpecToBeEqual(MyController, expectedParamSpec);
269+
});
270+
});
222271
});
223272

224273
function expectSpecToBeEqual(controller: Function, paramSpec: object) {

packages/rest/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
"@types/cors": "^2.8.3",
3232
"@types/express": "^4.11.1",
3333
"@types/http-errors": "^1.6.1",
34+
"@types/parseurl": "^1.3.1",
35+
"@types/qs": "^6.5.1",
3436
"ajv": "^6.5.1",
3537
"body": "^5.1.0",
3638
"cors": "^2.8.4",
@@ -40,7 +42,9 @@
4042
"js-yaml": "^3.11.0",
4143
"lodash": "^4.17.5",
4244
"openapi-schema-to-json-schema": "^2.1.0",
45+
"parseurl": "^1.3.2",
4346
"path-to-regexp": "^2.2.0",
47+
"qs": "^6.5.2",
4448
"strong-error-handler": "^3.2.0",
4549
"validator": "^10.4.0"
4650
},

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

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,10 @@ export function coerceParameter(
6363
return coerceInteger(data, spec);
6464
case 'boolean':
6565
return coerceBoolean(data, spec);
66+
case 'object':
67+
return coerceObject(data, spec);
6668
case 'string':
6769
case 'password':
68-
// serialize will be supported in next PR
69-
case 'serialize':
7070
default:
7171
return data;
7272
}
@@ -140,3 +140,51 @@ function coerceBoolean(data: string | object, spec: ParameterObject) {
140140
if (isFalse(data)) return false;
141141
throw RestHttpErrors.invalidData(data, spec.name);
142142
}
143+
144+
function coerceObject(input: string | object, spec: ParameterObject) {
145+
const data = parseJsonIfNeeded(input, spec);
146+
147+
if (data === undefined) {
148+
// Skip any further checks and coercions, nothing we can do with `undefined`
149+
return undefined;
150+
}
151+
152+
if (typeof data !== 'object' || Array.isArray(data))
153+
throw RestHttpErrors.invalidData(input, spec.name);
154+
155+
// TODO(bajtos) apply coercion based on properties defined by spec.schema
156+
return data;
157+
}
158+
159+
function parseJsonIfNeeded(
160+
data: string | object,
161+
spec: ParameterObject,
162+
): string | object | undefined {
163+
if (typeof data !== 'string') return data;
164+
165+
if (spec.in !== 'query' || spec.style !== 'deepObject') {
166+
debug(
167+
'Skipping JSON.parse, argument %s is not in:query style:deepObject',
168+
spec.name,
169+
);
170+
return data;
171+
}
172+
173+
if (data === '') {
174+
debug('Converted empty string to object value `undefined`');
175+
return undefined;
176+
}
177+
178+
try {
179+
const result = JSON.parse(data);
180+
debug('Parsed parameter %s as %j', spec.name, result);
181+
return result;
182+
} catch (err) {
183+
debug('Cannot parse %s value %j as JSON: %s', spec.name, data, err.message);
184+
throw RestHttpErrors.invalidData(data, spec.name, {
185+
details: {
186+
syntaxError: err.message,
187+
},
188+
});
189+
}
190+
}

packages/rest/src/coercion/utils.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ export type IntegerCoercionOptions = {
2121
};
2222

2323
export function isEmpty(data: string) {
24-
debug('isEmpty %s', data);
25-
return data === '';
24+
const result = data === '';
25+
debug('isEmpty(%j) -> %s', data, result);
26+
return result;
2627
}
2728
/**
2829
* A set of truthy values. A data in this set will be coerced to `true`.
@@ -59,8 +60,9 @@ const REGEX_RFC3339_DATE = /^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]
5960
*/
6061
export function matchDateFormat(date: string) {
6162
const pattern = new RegExp(REGEX_RFC3339_DATE);
62-
debug('matchDateFormat: %s', pattern.test(date));
63-
return pattern.test(date);
63+
const result = pattern.test(date);
64+
debug('matchDateFormat(%j) -> %s', date, result);
65+
return result;
6466
}
6567

6668
/**
@@ -70,8 +72,7 @@ export function matchDateFormat(date: string) {
7072
* @param format The format in an OpenAPI schema specification
7173
*/
7274
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 === 'object' || type === 'array') return type;
7576
if (type === 'string') {
7677
switch (format) {
7778
case 'byte':

packages/rest/src/coercion/validator.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// This file is licensed under the MIT License.
44
// License text available at https://opensource.org/licenses/MIT
55

6-
import {ParameterObject} from '@loopback/openapi-v3-types';
6+
import {ParameterObject, SchemaObject} from '@loopback/openapi-v3-types';
77
import {RestHttpErrors} from '../';
88

99
/**
@@ -63,6 +63,11 @@ export class Validator {
6363
*/
6464
// tslint:disable-next-line:no-any
6565
isAbsent(value: any) {
66-
return value === '' || value === undefined;
66+
if (value === '' || value === undefined) return true;
67+
68+
const schema: SchemaObject = this.ctx.parameterSpec.schema || {};
69+
if (schema.type === 'object' && value === 'null') return true;
70+
71+
return false;
6772
}
6873
}

packages/rest/src/parser.ts

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,32 @@
33
// This file is licensed under the MIT License.
44
// License text available at https://opensource.org/licenses/MIT
55

6-
import {ServerRequest} from 'http';
7-
import * as HttpErrors from 'http-errors';
6+
import {REQUEST_BODY_INDEX} from '@loopback/openapi-v3';
87
import {
8+
isReferenceObject,
99
OperationObject,
1010
ParameterObject,
11-
isReferenceObject,
1211
SchemasObject,
1312
} from '@loopback/openapi-v3-types';
14-
import {REQUEST_BODY_INDEX} from '@loopback/openapi-v3';
13+
import * as debugModule from 'debug';
14+
import {ServerRequest} from 'http';
15+
import * as HttpErrors from 'http-errors';
16+
import * as parseUrl from 'parseurl';
17+
import {parse as parseQuery} from 'qs';
1518
import {promisify} from 'util';
16-
import {OperationArgs, Request, PathParameterValues} from './types';
17-
import {ResolvedRoute} from './router/routing-table';
1819
import {coerceParameter} from './coercion/coerce-parameter';
19-
import {validateRequestBody} from './validation/request-body.validator';
2020
import {RestHttpErrors} from './index';
21+
import {ResolvedRoute} from './router/routing-table';
22+
import {OperationArgs, PathParameterValues, Request} from './types';
23+
import {validateRequestBody} from './validation/request-body.validator';
24+
2125
type HttpError = HttpErrors.HttpError;
22-
import * as debugModule from 'debug';
26+
2327
const debug = debugModule('loopback:rest:parser');
2428

29+
export const QUERY_NOT_PARSED = {};
30+
Object.freeze(QUERY_NOT_PARSED);
31+
2532
// tslint:disable-next-line:no-any
2633
type MaybeBody = any | undefined;
2734

@@ -134,22 +141,31 @@ function getParamFromRequest(
134141
request: Request,
135142
pathParams: PathParameterValues,
136143
) {
137-
let result;
138144
switch (spec.in) {
139145
case 'query':
140-
result = request.query[spec.name];
141-
break;
146+
ensureRequestQueryWasParsed(request);
147+
return request.query[spec.name];
142148
case 'path':
143-
result = pathParams[spec.name];
144-
break;
149+
return pathParams[spec.name];
145150
case 'header':
146151
// @jannyhou TBD: check edge cases
147-
result = request.headers[spec.name.toLowerCase()];
152+
return request.headers[spec.name.toLowerCase()];
148153
break;
149154
// TODO(jannyhou) to support `cookie`,
150155
// see issue https://github.com/strongloop/loopback-next/issues/997
151156
default:
152157
throw RestHttpErrors.invalidParamLocation(spec.in);
153158
}
154-
return result;
159+
}
160+
161+
function ensureRequestQueryWasParsed(request: Request) {
162+
if (request.query && request.query !== QUERY_NOT_PARSED) return;
163+
164+
const input = parseUrl(request)!.query;
165+
if (input && typeof input === 'string') {
166+
request.query = parseQuery(input);
167+
} else {
168+
request.query = {};
169+
}
170+
debug('Parsed request query: ', request.query);
155171
}

0 commit comments

Comments
 (0)