From f42e71e43d10168e56364c7c780a8018cd13b4de Mon Sep 17 00:00:00 2001 From: InvictusMB Date: Tue, 26 May 2020 23:24:06 +0200 Subject: [PATCH 1/2] feat!: building jsonSchema with relations produces composition --- .../integration/build-schema.integration.ts | 104 +++++++++++++----- .../src/__tests__/unit/build-schema.unit.ts | 19 ++-- .../src/build-schema.ts | 56 ++++++---- 3 files changed, 121 insertions(+), 58 deletions(-) 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 85b0769e1679..c486458fd505 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 @@ -1019,27 +1019,54 @@ describe('build-schema', () => { const expectedSchema: JsonSchema = { definitions: { - ProductWithRelations: { - title: 'ProductWithRelations', + Category: { + additionalProperties: false, description: - `(tsType: ProductWithRelations, ` + - `schemaOptions: { includeRelations: true })`, + '(tsType: Category, schemaOptions: { includeRelations: false })', + properties: { + id: { + type: 'number', + }, + }, + title: 'Category', + }, + Product: { + title: 'Product', + description: + `(tsType: Product, ` + + `schemaOptions: { includeRelations: false })`, properties: { id: {type: 'number'}, categoryId: {type: 'number'}, - category: {$ref: '#/definitions/CategoryWithRelations'}, }, additionalProperties: false, }, - }, - properties: { - id: {type: 'number'}, - products: { - type: 'array', - items: {$ref: '#/definitions/ProductWithRelations'}, + ProductWithRelations: { + title: 'ProductWithRelations', + description: + `(tsType: ProductWithRelations, ` + + `schemaOptions: { includeRelations: true })`, + allOf: [ + {$ref: '#/definitions/Product'}, + { + properties: { + category: {$ref: '#/definitions/CategoryWithRelations'}, + }, + }, + ], }, }, - additionalProperties: false, + allOf: [ + {$ref: '#/definitions/Category'}, + { + properties: { + products: { + type: 'array', + items: {$ref: '#/definitions/ProductWithRelations'}, + }, + }, + }, + ], title: 'CategoryWithRelations', description: `(tsType: CategoryWithRelations, ` + @@ -1066,28 +1093,53 @@ describe('build-schema', () => { } const expectedSchema: JsonSchema = { definitions: { - ProductWithRelations: { - title: 'ProductWithRelations', + CategoryWithoutProp: { + additionalProperties: false, description: - `(tsType: ProductWithRelations, ` + - `schemaOptions: { includeRelations: true })`, + '(tsType: CategoryWithoutProp, schemaOptions: { includeRelations: false })', + title: 'CategoryWithoutProp', + }, + Product: { + title: 'Product', + description: + '(tsType: Product, schemaOptions: { includeRelations: false })', properties: { - id: {type: 'number'}, - categoryId: {type: 'number'}, - category: { - $ref: '#/definitions/CategoryWithoutPropWithRelations', + categoryId: { + type: 'number', + }, + id: { + type: 'number', }, }, additionalProperties: false, }, - }, - properties: { - products: { - type: 'array', - items: {$ref: '#/definitions/ProductWithRelations'}, + ProductWithRelations: { + allOf: [ + {$ref: '#/definitions/Product'}, + { + properties: { + category: { + $ref: '#/definitions/CategoryWithoutPropWithRelations', + }, + }, + }, + ], + description: + '(tsType: ProductWithRelations, schemaOptions: { includeRelations: true })', + title: 'ProductWithRelations', }, }, - additionalProperties: false, + allOf: [ + {$ref: '#/definitions/CategoryWithoutProp'}, + { + properties: { + products: { + type: 'array', + items: {$ref: '#/definitions/ProductWithRelations'}, + }, + }, + }, + ], title: 'CategoryWithoutPropWithRelations', description: `(tsType: CategoryWithoutPropWithRelations, ` + diff --git a/packages/repository-json-schema/src/__tests__/unit/build-schema.unit.ts b/packages/repository-json-schema/src/__tests__/unit/build-schema.unit.ts index 2d4490a3dc75..8e8226aa5b87 100644 --- a/packages/repository-json-schema/src/__tests__/unit/build-schema.unit.ts +++ b/packages/repository-json-schema/src/__tests__/unit/build-schema.unit.ts @@ -313,15 +313,15 @@ describe('build-schema', () => { title: 'ParentWithItsChildren', includeRelations: true, }); - expect(schema.properties).to.containEql({ - children: { - type: 'array', - // The reference here should be `ChildWithRelations`, - // instead of `ParentWithItsChildren` - items: {$ref: '#/definitions/ChildWithRelations'}, + expect(schema.allOf).to.containEql({ + properties: { + children: { + type: 'array', + // The reference here should be `ChildWithRelations`, + // instead of `ParentWithItsChildren` + items: {$ref: '#/definitions/ChildWithRelations'}, + }, }, - benchmarkId: {type: 'string'}, - color: {type: 'string'}, }); // The recursive calls should NOT inherit // `title` from the previous call's `options`. @@ -332,8 +332,7 @@ describe('build-schema', () => { title: 'ChildWithRelations', description: '(tsType: ChildWithRelations, schemaOptions: { includeRelations: true })', - properties: {name: {type: 'string'}}, - additionalProperties: false, + allOf: [{$ref: '#/definitions/Child'}, {properties: {}}], }, }); }); diff --git a/packages/repository-json-schema/src/build-schema.ts b/packages/repository-json-schema/src/build-schema.ts index 257f14fc4749..72c65d6b90ba 100644 --- a/packages/repository-json-schema/src/build-schema.ts +++ b/packages/repository-json-schema/src/build-schema.ts @@ -458,6 +458,40 @@ export function modelToJsonSchema( result.description = descriptionSuffix; } + if (options.includeRelations) { + const parentSchema = modelToJsonSchema(ctor, { + ...options, + includeRelations: false, + }); + const properties: typeof result.properties = {}; + + for (const r in meta.relations) { + const relMeta = meta.relations[r]; + const targetType = resolveType(relMeta.target); + + // `title` is the unique identity of a schema, + // it should be removed from the `options` + // when generating the relation or property schemas + const targetOptions = {...options}; + delete targetOptions.title; + + const targetSchema = getJsonSchema(targetType, targetOptions); + const targetRef = {$ref: `#/definitions/${targetSchema.title}`}; + const propDef = getNavigationalPropertyForRelation(relMeta, targetRef); + + properties[relMeta.name] = properties[relMeta.name] || propDef; + includeReferencedSchema(targetSchema.title!, targetSchema); + } + result.allOf = [ + {$ref: `#/definitions/${parentSchema.title}`}, + { + properties, + }, + ]; + includeReferencedSchema(parentSchema.title!, parentSchema); + return result; + } + for (const p in meta.properties) { if (options.exclude && options.exclude.includes(p as keyof T)) { debug('Property % is excluded by %s', p, options.exclude); @@ -532,28 +566,6 @@ export function modelToJsonSchema( result.additionalProperties = meta.settings.strict === false; debug(' additionalProperties?', result.additionalProperties); - if (options.includeRelations) { - for (const r in meta.relations) { - result.properties = result.properties ?? {}; - const relMeta = meta.relations[r]; - const targetType = resolveType(relMeta.target); - - // `title` is the unique identity of a schema, - // it should be removed from the `options` - // when generating the relation or property schemas - const targetOptions = {...options}; - delete targetOptions.title; - - const targetSchema = getJsonSchema(targetType, targetOptions); - const targetRef = {$ref: `#/definitions/${targetSchema.title}`}; - const propDef = getNavigationalPropertyForRelation(relMeta, targetRef); - - result.properties[relMeta.name] = - result.properties[relMeta.name] || propDef; - includeReferencedSchema(targetSchema.title!, targetSchema); - } - } - function includeReferencedSchema(name: string, schema: JsonSchema) { if (!schema || !Object.keys(schema).length) return; From f069b5b05f84947aec6f3469f75738bec47963a2 Mon Sep 17 00:00:00 2001 From: InvictusMB Date: Wed, 27 May 2020 22:32:01 +0200 Subject: [PATCH 2/2] feat!: model inheritance produces jsonSchema composition --- .../src/__tests__/unit/build-schema.unit.ts | 18 +++-- .../src/build-schema.ts | 71 ++++++++++++++++--- 2 files changed, 73 insertions(+), 16 deletions(-) diff --git a/packages/repository-json-schema/src/__tests__/unit/build-schema.unit.ts b/packages/repository-json-schema/src/__tests__/unit/build-schema.unit.ts index 8e8226aa5b87..083df29cc76d 100644 --- a/packages/repository-json-schema/src/__tests__/unit/build-schema.unit.ts +++ b/packages/repository-json-schema/src/__tests__/unit/build-schema.unit.ts @@ -422,13 +422,19 @@ describe('build-schema', () => { const newUserSchema = modelToJsonSchema(NewUser, {}); expect(newUserSchema).to.eql({ title: 'NewUser', - properties: { - id: {type: 'string'}, - name: {type: 'string'}, - password: {type: 'string'}, + allOf: [ + {$ref: `#/definitions/User`}, + { + properties: { + password: {type: 'string'}, + }, + required: ['password'], + additionalProperties: false, + }, + ], + definitions: { + User: userSchema, }, - required: ['name', 'password'], - additionalProperties: false, }); }); diff --git a/packages/repository-json-schema/src/build-schema.ts b/packages/repository-json-schema/src/build-schema.ts index 72c65d6b90ba..4548948e8cd5 100644 --- a/packages/repository-json-schema/src/build-schema.ts +++ b/packages/repository-json-schema/src/build-schema.ts @@ -6,6 +6,7 @@ import {MetadataInspector} from '@loopback/context'; import { isBuiltinType, + Model, ModelDefinition, ModelMetadataHelper, Null, @@ -492,7 +493,21 @@ export function modelToJsonSchema( return result; } + const ownPropsSchema: Pick< + typeof result, + 'required' | 'properties' | 'additionalProperties' + > = {}; + const parentModel = getParentModel(ctor); + const parentMeta = parentModel + ? (ModelMetadataHelper.getModelMetadata(parentModel) as ModelDefinition) + : null; + for (const p in meta.properties) { + if (isInherited(parentMeta, meta, p)) { + debug('Property % is excluded by inheritance', p); + continue; + } + if (options.exclude && options.exclude.includes(p as keyof T)) { debug('Property % is excluded by %s', p, options.exclude); continue; @@ -505,20 +520,20 @@ export function modelToJsonSchema( ); } - result.properties = result.properties ?? {}; - result.properties[p] = result.properties[p] || {}; + ownPropsSchema.properties = ownPropsSchema.properties ?? {}; + ownPropsSchema.properties[p] = ownPropsSchema.properties[p] || {}; const metaProperty = Object.assign({}, meta.properties[p]); // populating "properties" key - result.properties[p] = metaToJsonProperty(metaProperty); + ownPropsSchema.properties[p] = metaToJsonProperty(metaProperty); // handling 'required' metadata const optional = options.optional.includes(p as keyof T); if (metaProperty.required && !(partial || optional)) { - result.required = result.required ?? []; - result.required.push(p); + ownPropsSchema.required = ownPropsSchema.required ?? []; + ownPropsSchema.required.push(p); } // populating JSON Schema 'definitions' @@ -548,8 +563,8 @@ export function modelToJsonSchema( const propSchema = getJsonSchema(referenceType, propOptions); // JSONSchema6Definition allows both boolean and JSONSchema6 types - if (typeof result.properties[p] !== 'boolean') { - const prop = result.properties[p] as JsonSchema; + if (typeof ownPropsSchema.properties[p] !== 'boolean') { + const prop = ownPropsSchema.properties[p] as JsonSchema; const propTitle = propSchema.title ?? referenceType.name; const targetRef = {$ref: `#/definitions/${propTitle}`}; @@ -557,14 +572,28 @@ export function modelToJsonSchema( // Update $ref for array type prop.items = targetRef; } else { - result.properties[p] = targetRef; + ownPropsSchema.properties[p] = targetRef; } includeReferencedSchema(propTitle, propSchema); } } - result.additionalProperties = meta.settings.strict === false; - debug(' additionalProperties?', result.additionalProperties); + ownPropsSchema.additionalProperties = meta.settings.strict === false; + debug(' additionalProperties?', ownPropsSchema.additionalProperties); + + const hasProperties = !!ownPropsSchema.properties; + if (parentModel) { + const parentOptions = {...options}; + delete parentOptions.title; + const parentSchema = modelToJsonSchema(parentModel, parentOptions); + result.allOf = [ + {$ref: `#/definitions/${parentSchema.title}`}, + hasProperties && ownPropsSchema, + ].filter(Boolean); + includeReferencedSchema(parentSchema.title!, parentSchema); + } else { + Object.assign(result, ownPropsSchema); + } function includeReferencedSchema(name: string, schema: JsonSchema) { if (!schema || !Object.keys(schema).length) return; @@ -590,3 +619,25 @@ export function modelToJsonSchema( } return result; } + +function getParentModel(ctor: Function) { + const proto = Object.getPrototypeOf(ctor) as Function; + const isBaseType = proto.name === 'Model' || proto.name === 'Entity'; + const isModel = proto instanceof Model.constructor; + return !isBaseType && isModel ? proto : null; +} + +function isInherited( + parentModel: ModelDefinition | null, + model: ModelDefinition, + propertyName: string, +) { + if (!parentModel) { + return false; + } + // TODO(InvictusMB): deep equality check here? + return ( + model.properties[propertyName]?.type === + parentModel.properties[propertyName]?.type + ); +}