Skip to content

Commit 39c0a01

Browse files
committed
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
1 parent a4ae384 commit 39c0a01

File tree

6 files changed

+332
-4
lines changed

6 files changed

+332
-4
lines changed

packages/openapi-v3/package-lock.json

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/openapi-v3/package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66
"node": ">=8.9"
77
},
88
"dependencies": {
9-
"@loopback/repository-json-schema": "^1.11.3",
109
"@loopback/core": "^1.12.0",
10+
"@loopback/repository-json-schema": "^1.11.3",
1111
"debug": "^4.1.1",
12+
"json-merge-patch": "^0.2.3",
13+
"json-schema-compare": "^0.2.2",
1214
"lodash": "^4.17.15",
13-
"openapi3-ts": "^1.3.0",
14-
"json-merge-patch": "^0.2.3"
15+
"openapi3-ts": "^1.3.0"
1516
},
1617
"devDependencies": {
1718
"@loopback/build": "^3.0.0",
@@ -20,6 +21,7 @@
2021
"@loopback/repository": "^1.16.0",
2122
"@loopback/testlab": "^1.10.0",
2223
"@types/debug": "^4.1.5",
24+
"@types/json-schema-compare": "^0.2.0",
2325
"@types/lodash": "^4.14.149",
2426
"@types/node": "^10.17.13"
2527
},
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// Copyright IBM Corp. 2019. All Rights Reserved.
2+
// Node module: @loopback/openapi-v3
3+
// This file is licensed under the MIT License.
4+
// License text available at https://opensource.org/licenses/MIT
5+
6+
import {expect} from '@loopback/testlab';
7+
import {ConsolidateSchemaObjects, OpenAPIObject} from '../..';
8+
9+
describe('ConsolidateSchemaObjects', () => {
10+
it('moves schema with title to component.schemas, replace with reference', () => {
11+
const inputSpec: OpenAPIObject = {
12+
openapi: '',
13+
info: {
14+
title: '',
15+
version: '',
16+
},
17+
paths: {
18+
schema: {
19+
title: 'loopback.example',
20+
properties: {
21+
test: {
22+
type: 'string',
23+
},
24+
},
25+
},
26+
},
27+
};
28+
const expectedSpec: OpenAPIObject = {
29+
openapi: '',
30+
info: {
31+
title: '',
32+
version: '',
33+
},
34+
paths: {
35+
schema: {
36+
$ref: '#/components/schemas/LoopbackExample',
37+
},
38+
},
39+
components: {
40+
schemas: {
41+
LoopbackExample: {
42+
title: 'loopback.example',
43+
properties: {
44+
test: {
45+
type: 'string',
46+
},
47+
},
48+
},
49+
},
50+
},
51+
};
52+
expect(ConsolidateSchemaObjects(inputSpec)).to.eql(expectedSpec);
53+
});
54+
55+
it('ignores schema without title property', () => {
56+
const inputSpec: OpenAPIObject = {
57+
openapi: '',
58+
info: {
59+
title: '',
60+
version: '',
61+
},
62+
paths: {
63+
schema: {
64+
properties: {
65+
test: {
66+
type: 'string',
67+
},
68+
},
69+
},
70+
},
71+
};
72+
expect(ConsolidateSchemaObjects(inputSpec)).to.eql(inputSpec);
73+
});
74+
75+
it('Avoids naming collision', () => {
76+
const inputSpec: OpenAPIObject = {
77+
openapi: '',
78+
info: {
79+
title: '',
80+
version: '',
81+
},
82+
paths: {
83+
schema: {
84+
title: 'loopback.example',
85+
properties: {
86+
test: {
87+
type: 'string',
88+
},
89+
},
90+
},
91+
},
92+
components: {
93+
schemas: {
94+
LoopbackExample: {
95+
title: 'Different LoopbackExample exists',
96+
properties: {
97+
test_diff: {
98+
type: 'string',
99+
},
100+
},
101+
},
102+
},
103+
},
104+
};
105+
const expectedSpec: OpenAPIObject = {
106+
openapi: '',
107+
info: {
108+
title: '',
109+
version: '',
110+
},
111+
paths: {
112+
schema: {
113+
$ref: '#/components/schemas/LoopbackExample2',
114+
},
115+
},
116+
components: {
117+
schemas: {
118+
LoopbackExample: {
119+
title: 'Different LoopbackExample exists',
120+
properties: {
121+
test_diff: {
122+
type: 'string',
123+
},
124+
},
125+
},
126+
LoopbackExample2: {
127+
title: 'loopback.example',
128+
properties: {
129+
test: {
130+
type: 'string',
131+
},
132+
},
133+
},
134+
},
135+
},
136+
};
137+
expect(ConsolidateSchemaObjects(inputSpec)).to.eql(expectedSpec);
138+
});
139+
140+
it('If array items has no title, copy parent title if exists', () => {
141+
const inputSpec: OpenAPIObject = {
142+
openapi: '',
143+
info: {
144+
title: '',
145+
version: '',
146+
},
147+
paths: {
148+
schema: {
149+
myarray: {
150+
title: 'my.array',
151+
type: 'array',
152+
items: {
153+
properties: {
154+
test: {
155+
type: 'string',
156+
},
157+
},
158+
},
159+
},
160+
},
161+
},
162+
};
163+
const expectedSpec: OpenAPIObject = {
164+
openapi: '',
165+
info: {
166+
title: '',
167+
version: '',
168+
},
169+
paths: {
170+
schema: {
171+
myarray: {
172+
title: 'my.array',
173+
type: 'array',
174+
items: {
175+
$ref: '#/components/schemas/MyArray',
176+
},
177+
},
178+
},
179+
},
180+
components: {
181+
schemas: {
182+
MyArray: {
183+
title: 'my.array',
184+
properties: {
185+
test: {
186+
type: 'string',
187+
},
188+
},
189+
},
190+
},
191+
},
192+
};
193+
expect(ConsolidateSchemaObjects(inputSpec)).to.eql(expectedSpec);
194+
});
195+
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import compare from 'json-schema-compare';
2+
import _ from 'lodash';
3+
import {
4+
ISpecificationExtension,
5+
isSchemaObject,
6+
OpenApiSpec,
7+
PathsObject,
8+
ReferenceObject,
9+
SchemaObject,
10+
} from './types';
11+
12+
let $schemaRefs: {
13+
[schema: string]: SchemaObject | ReferenceObject;
14+
};
15+
let $paths: PathsObject;
16+
17+
/*
18+
* Recursively search OpenApiSpec PathsObject for SchemaObjects with title property.
19+
* Move reusable schema bodies to #/components/schemas and replace with json pointer.
20+
* Handles collisions /w title, schema body pair comparision.
21+
*/
22+
export function ConsolidateSchemaObjects(spec: OpenApiSpec): OpenApiSpec {
23+
$schemaRefs =
24+
spec.components && spec.components.schemas
25+
? _.cloneDeep(spec.components.schemas)
26+
: {};
27+
$paths = _.cloneDeep(spec.paths);
28+
29+
recursiveWalk($paths);
30+
31+
const updatedSpec = {
32+
...spec,
33+
...{
34+
paths: $paths,
35+
components: {...spec.components, ...{schemas: $schemaRefs}},
36+
},
37+
};
38+
39+
// tidy up empty objects
40+
if (Object.keys(updatedSpec.components.schemas).length === 0) {
41+
delete updatedSpec.components.schemas;
42+
}
43+
if (Object.keys(updatedSpec.components).length === 0) {
44+
delete updatedSpec.components;
45+
}
46+
47+
return updatedSpec;
48+
}
49+
50+
function recursiveWalk(rootSchema: ISpecificationExtension) {
51+
if (rootSchema !== null && typeof rootSchema == 'object') {
52+
Object.entries(rootSchema).map(([key, subSchema]) => {
53+
preProcessSchema(subSchema);
54+
recursiveWalk(subSchema);
55+
const updatedSchema = postProcessSchema(subSchema);
56+
if (updatedSchema) {
57+
rootSchema[key] = updatedSchema;
58+
}
59+
});
60+
}
61+
}
62+
63+
function preProcessSchema(schema: SchemaObject | ReferenceObject) {
64+
// TODO:(dougal83) Possible update needed for items such as model specific includes
65+
// ensure array items have parent title
66+
if (isSchemaObject(schema) && schema.items && schema.title) {
67+
if (isSchemaObject(schema.items) && !schema.items.title) {
68+
schema.items = {title: schema.title, ...schema.items};
69+
}
70+
}
71+
}
72+
73+
function postProcessSchema(
74+
schema: SchemaObject | ReferenceObject,
75+
): ReferenceObject | undefined {
76+
// use title to discriminate references
77+
if (isSchemaObject(schema) && schema.properties && schema.title) {
78+
let i = 1;
79+
const titlePrefix = _.upperFirst(_.camelCase(schema.title));
80+
let title = titlePrefix;
81+
while (
82+
refExists(title) &&
83+
!compare(schema as ISpecificationExtension, getRefValue(title), {
84+
ignore: ['description'],
85+
})
86+
) {
87+
i++;
88+
title = `${titlePrefix}${i}`;
89+
}
90+
if (!refExists(title)) {
91+
setRefValue(title, schema);
92+
}
93+
return <ReferenceObject>{$ref: `#/components/schemas/${title}`};
94+
}
95+
return undefined;
96+
}
97+
98+
function refExists(name: string): boolean {
99+
return _.has($schemaRefs, name);
100+
}
101+
function getRefValue(name: string): ISpecificationExtension {
102+
return $schemaRefs[name];
103+
}
104+
function setRefValue(name: string, value: ISpecificationExtension) {
105+
$schemaRefs[name] = value;
106+
}

packages/openapi-v3/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// License text available at https://opensource.org/licenses/MIT
55

66
export * from '@loopback/repository-json-schema';
7+
export * from './consolidate-schema';
78
export * from './controller-spec';
89
export * from './decorators';
910
export * from './enhancers';

packages/rest/src/rest.server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import {Application, CoreBindings, Server} from '@loopback/core';
1515
import {HttpServer, HttpServerOptions} from '@loopback/http-server';
1616
import {
17+
ConsolidateSchemaObjects,
1718
getControllerSpec,
1819
OpenAPIObject,
1920
OpenApiSpec,
@@ -425,7 +426,7 @@ export class RestServer extends Context implements Server, HttpServerLike {
425426
);
426427

427428
specForm = specForm ?? {version: '3.0.0', format: 'json'};
428-
const specObj = this.getApiSpec(requestContext);
429+
const specObj = ConsolidateSchemaObjects(this.getApiSpec(requestContext));
429430

430431
if (specForm.format === 'json') {
431432
const spec = JSON.stringify(specObj, null, 2);

0 commit comments

Comments
 (0)