Skip to content

Commit

Permalink
feat: builders for Filter and Where schemas
Browse files Browse the repository at this point in the history
Implement new APIs for building JSON and OpenAPI schemas describing
the "filter" and "where" objects used to query or modify model
instances.
  • Loading branch information
bajtos committed Sep 25, 2018
1 parent 49454aa commit ca8d96e
Show file tree
Hide file tree
Showing 13 changed files with 401 additions and 10 deletions.
1 change: 1 addition & 0 deletions packages/openapi-v3/src/decorators/parameter.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ export namespace param {
name,
in: 'query',
style: 'deepObject',
explode: true,
schema,
});
},
Expand Down
42 changes: 42 additions & 0 deletions packages/openapi-v3/src/filter-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright IBM Corp. 2018. All Rights Reserved.
// Node module: @loopback/openapi-v3
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {SchemaObject} from '@loopback/openapi-v3-types';
import {
getFilterJsonSchemaFor,
getWhereJsonSchemaFor,
Model,
} from '@loopback/repository-json-schema';
import {jsonToSchemaObject} from './json-to-schema';

/**
* Build an OpenAPI schema describing the format of the "filter" object
* used to query model instances.
*
* Note we don't take the model properties into account yet and return
* a generic json schema allowing any "where" condition.
*
* @param modelCtor The model constructor to build the filter schema for.
*/
export function getFilterSchemaFor(modelCtor: typeof Model): SchemaObject {
const jsonSchema = getFilterJsonSchemaFor(modelCtor);
const schema = jsonToSchemaObject(jsonSchema);
return schema;
}

/**
* Build a OpenAPI schema describing the format of the "where" object
* used to filter model instances to query, update or delete.
*
* Note we don't take the model properties into account yet and return
* a generic json schema allowing any "where" condition.
*
* @param modelCtor The model constructor to build the filter schema for.
*/
export function getWhereSchemaFor(modelCtor: typeof Model): SchemaObject {
const jsonSchema = getWhereJsonSchemaFor(modelCtor);
const schema = jsonToSchemaObject(jsonSchema);
return schema;
}
1 change: 1 addition & 0 deletions packages/openapi-v3/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@
export * from './decorators';
export * from './controller-spec';
export * from './json-to-schema';
export * from './filter-schema';

export * from '@loopback/repository-json-schema';
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ describe('Routing metadata for parameters', () => {
name: 'filter',
in: 'query',
style: 'deepObject',
explode: true,
schema: {
type: 'object',
additionalProperties: true,
Expand All @@ -257,6 +258,7 @@ describe('Routing metadata for parameters', () => {
name: 'filter',
in: 'query',
style: 'deepObject',
explode: true,
schema: {
type: 'object',
properties: {
Expand Down
97 changes: 97 additions & 0 deletions packages/repository-json-schema/src/filter-json-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Copyright IBM Corp. 2018. All Rights Reserved.
// Node module: @loopback/repository-json-schema
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {Model, model, getModelRelations} from '@loopback/repository';
import {JSONSchema6 as JsonSchema} from 'json-schema';

@model({settings: {strict: false}})
class EmptyModel extends Model {}

const scopeFilter = getFilterJsonSchemaFor(EmptyModel);

/**
* Build a JSON schema describing the format of the "filter" object
* used to query model instances.
*
* Note we don't take the model properties into account yet and return
* a generic json schema allowing any "where" condition.
*
* @param modelCtor The model constructor to build the filter schema for.
*/
export function getFilterJsonSchemaFor(modelCtor: typeof Model): JsonSchema {
const schema: JsonSchema = {
properties: {
where: getWhereJsonSchemaFor(modelCtor),

fields: {
type: 'object',
// TODO(bajtos) enumerate "model" properties
// See https://github.com/strongloop/loopback-next/issues/1748
additionalProperties: true,
},

offset: {
type: 'integer',
minimum: 0,
},

limit: {
type: 'integer',
minimum: 0,
},

skip: {
type: 'integer',
minimum: 0,
},

order: {
type: 'array',
items: {
type: 'string',
},
},
},
};

const modelRelations = getModelRelations(modelCtor);
const hasRelations = Object.keys(modelRelations).length > 0;

if (hasRelations) {
schema.properties!.include = {
type: 'array',
items: {
type: 'object',
properties: {
// TODO(bajtos) restrict values to relations defined by "model"
relation: {type: 'string'},
// TODO(bajtos) describe the filter for the relation target model
scope: scopeFilter,
},
},
};
}

return schema;
}

/**
* Build a JSON schema describing the format of the "where" object
* used to filter model instances to query, update or delete.
*
* Note we don't take the model properties into account yet and return
* a generic json schema allowing any "where" condition.
*
* @param modelCtor The model constructor to build the filter schema for.
*/
export function getWhereJsonSchemaFor(modelCtor: typeof Model): JsonSchema {
const schema: JsonSchema = {
type: 'object',
// TODO(bajtos) enumerate "model" properties and operators like "and"
// See https://github.com/strongloop/loopback-next/issues/1748
additionalProperties: true,
};
return schema;
}
3 changes: 3 additions & 0 deletions packages/repository-json-schema/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@

export * from './build-schema';
export * from './keys';
export * from './filter-json-schema';

import {JSONSchema6 as JsonSchema} from 'json-schema';
export {JsonSchema};

export {Model} from '@loopback/repository';
186 changes: 186 additions & 0 deletions packages/repository-json-schema/test/unit/filter-json-schema.unit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// Copyright IBM Corp. 2018. All Rights Reserved.
// Node module: @loopback/repository-json-schema
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {Entity, Filter, hasMany, model, property} from '@loopback/repository';
import {expect} from '@loopback/testlab';
import * as Ajv from 'ajv';
import {JsonSchema} from '../../src';
import {
getFilterJsonSchemaFor,
getWhereJsonSchemaFor,
} from '../../src/filter-json-schema';

describe('getFilterJsonSchemaFor', () => {
let ajv: Ajv.Ajv;
let customerFilterSchema: JsonSchema;
let orderFilterSchema: JsonSchema;

beforeEach(() => {
ajv = new Ajv();
customerFilterSchema = getFilterJsonSchemaFor(Customer);
orderFilterSchema = getFilterJsonSchemaFor(Order);
});

it('produces a valid schema', () => {
const isValid = ajv.validateSchema(customerFilterSchema);

const SUCCESS_MSG = 'Filter schema is a valid JSON Schema';
const result = isValid ? SUCCESS_MSG : ajv.errorsText(ajv.errors!);
expect(result).to.equal(SUCCESS_MSG);
});

it('allows an empty filter', () => {
expectSchemaToAllowFilter(customerFilterSchema, {});
});

it('allows all top-level filter properties', () => {
const filter: Required<Filter> = {
where: {id: 1},
fields: {id: true, name: true},
include: [{relation: 'orders'}],
offset: 0,
limit: 10,
order: ['id DESC'],
skip: 0,
};

expectSchemaToAllowFilter(customerFilterSchema, filter);
});

it('describes "where" as an object', () => {
const filter = {where: 'invalid-where'};
ajv.validate(customerFilterSchema, filter);
expect(ajv.errors || []).to.containDeep([
{
keyword: 'type',
dataPath: '.where',
message: 'should be object',
},
]);
});

it('describes "fields" as an object', () => {
const filter = {fields: 'invalid-fields'};
ajv.validate(customerFilterSchema, filter);
expect(ajv.errors || []).to.containDeep([
{
keyword: 'type',
dataPath: '.fields',
message: 'should be object',
},
]);
});

it('describes "include" as an array for models with relations', () => {
const filter = {include: 'invalid-include'};
ajv.validate(customerFilterSchema, filter);
expect(ajv.errors || []).to.containDeep([
{
keyword: 'type',
dataPath: '.include',
message: 'should be array',
},
]);
});

it('leaves out "include" for models with no relations', () => {
const filterProperties = Object.keys(orderFilterSchema.properties || {});
expect(filterProperties).to.not.containEql('include');
});

it('describes "offset" as an integer', () => {
const filter = {offset: 'invalid-offset'};
ajv.validate(customerFilterSchema, filter);
expect(ajv.errors || []).to.containDeep([
{
keyword: 'type',
dataPath: '.offset',
message: 'should be integer',
},
]);
});

it('describes "limit" as an integer', () => {
const filter = {limit: 'invalid-limit'};
ajv.validate(customerFilterSchema, filter);
expect(ajv.errors || []).to.containDeep([
{
keyword: 'type',
dataPath: '.limit',
message: 'should be integer',
},
]);
});

it('describes "skip" as an integer', () => {
const filter = {skip: 'invalid-skip'};
ajv.validate(customerFilterSchema, filter);
expect(ajv.errors || []).to.containDeep([
{
keyword: 'type',
dataPath: '.skip',
message: 'should be integer',
},
]);
});

it('describes "order" as an array', () => {
const filter = {order: 'invalid-order'};
ajv.validate(customerFilterSchema, filter);
expect(ajv.errors || []).to.containDeep([
{
keyword: 'type',
dataPath: '.order',
message: 'should be array',
},
]);
});

function expectSchemaToAllowFilter<T>(schema: JsonSchema, value: T) {
const isValid = ajv.validate(schema, value);
const SUCCESS_MSG = 'Filter instance is valid according to Filter schema';
const result = isValid ? SUCCESS_MSG : ajv.errorsText(ajv.errors!);
expect(result).to.equal(SUCCESS_MSG);
}
});

describe('getWhereJsonSchemaFor', () => {
let ajv: Ajv.Ajv;
let customerWhereSchema: JsonSchema;

beforeEach(() => {
ajv = new Ajv();
customerWhereSchema = getWhereJsonSchemaFor(Customer);
});

it('produces a valid schema', () => {
const isValid = ajv.validateSchema(customerWhereSchema);

const SUCCESS_MSG = 'Where schema is a valid JSON Schema';
const result = isValid ? SUCCESS_MSG : ajv.errorsText(ajv.errors!);
expect(result).to.equal(SUCCESS_MSG);
});
});

@model()
class Order extends Entity {
@property({id: true})
id: number;

@property()
customerId: number;
}

@model()
class Customer extends Entity {
@property({id: true})
id: number;

@property()
name: string;

@hasMany(Order)
orders?: Order[];
}
6 changes: 3 additions & 3 deletions packages/repository/src/decorators/model.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ import {
ModelDefinition,
ModelDefinitionSyntax,
PropertyDefinition,
RelationDefinitionMap,
} from '../model';
import {RELATIONS_KEY, RelationDefinitionBase} from './relation.decorator';
import {RELATIONS_KEY} from './relation.decorator';

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

export type PropertyMap = MetadataMap<PropertyDefinition>;
export type RelationMap = MetadataMap<RelationDefinitionBase>;

// tslint:disable:no-any

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

target.definition = modelDef;

const relationMap: RelationMap =
const relationMap: RelationDefinitionMap =
MetadataInspector.getAllPropertyMetadata(
RELATIONS_KEY,
target.prototype,
Expand Down
Loading

0 comments on commit ca8d96e

Please sign in to comment.