From 5f394d5b325ded248cbf3a6e942ad858924bb9e3 Mon Sep 17 00:00:00 2001 From: Douglas McConnachie Date: Sat, 11 Jan 2020 17:09:32 +0000 Subject: [PATCH] refactor: apply feedback apply feedback --- .../consolidate-schema.enhancer.unit.ts} | 40 ++-- packages/openapi-v3/src/consolidate-schema.ts | 106 ----------- .../enhancers/consolidate-schema.enhancer.ts | 172 ++++++++++++++++++ packages/openapi-v3/src/enhancers/index.ts | 1 + packages/openapi-v3/src/index.ts | 1 - packages/rest/src/rest.server.ts | 6 +- 6 files changed, 198 insertions(+), 128 deletions(-) rename packages/openapi-v3/src/__tests__/unit/{consolidate-schema.unit.ts => enhancers/consolidate-schema.enhancer.unit.ts} (77%) delete mode 100644 packages/openapi-v3/src/consolidate-schema.ts create mode 100644 packages/openapi-v3/src/enhancers/consolidate-schema.enhancer.ts diff --git a/packages/openapi-v3/src/__tests__/unit/consolidate-schema.unit.ts b/packages/openapi-v3/src/__tests__/unit/enhancers/consolidate-schema.enhancer.unit.ts similarity index 77% rename from packages/openapi-v3/src/__tests__/unit/consolidate-schema.unit.ts rename to packages/openapi-v3/src/__tests__/unit/enhancers/consolidate-schema.enhancer.unit.ts index 87cd700f26b4..4aeab7880ba2 100644 --- a/packages/openapi-v3/src/__tests__/unit/consolidate-schema.unit.ts +++ b/packages/openapi-v3/src/__tests__/unit/enhancers/consolidate-schema.enhancer.unit.ts @@ -4,9 +4,11 @@ // License text available at https://opensource.org/licenses/MIT import {expect} from '@loopback/testlab'; -import {ConsolidateSchemaObjects, OpenAPIObject} from '../..'; +import {ConsolidationEnhancer, OpenAPIObject} from '../../..'; -describe('ConsolidateSchemaObjects', () => { +const consolidationEnhancer = new ConsolidationEnhancer(); + +describe('consolidateSchemaObjects', () => { it('moves schema with title to component.schemas, replace with reference', () => { const inputSpec: OpenAPIObject = { openapi: '', @@ -33,12 +35,12 @@ describe('ConsolidateSchemaObjects', () => { }, paths: { schema: { - $ref: '#/components/schemas/LoopbackExample', + $ref: '#/components/schemas/loopback.example', }, }, components: { schemas: { - LoopbackExample: { + 'loopback.example': { title: 'loopback.example', properties: { test: { @@ -49,7 +51,7 @@ describe('ConsolidateSchemaObjects', () => { }, }, }; - expect(ConsolidateSchemaObjects(inputSpec)).to.eql(expectedSpec); + expect(consolidationEnhancer.modifySpec(inputSpec)).to.eql(expectedSpec); }); it('ignores schema without title property', () => { @@ -69,7 +71,7 @@ describe('ConsolidateSchemaObjects', () => { }, }, }; - expect(ConsolidateSchemaObjects(inputSpec)).to.eql(inputSpec); + expect(consolidationEnhancer.modifySpec(inputSpec)).to.eql(inputSpec); }); it('Avoids naming collision', () => { @@ -91,8 +93,8 @@ describe('ConsolidateSchemaObjects', () => { }, components: { schemas: { - LoopbackExample: { - title: 'Different LoopbackExample exists', + 'loopback.example': { + title: 'Different loopback.example exists', properties: { testDiff: { type: 'string', @@ -110,20 +112,20 @@ describe('ConsolidateSchemaObjects', () => { }, paths: { schema: { - $ref: '#/components/schemas/LoopbackExample2', + $ref: '#/components/schemas/loopback.example1', }, }, components: { schemas: { - LoopbackExample: { - title: 'Different LoopbackExample exists', + 'loopback.example': { + title: 'Different loopback.example exists', properties: { testDiff: { type: 'string', }, }, }, - LoopbackExample2: { + 'loopback.example1': { title: 'loopback.example', properties: { test: { @@ -134,7 +136,7 @@ describe('ConsolidateSchemaObjects', () => { }, }, }; - expect(ConsolidateSchemaObjects(inputSpec)).to.eql(expectedSpec); + expect(consolidationEnhancer.modifySpec(inputSpec)).to.eql(expectedSpec); }); it('If array items has no title, copy parent title if exists', () => { @@ -147,7 +149,7 @@ describe('ConsolidateSchemaObjects', () => { paths: { schema: { myarray: { - title: 'my.array', + title: 'MyArray', type: 'array', items: { properties: { @@ -169,18 +171,18 @@ describe('ConsolidateSchemaObjects', () => { paths: { schema: { myarray: { - title: 'my.array', + title: 'MyArray', type: 'array', items: { - $ref: '#/components/schemas/MyArray', + $ref: '#/components/schemas/MyArray.Items', }, }, }, }, components: { schemas: { - MyArray: { - title: 'my.array', + 'MyArray.Items': { + title: 'MyArray.Items', properties: { test: { type: 'string', @@ -190,6 +192,6 @@ describe('ConsolidateSchemaObjects', () => { }, }, }; - expect(ConsolidateSchemaObjects(inputSpec)).to.eql(expectedSpec); + expect(consolidationEnhancer.modifySpec(inputSpec)).to.eql(expectedSpec); }); }); diff --git a/packages/openapi-v3/src/consolidate-schema.ts b/packages/openapi-v3/src/consolidate-schema.ts deleted file mode 100644 index 3acd3517d886..000000000000 --- a/packages/openapi-v3/src/consolidate-schema.ts +++ /dev/null @@ -1,106 +0,0 @@ -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/enhancers/consolidate-schema.enhancer.ts b/packages/openapi-v3/src/enhancers/consolidate-schema.enhancer.ts new file mode 100644 index 000000000000..3228480b03fd --- /dev/null +++ b/packages/openapi-v3/src/enhancers/consolidate-schema.enhancer.ts @@ -0,0 +1,172 @@ +import {bind} from '@loopback/core'; +import compare from 'json-schema-compare'; +import _ from 'lodash'; +import { + ISpecificationExtension, + isSchemaObject, + OpenApiSpec, + PathsObject, + ReferenceObject, + SchemaObject, +} from '../types'; +import {asSpecEnhancer, OASEnhancer} from './types'; + +/** + * A spec enhancer to consolidate OpenAPI specs + */ +@bind(asSpecEnhancer) +export class ConsolidationEnhancer implements OASEnhancer { + name = 'consolidate'; + + modifySpec(spec: OpenApiSpec): OpenApiSpec { + const ctx: ConsolidateContext = { + paths: _.cloneDeep(spec.paths), + refs: + spec.components && spec.components.schemas + ? _.cloneDeep(spec.components.schemas) + : {}, + }; + const updatedSpec = this.consolidateSchemaObjects(spec, ctx); + + return updatedSpec; + } + + /** + * 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. + * + */ + private consolidateSchemaObjects( + spec: OpenApiSpec, + ctx: ConsolidateContext, + ): OpenApiSpec { + this.recursiveWalk(ctx.paths, ctx); + + const updatedSpec = { + ...spec, + ...{ + paths: ctx.paths, + components: {...spec.components, ...{schemas: ctx.refs}}, + }, + }; + + // 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; + } + + private recursiveWalk( + rootSchema: ISpecificationExtension, + ctx: ConsolidateContext, + ) { + if (rootSchema !== null && typeof rootSchema == 'object') { + Object.entries(rootSchema).forEach(([key, subSchema]) => { + this.preProcessSchema(subSchema); + this.recursiveWalk(subSchema, ctx); + const updatedSchema = this.postProcessSchema(subSchema, ctx); + if (updatedSchema) { + rootSchema[key] = updatedSchema; + } + }); + } + } + + /** + * Prepare current schema for processing before further tree traversal. + * + * Features: + * - ensure schema array items can be consolidated if parent array has + * title set. + * + * @param schema - current schema element to prepare for processing + * + */ + private preProcessSchema(schema: SchemaObject | ReferenceObject) { + // ensure schema array items can be consolidated + if (isSchemaObject(schema) && schema.items && schema.title) { + if (isSchemaObject(schema.items) && !schema.items.title) { + schema.items = { + title: `${schema.title}.Items`, + ...schema.items, + }; + } + } + } + + /** + * Carry out schema consolidation after tree traversal. If 'title' property + * set then we consider current schema for consoildation. SchemaObjects with + * properties (and title set) are moved to #/components/schemas/ and + * replaced with ReferenceObject. + * + * Features: + * - name collision protection + * + * @param schema - current schema element to process + * @param ctx - context object holding working data + * + */ + private postProcessSchema( + schema: SchemaObject | ReferenceObject, + ctx: ConsolidateContext, + ): ReferenceObject | undefined { + // use title to discriminate references + if (isSchemaObject(schema) && schema.properties && schema.title) { + // name collison protection + let instanceNo = 1; + let title = schema.title; + while ( + this.refExists(title, ctx) && + !compare( + schema as ISpecificationExtension, + this.getRefValue(title, ctx), + { + ignore: ['description'], + }, + ) + ) { + title = `${schema.title}${instanceNo++}`; + } + // only add new reference schema + if (!this.refExists(title, ctx)) { + this.setRefValue(title, schema, ctx); + } + return <ReferenceObject>{$ref: `#/components/schemas/${title}`}; + } + return undefined; + } + + private refExists(name: string, ctx: ConsolidateContext): boolean { + return _.has(ctx.refs, name); + } + private getRefValue( + name: string, + ctx: ConsolidateContext, + ): ISpecificationExtension { + return ctx.refs[name]; + } + private setRefValue( + name: string, + value: ISpecificationExtension, + ctx: ConsolidateContext, + ) { + ctx.refs[name] = value; + } +} + +/** + * Type description for consolidation context + */ +interface ConsolidateContext { + paths: PathsObject; + refs: { + [schema: string]: SchemaObject | ReferenceObject; + }; +} diff --git a/packages/openapi-v3/src/enhancers/index.ts b/packages/openapi-v3/src/enhancers/index.ts index 6df3b5cb32b8..bc387ff36a2d 100644 --- a/packages/openapi-v3/src/enhancers/index.ts +++ b/packages/openapi-v3/src/enhancers/index.ts @@ -3,6 +3,7 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +export * from './consolidate-schema.enhancer'; export * from './keys'; export * from './spec-enhancer.service'; export * from './types'; diff --git a/packages/openapi-v3/src/index.ts b/packages/openapi-v3/src/index.ts index daa60eb43617..08f3a6489ac9 100644 --- a/packages/openapi-v3/src/index.ts +++ b/packages/openapi-v3/src/index.ts @@ -4,7 +4,6 @@ // 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 c4032f4e472d..5abcce04dd9e 100644 --- a/packages/rest/src/rest.server.ts +++ b/packages/rest/src/rest.server.ts @@ -14,7 +14,7 @@ import { import {Application, CoreBindings, Server} from '@loopback/core'; import {HttpServer, HttpServerOptions} from '@loopback/http-server'; import { - ConsolidateSchemaObjects, + ConsolidationEnhancer, getControllerSpec, OpenAPIObject, OpenApiSpec, @@ -426,7 +426,7 @@ export class RestServer extends Context implements Server, HttpServerLike { ); specForm = specForm ?? {version: '3.0.0', format: 'json'}; - const specObj = ConsolidateSchemaObjects(this.getApiSpec(requestContext)); + const specObj = this.getApiSpec(requestContext); if (specForm.format === 'json') { const spec = JSON.stringify(specObj, null, 2); @@ -717,6 +717,8 @@ export class RestServer extends Context implements Server, HttpServerLike { if (requestContext) { spec = this.updateSpecFromRequest(spec, requestContext); } + const consolidationEnhancer = new ConsolidationEnhancer(); + spec = consolidationEnhancer.modifySpec(spec); return spec; }