Skip to content

Commit ca8d96e

Browse files
committed
feat: builders for Filter and Where schemas
Implement new APIs for building JSON and OpenAPI schemas describing the "filter" and "where" objects used to query or modify model instances.
1 parent 49454aa commit ca8d96e

File tree

13 files changed

+401
-10
lines changed

13 files changed

+401
-10
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ export namespace param {
224224
name,
225225
in: 'query',
226226
style: 'deepObject',
227+
explode: true,
227228
schema,
228229
});
229230
},
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Copyright IBM Corp. 2018. All Rights Reserved.
2+
// Node module: @loopback/openapi-v3
3+
// This file is licensed under the MIT License.
4+
// License text available at https://opensource.org/licenses/MIT
5+
6+
import {SchemaObject} from '@loopback/openapi-v3-types';
7+
import {
8+
getFilterJsonSchemaFor,
9+
getWhereJsonSchemaFor,
10+
Model,
11+
} from '@loopback/repository-json-schema';
12+
import {jsonToSchemaObject} from './json-to-schema';
13+
14+
/**
15+
* Build an OpenAPI schema describing the format of the "filter" object
16+
* used to query model instances.
17+
*
18+
* Note we don't take the model properties into account yet and return
19+
* a generic json schema allowing any "where" condition.
20+
*
21+
* @param modelCtor The model constructor to build the filter schema for.
22+
*/
23+
export function getFilterSchemaFor(modelCtor: typeof Model): SchemaObject {
24+
const jsonSchema = getFilterJsonSchemaFor(modelCtor);
25+
const schema = jsonToSchemaObject(jsonSchema);
26+
return schema;
27+
}
28+
29+
/**
30+
* Build a OpenAPI schema describing the format of the "where" object
31+
* used to filter model instances to query, update or delete.
32+
*
33+
* Note we don't take the model properties into account yet and return
34+
* a generic json schema allowing any "where" condition.
35+
*
36+
* @param modelCtor The model constructor to build the filter schema for.
37+
*/
38+
export function getWhereSchemaFor(modelCtor: typeof Model): SchemaObject {
39+
const jsonSchema = getWhereJsonSchemaFor(modelCtor);
40+
const schema = jsonToSchemaObject(jsonSchema);
41+
return schema;
42+
}

packages/openapi-v3/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@
66
export * from './decorators';
77
export * from './controller-spec';
88
export * from './json-to-schema';
9+
export * from './filter-schema';
910

1011
export * from '@loopback/repository-json-schema';

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ describe('Routing metadata for parameters', () => {
231231
name: 'filter',
232232
in: 'query',
233233
style: 'deepObject',
234+
explode: true,
234235
schema: {
235236
type: 'object',
236237
additionalProperties: true,
@@ -257,6 +258,7 @@ describe('Routing metadata for parameters', () => {
257258
name: 'filter',
258259
in: 'query',
259260
style: 'deepObject',
261+
explode: true,
260262
schema: {
261263
type: 'object',
262264
properties: {
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright IBM Corp. 2018. All Rights Reserved.
2+
// Node module: @loopback/repository-json-schema
3+
// This file is licensed under the MIT License.
4+
// License text available at https://opensource.org/licenses/MIT
5+
6+
import {Model, model, getModelRelations} from '@loopback/repository';
7+
import {JSONSchema6 as JsonSchema} from 'json-schema';
8+
9+
@model({settings: {strict: false}})
10+
class EmptyModel extends Model {}
11+
12+
const scopeFilter = getFilterJsonSchemaFor(EmptyModel);
13+
14+
/**
15+
* Build a JSON schema describing the format of the "filter" object
16+
* used to query model instances.
17+
*
18+
* Note we don't take the model properties into account yet and return
19+
* a generic json schema allowing any "where" condition.
20+
*
21+
* @param modelCtor The model constructor to build the filter schema for.
22+
*/
23+
export function getFilterJsonSchemaFor(modelCtor: typeof Model): JsonSchema {
24+
const schema: JsonSchema = {
25+
properties: {
26+
where: getWhereJsonSchemaFor(modelCtor),
27+
28+
fields: {
29+
type: 'object',
30+
// TODO(bajtos) enumerate "model" properties
31+
// See https://github.com/strongloop/loopback-next/issues/1748
32+
additionalProperties: true,
33+
},
34+
35+
offset: {
36+
type: 'integer',
37+
minimum: 0,
38+
},
39+
40+
limit: {
41+
type: 'integer',
42+
minimum: 0,
43+
},
44+
45+
skip: {
46+
type: 'integer',
47+
minimum: 0,
48+
},
49+
50+
order: {
51+
type: 'array',
52+
items: {
53+
type: 'string',
54+
},
55+
},
56+
},
57+
};
58+
59+
const modelRelations = getModelRelations(modelCtor);
60+
const hasRelations = Object.keys(modelRelations).length > 0;
61+
62+
if (hasRelations) {
63+
schema.properties!.include = {
64+
type: 'array',
65+
items: {
66+
type: 'object',
67+
properties: {
68+
// TODO(bajtos) restrict values to relations defined by "model"
69+
relation: {type: 'string'},
70+
// TODO(bajtos) describe the filter for the relation target model
71+
scope: scopeFilter,
72+
},
73+
},
74+
};
75+
}
76+
77+
return schema;
78+
}
79+
80+
/**
81+
* Build a JSON schema describing the format of the "where" object
82+
* used to filter model instances to query, update or delete.
83+
*
84+
* Note we don't take the model properties into account yet and return
85+
* a generic json schema allowing any "where" condition.
86+
*
87+
* @param modelCtor The model constructor to build the filter schema for.
88+
*/
89+
export function getWhereJsonSchemaFor(modelCtor: typeof Model): JsonSchema {
90+
const schema: JsonSchema = {
91+
type: 'object',
92+
// TODO(bajtos) enumerate "model" properties and operators like "and"
93+
// See https://github.com/strongloop/loopback-next/issues/1748
94+
additionalProperties: true,
95+
};
96+
return schema;
97+
}

packages/repository-json-schema/src/index.ts

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

66
export * from './build-schema';
77
export * from './keys';
8+
export * from './filter-json-schema';
89

910
import {JSONSchema6 as JsonSchema} from 'json-schema';
1011
export {JsonSchema};
12+
13+
export {Model} from '@loopback/repository';
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
// Copyright IBM Corp. 2018. All Rights Reserved.
2+
// Node module: @loopback/repository-json-schema
3+
// This file is licensed under the MIT License.
4+
// License text available at https://opensource.org/licenses/MIT
5+
6+
import {Entity, Filter, hasMany, model, property} from '@loopback/repository';
7+
import {expect} from '@loopback/testlab';
8+
import * as Ajv from 'ajv';
9+
import {JsonSchema} from '../../src';
10+
import {
11+
getFilterJsonSchemaFor,
12+
getWhereJsonSchemaFor,
13+
} from '../../src/filter-json-schema';
14+
15+
describe('getFilterJsonSchemaFor', () => {
16+
let ajv: Ajv.Ajv;
17+
let customerFilterSchema: JsonSchema;
18+
let orderFilterSchema: JsonSchema;
19+
20+
beforeEach(() => {
21+
ajv = new Ajv();
22+
customerFilterSchema = getFilterJsonSchemaFor(Customer);
23+
orderFilterSchema = getFilterJsonSchemaFor(Order);
24+
});
25+
26+
it('produces a valid schema', () => {
27+
const isValid = ajv.validateSchema(customerFilterSchema);
28+
29+
const SUCCESS_MSG = 'Filter schema is a valid JSON Schema';
30+
const result = isValid ? SUCCESS_MSG : ajv.errorsText(ajv.errors!);
31+
expect(result).to.equal(SUCCESS_MSG);
32+
});
33+
34+
it('allows an empty filter', () => {
35+
expectSchemaToAllowFilter(customerFilterSchema, {});
36+
});
37+
38+
it('allows all top-level filter properties', () => {
39+
const filter: Required<Filter> = {
40+
where: {id: 1},
41+
fields: {id: true, name: true},
42+
include: [{relation: 'orders'}],
43+
offset: 0,
44+
limit: 10,
45+
order: ['id DESC'],
46+
skip: 0,
47+
};
48+
49+
expectSchemaToAllowFilter(customerFilterSchema, filter);
50+
});
51+
52+
it('describes "where" as an object', () => {
53+
const filter = {where: 'invalid-where'};
54+
ajv.validate(customerFilterSchema, filter);
55+
expect(ajv.errors || []).to.containDeep([
56+
{
57+
keyword: 'type',
58+
dataPath: '.where',
59+
message: 'should be object',
60+
},
61+
]);
62+
});
63+
64+
it('describes "fields" as an object', () => {
65+
const filter = {fields: 'invalid-fields'};
66+
ajv.validate(customerFilterSchema, filter);
67+
expect(ajv.errors || []).to.containDeep([
68+
{
69+
keyword: 'type',
70+
dataPath: '.fields',
71+
message: 'should be object',
72+
},
73+
]);
74+
});
75+
76+
it('describes "include" as an array for models with relations', () => {
77+
const filter = {include: 'invalid-include'};
78+
ajv.validate(customerFilterSchema, filter);
79+
expect(ajv.errors || []).to.containDeep([
80+
{
81+
keyword: 'type',
82+
dataPath: '.include',
83+
message: 'should be array',
84+
},
85+
]);
86+
});
87+
88+
it('leaves out "include" for models with no relations', () => {
89+
const filterProperties = Object.keys(orderFilterSchema.properties || {});
90+
expect(filterProperties).to.not.containEql('include');
91+
});
92+
93+
it('describes "offset" as an integer', () => {
94+
const filter = {offset: 'invalid-offset'};
95+
ajv.validate(customerFilterSchema, filter);
96+
expect(ajv.errors || []).to.containDeep([
97+
{
98+
keyword: 'type',
99+
dataPath: '.offset',
100+
message: 'should be integer',
101+
},
102+
]);
103+
});
104+
105+
it('describes "limit" as an integer', () => {
106+
const filter = {limit: 'invalid-limit'};
107+
ajv.validate(customerFilterSchema, filter);
108+
expect(ajv.errors || []).to.containDeep([
109+
{
110+
keyword: 'type',
111+
dataPath: '.limit',
112+
message: 'should be integer',
113+
},
114+
]);
115+
});
116+
117+
it('describes "skip" as an integer', () => {
118+
const filter = {skip: 'invalid-skip'};
119+
ajv.validate(customerFilterSchema, filter);
120+
expect(ajv.errors || []).to.containDeep([
121+
{
122+
keyword: 'type',
123+
dataPath: '.skip',
124+
message: 'should be integer',
125+
},
126+
]);
127+
});
128+
129+
it('describes "order" as an array', () => {
130+
const filter = {order: 'invalid-order'};
131+
ajv.validate(customerFilterSchema, filter);
132+
expect(ajv.errors || []).to.containDeep([
133+
{
134+
keyword: 'type',
135+
dataPath: '.order',
136+
message: 'should be array',
137+
},
138+
]);
139+
});
140+
141+
function expectSchemaToAllowFilter<T>(schema: JsonSchema, value: T) {
142+
const isValid = ajv.validate(schema, value);
143+
const SUCCESS_MSG = 'Filter instance is valid according to Filter schema';
144+
const result = isValid ? SUCCESS_MSG : ajv.errorsText(ajv.errors!);
145+
expect(result).to.equal(SUCCESS_MSG);
146+
}
147+
});
148+
149+
describe('getWhereJsonSchemaFor', () => {
150+
let ajv: Ajv.Ajv;
151+
let customerWhereSchema: JsonSchema;
152+
153+
beforeEach(() => {
154+
ajv = new Ajv();
155+
customerWhereSchema = getWhereJsonSchemaFor(Customer);
156+
});
157+
158+
it('produces a valid schema', () => {
159+
const isValid = ajv.validateSchema(customerWhereSchema);
160+
161+
const SUCCESS_MSG = 'Where schema is a valid JSON Schema';
162+
const result = isValid ? SUCCESS_MSG : ajv.errorsText(ajv.errors!);
163+
expect(result).to.equal(SUCCESS_MSG);
164+
});
165+
});
166+
167+
@model()
168+
class Order extends Entity {
169+
@property({id: true})
170+
id: number;
171+
172+
@property()
173+
customerId: number;
174+
}
175+
176+
@model()
177+
class Customer extends Entity {
178+
@property({id: true})
179+
id: number;
180+
181+
@property()
182+
name: string;
183+
184+
@hasMany(Order)
185+
orders?: Order[];
186+
}

packages/repository/src/decorators/model.decorator.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ import {
1414
ModelDefinition,
1515
ModelDefinitionSyntax,
1616
PropertyDefinition,
17+
RelationDefinitionMap,
1718
} from '../model';
18-
import {RELATIONS_KEY, RelationDefinitionBase} from './relation.decorator';
19+
import {RELATIONS_KEY} from './relation.decorator';
1920

2021
export const MODEL_KEY = MetadataAccessor.create<
2122
Partial<ModelDefinitionSyntax>,
@@ -31,7 +32,6 @@ export const MODEL_WITH_PROPERTIES_KEY = MetadataAccessor.create<
3132
>('loopback:model-and-properties');
3233

3334
export type PropertyMap = MetadataMap<PropertyDefinition>;
34-
export type RelationMap = MetadataMap<RelationDefinitionBase>;
3535

3636
// tslint:disable:no-any
3737

@@ -76,7 +76,7 @@ export function model(definition?: Partial<ModelDefinitionSyntax>) {
7676

7777
target.definition = modelDef;
7878

79-
const relationMap: RelationMap =
79+
const relationMap: RelationDefinitionMap =
8080
MetadataInspector.getAllPropertyMetadata(
8181
RELATIONS_KEY,
8282
target.prototype,

0 commit comments

Comments
 (0)