Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/site/Parsing-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,36 @@ async replaceTodo(
}
```

#### Object values

OpenAPI specification describes several ways how to encode object values into a
string, see
[Style Values](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#style-values)
and
[Style Examples](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#style-examples).

At the moment, LoopBack supports object values for parameters in query strings
with `style: "deepObject"` only. Please note that this style does not preserve
encoding of primitive types, numbers and booleans are always parsed as strings.

For example:

```
GET /todos?filter[where][completed]=false
// filter={where: {completed: 'false'}}
```

As an extension to the deep-object encoding described by OpenAPI, when the
parameter is specified with `style: "deepObject"`, we allow clients to provide
the object value as a JSON-encoded string too.

For example:

```
GET /todos?filter={"where":{"completed":false}}
// filter={where: {completed: false}}
```

### Validation

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

#### Request Body

Expand Down
2 changes: 1 addition & 1 deletion packages/openapi-v3-types/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
},
"dependencies": {
"@loopback/dist-util": "^0.3.6",
"openapi3-ts": "^0.11.0"
"openapi3-ts": "^1.0.0"
},
"devDependencies": {
"@loopback/build": "^0.7.1",
Expand Down
23 changes: 23 additions & 0 deletions packages/openapi-v3/src/decorators/parameter.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,29 @@ export namespace param {
* @param name Parameter name.
*/
password: createParamShortcut('query', builtinTypes.password),

/**
* Define a parameter accepting an object value encoded
* - as a JSON string, e.g. `filter={"where":{"id":1}}`); or
* - in multiple nested keys, e.g. `filter[where][id]=1`
*
* @param name Parameter name
* @param schema Optional OpenAPI Schema describing the object value.
*/
object: function(
Copy link
Contributor

@jannyHou jannyHou Sep 4, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall we name it as deepObject? Or something could infer that {in: 'query', style: 'deepObject'} is hardcorded for this decorator.

I understand that for most situations the object is provided in query. While just in case people misuse it with parameters got from path(with style matrix or label), thought?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to distinguish between two bits of information provided by @param.query.deepObject:

  • the value has type "object"
  • the value is encoded using deepObject style.

Object values provided in query can use the following encoding styles (see the section "Style values"):

  • deepObject (e.g. ?color[R]=100&color[G]=200&color[B]=150)
  • form + explode: true (e.g. ?color=R,100,G,200,B,150)
  • form + explode: false (e.g. ?R=100&G=200&B=150)

As I see it, @param.query.object is saying that the value is of object type, similar how @param.query.number or @param.query.boolean works. To me, the fact that we allow deepObject style only, is a limitation we can lift later in the future. For example, we can allow callers to specify a different style and explode values by adding an optional third parameter:

@param.query.object('filter', FilterSchema, {style: 'deepObject'});
@param.query.object('filter', FilterSchema, {style: 'form', explode: false});
@param.query.object('filter', FilterSchema, {style: 'form', explode: true});

While just in case people misuse it with parameters got from path(with style matrix or label), thought?

At the moment, object method is defined on param.query only. I agree we should eventually add param.path.object and param.header.object in the future, supporting encoding styles that are appropriate for those sources. To me, it still makes sense to use the same method name object, because the method name is describing the value type, not the encoding style.

Maybe I don't understand your concern, what kind of misuse do you have in mind?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bajtos

To me, the fact that we allow deepObject style only, is a limitation we can lift later in the future.

ok I got it 👍 I think we are on the same page now, previously I thought you are creating a decorator particularly for {style: 'deepObject'}.

And my bad I mixed the usage of param.query.object and param.object.

Let's keep the name as param.query.object

name: string,
schema: SchemaObject | ReferenceObject = {
type: 'object',
additionalProperties: true,
},
) {
return param({
name,
in: 'query',
style: 'deepObject',
schema,
});
},
};

export const header = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

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

describe('Routing metadata for parameters', () => {
describe('@param.query.string', () => {
Expand Down Expand Up @@ -219,6 +220,54 @@ describe('Routing metadata for parameters', () => {
expectSpecToBeEqual(MyController, expectedParamSpec);
});
});

describe('@param.query.object', () => {
it('sets in:query style:deepObject and a default schema', () => {
class MyController {
@get('/greet')
greet(@param.query.object('filter') filter: Object) {}
}
const expectedParamSpec = <ParameterObject>{
name: 'filter',
in: 'query',
style: 'deepObject',
schema: {
type: 'object',
additionalProperties: true,
},
};
expectSpecToBeEqual(MyController, expectedParamSpec);
});

it('supports user-defined schema', () => {
class MyController {
@get('/greet')
greet(
@param.query.object('filter', {
type: 'object',
properties: {
where: {type: 'object', additionalProperties: true},
limit: {type: 'number'},
},
})
filter: Object,
) {}
}
const expectedParamSpec: ParameterObject = {
name: 'filter',
in: 'query',
style: 'deepObject',
schema: {
type: 'object',
properties: {
where: {type: 'object', additionalProperties: true},
limit: {type: 'number'},
},
},
};
expectSpecToBeEqual(MyController, expectedParamSpec);
});
});
});

function expectSpecToBeEqual(controller: Function, paramSpec: object) {
Expand Down
4 changes: 4 additions & 0 deletions packages/rest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
"@types/cors": "^2.8.3",
"@types/express": "^4.11.1",
"@types/http-errors": "^1.6.1",
"@types/parseurl": "^1.3.1",
"@types/qs": "^6.5.1",
"ajv": "^6.5.1",
"body": "^5.1.0",
"cors": "^2.8.4",
Expand All @@ -40,7 +42,9 @@
"js-yaml": "^3.11.0",
"lodash": "^4.17.5",
"openapi-schema-to-json-schema": "^2.1.0",
"parseurl": "^1.3.2",
"path-to-regexp": "^2.2.0",
"qs": "^6.5.2",
"strong-error-handler": "^3.2.0",
"validator": "^10.4.0"
},
Expand Down
52 changes: 50 additions & 2 deletions packages/rest/src/coercion/coerce-parameter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,10 @@ export function coerceParameter(
return coerceInteger(data, spec);
case 'boolean':
return coerceBoolean(data, spec);
case 'object':
return coerceObject(data, spec);
case 'string':
case 'password':
// serialize will be supported in next PR
case 'serialize':
default:
return data;
}
Expand Down Expand Up @@ -140,3 +140,51 @@ function coerceBoolean(data: string | object, spec: ParameterObject) {
if (isFalse(data)) return false;
throw RestHttpErrors.invalidData(data, spec.name);
}

function coerceObject(input: string | object, spec: ParameterObject) {
const data = parseJsonIfNeeded(input, spec);

if (data === undefined) {
// Skip any further checks and coercions, nothing we can do with `undefined`
return undefined;
}

if (typeof data !== 'object' || Array.isArray(data))
throw RestHttpErrors.invalidData(input, spec.name);

// TODO(bajtos) apply coercion based on properties defined by spec.schema
return data;
}

function parseJsonIfNeeded(
data: string | object,
spec: ParameterObject,
): string | object | undefined {
if (typeof data !== 'string') return data;

if (spec.in !== 'query' || spec.style !== 'deepObject') {
debug(
'Skipping JSON.parse, argument %s is not in:query style:deepObject',
spec.name,
);
return data;
}

if (data === '') {
debug('Converted empty string to object value `undefined`');
return undefined;
}

try {
const result = JSON.parse(data);
debug('Parsed parameter %s as %j', spec.name, result);
return result;
} catch (err) {
debug('Cannot parse %s value %j as JSON: %s', spec.name, data, err.message);
throw RestHttpErrors.invalidData(data, spec.name, {
details: {
syntaxError: err.message,
},
});
}
}
13 changes: 7 additions & 6 deletions packages/rest/src/coercion/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ export type IntegerCoercionOptions = {
};

export function isEmpty(data: string) {
debug('isEmpty %s', data);
return data === '';
const result = data === '';
debug('isEmpty(%j) -> %s', data, result);
return result;
}
/**
* A set of truthy values. A data in this set will be coerced to `true`.
Expand Down Expand Up @@ -59,8 +60,9 @@ const REGEX_RFC3339_DATE = /^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]
*/
export function matchDateFormat(date: string) {
const pattern = new RegExp(REGEX_RFC3339_DATE);
debug('matchDateFormat: %s', pattern.test(date));
return pattern.test(date);
const result = pattern.test(date);
debug('matchDateFormat(%j) -> %s', date, result);
return result;
}

/**
Expand All @@ -70,8 +72,7 @@ export function matchDateFormat(date: string) {
* @param format The format in an OpenAPI schema specification
*/
export function getOAIPrimitiveType(type?: string, format?: string) {
// serizlize will be supported in next PR
if (type === 'object' || type === 'array') return 'serialize';
if (type === 'object' || type === 'array') return type;
if (type === 'string') {
switch (format) {
case 'byte':
Expand Down
9 changes: 7 additions & 2 deletions packages/rest/src/coercion/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// 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 {ParameterObject, SchemaObject} from '@loopback/openapi-v3-types';
import {RestHttpErrors} from '../';

/**
Expand Down Expand Up @@ -63,6 +63,11 @@ export class Validator {
*/
// tslint:disable-next-line:no-any
isAbsent(value: any) {
return value === '' || value === undefined;
if (value === '' || value === undefined) return true;

const schema: SchemaObject = this.ctx.parameterSpec.schema || {};
if (schema.type === 'object' && value === 'null') return true;

return false;
}
}
46 changes: 31 additions & 15 deletions packages/rest/src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,32 @@
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {ServerRequest} from 'http';
import * as HttpErrors from 'http-errors';
import {REQUEST_BODY_INDEX} from '@loopback/openapi-v3';
import {
isReferenceObject,
OperationObject,
ParameterObject,
isReferenceObject,
SchemasObject,
} from '@loopback/openapi-v3-types';
import {REQUEST_BODY_INDEX} from '@loopback/openapi-v3';
import * as debugModule from 'debug';
import {ServerRequest} from 'http';
import * as HttpErrors from 'http-errors';
import * as parseUrl from 'parseurl';
import {parse as parseQuery} from 'qs';
import {promisify} from 'util';
import {OperationArgs, Request, PathParameterValues} from './types';
import {ResolvedRoute} from './router/routing-table';
import {coerceParameter} from './coercion/coerce-parameter';
import {validateRequestBody} from './validation/request-body.validator';
import {RestHttpErrors} from './index';
import {ResolvedRoute} from './router/routing-table';
import {OperationArgs, PathParameterValues, Request} from './types';
import {validateRequestBody} from './validation/request-body.validator';

type HttpError = HttpErrors.HttpError;
import * as debugModule from 'debug';

const debug = debugModule('loopback:rest:parser');

export const QUERY_NOT_PARSED = {};
Object.freeze(QUERY_NOT_PARSED);

// tslint:disable-next-line:no-any
type MaybeBody = any | undefined;

Expand Down Expand Up @@ -134,22 +141,31 @@ function getParamFromRequest(
request: Request,
pathParams: PathParameterValues,
) {
let result;
switch (spec.in) {
case 'query':
result = request.query[spec.name];
break;
ensureRequestQueryWasParsed(request);
return request.query[spec.name];
case 'path':
result = pathParams[spec.name];
break;
return pathParams[spec.name];
case 'header':
// @jannyhou TBD: check edge cases
result = request.headers[spec.name.toLowerCase()];
return request.headers[spec.name.toLowerCase()];
break;
// TODO(jannyhou) to support `cookie`,
// see issue https://github.com/strongloop/loopback-next/issues/997
default:
throw RestHttpErrors.invalidParamLocation(spec.in);
}
return result;
}

function ensureRequestQueryWasParsed(request: Request) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Configure the maximum depth on per-endpoint basis depending on the parameter schema. E.g. if a parameter accepts only top-level properties R, G and B, then it's enough to set depth to 1.

How is the above done here where we're calling qs?

Copy link
Member Author

@bajtos bajtos Sep 6, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left that out of this initial pull request and use qs default configuration of depth: 5.

Determining the minimum depth is a bit involved, as we have to visit all nested schemas and $ref links. And then there is the schema option additionalProperties: true which basically enables unlimited depth.

if (request.query && request.query !== QUERY_NOT_PARSED) return;

const input = parseUrl(request)!.query;
if (input && typeof input === 'string') {
request.query = parseQuery(input);
} else {
request.query = {};
}
debug('Parsed request query: ', request.query);
}
Loading