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.