Skip to content

Commit

Permalink
feat(cli): allow annonymous schemas in openapi to be mapped to models
Browse files Browse the repository at this point in the history
  • Loading branch information
raymondfeng committed Dec 12, 2018
1 parent 34d2e81 commit eedec1e
Show file tree
Hide file tree
Showing 10 changed files with 481 additions and 47 deletions.
150 changes: 145 additions & 5 deletions docs/site/OpenAPI-generator.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ lb4 openapi [<url>] [options]
### Options

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

### Arguments

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

2. The generator groups operations (`paths.<path>.<verb>`) by tags. If no tag
is present, it defaults to `OpenApi`. For each tag, a controller class is
generated as `src/controllers/<tag-name>.controller.ts` to hold all
operations with the same tag.
2. Anonymous schemas of object/array types are generated as inline TypeScript
type literals or separate model classes/types depending on
`--promote-anonymous-schemas` flag (default to `false`).

For example, the following OpenAPI spec snippet uses anonymous schemas for
request and response body objects.

```yaml
openapi: 3.0.0
// ...
paths:
// ...
/{dataset}/{version}/records:
post:
// ...
operationId: perform-search
parameters:
// ...
responses:
'200':
description: successful operation
content:
application/json:
schema:
type: array
items:
type: object
additionalProperties:
type: object
'404':
description: No matching record found for the given criteria.
requestBody:
content:
application/x-www-form-urlencoded:
schema:
type: object
properties:
criteria:
description: >-
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.
type: string
default: '*:*'
start:
description: Starting record number. Default value is 0.
type: integer
default: 0
rows:
description: >-
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.
type: integer
default: 100
required:
- criteria
```

Without `--promote-anonymous-schemas`, no separate files are generated for
anonymous schemas. The controller class uses inline TypeScript type literals as
shown below.

{% include code-caption.html content="src/controllers/search.controller.ts" %}

```ts
@operation('post', '/{dataset}/{version}/records')
async performSearch(
@requestBody()
body: {
criteria: string;
start?: number;
rows?: number;
},
@param({name: 'version', in: 'path'}) version: string,
@param({name: 'dataset', in: 'path'}) dataset: string,
): Promise<
{
[additionalProperty: string]: {};
}[]
> {
throw new Error('Not implemented');
}
```

On contrast, if `lb4 openapi --promote-anonymous-schemas` is used, two
additional model files are generated:

{% include code-caption.html content="src/models/perform-search-body.model.ts" %}

```ts
/* tslint:disable:no-any */
import {model, property} from '@loopback/repository';

/**
* The model class is generated from OpenAPI schema - performSearchBody
* performSearchBody
*/
@model({name: 'performSearchBody'})
export class PerformSearchBody {
constructor(data?: Partial<PerformSearchBody>) {
if (data != null && typeof data === 'object') {
Object.assign(this, data);
}
}

/**
* 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.
*/
@property({name: 'criteria'})
criteria: string = '*:*';

/**
* Starting record number. Default value is 0.
*/
@property({name: 'start'})
start?: number = 0;

/**
* 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.
*/
@property({name: 'rows'})
rows?: number = 100;
}
```

{% include code-caption.html content="src/models/perform-search-response-body.model.ts" %}

```ts
export type PerformSearchResponseBody = {
[additionalProperty: string]: {};
}[];
```

3. The generator groups operations (`paths.<path>.<verb>`) by tags. If no tag is
present, it defaults to `OpenApi`. For each tag, a controller class is
generated as `src/controllers/<tag-name>.controller.ts` to hold all
operations with the same tag.

Controller class names are derived from tag names. The `x-controller-name`
property of an operation can be used to customize the controller name. Method
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/generators/openapi/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ module.exports = class OpenApiGenerator extends BaseGenerator {
default: false,
type: Boolean,
});

this.option('promote-anonymous-schemas', {
description: 'Promote anonymous schemas as models',
required: false,
default: false,
type: Boolean,
});

return super._setupGenerator();
}

Expand Down Expand Up @@ -70,6 +78,7 @@ module.exports = class OpenApiGenerator extends BaseGenerator {
const result = await loadAndBuildSpec(this.url, {
log: this.log,
validate: this.options.validate,
promoteAnonymousSchemas: this.options['promote-anonymous-schemas'],
});
debugJson('OpenAPI spec', result.apiSpec);
Object.assign(this, result);
Expand Down
73 changes: 53 additions & 20 deletions packages/cli/generators/openapi/schema-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
// License text available at https://opensource.org/licenses/MIT

'use strict';
const util = require('util');

const {
isExtension,
Expand Down Expand Up @@ -181,6 +180,17 @@ function mapObjectType(schema, options) {
}
typeSpec.properties = properties;
const propertySignatures = properties.map(p => p.signature);

// Handle `additionalProperties`
if (schema.additionalProperties === true) {
propertySignatures.push('[additionalProperty: string]: any;');
} else if (schema.additionalProperties) {
propertySignatures.push(
'[additionalProperty: string]: ' +
mapSchemaType(schema.additionalProperties).signature +
';',
);
}
typeSpec.declaration = `{
${propertySignatures.join('\n ')}
}`;
Expand Down Expand Up @@ -301,29 +311,13 @@ function generateModelSpecs(apiSpec, options) {

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

const schemas =
(apiSpec && apiSpec.components && apiSpec.components.schemas) || {};

// First map schema objects to names
for (const s in schemas) {
if (isExtension(s)) continue;
schemaMapping[`#/components/schemas/${s}`] = schemas[s];
const className = titleCase(s);
objectTypeMapping.set(schemas[s], {
description: schemas[s].description || s,
name: s,
className,
fileName: getModelFileName(s),
properties: [],
imports: [],
});
}
registerNamedSchemas(apiSpec, options);

const models = [];
// Generate models from schema objects
for (const s in schemas) {
for (const s in options.schemaMapping) {
if (isExtension(s)) continue;
const schema = schemas[s];
const schema = options.schemaMapping[s];
const model = mapSchemaType(schema, {objectTypeMapping, schemaMapping});
// `model` is `undefined` for primitive types
if (model == null) continue;
Expand All @@ -334,6 +328,43 @@ function generateModelSpecs(apiSpec, options) {
return models;
}

/**
* Register the named schema
* @param {string} schemaName Schema name
* @param {object} schema Schema object
* @param {object} typeRegistry Options for objectTypeMapping & schemaMapping
*/
function registerSchema(schemaName, schema, typeRegistry) {
if (typeRegistry.objectTypeMapping.get(schema)) return;
typeRegistry.schemaMapping[`#/components/schemas/${schemaName}`] = schema;
const className = titleCase(schemaName);
typeRegistry.objectTypeMapping.set(schema, {
description: schema.description || schemaName,
name: schemaName,
className,
fileName: getModelFileName(schemaName),
properties: [],
imports: [],
});
}

/**
* Register spec.components.schemas
* @param {*} apiSpec OpenAPI spec
* @param {*} typeRegistry options for objectTypeMapping & schemaMapping
*/
function registerNamedSchemas(apiSpec, typeRegistry) {
const schemas =
(apiSpec && apiSpec.components && apiSpec.components.schemas) || {};

// First map schema objects to names
for (const s in schemas) {
if (isExtension(s)) continue;
const schema = schemas[s];
registerSchema(s, schema, typeRegistry);
}
}

function getModelFileName(modelName) {
let name = modelName;
if (modelName.endsWith('Model')) {
Expand All @@ -344,6 +375,8 @@ function getModelFileName(modelName) {

module.exports = {
mapSchemaType,
registerSchema,
registerNamedSchemas,
generateModelSpecs,
getModelFileName,
};
Loading

0 comments on commit eedec1e

Please sign in to comment.