From 39c0a01c698eb0c8a895b4bc31f07bc2dad7c20d Mon Sep 17 00:00:00 2001 From: Douglas McConnachie Date: Sun, 5 Jan 2020 17:46:42 +0000 Subject: [PATCH] feat(rest): add openapi schema consolidation Add openapi schema consolidation to openapi-v3, call from rest Add openapi schema consolidation at openapi-v3.consolidate.schema, call from rest.server - WIP --- packages/openapi-v3/package-lock.json | 23 +++ packages/openapi-v3/package.json | 8 +- .../__tests__/unit/consolidate-schema.unit.ts | 195 ++++++++++++++++++ packages/openapi-v3/src/consolidate-schema.ts | 106 ++++++++++ packages/openapi-v3/src/index.ts | 1 + packages/rest/src/rest.server.ts | 3 +- 6 files changed, 332 insertions(+), 4 deletions(-) create mode 100644 packages/openapi-v3/src/__tests__/unit/consolidate-schema.unit.ts create mode 100644 packages/openapi-v3/src/consolidate-schema.ts diff --git a/packages/openapi-v3/package-lock.json b/packages/openapi-v3/package-lock.json index 9d380fee6ecb..9d6b73bb8572 100644 --- a/packages/openapi-v3/package-lock.json +++ b/packages/openapi-v3/package-lock.json @@ -10,6 +10,21 @@ "integrity": "sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ==", "dev": true }, + "@types/json-schema": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.4.tgz", + "integrity": "sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA==", + "dev": true + }, + "@types/json-schema-compare": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@types/json-schema-compare/-/json-schema-compare-0.2.0.tgz", + "integrity": "sha512-TtCXQjsCQi+fcandEbzDJhqyztpVM9c5mtGuk7Hf8yQsdaBpfjEkOicfydAEWB684wGCzUrV5ttvt9hCyDCoxA==", + "dev": true, + "requires": { + "@types/json-schema": "*" + } + }, "@types/lodash": { "version": "4.14.149", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.149.tgz", @@ -136,6 +151,14 @@ "deep-equal": "^1.0.0" } }, + "json-schema-compare": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/json-schema-compare/-/json-schema-compare-0.2.2.tgz", + "integrity": "sha512-c4WYmDKyJXhs7WWvAWm3uIYnfyWFoIp+JEoX34rctVvEkMYCPGhXtvmFFXiffBbxfZsvQ0RNnV5H7GvDF5HCqQ==", + "requires": { + "lodash": "^4.17.4" + } + }, "lodash": { "version": "4.17.15", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", diff --git a/packages/openapi-v3/package.json b/packages/openapi-v3/package.json index 96d181728271..fb3457fe43fc 100644 --- a/packages/openapi-v3/package.json +++ b/packages/openapi-v3/package.json @@ -6,12 +6,13 @@ "node": ">=8.9" }, "dependencies": { - "@loopback/repository-json-schema": "^1.11.3", "@loopback/core": "^1.12.0", + "@loopback/repository-json-schema": "^1.11.3", "debug": "^4.1.1", + "json-merge-patch": "^0.2.3", + "json-schema-compare": "^0.2.2", "lodash": "^4.17.15", - "openapi3-ts": "^1.3.0", - "json-merge-patch": "^0.2.3" + "openapi3-ts": "^1.3.0" }, "devDependencies": { "@loopback/build": "^3.0.0", @@ -20,6 +21,7 @@ "@loopback/repository": "^1.16.0", "@loopback/testlab": "^1.10.0", "@types/debug": "^4.1.5", + "@types/json-schema-compare": "^0.2.0", "@types/lodash": "^4.14.149", "@types/node": "^10.17.13" }, diff --git a/packages/openapi-v3/src/__tests__/unit/consolidate-schema.unit.ts b/packages/openapi-v3/src/__tests__/unit/consolidate-schema.unit.ts new file mode 100644 index 000000000000..aa22ecac86d2 --- /dev/null +++ b/packages/openapi-v3/src/__tests__/unit/consolidate-schema.unit.ts @@ -0,0 +1,195 @@ +// 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 {expect} from '@loopback/testlab'; +import {ConsolidateSchemaObjects, OpenAPIObject} from '../..'; + +describe('ConsolidateSchemaObjects', () => { + it('moves schema with title to component.schemas, replace with reference', () => { + const inputSpec: OpenAPIObject = { + openapi: '', + info: { + title: '', + version: '', + }, + paths: { + schema: { + title: 'loopback.example', + properties: { + test: { + type: 'string', + }, + }, + }, + }, + }; + const expectedSpec: OpenAPIObject = { + openapi: '', + info: { + title: '', + version: '', + }, + paths: { + schema: { + $ref: '#/components/schemas/LoopbackExample', + }, + }, + components: { + schemas: { + LoopbackExample: { + title: 'loopback.example', + properties: { + test: { + type: 'string', + }, + }, + }, + }, + }, + }; + expect(ConsolidateSchemaObjects(inputSpec)).to.eql(expectedSpec); + }); + + it('ignores schema without title property', () => { + const inputSpec: OpenAPIObject = { + openapi: '', + info: { + title: '', + version: '', + }, + paths: { + schema: { + properties: { + test: { + type: 'string', + }, + }, + }, + }, + }; + expect(ConsolidateSchemaObjects(inputSpec)).to.eql(inputSpec); + }); + + it('Avoids naming collision', () => { + const inputSpec: OpenAPIObject = { + openapi: '', + info: { + title: '', + version: '', + }, + paths: { + schema: { + title: 'loopback.example', + properties: { + test: { + type: 'string', + }, + }, + }, + }, + components: { + schemas: { + LoopbackExample: { + title: 'Different LoopbackExample exists', + properties: { + test_diff: { + type: 'string', + }, + }, + }, + }, + }, + }; + const expectedSpec: OpenAPIObject = { + openapi: '', + info: { + title: '', + version: '', + }, + paths: { + schema: { + $ref: '#/components/schemas/LoopbackExample2', + }, + }, + components: { + schemas: { + LoopbackExample: { + title: 'Different LoopbackExample exists', + properties: { + test_diff: { + type: 'string', + }, + }, + }, + LoopbackExample2: { + title: 'loopback.example', + properties: { + test: { + type: 'string', + }, + }, + }, + }, + }, + }; + expect(ConsolidateSchemaObjects(inputSpec)).to.eql(expectedSpec); + }); + + it('If array items has no title, copy parent title if exists', () => { + const inputSpec: OpenAPIObject = { + openapi: '', + info: { + title: '', + version: '', + }, + paths: { + schema: { + myarray: { + title: 'my.array', + type: 'array', + items: { + properties: { + test: { + type: 'string', + }, + }, + }, + }, + }, + }, + }; + const expectedSpec: OpenAPIObject = { + openapi: '', + info: { + title: '', + version: '', + }, + paths: { + schema: { + myarray: { + title: 'my.array', + type: 'array', + items: { + $ref: '#/components/schemas/MyArray', + }, + }, + }, + }, + components: { + schemas: { + MyArray: { + title: 'my.array', + properties: { + test: { + type: 'string', + }, + }, + }, + }, + }, + }; + expect(ConsolidateSchemaObjects(inputSpec)).to.eql(expectedSpec); + }); +}); diff --git a/packages/openapi-v3/src/consolidate-schema.ts b/packages/openapi-v3/src/consolidate-schema.ts new file mode 100644 index 000000000000..3acd3517d886 --- /dev/null +++ b/packages/openapi-v3/src/consolidate-schema.ts @@ -0,0 +1,106 @@ +import compare from 'json-schema-compare'; +import _ from 'lodash'; +import { + ISpecificationExtension, + isSchemaObject, + OpenApiSpec, + PathsObject, + ReferenceObject, + SchemaObject, +} from './types'; + +let $schemaRefs: { + [schema: string]: SchemaObject | ReferenceObject; +}; +let $paths: PathsObject; + +/* + * Recursively search OpenApiSpec PathsObject for SchemaObjects with title property. + * Move reusable schema bodies to #/components/schemas and replace with json pointer. + * Handles collisions /w title, schema body pair comparision. + */ +export function ConsolidateSchemaObjects(spec: OpenApiSpec): OpenApiSpec { + $schemaRefs = + spec.components && spec.components.schemas + ? _.cloneDeep(spec.components.schemas) + : {}; + $paths = _.cloneDeep(spec.paths); + + recursiveWalk($paths); + + const updatedSpec = { + ...spec, + ...{ + paths: $paths, + components: {...spec.components, ...{schemas: $schemaRefs}}, + }, + }; + + // tidy up empty objects + if (Object.keys(updatedSpec.components.schemas).length === 0) { + delete updatedSpec.components.schemas; + } + if (Object.keys(updatedSpec.components).length === 0) { + delete updatedSpec.components; + } + + return updatedSpec; +} + +function recursiveWalk(rootSchema: ISpecificationExtension) { + if (rootSchema !== null && typeof rootSchema == 'object') { + Object.entries(rootSchema).map(([key, subSchema]) => { + preProcessSchema(subSchema); + recursiveWalk(subSchema); + const updatedSchema = postProcessSchema(subSchema); + if (updatedSchema) { + rootSchema[key] = updatedSchema; + } + }); + } +} + +function preProcessSchema(schema: SchemaObject | ReferenceObject) { + // TODO:(dougal83) Possible update needed for items such as model specific includes + // ensure array items have parent title + if (isSchemaObject(schema) && schema.items && schema.title) { + if (isSchemaObject(schema.items) && !schema.items.title) { + schema.items = {title: schema.title, ...schema.items}; + } + } +} + +function postProcessSchema( + schema: SchemaObject | ReferenceObject, +): ReferenceObject | undefined { + // use title to discriminate references + if (isSchemaObject(schema) && schema.properties && schema.title) { + let i = 1; + const titlePrefix = _.upperFirst(_.camelCase(schema.title)); + let title = titlePrefix; + while ( + refExists(title) && + !compare(schema as ISpecificationExtension, getRefValue(title), { + ignore: ['description'], + }) + ) { + i++; + title = `${titlePrefix}${i}`; + } + if (!refExists(title)) { + setRefValue(title, schema); + } + return {$ref: `#/components/schemas/${title}`}; + } + return undefined; +} + +function refExists(name: string): boolean { + return _.has($schemaRefs, name); +} +function getRefValue(name: string): ISpecificationExtension { + return $schemaRefs[name]; +} +function setRefValue(name: string, value: ISpecificationExtension) { + $schemaRefs[name] = value; +} diff --git a/packages/openapi-v3/src/index.ts b/packages/openapi-v3/src/index.ts index 08f3a6489ac9..daa60eb43617 100644 --- a/packages/openapi-v3/src/index.ts +++ b/packages/openapi-v3/src/index.ts @@ -4,6 +4,7 @@ // License text available at https://opensource.org/licenses/MIT export * from '@loopback/repository-json-schema'; +export * from './consolidate-schema'; export * from './controller-spec'; export * from './decorators'; export * from './enhancers'; diff --git a/packages/rest/src/rest.server.ts b/packages/rest/src/rest.server.ts index bfb6f3d9409f..c4032f4e472d 100644 --- a/packages/rest/src/rest.server.ts +++ b/packages/rest/src/rest.server.ts @@ -14,6 +14,7 @@ import { import {Application, CoreBindings, Server} from '@loopback/core'; import {HttpServer, HttpServerOptions} from '@loopback/http-server'; import { + ConsolidateSchemaObjects, getControllerSpec, OpenAPIObject, OpenApiSpec, @@ -425,7 +426,7 @@ export class RestServer extends Context implements Server, HttpServerLike { ); specForm = specForm ?? {version: '3.0.0', format: 'json'}; - const specObj = this.getApiSpec(requestContext); + const specObj = ConsolidateSchemaObjects(this.getApiSpec(requestContext)); if (specForm.format === 'json') { const spec = JSON.stringify(specObj, null, 2);