Skip to content

Commit eedec1e

Browse files
committed
feat(cli): allow annonymous schemas in openapi to be mapped to models
1 parent 34d2e81 commit eedec1e

File tree

10 files changed

+481
-47
lines changed

10 files changed

+481
-47
lines changed

docs/site/OpenAPI-generator.md

Lines changed: 145 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ lb4 openapi [<url>] [options]
1919
### Options
2020

2121
- `--url`: URL or file path of the OpenAPI spec.
22-
- `--validate`: Validate the OpenAPI spec. Default: false.
22+
- `--validate`: Validate the OpenAPI spec. Default: `false`.
23+
- `--promote-anonymous-schemas`: Promote anonymous schemas as models classes.
24+
Default: `false`.
2325

2426
### Arguments
2527

@@ -135,10 +137,148 @@ import {NewPet} from './new-pet.model.ts';
135137
export type Pet = NewPet & {id: number};
136138
```
137139

138-
2. The generator groups operations (`paths.<path>.<verb>`) by tags. If no tag
139-
is present, it defaults to `OpenApi`. For each tag, a controller class is
140-
generated as `src/controllers/<tag-name>.controller.ts` to hold all
141-
operations with the same tag.
140+
2. Anonymous schemas of object/array types are generated as inline TypeScript
141+
type literals or separate model classes/types depending on
142+
`--promote-anonymous-schemas` flag (default to `false`).
143+
144+
For example, the following OpenAPI spec snippet uses anonymous schemas for
145+
request and response body objects.
146+
147+
```yaml
148+
openapi: 3.0.0
149+
// ...
150+
paths:
151+
// ...
152+
/{dataset}/{version}/records:
153+
post:
154+
// ...
155+
operationId: perform-search
156+
parameters:
157+
// ...
158+
responses:
159+
'200':
160+
description: successful operation
161+
content:
162+
application/json:
163+
schema:
164+
type: array
165+
items:
166+
type: object
167+
additionalProperties:
168+
type: object
169+
'404':
170+
description: No matching record found for the given criteria.
171+
requestBody:
172+
content:
173+
application/x-www-form-urlencoded:
174+
schema:
175+
type: object
176+
properties:
177+
criteria:
178+
description: >-
179+
Uses Lucene Query Syntax in the format of
180+
propertyName:value, propertyName:[num1 TO num2] and date
181+
range format: propertyName:[yyyyMMdd TO yyyyMMdd]. In the
182+
response please see the 'docs' element which has the list of
183+
record objects. Each record structure would consist of all
184+
the fields and their corresponding values.
185+
type: string
186+
default: '*:*'
187+
start:
188+
description: Starting record number. Default value is 0.
189+
type: integer
190+
default: 0
191+
rows:
192+
description: >-
193+
Specify number of rows to be returned. If you run the search
194+
with default values, in the response you will see 'numFound'
195+
attribute which will tell the number of records available in
196+
the dataset.
197+
type: integer
198+
default: 100
199+
required:
200+
- criteria
201+
```
202+
203+
Without `--promote-anonymous-schemas`, no separate files are generated for
204+
anonymous schemas. The controller class uses inline TypeScript type literals as
205+
shown below.
206+
207+
{% include code-caption.html content="src/controllers/search.controller.ts" %}
208+
209+
```ts
210+
@operation('post', '/{dataset}/{version}/records')
211+
async performSearch(
212+
@requestBody()
213+
body: {
214+
criteria: string;
215+
start?: number;
216+
rows?: number;
217+
},
218+
@param({name: 'version', in: 'path'}) version: string,
219+
@param({name: 'dataset', in: 'path'}) dataset: string,
220+
): Promise<
221+
{
222+
[additionalProperty: string]: {};
223+
}[]
224+
> {
225+
throw new Error('Not implemented');
226+
}
227+
```
228+
229+
On contrast, if `lb4 openapi --promote-anonymous-schemas` is used, two
230+
additional model files are generated:
231+
232+
{% include code-caption.html content="src/models/perform-search-body.model.ts" %}
233+
234+
```ts
235+
/* tslint:disable:no-any */
236+
import {model, property} from '@loopback/repository';
237+
238+
/**
239+
* The model class is generated from OpenAPI schema - performSearchBody
240+
* performSearchBody
241+
*/
242+
@model({name: 'performSearchBody'})
243+
export class PerformSearchBody {
244+
constructor(data?: Partial<PerformSearchBody>) {
245+
if (data != null && typeof data === 'object') {
246+
Object.assign(this, data);
247+
}
248+
}
249+
250+
/**
251+
* Uses Lucene Query Syntax in the format of propertyName:value, propertyName:[num1 TO num2] and date range format: propertyName:[yyyyMMdd TO yyyyMMdd]. In the response please see the 'docs' element which has the list of record objects. Each record structure would consist of all the fields and their corresponding values.
252+
*/
253+
@property({name: 'criteria'})
254+
criteria: string = '*:*';
255+
256+
/**
257+
* Starting record number. Default value is 0.
258+
*/
259+
@property({name: 'start'})
260+
start?: number = 0;
261+
262+
/**
263+
* Specify number of rows to be returned. If you run the search with default values, in the response you will see 'numFound' attribute which will tell the number of records available in the dataset.
264+
*/
265+
@property({name: 'rows'})
266+
rows?: number = 100;
267+
}
268+
```
269+
270+
{% include code-caption.html content="src/models/perform-search-response-body.model.ts" %}
271+
272+
```ts
273+
export type PerformSearchResponseBody = {
274+
[additionalProperty: string]: {};
275+
}[];
276+
```
277+
278+
3. The generator groups operations (`paths.<path>.<verb>`) by tags. If no tag is
279+
present, it defaults to `OpenApi`. For each tag, a controller class is
280+
generated as `src/controllers/<tag-name>.controller.ts` to hold all
281+
operations with the same tag.
142282

143283
Controller class names are derived from tag names. The `x-controller-name`
144284
property of an operation can be used to customize the controller name. Method

packages/cli/generators/openapi/index.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ module.exports = class OpenApiGenerator extends BaseGenerator {
3838
default: false,
3939
type: Boolean,
4040
});
41+
42+
this.option('promote-anonymous-schemas', {
43+
description: 'Promote anonymous schemas as models',
44+
required: false,
45+
default: false,
46+
type: Boolean,
47+
});
48+
4149
return super._setupGenerator();
4250
}
4351

@@ -70,6 +78,7 @@ module.exports = class OpenApiGenerator extends BaseGenerator {
7078
const result = await loadAndBuildSpec(this.url, {
7179
log: this.log,
7280
validate: this.options.validate,
81+
promoteAnonymousSchemas: this.options['promote-anonymous-schemas'],
7382
});
7483
debugJson('OpenAPI spec', result.apiSpec);
7584
Object.assign(this, result);

packages/cli/generators/openapi/schema-helper.js

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

66
'use strict';
7-
const util = require('util');
87

98
const {
109
isExtension,
@@ -181,6 +180,17 @@ function mapObjectType(schema, options) {
181180
}
182181
typeSpec.properties = properties;
183182
const propertySignatures = properties.map(p => p.signature);
183+
184+
// Handle `additionalProperties`
185+
if (schema.additionalProperties === true) {
186+
propertySignatures.push('[additionalProperty: string]: any;');
187+
} else if (schema.additionalProperties) {
188+
propertySignatures.push(
189+
'[additionalProperty: string]: ' +
190+
mapSchemaType(schema.additionalProperties).signature +
191+
';',
192+
);
193+
}
184194
typeSpec.declaration = `{
185195
${propertySignatures.join('\n ')}
186196
}`;
@@ -301,29 +311,13 @@ function generateModelSpecs(apiSpec, options) {
301311

302312
const schemaMapping = (options.schemaMapping = options.schemaMapping || {});
303313

304-
const schemas =
305-
(apiSpec && apiSpec.components && apiSpec.components.schemas) || {};
306-
307-
// First map schema objects to names
308-
for (const s in schemas) {
309-
if (isExtension(s)) continue;
310-
schemaMapping[`#/components/schemas/${s}`] = schemas[s];
311-
const className = titleCase(s);
312-
objectTypeMapping.set(schemas[s], {
313-
description: schemas[s].description || s,
314-
name: s,
315-
className,
316-
fileName: getModelFileName(s),
317-
properties: [],
318-
imports: [],
319-
});
320-
}
314+
registerNamedSchemas(apiSpec, options);
321315

322316
const models = [];
323317
// Generate models from schema objects
324-
for (const s in schemas) {
318+
for (const s in options.schemaMapping) {
325319
if (isExtension(s)) continue;
326-
const schema = schemas[s];
320+
const schema = options.schemaMapping[s];
327321
const model = mapSchemaType(schema, {objectTypeMapping, schemaMapping});
328322
// `model` is `undefined` for primitive types
329323
if (model == null) continue;
@@ -334,6 +328,43 @@ function generateModelSpecs(apiSpec, options) {
334328
return models;
335329
}
336330

331+
/**
332+
* Register the named schema
333+
* @param {string} schemaName Schema name
334+
* @param {object} schema Schema object
335+
* @param {object} typeRegistry Options for objectTypeMapping & schemaMapping
336+
*/
337+
function registerSchema(schemaName, schema, typeRegistry) {
338+
if (typeRegistry.objectTypeMapping.get(schema)) return;
339+
typeRegistry.schemaMapping[`#/components/schemas/${schemaName}`] = schema;
340+
const className = titleCase(schemaName);
341+
typeRegistry.objectTypeMapping.set(schema, {
342+
description: schema.description || schemaName,
343+
name: schemaName,
344+
className,
345+
fileName: getModelFileName(schemaName),
346+
properties: [],
347+
imports: [],
348+
});
349+
}
350+
351+
/**
352+
* Register spec.components.schemas
353+
* @param {*} apiSpec OpenAPI spec
354+
* @param {*} typeRegistry options for objectTypeMapping & schemaMapping
355+
*/
356+
function registerNamedSchemas(apiSpec, typeRegistry) {
357+
const schemas =
358+
(apiSpec && apiSpec.components && apiSpec.components.schemas) || {};
359+
360+
// First map schema objects to names
361+
for (const s in schemas) {
362+
if (isExtension(s)) continue;
363+
const schema = schemas[s];
364+
registerSchema(s, schema, typeRegistry);
365+
}
366+
}
367+
337368
function getModelFileName(modelName) {
338369
let name = modelName;
339370
if (modelName.endsWith('Model')) {
@@ -344,6 +375,8 @@ function getModelFileName(modelName) {
344375

345376
module.exports = {
346377
mapSchemaType,
378+
registerSchema,
379+
registerNamedSchemas,
347380
generateModelSpecs,
348381
getModelFileName,
349382
};

0 commit comments

Comments
 (0)