Skip to content

Commit

Permalink
feat(rest): add openapi schema consolidation
Browse files Browse the repository at this point in the history
Add openapi schema consolidation to openapi-v3, call from rest

Add openapi schema consolidation at openapi-v3.consolidate.schema, call from rest.server - WIP
  • Loading branch information
dougal83 committed Jan 5, 2020
1 parent a4ae384 commit 39c0a01
Show file tree
Hide file tree
Showing 6 changed files with 332 additions and 4 deletions.
23 changes: 23 additions & 0 deletions packages/openapi-v3/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions packages/openapi-v3/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
},
Expand Down
195 changes: 195 additions & 0 deletions packages/openapi-v3/src/__tests__/unit/consolidate-schema.unit.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
106 changes: 106 additions & 0 deletions packages/openapi-v3/src/consolidate-schema.ts
Original file line number Diff line number Diff line change
@@ -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 <ReferenceObject>{$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;
}
1 change: 1 addition & 0 deletions packages/openapi-v3/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
3 changes: 2 additions & 1 deletion packages/rest/src/rest.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import {Application, CoreBindings, Server} from '@loopback/core';
import {HttpServer, HttpServerOptions} from '@loopback/http-server';
import {
ConsolidateSchemaObjects,
getControllerSpec,
OpenAPIObject,
OpenApiSpec,
Expand Down Expand Up @@ -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);
Expand Down

0 comments on commit 39c0a01

Please sign in to comment.