From 43baf2f8181ea4e026470adbc9eb1109df486061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Thu, 14 Mar 2019 10:09:54 +0100 Subject: [PATCH 01/10] feat: typescript API and example impl for inclusion of related models --- .../acceptance/todo-list.acceptance.ts | 25 ++++++++++++++-- .../__tests__/acceptance/todo.acceptance.ts | 14 +++++++++ .../src/models/todo-list-image.model.ts | 8 +++-- .../todo-list/src/models/todo-list.model.ts | 9 ++++-- examples/todo-list/src/models/todo.model.ts | 6 +++- .../src/repositories/todo-list.repository.ts | 30 +++++++++++++++++-- .../src/repositories/todo.repository.ts | 30 +++++++++++++++++-- packages/repository/src/model.ts | 6 ++++ .../src/repositories/legacy-juggler-bridge.ts | 26 +++++++++++----- .../repository/src/repositories/repository.ts | 22 +++++++++----- 10 files changed, 149 insertions(+), 27 deletions(-) diff --git a/examples/todo-list/src/__tests__/acceptance/todo-list.acceptance.ts b/examples/todo-list/src/__tests__/acceptance/todo-list.acceptance.ts index 867f54b99198..38691e5f0323 100644 --- a/examples/todo-list/src/__tests__/acceptance/todo-list.acceptance.ts +++ b/examples/todo-list/src/__tests__/acceptance/todo-list.acceptance.ts @@ -12,9 +12,9 @@ import { toJSON, } from '@loopback/testlab'; import {TodoListApplication} from '../../application'; -import {TodoList} from '../../models/'; -import {TodoListRepository} from '../../repositories/'; -import {givenTodoList} from '../helpers'; +import {Todo, TodoList} from '../../models'; +import {TodoListRepository, TodoRepository} from '../../repositories'; +import {givenTodo, givenTodoList} from '../helpers'; describe('TodoListApplication', () => { let app: TodoListApplication; @@ -178,6 +178,20 @@ describe('TodoListApplication', () => { .expect(200, [toJSON(listInBlack)]); }); + it('includes Todos in query result', async () => { + const list = await givenTodoListInstance(); + const todo = await givenTodoInstance({todoListId: list.id}); + + const response = await client.get('/todo-lists').query({ + filter: JSON.stringify({include: [{relation: 'todos'}]}), + }); + expect(response.body).to.have.length(1); + expect(response.body[0]).to.deepEqual({ + ...toJSON(list), + todos: [toJSON(todo)], + }); + }); + /* ============================================================================ TEST HELPERS @@ -218,6 +232,11 @@ describe('TodoListApplication', () => { return await todoListRepo.create(givenTodoList(todoList)); } + async function givenTodoInstance(todo?: Partial) { + const repo = await app.getRepository(TodoRepository); + return await repo.create(givenTodo(todo)); + } + function givenMutlipleTodoListInstances() { return Promise.all([ givenTodoListInstance(), diff --git a/examples/todo-list/src/__tests__/acceptance/todo.acceptance.ts b/examples/todo-list/src/__tests__/acceptance/todo.acceptance.ts index 61d37703f81d..326071d88551 100644 --- a/examples/todo-list/src/__tests__/acceptance/todo.acceptance.ts +++ b/examples/todo-list/src/__tests__/acceptance/todo.acceptance.ts @@ -149,6 +149,20 @@ describe('TodoListApplication', () => { .expect(200, [toJSON(todoInProgress)]); }); + it('includes TodoList in query result', async () => { + const list = await givenTodoListInstance(); + const todo = await givenTodoInstance({todoListId: list.id}); + + const response = await client.get('/todos').query({ + filter: JSON.stringify({include: [{relation: 'todoList'}]}), + }); + expect(response.body).to.have.length(1); + expect(response.body[0]).to.deepEqual({ + ...toJSON(todo), + todoList: toJSON(list), + }); + }); + /* ============================================================================ TEST HELPERS diff --git a/examples/todo-list/src/models/todo-list-image.model.ts b/examples/todo-list/src/models/todo-list-image.model.ts index 92c5077cec8a..7b174f0a6864 100644 --- a/examples/todo-list/src/models/todo-list-image.model.ts +++ b/examples/todo-list/src/models/todo-list-image.model.ts @@ -3,8 +3,8 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Entity, model, property, belongsTo} from '@loopback/repository'; -import {TodoList} from './todo-list.model'; +import {belongsTo, Entity, model, property} from '@loopback/repository'; +import {TodoList, TodoListLinks} from './todo-list.model'; @model() export class TodoListImage extends Entity { @@ -29,3 +29,7 @@ export class TodoListImage extends Entity { super(data); } } + +export interface TodoListImageLinks { + todoList?: TodoList & TodoListLinks; +} diff --git a/examples/todo-list/src/models/todo-list.model.ts b/examples/todo-list/src/models/todo-list.model.ts index bdb1eb8d1382..efae1f7034bf 100644 --- a/examples/todo-list/src/models/todo-list.model.ts +++ b/examples/todo-list/src/models/todo-list.model.ts @@ -3,9 +3,9 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Entity, model, property, hasMany, hasOne} from '@loopback/repository'; +import {Entity, hasMany, hasOne, model, property} from '@loopback/repository'; +import {TodoListImage, TodoListImageLinks} from './todo-list-image.model'; import {Todo} from './todo.model'; -import {TodoListImage} from './todo-list-image.model'; @model() export class TodoList extends Entity { @@ -36,3 +36,8 @@ export class TodoList extends Entity { super(data); } } + +export interface TodoListLinks { + todos?: (Todo & TodoListLinks)[]; + image?: TodoListImage & TodoListImageLinks; +} diff --git a/examples/todo-list/src/models/todo.model.ts b/examples/todo-list/src/models/todo.model.ts index bc30ce80def0..083657367c4a 100644 --- a/examples/todo-list/src/models/todo.model.ts +++ b/examples/todo-list/src/models/todo.model.ts @@ -4,7 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import {Entity, property, model, belongsTo} from '@loopback/repository'; -import {TodoList} from './todo-list.model'; +import {TodoList, TodoListLinks} from './todo-list.model'; @model() export class Todo extends Entity { @@ -41,3 +41,7 @@ export class Todo extends Entity { super(data); } } + +export interface TodoLinks { + todoList?: TodoList & TodoListLinks; +} diff --git a/examples/todo-list/src/repositories/todo-list.repository.ts b/examples/todo-list/src/repositories/todo-list.repository.ts index 4b1d63fe8f71..298ff23050c1 100644 --- a/examples/todo-list/src/repositories/todo-list.repository.ts +++ b/examples/todo-list/src/repositories/todo-list.repository.ts @@ -10,14 +10,17 @@ import { juggler, repository, HasOneRepositoryFactory, + Filter, + Options, } from '@loopback/repository'; -import {Todo, TodoList, TodoListImage} from '../models'; +import {Todo, TodoList, TodoListImage, TodoListLinks} from '../models'; import {TodoRepository} from './todo.repository'; import {TodoListImageRepository} from './todo-list-image.repository'; export class TodoListRepository extends DefaultCrudRepository< TodoList, - typeof TodoList.prototype.id + typeof TodoList.prototype.id, + TodoListLinks > { public readonly todos: HasManyRepositoryFactory< Todo, @@ -49,4 +52,27 @@ export class TodoListRepository extends DefaultCrudRepository< public findByTitle(title: string) { return this.findOne({where: {title}}); } + + async find( + filter?: Filter, + options?: Options, + ): Promise<(TodoList & Partial)[]> { + // Prevent juggler for applying "include" filter + // Juggler is not aware of LB4 relations + const include = filter && filter.include; + filter = filter && Object.assign(filter, {include: undefined}); + + const result = await super.find(filter, options); + + // poor-mans inclusion resolver, this should be handled by DefaultCrudRepo + // and use `inq` operator to fetch related todos in fewer DB queries + if (include && include.length && include[0].relation === 'todos') { + await Promise.all( + result.map(async r => { + r.todos = await this.todos(r.id).find(); + }), + ); + } + return result; + } } diff --git a/examples/todo-list/src/repositories/todo.repository.ts b/examples/todo-list/src/repositories/todo.repository.ts index 4f26693b602c..46800015a808 100644 --- a/examples/todo-list/src/repositories/todo.repository.ts +++ b/examples/todo-list/src/repositories/todo.repository.ts @@ -9,13 +9,16 @@ import { DefaultCrudRepository, juggler, repository, + Options, + Filter, } from '@loopback/repository'; -import {Todo, TodoList} from '../models'; +import {Todo, TodoList, TodoLinks} from '../models'; import {TodoListRepository} from './todo-list.repository'; export class TodoRepository extends DefaultCrudRepository< Todo, - typeof Todo.prototype.id + typeof Todo.prototype.id, + TodoLinks > { public readonly todoList: BelongsToAccessor< TodoList, @@ -34,4 +37,27 @@ export class TodoRepository extends DefaultCrudRepository< todoListRepositoryGetter, ); } + + async find( + filter?: Filter, + options?: Options, + ): Promise<(Todo & Partial)[]> { + // Prevent juggler for applying "include" filter + // Juggler is not aware of LB4 relations + const include = filter && filter.include; + filter = filter && Object.assign(filter, {include: undefined}); + + const result = await super.find(filter, options); + + // poor-mans inclusion resolver, this should be handled by DefaultCrudRepo + // and use `inq` operator to fetch related todos in fewer DB queries + if (include && include.length && include[0].relation === 'todoList') { + await Promise.all( + result.map(async r => { + r.todoList = await this.todoList(r.id); + }), + ); + } + return result; + } } diff --git a/packages/repository/src/model.ts b/packages/repository/src/model.ts index 8586f9375644..300d3cb428d0 100644 --- a/packages/repository/src/model.ts +++ b/packages/repository/src/model.ts @@ -209,6 +209,12 @@ export abstract class Model { json[p] = asJSON((this as AnyObject)[p]); } } + // Include relational links in the JSON representation + for (const r in def.relations) { + if (r in this) { + json[r] = asJSON((this as AnyObject)[r]); + } + } return json; } diff --git a/packages/repository/src/repositories/legacy-juggler-bridge.ts b/packages/repository/src/repositories/legacy-juggler-bridge.ts index bd522d8e5392..12fd567e3530 100644 --- a/packages/repository/src/repositories/legacy-juggler-bridge.ts +++ b/packages/repository/src/repositories/legacy-juggler-bridge.ts @@ -88,8 +88,11 @@ export function ensurePromise(p: legacy.PromiseOrVoid): Promise { * Default implementation of CRUD repository using legacy juggler model * and data source */ -export class DefaultCrudRepository - implements EntityCrudRepository { +export class DefaultCrudRepository< + T extends Entity, + ID, + Links extends object = {} +> implements EntityCrudRepository { modelClass: juggler.PersistedModelClass; /** @@ -334,7 +337,10 @@ export class DefaultCrudRepository } } - async find(filter?: Filter, options?: Options): Promise { + async find( + filter?: Filter, + options?: Options, + ): Promise<(T & Partial)[]> { const models = await ensurePromise( this.modelClass.find(filter as legacy.Filter, options), ); @@ -349,14 +355,18 @@ export class DefaultCrudRepository return this.toEntity(model); } - async findById(id: ID, filter?: Filter, options?: Options): Promise { + async findById( + id: ID, + filter?: Filter, + options?: Options, + ): Promise> { const model = await ensurePromise( this.modelClass.findById(id, filter as legacy.Filter, options), ); if (!model) { throw new EntityNotFoundError(this.entityClass, id); } - return this.toEntity(model); + return this.toEntity>(model); } update(entity: T, options?: Options): Promise { @@ -441,11 +451,11 @@ export class DefaultCrudRepository throw new Error('Not implemented'); } - protected toEntity(model: juggler.PersistedModel): T { - return new this.entityClass(model.toObject()) as T; + protected toEntity(model: juggler.PersistedModel): R { + return new this.entityClass(model.toObject()) as R; } - protected toEntities(models: juggler.PersistedModel[]): T[] { + protected toEntities(models: juggler.PersistedModel[]): R[] { return models.map(m => this.toEntity(m)); } } diff --git a/packages/repository/src/repositories/repository.ts b/packages/repository/src/repositories/repository.ts index 1f8a36d9ea49..f15a946cbb06 100644 --- a/packages/repository/src/repositories/repository.ts +++ b/packages/repository/src/repositories/repository.ts @@ -40,8 +40,10 @@ export interface ExecutableRepository extends Repository { /** * Basic CRUD operations for ValueObject and Entity. No ID is required. */ -export interface CrudRepository - extends Repository { +export interface CrudRepository< + T extends ValueObject | Entity, + Links extends object = {} +> extends Repository { /** * Create a new record * @param dataObject The data to be created @@ -64,7 +66,7 @@ export interface CrudRepository * @param options Options for the operations * @returns A promise of an array of records found */ - find(filter?: Filter, options?: Options): Promise; + find(filter?: Filter, options?: Options): Promise<(T & Partial)[]>; /** * Updating matching records with attributes from the data object @@ -105,9 +107,11 @@ export interface EntityRepository /** * CRUD operations for a repository of entities */ -export interface EntityCrudRepository - extends EntityRepository, - CrudRepository { +export interface EntityCrudRepository< + T extends Entity, + ID, + Links extends object = {} +> extends EntityRepository, CrudRepository { // entityClass should have type "typeof T", but that's not supported by TSC entityClass: typeof Entity & {prototype: T}; @@ -146,7 +150,11 @@ export interface EntityCrudRepository * @param options Options for the operations * @returns A promise of an entity found for the id */ - findById(id: ID, filter?: Filter, options?: Options): Promise; + findById( + id: ID, + filter?: Filter, + options?: Options, + ): Promise>; /** * Update an entity by id with property/value pairs in the data object From 2256435f2affe7c3962267dc4d2a2148cb15a6d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Thu, 14 Mar 2019 11:08:04 +0100 Subject: [PATCH 02/10] feat: generate schema for relation links --- .../integration/build-schema.integration.ts | 2 +- .../integration/spike.integration.ts | 94 ++++++++++++++ .../src/build-schema.ts | 116 +++++++++++++++--- packages/repository-json-schema/src/keys.ts | 2 +- .../relation.factory.integration.ts | 4 + .../unit/decorator/relation.decorator.unit.ts | 5 + .../errors/invalid-relation-error.test.ts | 1 + .../belongs-to-repository-factory.unit.ts | 2 + .../has-many-repository-factory.unit.ts | 1 + .../has-one-repository-factory.unit.ts | 1 + .../repository/src/decorators/metadata.ts | 13 +- .../belongs-to/belongs-to.decorator.ts | 1 + .../relations/has-many/has-many.decorator.ts | 1 + .../relations/has-one/has-one.decorator.ts | 1 + .../src/relations/relation.types.ts | 10 ++ 15 files changed, 232 insertions(+), 22 deletions(-) create mode 100644 packages/repository-json-schema/src/__tests__/integration/spike.integration.ts diff --git a/packages/repository-json-schema/src/__tests__/integration/build-schema.integration.ts b/packages/repository-json-schema/src/__tests__/integration/build-schema.integration.ts index ae24f4a0a0a9..c295f223bb18 100644 --- a/packages/repository-json-schema/src/__tests__/integration/build-schema.integration.ts +++ b/packages/repository-json-schema/src/__tests__/integration/build-schema.integration.ts @@ -582,7 +582,7 @@ describe('build-schema', () => { }; MetadataInspector.defineMetadata( JSON_SCHEMA_KEY, - cachedSchema, + {modelOnly: cachedSchema}, TestModel, ); const jsonSchema = getJsonSchema(TestModel); diff --git a/packages/repository-json-schema/src/__tests__/integration/spike.integration.ts b/packages/repository-json-schema/src/__tests__/integration/spike.integration.ts new file mode 100644 index 000000000000..9fc94a6aed8e --- /dev/null +++ b/packages/repository-json-schema/src/__tests__/integration/spike.integration.ts @@ -0,0 +1,94 @@ +// 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 { + belongsTo, + Entity, + hasMany, + model, + property, +} from '@loopback/repository'; +import {expect} from '@loopback/testlab'; +import * as Ajv from 'ajv'; +import {JsonSchema, modelToJsonSchema} from '../..'; + +describe('build-schema', () => { + describe('modelToJsonSchema', () => { + it('converts basic model', () => { + @model() + class TestModel { + @property() + foo: string; + } + + const jsonSchema = modelToJsonSchema(TestModel); + expectValidJsonSchema(jsonSchema); + expect(jsonSchema.properties).to.containDeep({ + foo: { + type: 'string', + }, + }); + }); + + it('converts HasMany and BelongsTo relation links', () => { + @model() + class Product extends Entity { + @property({id: true}) + id: number; + + @belongsTo(() => Category) + categoryId: number; + } + + @model() + class Category extends Entity { + @property({id: true}) + id: number; + + @hasMany(() => Product) + products?: Product[]; + } + + const jsonSchema = modelToJsonSchema(Category, {includeRelations: true}); + expectValidJsonSchema(jsonSchema); + expect(jsonSchema).to.deepEqual({ + title: 'CategoryWithLinks', + properties: { + // TODO(bajtos): inherit these properties from Category schema + // See https://swagger.io/docs/specification/data-models/inheritance-and-polymorphism/ + id: {type: 'number'}, + products: { + type: 'array', + items: {$ref: '#/definitions/ProductWithLinks'}, + }, + }, + definitions: { + ProductWithLinks: { + title: 'ProductWithLinks', + properties: { + // TODO(bajtos): inherit these properties from Product schema + // See https://swagger.io/docs/specification/data-models/inheritance-and-polymorphism/ + id: {type: 'number'}, + categoryId: {type: 'number'}, + category: {$ref: '#/definitions/CategoryWithLinks'}, + }, + }, + }, + }); + }); + }); +}); + +function expectValidJsonSchema(schema: JsonSchema) { + const ajv = new Ajv(); + const validate = ajv.compile( + require('ajv/lib/refs/json-schema-draft-06.json'), + ); + const isValid = validate(schema); + const result = isValid + ? 'JSON Schema is valid' + : ajv.errorsText(validate.errors!); + expect(result).to.equal('JSON Schema is valid'); +} diff --git a/packages/repository-json-schema/src/build-schema.ts b/packages/repository-json-schema/src/build-schema.ts index 9824ea5b27a2..7cc7064b103e 100644 --- a/packages/repository-json-schema/src/build-schema.ts +++ b/packages/repository-json-schema/src/build-schema.ts @@ -14,23 +14,62 @@ import { import {JSONSchema6 as JSONSchema} from 'json-schema'; import {JSON_SCHEMA_KEY} from './keys'; +export interface JsonSchemaOptions { + includeRelations?: boolean; + visited?: {[key: string]: JSONSchema}; +} + /** * Gets the JSON Schema of a TypeScript model/class by seeing if one exists * in a cache. If not, one is generated and then cached. * @param ctor Contructor of class to get JSON Schema from */ -export function getJsonSchema(ctor: Function): JSONSchema { +export function getJsonSchema( + ctor: Function, + options: JsonSchemaOptions = {}, +): JSONSchema { // NOTE(shimks) currently impossible to dynamically update - const jsonSchema = MetadataInspector.getClassMetadata(JSON_SCHEMA_KEY, ctor); - if (jsonSchema) { - return jsonSchema; + const cached = MetadataInspector.getClassMetadata(JSON_SCHEMA_KEY, ctor); + const key = options.includeRelations ? 'modelWithLinks' : 'modelOnly'; + + if (cached && cached[key]) { + return cached[key]; } else { - const newSchema = modelToJsonSchema(ctor); - MetadataInspector.defineMetadata(JSON_SCHEMA_KEY.key, newSchema, ctor); + const newSchema = modelToJsonSchema(ctor, options); + if (cached) { + cached[key] = newSchema; + } else { + MetadataInspector.defineMetadata( + JSON_SCHEMA_KEY.key, + {[key]: newSchema}, + ctor, + ); + } return newSchema; } } +export function getJsonSchemaRef( + ctor: Function, + options: JsonSchemaOptions = {}, +): JSONSchema { + const schemaWithDefinitions = getJsonSchema(ctor, options); + const key = schemaWithDefinitions.title; + + // ctor is not a model + if (!key) return schemaWithDefinitions; + + const definitions = Object.assign({}, schemaWithDefinitions.definitions); + const schema = Object.assign({}, schemaWithDefinitions); + delete schema.definitions; + definitions[key] = schema; + + return { + $ref: `#/definitions/${key}`, + definitions, + }; +} + /** * Gets the wrapper function of primitives string, number, and boolean * @param type Name of type @@ -138,16 +177,28 @@ export function metaToJsonProperty(meta: PropertyDefinition): JSONSchema { * reflection API * @param ctor Constructor of class to convert from */ -export function modelToJsonSchema(ctor: Function): JSONSchema { +export function modelToJsonSchema( + ctor: Function, + options: JsonSchemaOptions = {}, +): JSONSchema { + options.visited = options.visited || {}; + const meta: ModelDefinition | {} = ModelMetadataHelper.getModelMetadata(ctor); - const result: JSONSchema = {}; // returns an empty object if metadata is an empty object if (!(meta instanceof ModelDefinition)) { return {}; } - result.title = meta.title || ctor.name; + let title = meta.title || ctor.name; + if (options.includeRelations) { + title += 'WithLinks'; + } + + if (title in options.visited) return options.visited[title]; + + const result: JSONSchema = {title}; + options.visited[title] = result; if (meta.description) { result.description = meta.description; @@ -187,20 +238,47 @@ export function modelToJsonSchema(ctor: Function): JSONSchema { } const propSchema = getJsonSchema(referenceType); + includeReferencedSchema(referenceType.name, propSchema); + } - if (propSchema && Object.keys(propSchema).length > 0) { - result.definitions = result.definitions || {}; + if (options.includeRelations) { + for (const r in meta.relations) { + result.properties = result.properties || {}; + const relMeta = meta.relations[r]; + const targetType = resolveType(relMeta.target); + const targetSchema = getJsonSchema(targetType, options); + const targetRef = {$ref: `#/definitions/${targetSchema.title}`}; - // delete nested definition - if (propSchema.definitions) { - for (const key in propSchema.definitions) { - result.definitions[key] = propSchema.definitions[key]; - } - delete propSchema.definitions; - } + const propDef = relMeta.targetsMany + ? { + type: 'array', + items: targetRef, + } + : targetRef; - result.definitions[referenceType.name] = propSchema; + // IMPORTANT: r !== relMeta.name + // E.g. belongsTo sets r="categoryId" but name="category" + result.properties[relMeta.name] = + result.properties[relMeta.name] || propDef; + includeReferencedSchema(targetSchema.title!, targetSchema); } } return result; + + function includeReferencedSchema(name: string, propSchema: JSONSchema) { + if (!propSchema || !Object.keys(propSchema).length) return; + + result.definitions = result.definitions || {}; + + // promote nested definition to the top level + if (propSchema.definitions) { + for (const key in propSchema.definitions) { + if (key === title) continue; + result.definitions[key] = propSchema.definitions[key]; + } + delete propSchema.definitions; + } + + result.definitions[name] = propSchema; + } } diff --git a/packages/repository-json-schema/src/keys.ts b/packages/repository-json-schema/src/keys.ts index 9975efa1749c..556e7eb144cf 100644 --- a/packages/repository-json-schema/src/keys.ts +++ b/packages/repository-json-schema/src/keys.ts @@ -10,6 +10,6 @@ import {JSONSchema6 as JSONSchema} from 'json-schema'; * Metadata key used to set or retrieve repository JSON Schema */ export const JSON_SCHEMA_KEY = MetadataAccessor.create< - JSONSchema, + {[options: string]: JSONSchema}, ClassDecorator >('loopback:json-schema'); diff --git a/packages/repository/src/__tests__/integration/repositories/relation.factory.integration.ts b/packages/repository/src/__tests__/integration/repositories/relation.factory.integration.ts index 4080ee292ad7..11df6785ac25 100644 --- a/packages/repository/src/__tests__/integration/repositories/relation.factory.integration.ts +++ b/packages/repository/src/__tests__/integration/repositories/relation.factory.integration.ts @@ -217,6 +217,7 @@ class Order extends Entity { .addRelation({ name: 'customer', type: RelationType.belongsTo, + targetsMany: false, source: Order, target: () => Customer, keyFrom: 'customerId', @@ -253,6 +254,7 @@ class Customer extends Entity { .addRelation({ name: 'orders', type: RelationType.hasMany, + targetsMany: true, source: Customer, target: () => Order, keyTo: 'customerId', @@ -260,6 +262,7 @@ class Customer extends Entity { .addRelation({ name: 'reviewsAuthored', type: RelationType.hasMany, + targetsMany: true, source: Customer, target: () => Review, keyTo: 'authorId', @@ -267,6 +270,7 @@ class Customer extends Entity { .addRelation({ name: 'reviewsApproved', type: RelationType.hasMany, + targetsMany: true, source: Customer, target: () => Review, keyTo: 'approvedId', diff --git a/packages/repository/src/__tests__/unit/decorator/relation.decorator.unit.ts b/packages/repository/src/__tests__/unit/decorator/relation.decorator.unit.ts index c95c7c98ff34..1212f693c7eb 100644 --- a/packages/repository/src/__tests__/unit/decorator/relation.decorator.unit.ts +++ b/packages/repository/src/__tests__/unit/decorator/relation.decorator.unit.ts @@ -49,6 +49,7 @@ describe('relation decorator', () => { ); expect(meta).to.eql({ type: RelationType.hasMany, + targetsMany: true, name: 'addresses', source: AddressBook, target: () => Address, @@ -60,6 +61,7 @@ describe('relation decorator', () => { expect(AddressBook.definition.relations).to.eql({ addresses: { type: RelationType.hasMany, + targetsMany: true, name: 'addresses', source: AddressBook, target: () => Address, @@ -93,6 +95,7 @@ describe('relation decorator', () => { ); expect(meta).to.eql({ type: RelationType.hasMany, + targetsMany: true, name: 'addresses', source: AddressBook, target: () => Address, @@ -131,6 +134,7 @@ describe('relation decorator', () => { keyFrom: 'addressBookId', name: 'addressBook', type: 'belongsTo', + targetsMany: false, }, }); }); @@ -156,6 +160,7 @@ describe('relation decorator', () => { ); expect(meta).to.eql({ type: RelationType.belongsTo, + targetsMany: false, name: 'addressBook', source: Address, target: () => AddressBook, diff --git a/packages/repository/src/__tests__/unit/errors/invalid-relation-error.test.ts b/packages/repository/src/__tests__/unit/errors/invalid-relation-error.test.ts index 8a228d881e6c..3cdeb78f4898 100644 --- a/packages/repository/src/__tests__/unit/errors/invalid-relation-error.test.ts +++ b/packages/repository/src/__tests__/unit/errors/invalid-relation-error.test.ts @@ -48,6 +48,7 @@ function givenAnErrorInstance() { return new InvalidRelationError('a reason', { name: 'products', type: RelationType.hasMany, + targetsMany: true, source: Category, target: () => Product, }); diff --git a/packages/repository/src/__tests__/unit/repositories/belongs-to-repository-factory.unit.ts b/packages/repository/src/__tests__/unit/repositories/belongs-to-repository-factory.unit.ts index a4d19257a50d..a8b047faf0c3 100644 --- a/packages/repository/src/__tests__/unit/repositories/belongs-to-repository-factory.unit.ts +++ b/packages/repository/src/__tests__/unit/repositories/belongs-to-repository-factory.unit.ts @@ -98,6 +98,7 @@ describe('createBelongsToAccessor', () => { const relationMeta: BelongsToDefinition = { type: RelationType.belongsTo, + targetsMany: false, name: 'category', source: Product, target: () => Category, @@ -165,6 +166,7 @@ describe('createBelongsToAccessor', () => { ): BelongsToDefinition { const defaults: BelongsToDefinition = { type: RelationType.belongsTo, + targetsMany: false, name: 'company', source: Company, target: () => Customer, diff --git a/packages/repository/src/__tests__/unit/repositories/has-many-repository-factory.unit.ts b/packages/repository/src/__tests__/unit/repositories/has-many-repository-factory.unit.ts index 5e5b9ed6cd7f..181a3b4da5cd 100644 --- a/packages/repository/src/__tests__/unit/repositories/has-many-repository-factory.unit.ts +++ b/packages/repository/src/__tests__/unit/repositories/has-many-repository-factory.unit.ts @@ -114,6 +114,7 @@ describe('createHasManyRepositoryFactory', () => { const defaults: HasManyDefinition = { type: RelationType.hasMany, + targetsMany: true, name: 'customers', target: () => Customer, source: Company, diff --git a/packages/repository/src/__tests__/unit/repositories/has-one-repository-factory.unit.ts b/packages/repository/src/__tests__/unit/repositories/has-one-repository-factory.unit.ts index fc727dc2af9f..9dbc00e8d5ef 100644 --- a/packages/repository/src/__tests__/unit/repositories/has-one-repository-factory.unit.ts +++ b/packages/repository/src/__tests__/unit/repositories/has-one-repository-factory.unit.ts @@ -125,6 +125,7 @@ describe('createHasOneRepositoryFactory', () => { ): HasOneDefinition { const defaults: HasOneDefinition = { type: RelationType.hasOne, + targetsMany: false, name: 'address', target: () => Address, source: Customer, diff --git a/packages/repository/src/decorators/metadata.ts b/packages/repository/src/decorators/metadata.ts index 4de95d6b133d..2eabf346a320 100644 --- a/packages/repository/src/decorators/metadata.ts +++ b/packages/repository/src/decorators/metadata.ts @@ -10,7 +10,8 @@ import { MODEL_WITH_PROPERTIES_KEY, PropertyMap, } from './model.decorator'; -import {ModelDefinition} from '../model'; +import {ModelDefinition, RelationDefinitionMap} from '../model'; +import {RELATIONS_KEY} from '../relations'; export class ModelMetadataHelper { /** @@ -60,6 +61,16 @@ export class ModelMetadataHelper { options, ), ); + + meta.relations = Object.assign( + {}, + MetadataInspector.getAllPropertyMetadata( + RELATIONS_KEY, + target.prototype, + options, + ), + ); + MetadataInspector.defineMetadata( MODEL_WITH_PROPERTIES_KEY.key, meta, diff --git a/packages/repository/src/relations/belongs-to/belongs-to.decorator.ts b/packages/repository/src/relations/belongs-to/belongs-to.decorator.ts index b999ef69d95a..296c0734b285 100644 --- a/packages/repository/src/relations/belongs-to/belongs-to.decorator.ts +++ b/packages/repository/src/relations/belongs-to/belongs-to.decorator.ts @@ -55,6 +55,7 @@ export function belongsTo( // properties enforced by the decorator { type: RelationType.belongsTo, + targetsMany: false, source: decoratedTarget.constructor, target: targetResolver, }, diff --git a/packages/repository/src/relations/has-many/has-many.decorator.ts b/packages/repository/src/relations/has-many/has-many.decorator.ts index f67c66ed82c4..b10bbc0de063 100644 --- a/packages/repository/src/relations/has-many/has-many.decorator.ts +++ b/packages/repository/src/relations/has-many/has-many.decorator.ts @@ -28,6 +28,7 @@ export function hasMany( // properties enforced by the decorator { type: RelationType.hasMany, + targetsMany: true, source: decoratedTarget.constructor, target: targetResolver, }, diff --git a/packages/repository/src/relations/has-one/has-one.decorator.ts b/packages/repository/src/relations/has-one/has-one.decorator.ts index 4d4779a03e07..f338b4917b02 100644 --- a/packages/repository/src/relations/has-one/has-one.decorator.ts +++ b/packages/repository/src/relations/has-one/has-one.decorator.ts @@ -29,6 +29,7 @@ export function hasOne( // properties enforced by the decorator { type: RelationType.hasOne, + targetsMany: false, name: key, source: decoratedTarget.constructor, target: targetResolver, diff --git a/packages/repository/src/relations/relation.types.ts b/packages/repository/src/relations/relation.types.ts index e7912e812e25..4179df4b0f16 100644 --- a/packages/repository/src/relations/relation.types.ts +++ b/packages/repository/src/relations/relation.types.ts @@ -22,6 +22,13 @@ export interface RelationDefinitionBase { */ type: RelationType; + /** + * True for relations targeting multiple instances (e.g. HasMany), + * false for relations with a single target (e.g. BelongsTo, HasOne). + * This property is need by OpenAPI/JSON Schema generator. + */ + targetsMany: boolean; + /** * The relation name, typically matching the name of the accessor property * defined on the source model. For example "orders" or "customer". @@ -45,6 +52,7 @@ export interface RelationDefinitionBase { export interface HasManyDefinition extends RelationDefinitionBase { type: RelationType.hasMany; + targetsMany: true; /** * The foreign key used by the target model. @@ -58,6 +66,7 @@ export interface HasManyDefinition extends RelationDefinitionBase { export interface BelongsToDefinition extends RelationDefinitionBase { type: RelationType.belongsTo; + targetsMany: false; /* * The foreign key in the source model, e.g. Order#customerId. @@ -72,6 +81,7 @@ export interface BelongsToDefinition extends RelationDefinitionBase { export interface HasOneDefinition extends RelationDefinitionBase { type: RelationType.hasOne; + targetsMany: false; /** * The foreign key used by the target model. From d93dff7a264f29cf234b6ad34074743e277555d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Thu, 14 Mar 2019 14:15:24 +0100 Subject: [PATCH 03/10] feat: add schemas with relations to controllers --- .../src/controllers/todo-list.controller.ts | 10 ++- .../src/controllers/todo.controller.ts | 6 +- .../controller-spec.integration.ts | 78 ++++++++++++++++- packages/openapi-v3/src/controller-spec.ts | 83 ++++++++++++++----- .../src/build-schema.ts | 33 +++++++- 5 files changed, 182 insertions(+), 28 deletions(-) diff --git a/examples/todo-list/src/controllers/todo-list.controller.ts b/examples/todo-list/src/controllers/todo-list.controller.ts index e74260b273d3..f455e3f873ea 100644 --- a/examples/todo-list/src/controllers/todo-list.controller.ts +++ b/examples/todo-list/src/controllers/todo-list.controller.ts @@ -15,6 +15,7 @@ import { get, getFilterSchemaFor, getWhereSchemaFor, + getModelSchemaRef, param, patch, post, @@ -59,7 +60,14 @@ export class TodoListController { responses: { '200': { description: 'Array of TodoList model instances', - content: {'application/json': {schema: {'x-ts-type': TodoList}}}, + content: { + 'application/json': { + schema: { + type: 'array', + items: getModelSchemaRef(TodoList, {includeRelations: true}), + } + }, + }, }, }, }) diff --git a/examples/todo-list/src/controllers/todo.controller.ts b/examples/todo-list/src/controllers/todo.controller.ts index ec078880dbd5..ebcaf15eba97 100644 --- a/examples/todo-list/src/controllers/todo.controller.ts +++ b/examples/todo-list/src/controllers/todo.controller.ts @@ -13,6 +13,7 @@ import { post, put, requestBody, + getModelSchemaRef, } from '@loopback/rest'; import {Todo, TodoList} from '../models'; import {TodoRepository} from '../repositories'; @@ -53,7 +54,10 @@ export class TodoController { description: 'Array of Todo model instances', content: { 'application/json': { - schema: {type: 'array', items: {'x-ts-type': Todo}}, + schema: { + type: 'array', + items: getModelSchemaRef(Todo, {includeRelations: true}), + }, }, }, }, diff --git a/packages/openapi-v3/src/__tests__/integration/controller-spec.integration.ts b/packages/openapi-v3/src/__tests__/integration/controller-spec.integration.ts index 99c70c707c92..e2e2e7a5e572 100644 --- a/packages/openapi-v3/src/__tests__/integration/controller-spec.integration.ts +++ b/packages/openapi-v3/src/__tests__/integration/controller-spec.integration.ts @@ -3,13 +3,24 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {ParameterObject, SchemaObject} from '@loopback/openapi-v3-types'; -import {model, property} from '@loopback/repository'; +import { + OperationObject, + ParameterObject, + SchemaObject, +} from '@loopback/openapi-v3-types'; +import { + belongsTo, + Entity, + hasMany, + model, + property, +} from '@loopback/repository'; import {expect} from '@loopback/testlab'; import { ControllerSpec, get, getControllerSpec, + getModelSchemaRef, param, post, requestBody, @@ -421,4 +432,67 @@ describe('controller spec', () => { return MyController; } }); + + it('supports getModelSchemaRef with relations', () => { + @model() + class Product extends Entity { + @belongsTo(() => Category) + categoryId: number; + } + + @model() + class Category extends Entity { + @hasMany(() => Product) + products?: Product[]; + } + + class CategoryController { + @get('/categories', { + responses: { + '200': { + description: 'Array of Category model instances', + content: { + 'application/json': { + schema: getModelSchemaRef(Category, {includeRelations: true}), + }, + }, + }, + }, + }) + async find(): Promise { + return []; // dummy implementation, it's never called + } + } + + const spec = getControllerSpec(CategoryController); + const opSpec: OperationObject = spec.paths['/categories'].get; + const responseSpec = opSpec.responses['200'].content['application/json']; + expect(responseSpec.schema).to.deepEqual({ + $ref: '#/components/schemas/CategoryWithLinks', + }); + + const globalSchemas = (spec.components || {}).schemas; + expect(globalSchemas).to.deepEqual({ + CategoryWithLinks: { + title: 'CategoryWithLinks', + properties: { + products: { + type: 'array', + items: {$ref: '#/components/schemas/ProductWithLinks'}, + }, + }, + }, + ProductWithLinks: { + title: 'ProductWithLinks', + properties: { + categoryId: { + type: 'number', + }, + category: { + $ref: '#/components/schemas/CategoryWithLinks', + }, + }, + }, + }); + }); }); diff --git a/packages/openapi-v3/src/controller-spec.ts b/packages/openapi-v3/src/controller-spec.ts index 26992eb76ca7..9e6084a05a02 100644 --- a/packages/openapi-v3/src/controller-spec.ts +++ b/packages/openapi-v3/src/controller-spec.ts @@ -15,8 +15,13 @@ import { ReferenceObject, SchemaObject, isReferenceObject, + ISpecificationExtension, } from '@loopback/openapi-v3-types'; -import {getJsonSchema} from '@loopback/repository-json-schema'; +import { + getJsonSchema, + JsonSchemaOptions, + getJsonSchemaRef, +} from '@loopback/repository-json-schema'; import {OAI3Keys} from './keys'; import {jsonToSchemaObject} from './json-to-schema'; import * as _ from 'lodash'; @@ -56,6 +61,8 @@ export interface RestEndpoint { export const TS_TYPE_KEY = 'x-ts-type'; +type ComponentSchemaMap = {[key: string]: SchemaObject}; + /** * Build the api spec from class and method level decorations * @param constructor Controller class @@ -120,8 +127,8 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { if (isReferenceObject(responseObject)) continue; const content = responseObject.content || {}; for (const c in content) { - debug(' evaluating response code %s with content: %o', code, c); - resolveTSType(spec, content[c].schema); + debug(' processing response code %s with content-type %', code, c); + processSchemaExtensions(spec, content[c].schema); } } @@ -180,7 +187,7 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { const content = requestBody.content || {}; for (const mediaType in content) { - resolveTSType(spec, content[mediaType].schema); + processSchemaExtensions(spec, content[mediaType].schema); } } } @@ -235,12 +242,18 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { * @param spec Controller spec * @param schema Schema object */ -function resolveTSType( +function processSchemaExtensions( spec: ControllerSpec, - schema?: SchemaObject | ReferenceObject, + schema?: SchemaObject | (ReferenceObject & ISpecificationExtension), ) { - debug(' evaluating schema: %j', schema); - if (!schema || isReferenceObject(schema)) return; + debug(' processing extensions in schema: %j', schema); + if (!schema) return; + + assignRelatedSchemas(spec, schema.definitions); + delete schema.definitions; + + if (isReferenceObject(schema)) return; + const tsType = schema[TS_TYPE_KEY]; debug(' %s => %o', TS_TYPE_KEY, tsType); if (tsType) { @@ -252,11 +265,11 @@ function resolveTSType( return; } if (schema.type === 'array') { - resolveTSType(spec, schema.items); + processSchemaExtensions(spec, schema.items); } else if (schema.type === 'object') { if (schema.properties) { for (const p in schema.properties) { - resolveTSType(spec, schema.properties[p]); + processSchemaExtensions(spec, schema.properties[p]); } } } @@ -281,20 +294,38 @@ function generateOpenAPISchema(spec: ControllerSpec, tsType: Function) { } const jsonSchema = getJsonSchema(tsType); const openapiSchema = jsonToSchemaObject(jsonSchema); - const outputSchemas = spec.components.schemas; - if (openapiSchema.definitions) { - for (const key in openapiSchema.definitions) { - // Preserve user-provided definitions - if (key in outputSchemas) continue; - const relatedSchema = openapiSchema.definitions[key]; - debug(' defining referenced schema for %j: %j', key, relatedSchema); - outputSchemas[key] = relatedSchema; - } - delete openapiSchema.definitions; - } + + assignRelatedSchemas(spec, openapiSchema.definitions); + delete openapiSchema.definitions; debug(' defining schema for %j: %j', tsType.name, openapiSchema); - outputSchemas[tsType.name] = openapiSchema; + spec.components.schemas[tsType.name] = openapiSchema; +} + +function assignRelatedSchemas( + spec: ControllerSpec, + definitions?: ComponentSchemaMap, +) { + if (!definitions) return; + debug( + ' assigning related schemas: ', + definitions && Object.keys(definitions), + ); + if (!spec.components) { + spec.components = {}; + } + if (!spec.components.schemas) { + spec.components.schemas = {}; + } + const outputSchemas = spec.components.schemas; + + for (const key in definitions) { + // Preserve user-provided definitions + if (key in outputSchemas) continue; + const relatedSchema = definitions[key]; + debug(' defining referenced schema for %j: %j', key, relatedSchema); + outputSchemas[key] = relatedSchema; + } } /** @@ -317,3 +348,11 @@ export function getControllerSpec(constructor: Function): ControllerSpec { } return spec; } + +export function getModelSchemaRef( + modelCtor: Function, + options: JsonSchemaOptions, +) { + const jsonSchema = getJsonSchemaRef(modelCtor, options); + return jsonToSchemaObject(jsonSchema); +} diff --git a/packages/repository-json-schema/src/build-schema.ts b/packages/repository-json-schema/src/build-schema.ts index 7cc7064b103e..19dfde3e5dd4 100644 --- a/packages/repository-json-schema/src/build-schema.ts +++ b/packages/repository-json-schema/src/build-schema.ts @@ -169,6 +169,27 @@ export function metaToJsonProperty(meta: PropertyDefinition): JSONSchema { return result; } +export function modelToJsonSchemaRef( + ctor: Function, + options: JsonSchemaOptions = {}, +): JSONSchema { + const schemaWithDefinitions = modelToJsonSchema(ctor, options); + const key = schemaWithDefinitions.title; + + // ctor is not a model + if (!key) return schemaWithDefinitions; + + const definitions = Object.assign({}, schemaWithDefinitions.definitions); + const schema = Object.assign({}, schemaWithDefinitions); + delete schema.definitions; + definitions[key] = schema; + + return { + $ref: `#/definitions/${key}`, + definitions, + }; +} + // NOTE(shimks) no metadata for: union, optional, nested array, any, enum, // string literal, anonymous types, and inherited properties @@ -186,6 +207,8 @@ export function modelToJsonSchema( const meta: ModelDefinition | {} = ModelMetadataHelper.getModelMetadata(ctor); // returns an empty object if metadata is an empty object + // FIXME(bajtos) this pretty much breaks type validation. + // We should return `undefined` instead of an empty object. if (!(meta instanceof ModelDefinition)) { return {}; } @@ -247,7 +270,13 @@ export function modelToJsonSchema( const relMeta = meta.relations[r]; const targetType = resolveType(relMeta.target); const targetSchema = getJsonSchema(targetType, options); - const targetRef = {$ref: `#/definitions/${targetSchema.title}`}; + const targetTitle = targetSchema.title; + if (!targetTitle) { + throw new Error( + `The target of the relation ${title}.${relMeta.name} is not a model!`, + ); + } + const targetRef = {$ref: `#/definitions/${targetTitle}`}; const propDef = relMeta.targetsMany ? { @@ -260,7 +289,7 @@ export function modelToJsonSchema( // E.g. belongsTo sets r="categoryId" but name="category" result.properties[relMeta.name] = result.properties[relMeta.name] || propDef; - includeReferencedSchema(targetSchema.title!, targetSchema); + includeReferencedSchema(targetTitle, targetSchema); } } return result; From e3e6271923f433d82d0bc46d7b16db650dfba258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Fri, 15 Mar 2019 10:34:24 +0100 Subject: [PATCH 04/10] docs: spike notes --- _SPIKE_.md | 297 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 _SPIKE_.md diff --git a/_SPIKE_.md b/_SPIKE_.md new file mode 100644 index 000000000000..5db22cc18c6e --- /dev/null +++ b/_SPIKE_.md @@ -0,0 +1,297 @@ +# Spike documentation + +See the discussion in +[#2152](https://github.com/strongloop/loopback-next/issues/2152) for background +and information on different approaches I have researched and abandoned along +the way. + +## Overview + +In this feature branch, I am presenting a proof of concept implementation +demonstrating how to describe navigational properties for inclusion of related +models. + +The solution consists of three parts: + +1. A convention for describing navigational properties (links) via an interface, + including support in `CrudRepository` and `DefaultCrudRepository` classes. + +2. Improvements in repository-json-schema (`getJsonSchema` and + `modelToJsonSchema`): + + - 2.1: A new option `includeRelations` controlling whether navigational + properties are included in the generated schema. + - 2.2: Support for cyclic references. For example: `CategoryWithLinks` has a + property `products` containing an array of `ProductWithLinks`. + `ProductWithLinks` has a property `category` containing + `CategoryWithLinks`. + - 2.3: A new helper function `modelToJsonSchemaRef` that emits JSON Schema + reference and includes definitions of all references models. + +3. Improvements in code generating controller OpenAPI spec allowing app + developers to use `modelToJsonSchemaRef` to describe response schema. + +Additionally, the todo-list example has been updated to show the proposed +solution in practice. + +## Discussion + +We have the following requirements on a solution for describing navigational +properties: + +1. A (compile-time) type describing navigational properties of a model. For + example, when a `Category` model has many instances of a `Product` model, + then we want to define property `products` containing an array of `Product` + instances _with product navigational properties included_. + +2. Ability to define a compile-time type where all navigational properties are + defined as optional. This is needed by Repository implementation. + +3. Ability to generate two OpenAPI/JSON Schemas for each model: + + 1. Own properties only + 2. Both own properties and navigational properties + +4. SHOULD HAVE: To support JavaScript developers and declarative support, the + new types should be optional. At runtime, we should leverage the dynamic + nature of JavaScript objects and add navigational properties to an instance + of the original mo/contrdel. Specifically, we should not require another + model class to represent model with links. + +My proposed solution meets all requirements above. Additionally, it consists of +several smaller building blocks that can be used beyond the scope of +navigational properties too. + +## Solution details + +### Interface describing model links + +To describe navigation properties for TypeScript compiler, application +developers will define a new interface for each model. + +BelongsTo relation: + +```ts +@model() +class Product extends Entity { + @belongsTo(() => Category) + categoryId: number; +} + +interface ProductLinks { + category?: Category & CategoryLinks; +} +``` + +HasMany relation: + +```ts +@model() +class Category extends Entity { + @hasMany(() => Product) + products?: Product[]; +} + +interface CategoryLinks { + products?: Product & ProductLinks; +} +``` + +This solution has few important properties I'd like to explicitly point out: + +- It is independent on how the relations are defined. `@belongsTo` decorator is + applied on the foreign key property, `@hasMany` decorator is applied to a kind + of a navigational property. If we decide to apply relational decorators at + class level in the future, this solution will support that too. + +- It does not trigger circular-reference bug in design:type metadata, see + https://github.com/Microsoft/TypeScript/issues/27519 + +- It makes it easy to define a type where all navigational properties are + optional. For example: `Product & Partial` + +### Integration with CrudRepository APIs + +The CRUD-related Repository interfaces and classes are accepting a new generic +argument `Links` that's describing navigational properties. + +Example use in application-level repositories: + +```ts +export class CategoryRepository extends DefaultCrudRepository< + Category, + typeof Category.prototype.id, + CategoryLinks +> { + // (no changes here) +} +``` + +### OpenAPI Schema generation + +When building OpenAPI Schema from a model definition, we provide two modes: + +- Own properties only +- Both own properties and navigational properties + +The decision is controlled by a new option passed to `modelToJsonSchema` and +related methods. + +```ts +// own properties only +const spec = getJsonSchema(Product); +// include navigational properties too +const spec = getJsonSchema(Product, {includeRelations: true}); +``` + +An example of the produced schema: + +```js +{ + title: 'CategoryWithLinks', + properties: { + // own properties + id: {type: 'number'}, + // navigational properties + products: { + type: 'array', + items: {$ref: '#/definitions/ProductWithLinks'}, + }, + }, + definitions: { + ProductWithLinks: { + title: 'ProductWithLinks', + properties: { + // own properties + id: {type: 'number'}, + categoryId: {type: 'number'}, + // navigational properties + category: {$ref: '#/definitions/CategoryWithLinks'}, + }, + }, + }, +} +``` + +To support integration with OpenAPI spec of controllers, where we want to +reference a shared definition (component schema), we need a slightly different +schema. Here is an example as produced by `getJsonSchemaRef`: + +```js +{ + $ref: '#/definitions/CategoryWithLinks', + definitions: { + CategoryWithLinks: { + title: 'CategoryWithLinks', + properties: { + id: {type: 'number'}, + products: { + type: 'array', + items: {$ref: '#/definitions/ProductWithLinks'}, + }, + }, + } + ProductWithLinks: { + title: 'ProductWithLinks', + properties: { + id: {type: 'number'}, + categoryId: {type: 'number'}, + category: {$ref: '#/definitions/CategoryWithLinks'}, + }, + }, + }, +} +``` + +### Controller spec + +The last missing piece is integration with controller spec builder. + +At the moment, we use the following implementation of the controller method +`find`: + +```ts +class CategoryController { + // constructor with @inject() decorators + + @get('/categories', { + responses: { + '200': { + description: 'Array of Category model instances', + content: { + 'application/json': { + schema: { + type: 'array', + items: {'x-ts-type': Category}, + }, + }, + }, + }, + }, + }) + async find( + @param.query.object('filter', getFilterSchemaFor(Category)) filter?: Filter, + ): Promise { + return await this.categoryRepository.find(filter); + } +} +``` + +In my proposal, we replace `x-ts-type` extension with a call to +`getModelSchemaRef`. + +```diff + schema: { + type: 'array', +- items: {'x-ts-type': Category}, ++ items: getModelSchemaRef(Category, {includeRelations: true}) + }, +``` + +I particularly like how this solution makes it easy to use a different mechanism +for generating model schema. Consider developers using TypeORM instead of our +Juggler-based Repository for an example. If we implement my proposal, then it +will be possible to implement a TypeORM extension for LoopBack that will provide +a different implementation of `getModelSchemaRef` function, one that will use +TypeORM metadata instead of juggler's LDL. + +Later on, we can explore different ways how to enable `includeRelations` flag +via OpenAPI spec extensions. For example: + +```diff + schema: { + type: 'array', +- items: {'x-ts-type': Category}, ++ items: {'x-ts-type': Category, 'x-include-relations': true}, + }, +``` + +## Follow-up stories + +1. Handle circular references when generating model JSON Schema + +2. Support `schema: {$ref, definitions}` in resolveControllerSpec + +3. Enhance `getJsonSchema` to describe navigational properties (introduce + `includeRelations` option). + +- Add a new `RelationDefinition` property: `targetsMany: boolean` +- Implement support for `includeRelations` in `getJsonSchema` & co. + +4. Implement `getJsonSchemaRef` and `getModelSchemaRef` helpers + +5. Modify Repository `find*` method signatures to include links (navigational + properties) in the description of the return type + +- Add a new generic parameter `Links` to CRUD-related Repository interfaces and + implementations. +- Modify the signature `find` and `findById` to return `T & Partial` + instead of `T`. + +6. Update `examples/todo-list` to leverage these new features: + +- Define `{Model}Links` interfaces +- Update `{Model}Repository` implementations to use these new interfaces +- Update repositories to include related models: overwrite `find` and `findById` + methods, add a hard-coded retrieval of related models. +- Update response schemas for controller methods `find` and `findById` From 07bfd24c8fc6a49edd7d3c38b7dfd484d7804928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 18 Mar 2019 12:02:37 +0100 Subject: [PATCH 05/10] fixup! clarify task list --- _SPIKE_.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/_SPIKE_.md b/_SPIKE_.md index 5db22cc18c6e..0f6a3d5851d5 100644 --- a/_SPIKE_.md +++ b/_SPIKE_.md @@ -21,10 +21,16 @@ The solution consists of three parts: - 2.1: A new option `includeRelations` controlling whether navigational properties are included in the generated schema. - - 2.2: Support for cyclic references. For example: `CategoryWithLinks` has a - property `products` containing an array of `ProductWithLinks`. - `ProductWithLinks` has a property `category` containing - `CategoryWithLinks`. + + - 2.2: Support for cyclic references. + + For example: `CategoryWithLinks` has a property `products` containing an + array of `ProductWithLinks`. `ProductWithLinks` has a property `category` + containing `CategoryWithLinks`. + + Please note this example is NOT using the approach for model inclusion, + it's just an acceptance criteria. + - 2.3: A new helper function `modelToJsonSchemaRef` that emits JSON Schema reference and includes definitions of all references models. From bde5635efbb866d0aeb6d8539d80b7b82a2b3d14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 18 Mar 2019 12:40:38 +0100 Subject: [PATCH 06/10] fixup! introduce {Model}WithLink types --- _SPIKE_.md | 6 +++++- .../todo-list/src/controllers/todo-list.controller.ts | 2 +- examples/todo-list/src/models/todo-list-image.model.ts | 6 ++++-- examples/todo-list/src/models/todo-list.model.ts | 10 ++++++---- examples/todo-list/src/models/todo.model.ts | 2 ++ .../todo-list/src/repositories/todo-list.repository.ts | 10 ++++++++-- examples/todo-list/src/repositories/todo.repository.ts | 8 ++++---- 7 files changed, 30 insertions(+), 14 deletions(-) diff --git a/_SPIKE_.md b/_SPIKE_.md index 0f6a3d5851d5..f1a720596ee7 100644 --- a/_SPIKE_.md +++ b/_SPIKE_.md @@ -87,6 +87,8 @@ class Product extends Entity { interface ProductLinks { category?: Category & CategoryLinks; } + +type ProductWithLinks = Product & ProductLinks; ``` HasMany relation: @@ -101,6 +103,8 @@ class Category extends Entity { interface CategoryLinks { products?: Product & ProductLinks; } + +type CategoryWithLinks = Category & CategoryLinks; ``` This solution has few important properties I'd like to explicitly point out: @@ -296,7 +300,7 @@ via OpenAPI spec extensions. For example: 6. Update `examples/todo-list` to leverage these new features: -- Define `{Model}Links` interfaces +- Define `{Model}Links` interfaces and `{Model}WithLinks` types - Update `{Model}Repository` implementations to use these new interfaces - Update repositories to include related models: overwrite `find` and `findById` methods, add a hard-coded retrieval of related models. diff --git a/examples/todo-list/src/controllers/todo-list.controller.ts b/examples/todo-list/src/controllers/todo-list.controller.ts index f455e3f873ea..f19528e95c68 100644 --- a/examples/todo-list/src/controllers/todo-list.controller.ts +++ b/examples/todo-list/src/controllers/todo-list.controller.ts @@ -65,7 +65,7 @@ export class TodoListController { schema: { type: 'array', items: getModelSchemaRef(TodoList, {includeRelations: true}), - } + }, }, }, }, diff --git a/examples/todo-list/src/models/todo-list-image.model.ts b/examples/todo-list/src/models/todo-list-image.model.ts index 7b174f0a6864..2ad10b73cef7 100644 --- a/examples/todo-list/src/models/todo-list-image.model.ts +++ b/examples/todo-list/src/models/todo-list-image.model.ts @@ -4,7 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import {belongsTo, Entity, model, property} from '@loopback/repository'; -import {TodoList, TodoListLinks} from './todo-list.model'; +import {TodoList, TodoListWithLinks} from './todo-list.model'; @model() export class TodoListImage extends Entity { @@ -31,5 +31,7 @@ export class TodoListImage extends Entity { } export interface TodoListImageLinks { - todoList?: TodoList & TodoListLinks; + todoList?: TodoListWithLinks; } + +export type TodoListImageWithLinks = TodoListImage & TodoListImageLinks; diff --git a/examples/todo-list/src/models/todo-list.model.ts b/examples/todo-list/src/models/todo-list.model.ts index efae1f7034bf..f665f73c7f0d 100644 --- a/examples/todo-list/src/models/todo-list.model.ts +++ b/examples/todo-list/src/models/todo-list.model.ts @@ -4,8 +4,8 @@ // License text available at https://opensource.org/licenses/MIT import {Entity, hasMany, hasOne, model, property} from '@loopback/repository'; -import {TodoListImage, TodoListImageLinks} from './todo-list-image.model'; -import {Todo} from './todo.model'; +import {TodoListImage, TodoListImageWithLinks} from './todo-list-image.model'; +import {Todo, TodoWithLinks} from './todo.model'; @model() export class TodoList extends Entity { @@ -38,6 +38,8 @@ export class TodoList extends Entity { } export interface TodoListLinks { - todos?: (Todo & TodoListLinks)[]; - image?: TodoListImage & TodoListImageLinks; + todos?: TodoWithLinks[]; + image?: TodoListImageWithLinks; } + +export type TodoListWithLinks = TodoList & TodoListLinks; diff --git a/examples/todo-list/src/models/todo.model.ts b/examples/todo-list/src/models/todo.model.ts index 083657367c4a..8eaf54b3cce7 100644 --- a/examples/todo-list/src/models/todo.model.ts +++ b/examples/todo-list/src/models/todo.model.ts @@ -45,3 +45,5 @@ export class Todo extends Entity { export interface TodoLinks { todoList?: TodoList & TodoListLinks; } + +export type TodoWithLinks = Todo & TodoLinks; diff --git a/examples/todo-list/src/repositories/todo-list.repository.ts b/examples/todo-list/src/repositories/todo-list.repository.ts index 298ff23050c1..da9d068876fb 100644 --- a/examples/todo-list/src/repositories/todo-list.repository.ts +++ b/examples/todo-list/src/repositories/todo-list.repository.ts @@ -13,7 +13,13 @@ import { Filter, Options, } from '@loopback/repository'; -import {Todo, TodoList, TodoListImage, TodoListLinks} from '../models'; +import { + Todo, + TodoList, + TodoListImage, + TodoListLinks, + TodoListWithLinks, +} from '../models'; import {TodoRepository} from './todo.repository'; import {TodoListImageRepository} from './todo-list-image.repository'; @@ -56,7 +62,7 @@ export class TodoListRepository extends DefaultCrudRepository< async find( filter?: Filter, options?: Options, - ): Promise<(TodoList & Partial)[]> { + ): Promise { // Prevent juggler for applying "include" filter // Juggler is not aware of LB4 relations const include = filter && filter.include; diff --git a/examples/todo-list/src/repositories/todo.repository.ts b/examples/todo-list/src/repositories/todo.repository.ts index 46800015a808..97804621531f 100644 --- a/examples/todo-list/src/repositories/todo.repository.ts +++ b/examples/todo-list/src/repositories/todo.repository.ts @@ -7,12 +7,12 @@ import {Getter, inject} from '@loopback/core'; import { BelongsToAccessor, DefaultCrudRepository, + Filter, juggler, - repository, Options, - Filter, + repository, } from '@loopback/repository'; -import {Todo, TodoList, TodoLinks} from '../models'; +import {Todo, TodoLinks, TodoList, TodoWithLinks} from '../models'; import {TodoListRepository} from './todo-list.repository'; export class TodoRepository extends DefaultCrudRepository< @@ -41,7 +41,7 @@ export class TodoRepository extends DefaultCrudRepository< async find( filter?: Filter, options?: Options, - ): Promise<(Todo & Partial)[]> { + ): Promise { // Prevent juggler for applying "include" filter // Juggler is not aware of LB4 relations const include = filter && filter.include; From 67d44d58641f9ca4e1ca722d32b69934dc5c1d48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Thu, 21 Mar 2019 16:21:25 +0100 Subject: [PATCH 07/10] chore: address review feedback --- _SPIKE_.md | 37 ++++++++++++++++--- .../src/repositories/legacy-juggler-bridge.ts | 11 ++---- .../repository/src/repositories/repository.ts | 20 ++++------ 3 files changed, 44 insertions(+), 24 deletions(-) diff --git a/_SPIKE_.md b/_SPIKE_.md index f1a720596ee7..94df2856a6fe 100644 --- a/_SPIKE_.md +++ b/_SPIKE_.md @@ -84,10 +84,16 @@ class Product extends Entity { categoryId: number; } +/** + * Navigation properties of the Product model. + */ interface ProductLinks { - category?: Category & CategoryLinks; + category?: CategoryWithLinks; } +/** + * Product's own properties and navigation properties. + */ type ProductWithLinks = Product & ProductLinks; ``` @@ -100,10 +106,16 @@ class Category extends Entity { products?: Product[]; } +/** + * Navigation properties of the Category model. + */ interface CategoryLinks { - products?: Product & ProductLinks; + products?: ProductWithLinks[]; } +/** + * Category's own properties and navigation properties. + */ type CategoryWithLinks = Category & CategoryLinks; ``` @@ -120,6 +132,10 @@ This solution has few important properties I'd like to explicitly point out: - It makes it easy to define a type where all navigational properties are optional. For example: `Product & Partial` + UPDATE: As it turns out, it's not enough to mark all navigational properties + as optional. See the discussion in + https://github.com/strongloop/loopback-next/pull/2592#discussion_r267600322 + ### Integration with CrudRepository APIs The CRUD-related Repository interfaces and classes are accepting a new generic @@ -213,6 +229,12 @@ schema. Here is an example as produced by `getJsonSchemaRef`: } ``` +The first schema defines `CategoryWithLinks` as the top-level schema, +`definitions` contain only `ProductWithLinks` schema. + +The second schema contains only `$ref` entry at the top-level, the actual schema +for `CategoryWithLinks` is defined in `definitions`. + ### Controller spec The last missing piece is integration with controller spec builder. @@ -241,7 +263,7 @@ class CategoryController { }) async find( @param.query.object('filter', getFilterSchemaFor(Category)) filter?: Filter, - ): Promise { + ): Promise { return await this.categoryRepository.find(filter); } } @@ -295,8 +317,9 @@ via OpenAPI spec extensions. For example: - Add a new generic parameter `Links` to CRUD-related Repository interfaces and implementations. -- Modify the signature `find` and `findById` to return `T & Partial` - instead of `T`. +- Modify the signature `find` and `findById` to return `T & Links` instead of + `T`. If this requires too many explicit casts, then consider using + `T & Partial` instead, assuming it improves the situation. 6. Update `examples/todo-list` to leverage these new features: @@ -305,3 +328,7 @@ via OpenAPI spec extensions. For example: - Update repositories to include related models: overwrite `find` and `findById` methods, add a hard-coded retrieval of related models. - Update response schemas for controller methods `find` and `findById` + +7. Replace our temporary poor-man's relation resolver with a real one, as + described in https://github.com/strongloop/loopback-next/pull/2124. Update + the example app as part of this work. diff --git a/packages/repository/src/repositories/legacy-juggler-bridge.ts b/packages/repository/src/repositories/legacy-juggler-bridge.ts index 12fd567e3530..123c47d4665c 100644 --- a/packages/repository/src/repositories/legacy-juggler-bridge.ts +++ b/packages/repository/src/repositories/legacy-juggler-bridge.ts @@ -337,10 +337,7 @@ export class DefaultCrudRepository< } } - async find( - filter?: Filter, - options?: Options, - ): Promise<(T & Partial)[]> { + async find(filter?: Filter, options?: Options): Promise<(T & Links)[]> { const models = await ensurePromise( this.modelClass.find(filter as legacy.Filter, options), ); @@ -359,14 +356,14 @@ export class DefaultCrudRepository< id: ID, filter?: Filter, options?: Options, - ): Promise> { + ): Promise { const model = await ensurePromise( this.modelClass.findById(id, filter as legacy.Filter, options), ); if (!model) { throw new EntityNotFoundError(this.entityClass, id); } - return this.toEntity>(model); + return this.toEntity(model); } update(entity: T, options?: Options): Promise { @@ -456,6 +453,6 @@ export class DefaultCrudRepository< } protected toEntities(models: juggler.PersistedModel[]): R[] { - return models.map(m => this.toEntity(m)); + return models.map(m => this.toEntity(m)); } } diff --git a/packages/repository/src/repositories/repository.ts b/packages/repository/src/repositories/repository.ts index f15a946cbb06..ace8048e5d88 100644 --- a/packages/repository/src/repositories/repository.ts +++ b/packages/repository/src/repositories/repository.ts @@ -3,20 +3,20 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Entity, ValueObject, Model} from '../model'; import { - DataObject, - Options, AnyObject, Command, + Count, + DataObject, NamedParameters, + Options, PositionalParameters, - Count, } from '../common-types'; -import {DataSource} from '../datasource'; import {CrudConnector} from '../connectors'; -import {Filter, Where} from '../query'; +import {DataSource} from '../datasource'; import {EntityNotFoundError} from '../errors'; +import {Entity, Model, ValueObject} from '../model'; +import {Filter, Where} from '../query'; // tslint:disable:no-unused @@ -66,7 +66,7 @@ export interface CrudRepository< * @param options Options for the operations * @returns A promise of an array of records found */ - find(filter?: Filter, options?: Options): Promise<(T & Partial)[]>; + find(filter?: Filter, options?: Options): Promise<(T & Links)[]>; /** * Updating matching records with attributes from the data object @@ -150,11 +150,7 @@ export interface EntityCrudRepository< * @param options Options for the operations * @returns A promise of an entity found for the id */ - findById( - id: ID, - filter?: Filter, - options?: Options, - ): Promise>; + findById(id: ID, filter?: Filter, options?: Options): Promise; /** * Update an entity by id with property/value pairs in the data object From 8d49bfa3df496c9707e36b64740eeea6d0c5f466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Fri, 22 Mar 2019 09:35:35 +0100 Subject: [PATCH 08/10] fix: rename Links to Relations --- _SPIKE_.md | 78 +++++++++---------- .../src/models/todo-list-image.model.ts | 8 +- .../todo-list/src/models/todo-list.model.ts | 15 ++-- examples/todo-list/src/models/todo.model.ts | 10 +-- .../src/repositories/todo-list.repository.ts | 8 +- .../src/repositories/todo.repository.ts | 6 +- .../controller-spec.integration.ts | 14 ++-- .../integration/spike.integration.ts | 10 +-- .../src/build-schema.ts | 4 +- .../src/repositories/legacy-juggler-bridge.ts | 13 ++-- .../repository/src/repositories/repository.ts | 14 ++-- 11 files changed, 95 insertions(+), 85 deletions(-) diff --git a/_SPIKE_.md b/_SPIKE_.md index 94df2856a6fe..32b082411e25 100644 --- a/_SPIKE_.md +++ b/_SPIKE_.md @@ -24,9 +24,9 @@ The solution consists of three parts: - 2.2: Support for cyclic references. - For example: `CategoryWithLinks` has a property `products` containing an - array of `ProductWithLinks`. `ProductWithLinks` has a property `category` - containing `CategoryWithLinks`. + For example: `CategoryWithRelations` has a property `products` containing + an array of `ProductWithRelations`. `ProductWithRelations` has a property + `category` containing `CategoryWithRelations`. Please note this example is NOT using the approach for model inclusion, it's just an acceptance criteria. @@ -61,8 +61,8 @@ properties: 4. SHOULD HAVE: To support JavaScript developers and declarative support, the new types should be optional. At runtime, we should leverage the dynamic nature of JavaScript objects and add navigational properties to an instance - of the original mo/contrdel. Specifically, we should not require another - model class to represent model with links. + of the original model. Specifically, we should not require another model + class to represent model with relations. My proposed solution meets all requirements above. Additionally, it consists of several smaller building blocks that can be used beyond the scope of @@ -70,7 +70,7 @@ navigational properties too. ## Solution details -### Interface describing model links +### Interface describing model navigational properties To describe navigation properties for TypeScript compiler, application developers will define a new interface for each model. @@ -87,14 +87,14 @@ class Product extends Entity { /** * Navigation properties of the Product model. */ -interface ProductLinks { - category?: CategoryWithLinks; +interface ProductRelations { + category?: CategoryWithRelations; } /** * Product's own properties and navigation properties. */ -type ProductWithLinks = Product & ProductLinks; +type ProductWithRelations = Product & ProductRelations; ``` HasMany relation: @@ -109,14 +109,14 @@ class Category extends Entity { /** * Navigation properties of the Category model. */ -interface CategoryLinks { - products?: ProductWithLinks[]; +interface CategoryRelations { + products?: ProductWithRelations[]; } /** * Category's own properties and navigation properties. */ -type CategoryWithLinks = Category & CategoryLinks; +type CategoryWithRelations = Category & CategoryRelations; ``` This solution has few important properties I'd like to explicitly point out: @@ -130,7 +130,7 @@ This solution has few important properties I'd like to explicitly point out: https://github.com/Microsoft/TypeScript/issues/27519 - It makes it easy to define a type where all navigational properties are - optional. For example: `Product & Partial` + optional. For example: `Product & Partial` UPDATE: As it turns out, it's not enough to mark all navigational properties as optional. See the discussion in @@ -139,7 +139,7 @@ This solution has few important properties I'd like to explicitly point out: ### Integration with CrudRepository APIs The CRUD-related Repository interfaces and classes are accepting a new generic -argument `Links` that's describing navigational properties. +argument `Relations` that's describing navigational properties. Example use in application-level repositories: @@ -147,7 +147,7 @@ Example use in application-level repositories: export class CategoryRepository extends DefaultCrudRepository< Category, typeof Category.prototype.id, - CategoryLinks + CategoryRelations > { // (no changes here) } @@ -174,25 +174,25 @@ An example of the produced schema: ```js { - title: 'CategoryWithLinks', + title: 'CategoryWithRelations', properties: { // own properties id: {type: 'number'}, // navigational properties products: { type: 'array', - items: {$ref: '#/definitions/ProductWithLinks'}, + items: {$ref: '#/definitions/ProductWithRelations'}, }, }, definitions: { - ProductWithLinks: { - title: 'ProductWithLinks', + ProductWithRelations: { + title: 'ProductWithRelations', properties: { // own properties id: {type: 'number'}, categoryId: {type: 'number'}, // navigational properties - category: {$ref: '#/definitions/CategoryWithLinks'}, + category: {$ref: '#/definitions/CategoryWithRelations'}, }, }, }, @@ -205,35 +205,35 @@ schema. Here is an example as produced by `getJsonSchemaRef`: ```js { - $ref: '#/definitions/CategoryWithLinks', + $ref: '#/definitions/CategoryWithRelations', definitions: { - CategoryWithLinks: { - title: 'CategoryWithLinks', + CategoryWithRelations: { + title: 'CategoryWithRelations', properties: { id: {type: 'number'}, products: { type: 'array', - items: {$ref: '#/definitions/ProductWithLinks'}, + items: {$ref: '#/definitions/ProductWithRelations'}, }, }, } - ProductWithLinks: { - title: 'ProductWithLinks', + ProductWithRelations: { + title: 'ProductWithRelations', properties: { id: {type: 'number'}, categoryId: {type: 'number'}, - category: {$ref: '#/definitions/CategoryWithLinks'}, + category: {$ref: '#/definitions/CategoryWithRelations'}, }, }, }, } ``` -The first schema defines `CategoryWithLinks` as the top-level schema, -`definitions` contain only `ProductWithLinks` schema. +The first schema defines `CategoryWithRelations` as the top-level schema, +`definitions` contain only `ProductWithRelations` schema. The second schema contains only `$ref` entry at the top-level, the actual schema -for `CategoryWithLinks` is defined in `definitions`. +for `CategoryWithRelations` is defined in `definitions`. ### Controller spec @@ -263,7 +263,7 @@ class CategoryController { }) async find( @param.query.object('filter', getFilterSchemaFor(Category)) filter?: Filter, - ): Promise { + ): Promise { return await this.categoryRepository.find(filter); } } @@ -312,18 +312,18 @@ via OpenAPI spec extensions. For example: 4. Implement `getJsonSchemaRef` and `getModelSchemaRef` helpers -5. Modify Repository `find*` method signatures to include links (navigational - properties) in the description of the return type +5. Modify Repository `find*` method signatures to include navigational + properties in the description of the return type -- Add a new generic parameter `Links` to CRUD-related Repository interfaces and - implementations. -- Modify the signature `find` and `findById` to return `T & Links` instead of - `T`. If this requires too many explicit casts, then consider using - `T & Partial` instead, assuming it improves the situation. +- Add a new generic parameter `Relations` to CRUD-related Repository interfaces + and implementations. +- Modify the signature `find` and `findById` to return `T & Relations` instead + of `T`. If this requires too many explicit casts, then consider using + `T & Partial` instead, assuming it improves the situation. 6. Update `examples/todo-list` to leverage these new features: -- Define `{Model}Links` interfaces and `{Model}WithLinks` types +- Define `{Model}Relations` interfaces and `{Model}WithRelations` types - Update `{Model}Repository` implementations to use these new interfaces - Update repositories to include related models: overwrite `find` and `findById` methods, add a hard-coded retrieval of related models. diff --git a/examples/todo-list/src/models/todo-list-image.model.ts b/examples/todo-list/src/models/todo-list-image.model.ts index 2ad10b73cef7..2da331cd8125 100644 --- a/examples/todo-list/src/models/todo-list-image.model.ts +++ b/examples/todo-list/src/models/todo-list-image.model.ts @@ -4,7 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import {belongsTo, Entity, model, property} from '@loopback/repository'; -import {TodoList, TodoListWithLinks} from './todo-list.model'; +import {TodoList, TodoListWithRelations} from './todo-list.model'; @model() export class TodoListImage extends Entity { @@ -30,8 +30,8 @@ export class TodoListImage extends Entity { } } -export interface TodoListImageLinks { - todoList?: TodoListWithLinks; +export interface TodoListImageRelations { + todoList?: TodoListWithRelations; } -export type TodoListImageWithLinks = TodoListImage & TodoListImageLinks; +export type TodoListImageWithRelations = TodoListImage & TodoListImageRelations; diff --git a/examples/todo-list/src/models/todo-list.model.ts b/examples/todo-list/src/models/todo-list.model.ts index f665f73c7f0d..94c24762c7d3 100644 --- a/examples/todo-list/src/models/todo-list.model.ts +++ b/examples/todo-list/src/models/todo-list.model.ts @@ -4,8 +4,11 @@ // License text available at https://opensource.org/licenses/MIT import {Entity, hasMany, hasOne, model, property} from '@loopback/repository'; -import {TodoListImage, TodoListImageWithLinks} from './todo-list-image.model'; -import {Todo, TodoWithLinks} from './todo.model'; +import { + TodoListImage, + TodoListImageWithRelations, +} from './todo-list-image.model'; +import {Todo, TodoWithRelations} from './todo.model'; @model() export class TodoList extends Entity { @@ -37,9 +40,9 @@ export class TodoList extends Entity { } } -export interface TodoListLinks { - todos?: TodoWithLinks[]; - image?: TodoListImageWithLinks; +export interface TodoListRelations { + todos?: TodoWithRelations[]; + image?: TodoListImageWithRelations; } -export type TodoListWithLinks = TodoList & TodoListLinks; +export type TodoListWithRelations = TodoList & TodoListRelations; diff --git a/examples/todo-list/src/models/todo.model.ts b/examples/todo-list/src/models/todo.model.ts index 8eaf54b3cce7..4bbabb99d8cd 100644 --- a/examples/todo-list/src/models/todo.model.ts +++ b/examples/todo-list/src/models/todo.model.ts @@ -3,8 +3,8 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Entity, property, model, belongsTo} from '@loopback/repository'; -import {TodoList, TodoListLinks} from './todo-list.model'; +import {belongsTo, Entity, model, property} from '@loopback/repository'; +import {TodoList, TodoListRelations} from './todo-list.model'; @model() export class Todo extends Entity { @@ -42,8 +42,8 @@ export class Todo extends Entity { } } -export interface TodoLinks { - todoList?: TodoList & TodoListLinks; +export interface TodoRelations { + todoList?: TodoList & TodoListRelations; } -export type TodoWithLinks = Todo & TodoLinks; +export type TodoWithRelations = Todo & TodoRelations; diff --git a/examples/todo-list/src/repositories/todo-list.repository.ts b/examples/todo-list/src/repositories/todo-list.repository.ts index da9d068876fb..25a3cf3885d0 100644 --- a/examples/todo-list/src/repositories/todo-list.repository.ts +++ b/examples/todo-list/src/repositories/todo-list.repository.ts @@ -17,8 +17,8 @@ import { Todo, TodoList, TodoListImage, - TodoListLinks, - TodoListWithLinks, + TodoListRelations, + TodoListWithRelations, } from '../models'; import {TodoRepository} from './todo.repository'; import {TodoListImageRepository} from './todo-list-image.repository'; @@ -26,7 +26,7 @@ import {TodoListImageRepository} from './todo-list-image.repository'; export class TodoListRepository extends DefaultCrudRepository< TodoList, typeof TodoList.prototype.id, - TodoListLinks + TodoListRelations > { public readonly todos: HasManyRepositoryFactory< Todo, @@ -62,7 +62,7 @@ export class TodoListRepository extends DefaultCrudRepository< async find( filter?: Filter, options?: Options, - ): Promise { + ): Promise { // Prevent juggler for applying "include" filter // Juggler is not aware of LB4 relations const include = filter && filter.include; diff --git a/examples/todo-list/src/repositories/todo.repository.ts b/examples/todo-list/src/repositories/todo.repository.ts index 97804621531f..f97fc2b51588 100644 --- a/examples/todo-list/src/repositories/todo.repository.ts +++ b/examples/todo-list/src/repositories/todo.repository.ts @@ -12,13 +12,13 @@ import { Options, repository, } from '@loopback/repository'; -import {Todo, TodoLinks, TodoList, TodoWithLinks} from '../models'; +import {Todo, TodoRelations, TodoList, TodoWithRelations} from '../models'; import {TodoListRepository} from './todo-list.repository'; export class TodoRepository extends DefaultCrudRepository< Todo, typeof Todo.prototype.id, - TodoLinks + TodoRelations > { public readonly todoList: BelongsToAccessor< TodoList, @@ -41,7 +41,7 @@ export class TodoRepository extends DefaultCrudRepository< async find( filter?: Filter, options?: Options, - ): Promise { + ): Promise { // Prevent juggler for applying "include" filter // Juggler is not aware of LB4 relations const include = filter && filter.include; diff --git a/packages/openapi-v3/src/__tests__/integration/controller-spec.integration.ts b/packages/openapi-v3/src/__tests__/integration/controller-spec.integration.ts index e2e2e7a5e572..068b19bfcc85 100644 --- a/packages/openapi-v3/src/__tests__/integration/controller-spec.integration.ts +++ b/packages/openapi-v3/src/__tests__/integration/controller-spec.integration.ts @@ -468,28 +468,28 @@ describe('controller spec', () => { const opSpec: OperationObject = spec.paths['/categories'].get; const responseSpec = opSpec.responses['200'].content['application/json']; expect(responseSpec.schema).to.deepEqual({ - $ref: '#/components/schemas/CategoryWithLinks', + $ref: '#/components/schemas/CategoryWithRelations', }); const globalSchemas = (spec.components || {}).schemas; expect(globalSchemas).to.deepEqual({ - CategoryWithLinks: { - title: 'CategoryWithLinks', + CategoryWithRelations: { + title: 'CategoryWithRelations', properties: { products: { type: 'array', - items: {$ref: '#/components/schemas/ProductWithLinks'}, + items: {$ref: '#/components/schemas/ProductWithRelations'}, }, }, }, - ProductWithLinks: { - title: 'ProductWithLinks', + ProductWithRelations: { + title: 'ProductWithRelations', properties: { categoryId: { type: 'number', }, category: { - $ref: '#/components/schemas/CategoryWithLinks', + $ref: '#/components/schemas/CategoryWithRelations', }, }, }, diff --git a/packages/repository-json-schema/src/__tests__/integration/spike.integration.ts b/packages/repository-json-schema/src/__tests__/integration/spike.integration.ts index 9fc94a6aed8e..fff8111106de 100644 --- a/packages/repository-json-schema/src/__tests__/integration/spike.integration.ts +++ b/packages/repository-json-schema/src/__tests__/integration/spike.integration.ts @@ -54,25 +54,25 @@ describe('build-schema', () => { const jsonSchema = modelToJsonSchema(Category, {includeRelations: true}); expectValidJsonSchema(jsonSchema); expect(jsonSchema).to.deepEqual({ - title: 'CategoryWithLinks', + title: 'CategoryWithRelations', properties: { // TODO(bajtos): inherit these properties from Category schema // See https://swagger.io/docs/specification/data-models/inheritance-and-polymorphism/ id: {type: 'number'}, products: { type: 'array', - items: {$ref: '#/definitions/ProductWithLinks'}, + items: {$ref: '#/definitions/ProductWithRelations'}, }, }, definitions: { - ProductWithLinks: { - title: 'ProductWithLinks', + ProductWithRelations: { + title: 'ProductWithRelations', properties: { // TODO(bajtos): inherit these properties from Product schema // See https://swagger.io/docs/specification/data-models/inheritance-and-polymorphism/ id: {type: 'number'}, categoryId: {type: 'number'}, - category: {$ref: '#/definitions/CategoryWithLinks'}, + category: {$ref: '#/definitions/CategoryWithRelations'}, }, }, }, diff --git a/packages/repository-json-schema/src/build-schema.ts b/packages/repository-json-schema/src/build-schema.ts index 19dfde3e5dd4..2ed102de5fe4 100644 --- a/packages/repository-json-schema/src/build-schema.ts +++ b/packages/repository-json-schema/src/build-schema.ts @@ -30,7 +30,7 @@ export function getJsonSchema( ): JSONSchema { // NOTE(shimks) currently impossible to dynamically update const cached = MetadataInspector.getClassMetadata(JSON_SCHEMA_KEY, ctor); - const key = options.includeRelations ? 'modelWithLinks' : 'modelOnly'; + const key = options.includeRelations ? 'modelWithRelations' : 'modelOnly'; if (cached && cached[key]) { return cached[key]; @@ -215,7 +215,7 @@ export function modelToJsonSchema( let title = meta.title || ctor.name; if (options.includeRelations) { - title += 'WithLinks'; + title += 'WithRelations'; } if (title in options.visited) return options.visited[title]; diff --git a/packages/repository/src/repositories/legacy-juggler-bridge.ts b/packages/repository/src/repositories/legacy-juggler-bridge.ts index 123c47d4665c..426a36464c5a 100644 --- a/packages/repository/src/repositories/legacy-juggler-bridge.ts +++ b/packages/repository/src/repositories/legacy-juggler-bridge.ts @@ -91,8 +91,8 @@ export function ensurePromise(p: legacy.PromiseOrVoid): Promise { export class DefaultCrudRepository< T extends Entity, ID, - Links extends object = {} -> implements EntityCrudRepository { + Relations extends object = {} +> implements EntityCrudRepository { modelClass: juggler.PersistedModelClass; /** @@ -337,7 +337,10 @@ export class DefaultCrudRepository< } } - async find(filter?: Filter, options?: Options): Promise<(T & Links)[]> { + async find( + filter?: Filter, + options?: Options, + ): Promise<(T & Relations)[]> { const models = await ensurePromise( this.modelClass.find(filter as legacy.Filter, options), ); @@ -356,14 +359,14 @@ export class DefaultCrudRepository< id: ID, filter?: Filter, options?: Options, - ): Promise { + ): Promise { const model = await ensurePromise( this.modelClass.findById(id, filter as legacy.Filter, options), ); if (!model) { throw new EntityNotFoundError(this.entityClass, id); } - return this.toEntity(model); + return this.toEntity(model); } update(entity: T, options?: Options): Promise { diff --git a/packages/repository/src/repositories/repository.ts b/packages/repository/src/repositories/repository.ts index ace8048e5d88..be58cea9a146 100644 --- a/packages/repository/src/repositories/repository.ts +++ b/packages/repository/src/repositories/repository.ts @@ -42,7 +42,7 @@ export interface ExecutableRepository extends Repository { */ export interface CrudRepository< T extends ValueObject | Entity, - Links extends object = {} + Relations extends object = {} > extends Repository { /** * Create a new record @@ -66,7 +66,7 @@ export interface CrudRepository< * @param options Options for the operations * @returns A promise of an array of records found */ - find(filter?: Filter, options?: Options): Promise<(T & Links)[]>; + find(filter?: Filter, options?: Options): Promise<(T & Relations)[]>; /** * Updating matching records with attributes from the data object @@ -110,8 +110,8 @@ export interface EntityRepository export interface EntityCrudRepository< T extends Entity, ID, - Links extends object = {} -> extends EntityRepository, CrudRepository { + Relations extends object = {} +> extends EntityRepository, CrudRepository { // entityClass should have type "typeof T", but that's not supported by TSC entityClass: typeof Entity & {prototype: T}; @@ -150,7 +150,11 @@ export interface EntityCrudRepository< * @param options Options for the operations * @returns A promise of an entity found for the id */ - findById(id: ID, filter?: Filter, options?: Options): Promise; + findById( + id: ID, + filter?: Filter, + options?: Options, + ): Promise; /** * Update an entity by id with property/value pairs in the data object From 244f270a33d9a01287971699a93665aee11979eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Fri, 22 Mar 2019 10:44:39 +0100 Subject: [PATCH 09/10] feat: update SPIKE notes with links to issues, add more tests --- _SPIKE_.md | 14 ++++- .../integration/spike.integration.ts | 59 +++++++++++++++++++ .../integration/spike.integration.ts | 35 +++++++++++ .../src/build-schema.ts | 2 +- 4 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 packages/openapi-v3/src/__tests__/integration/spike.integration.ts diff --git a/_SPIKE_.md b/_SPIKE_.md index 32b082411e25..23d321a265c2 100644 --- a/_SPIKE_.md +++ b/_SPIKE_.md @@ -302,17 +302,25 @@ via OpenAPI spec extensions. For example: 1. Handle circular references when generating model JSON Schema + --> https://github.com/strongloop/loopback-next/issues/2628 + 2. Support `schema: {$ref, definitions}` in resolveControllerSpec + --> https://github.com/strongloop/loopback-next/issues/2629 + 3. Enhance `getJsonSchema` to describe navigational properties (introduce `includeRelations` option). -- Add a new `RelationDefinition` property: `targetsMany: boolean` -- Implement support for `includeRelations` in `getJsonSchema` & co. + - Add a new `RelationDefinition` property: `targetsMany: boolean` + - Implement support for `includeRelations` in `getJsonSchema` & co. + + --> https://github.com/strongloop/loopback-next/issues/2630 4. Implement `getJsonSchemaRef` and `getModelSchemaRef` helpers -5. Modify Repository `find*` method signatures to include navigational + --> https://github.com/strongloop/loopback-next/issues/2631 + +5) Modify Repository `find*` method signatures to include navigational properties in the description of the return type - Add a new generic parameter `Relations` to CRUD-related Repository interfaces diff --git a/packages/openapi-v3/src/__tests__/integration/spike.integration.ts b/packages/openapi-v3/src/__tests__/integration/spike.integration.ts new file mode 100644 index 000000000000..8a9d938f3f6e --- /dev/null +++ b/packages/openapi-v3/src/__tests__/integration/spike.integration.ts @@ -0,0 +1,59 @@ +// Copyright IBM Corp. 2019. 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 {OperationObject} from '@loopback/openapi-v3-types'; +import {expect} from '@loopback/testlab'; +import {get, getControllerSpec} from '../..'; + +describe('controller spec', () => { + it('allows operations to provide definition of referenced models', () => { + class MyController { + @get('/todos', { + responses: { + '200': { + description: 'Array of Category model instances', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Todo', + definitions: { + Todo: { + title: 'Todo', + properties: { + title: {type: 'string'}, + }, + }, + }, + }, + }, + }, + }, + }, + }) + async find(): Promise { + return []; // dummy implementation, it's never called + } + } + + const spec = getControllerSpec(MyController); + const opSpec: OperationObject = spec.paths['/todos'].get; + const responseSpec = opSpec.responses['200'].content['application/json']; + expect(responseSpec.schema).to.deepEqual({ + $ref: '#/components/schemas/Todo', + }); + + const globalSchemas = (spec.components || {}).schemas; + expect(globalSchemas).to.deepEqual({ + Todo: { + title: 'Todo', + properties: { + title: { + type: 'string', + }, + }, + }, + }); + }); +}); diff --git a/packages/repository-json-schema/src/__tests__/integration/spike.integration.ts b/packages/repository-json-schema/src/__tests__/integration/spike.integration.ts index fff8111106de..38000b5926e9 100644 --- a/packages/repository-json-schema/src/__tests__/integration/spike.integration.ts +++ b/packages/repository-json-schema/src/__tests__/integration/spike.integration.ts @@ -32,6 +32,41 @@ describe('build-schema', () => { }); }); + it('handles circular references', () => { + @model() + class Category { + @property.array(() => Product) + products?: Product[]; + } + + @model() + class Product { + @property(() => Category) + category?: Category; + } + + const schema = modelToJsonSchema(Category); + expect(schema).to.deepEqual({ + title: 'Category', + properties: { + products: { + type: 'array', + items: {$ref: '#/definitions/Product'}, + }, + }, + definitions: { + Product: { + title: 'Product', + properties: { + category: { + $ref: '#/definitions/Category', + }, + }, + }, + }, + }); + }); + it('converts HasMany and BelongsTo relation links', () => { @model() class Product extends Entity { diff --git a/packages/repository-json-schema/src/build-schema.ts b/packages/repository-json-schema/src/build-schema.ts index 2ed102de5fe4..3609527ebcd1 100644 --- a/packages/repository-json-schema/src/build-schema.ts +++ b/packages/repository-json-schema/src/build-schema.ts @@ -260,7 +260,7 @@ export function modelToJsonSchema( continue; } - const propSchema = getJsonSchema(referenceType); + const propSchema = getJsonSchema(referenceType, options); includeReferencedSchema(referenceType.name, propSchema); } From b21fb3b09c6eae94dbcf4d43b3920aebec9e05bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Fri, 22 Mar 2019 11:51:24 +0100 Subject: [PATCH 10/10] feat: add more new stories --- _SPIKE_.md | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/_SPIKE_.md b/_SPIKE_.md index 23d321a265c2..b3a856491182 100644 --- a/_SPIKE_.md +++ b/_SPIKE_.md @@ -323,20 +323,26 @@ via OpenAPI spec extensions. For example: 5) Modify Repository `find*` method signatures to include navigational properties in the description of the return type -- Add a new generic parameter `Relations` to CRUD-related Repository interfaces - and implementations. -- Modify the signature `find` and `findById` to return `T & Relations` instead - of `T`. If this requires too many explicit casts, then consider using - `T & Partial` instead, assuming it improves the situation. + - Add a new generic parameter `Relations` to CRUD-related Repository + interfaces and implementations. + - Modify the signature `find` and `findById` to return `T & Relations` + instead of `T`. If this requires too many explicit casts, then consider + using `T & Partial` instead, assuming it improves the situation. + + --> https://github.com/strongloop/loopback-next/issues/2632 6. Update `examples/todo-list` to leverage these new features: -- Define `{Model}Relations` interfaces and `{Model}WithRelations` types -- Update `{Model}Repository` implementations to use these new interfaces -- Update repositories to include related models: overwrite `find` and `findById` - methods, add a hard-coded retrieval of related models. -- Update response schemas for controller methods `find` and `findById` + - Define `{Model}Relations` interfaces and `{Model}WithRelations` types + - Update `{Model}Repository` implementations to use these new interfaces + - Update repositories to include related models: overwrite `find` and + `findById` methods, add a hard-coded retrieval of related models. + - Update response schemas for controller methods `find` and `findById` + + --> https://github.com/strongloop/loopback-next/issues/2633 7. Replace our temporary poor-man's relation resolver with a real one, as described in https://github.com/strongloop/loopback-next/pull/2124. Update the example app as part of this work. + + --> https://github.com/strongloop/loopback-next/issues/2634