Skip to content

Commit 1a1b12c

Browse files
committed
feat(cli): add cli for code generation from openapi
1 parent 2cbb449 commit 1a1b12c

27 files changed

+2913
-4
lines changed

packages/cli/bin/cli.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ function setupGenerators() {
6969
path.join(__dirname, '../generators/example'),
7070
PREFIX + 'example',
7171
);
72+
env.register(
73+
path.join(__dirname, '../generators/openapi'),
74+
PREFIX + 'openapi',
75+
);
7276
return env;
7377
}
7478

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
# lb4 openapi
2+
3+
The `openapi` command generates LoopBack 4 artifacts from an
4+
[OpenAPI specification](https://github.com/OAI/OpenAPI-Specification), including
5+
version 2.0 and 3.0.
6+
7+
## Basic use
8+
9+
```sh
10+
Usage:
11+
lb4 openapi [<url>] [options]
12+
13+
Options:
14+
-h, --help # Print the generator's options and usage
15+
--url # URL or file path of the OpenAPI spec
16+
--validate # Validate the OpenAPI spec Default: false
17+
18+
Arguments:
19+
url # URL or file path of the OpenAPI spec Type: String Required: false
20+
```
21+
22+
For example,
23+
24+
```sh
25+
lb4 openapi https://api.apis.guru/v2/specs/api2cart.com/1.0.0/swagger.json
26+
```
27+
28+
## Mappings
29+
30+
We map OpenAPI operations by tag into `controllers` and schemas into `models` as
31+
TypeScript classes or types.
32+
33+
### Schemas
34+
35+
The generator first iterates through the `components.schemas` of the
36+
specification document and maps them into TypeScript classes or types:
37+
38+
- Primitive types --> TypeScript type declaration
39+
40+
```ts
41+
export type Message = string;
42+
```
43+
44+
```ts
45+
export type OrderEnum = 'ascending' | 'descending';
46+
```
47+
48+
- Array types --> TypeScript type declaration
49+
50+
```ts
51+
import {Comment} from './comment.model';
52+
export type Comments = Comment[];
53+
```
54+
55+
- Object type --> TypeScript class definition
56+
57+
```ts
58+
import {model, property} from '@loopback/repository';
59+
import {CartShippingZone} from './cart-shipping-zone.model';
60+
import {CartStoreInfo} from './cart-store-info.model';
61+
import {CartWarehouse} from './cart-warehouse.model';
62+
63+
/**
64+
* The model class is generated from OpenAPI schema - Cart
65+
* Cart
66+
*/
67+
@model({name: 'Cart'})
68+
export class Cart {
69+
constructor(data?: Partial<Cart>) {
70+
if (data != null && typeof data === 'object') {
71+
Object.assign(this, data);
72+
}
73+
}
74+
75+
@property({name: 'additional_fields'})
76+
additional_fields?: {};
77+
78+
@property({name: 'custom_fields'})
79+
custom_fields?: {};
80+
81+
@property({name: 'db_prefix'})
82+
db_prefix?: string;
83+
84+
@property({name: 'name'})
85+
name?: string;
86+
87+
@property({name: 'shipping_zones'})
88+
shipping_zones?: CartShippingZone[];
89+
90+
@property({name: 'stores_info'})
91+
stores_info?: CartStoreInfo[];
92+
93+
@property({name: 'url'})
94+
url?: string;
95+
96+
@property({name: 'version'})
97+
version?: string;
98+
99+
@property({name: 'warehouses'})
100+
warehouses?: CartWarehouse[];
101+
}
102+
```
103+
104+
- Composite type (anyOf|oneOf|allOf) --> TypeScript union/intersection types
105+
106+
```ts
107+
export type IdType = string | number;
108+
```
109+
110+
```ts
111+
import {NewPet} from './new-pet.model.ts';
112+
export type Pet = NewPet & {id: number};
113+
```
114+
115+
Embedded schemas are mapped to TypeScript type literals.
116+
117+
### Operations
118+
119+
The generator groups operations (`paths.<path>.<verb>`) by tags. If no tag is
120+
present, it defaults to `OpenApi`. For each tag, a controller class is generated
121+
to hold all operations with the same tag.
122+
123+
```ts
124+
import {operation, param} from '@loopback/rest';
125+
/* tslint:disable:no-any */
126+
import {operation, param} from '@loopback/rest';
127+
import {DateTime} from '../models/date-time.model';
128+
129+
/**
130+
* The controller class is generated from OpenAPI spec with operations tagged
131+
* by account
132+
*
133+
*/
134+
export class AccountController {
135+
constructor() {}
136+
137+
/**
138+
* Get list of carts.
139+
*/
140+
@operation('get', '/account.cart.list.json')
141+
async accountCartList(
142+
@param({name: 'params', in: 'query'})
143+
params: string,
144+
@param({name: 'exclude', in: 'query'})
145+
exclude: string,
146+
@param({name: 'request_from_date', in: 'query'})
147+
request_from_date: string,
148+
@param({name: 'request_to_date', in: 'query'})
149+
request_to_date: string,
150+
): Promise<{
151+
result?: {
152+
carts?: {
153+
cart_id?: string;
154+
id?: string;
155+
store_key?: string;
156+
total_calls?: string;
157+
url?: string;
158+
}[];
159+
carts_count?: number;
160+
};
161+
return_code?: number;
162+
return_message?: string;
163+
}> {
164+
throw new Error('Not implemented');
165+
}
166+
167+
/**
168+
* Update configs in the API2Cart database.
169+
*/
170+
@operation('put', '/account.config.update.json')
171+
async accountConfigUpdate(
172+
@param({name: 'db_tables_prefix', in: 'query'})
173+
db_tables_prefix: string,
174+
@param({name: 'client_id', in: 'query'})
175+
client_id: string,
176+
@param({name: 'bridge_url', in: 'query'})
177+
bridge_url: string,
178+
@param({name: 'store_root', in: 'query'})
179+
store_root: string,
180+
@param({name: 'shared_secret', in: 'query'})
181+
shared_secret: string,
182+
): Promise<{
183+
result?: {
184+
updated_items?: number;
185+
};
186+
return_code?: number;
187+
return_message?: string;
188+
}> {
189+
throw new Error('Not implemented');
190+
}
191+
192+
/**
193+
* List webhooks that was not delivered to the callback.
194+
*/
195+
@operation('get', '/account.failed_webhooks.json')
196+
async accountFailedWebhooks(
197+
@param({name: 'count', in: 'query'})
198+
count: number,
199+
@param({name: 'start', in: 'query'})
200+
start: number,
201+
@param({name: 'ids', in: 'query'})
202+
ids: string,
203+
): Promise<{
204+
result?: {
205+
all_failed_webhook?: string;
206+
webhook?: {
207+
entity_id?: string;
208+
time?: DateTime;
209+
webhook_id?: number;
210+
}[];
211+
};
212+
return_code?: number;
213+
return_message?: string;
214+
}> {
215+
throw new Error('Not implemented');
216+
}
217+
}
218+
```
219+
220+
## OpenAPI Examples
221+
222+
- https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/examples/v3.0/petstore-expanded.yaml
223+
- https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/examples/v3.0/uspto.yaml
224+
- https://api.apis.guru/v2/specs/api2cart.com/1.0.0/swagger.json
225+
- https://api.apis.guru/v2/specs/amazonaws.com/codecommit/2015-04-13/swagger.json
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright IBM Corp. 2018. All Rights Reserved.
2+
// Node module: @loopback/cli
3+
// This file is licensed under the MIT License.
4+
// License text available at https://opensource.org/licenses/MIT
5+
6+
'use strict';
7+
8+
const BaseGenerator = require('../../lib/base-generator');
9+
const {debug, debugJson} = require('./utils');
10+
const {loadAndBuildSpec} = require('./spec-loader');
11+
const {validateUrlOrFile} = require('./utils');
12+
const {getControllerFileName} = require('./spec-helper');
13+
14+
const updateIndex = require('../../lib/update-index');
15+
16+
module.exports = class OpenApiGenerator extends BaseGenerator {
17+
// Note: arguments and options should be defined in the constructor.
18+
constructor(args, opts) {
19+
super(args, opts);
20+
}
21+
22+
_setupGenerator() {
23+
this.argument('url', {
24+
description: 'URL or file path of the OpenAPI spec',
25+
required: false,
26+
type: String,
27+
});
28+
29+
this.option('url', {
30+
description: 'URL or file path of the OpenAPI spec',
31+
required: false,
32+
type: String,
33+
});
34+
35+
this.option('validate', {
36+
description: 'Validate the OpenAPI spec',
37+
required: false,
38+
default: false,
39+
type: Boolean,
40+
});
41+
return super._setupGenerator();
42+
}
43+
44+
checkLoopBackProject() {
45+
return super.checkLoopBackProject();
46+
}
47+
48+
async askForSpecUrlOrPath() {
49+
const prompts = [
50+
{
51+
name: 'url',
52+
message: 'Enter the OpenAPI spec url or file path:',
53+
default: this.options.url,
54+
validate: validateUrlOrFile,
55+
when: this.options.url == null,
56+
},
57+
];
58+
const answers = await this.prompt(prompts);
59+
if (answers.url) {
60+
this.url = answers.url.trim();
61+
} else {
62+
this.url = this.options.url;
63+
}
64+
}
65+
66+
async loadAndBuildApiSpec() {
67+
try {
68+
const result = await loadAndBuildSpec(this.url, {
69+
log: this.log,
70+
validate: this.options.validate,
71+
});
72+
debugJson('OpenAPI spec', result.apiSpec);
73+
Object.assign(this, result);
74+
} catch (e) {
75+
this.exit(e);
76+
}
77+
}
78+
79+
async selectControllers() {
80+
if (this.shouldExit()) return;
81+
const choices = this.controllerSpecs.map(c => {
82+
return {
83+
name: c.tag ? `[${c.tag}] ${c.className}` : c.className,
84+
value: c.className,
85+
checked: true,
86+
};
87+
});
88+
const prompts = [
89+
{
90+
name: 'controllerSelections',
91+
message: 'Select controllers to be generated:',
92+
type: 'checkbox',
93+
choices: choices,
94+
},
95+
];
96+
const selections =
97+
(await this.prompt(prompts)).controllerSelections ||
98+
choices.map(c => c.value);
99+
this.selectedControllers = this.controllerSpecs.filter(c =>
100+
selections.some(a => a === c.className),
101+
);
102+
this.selectedControllers.forEach(
103+
c => (c.fileName = getControllerFileName(c.tag || c.className)),
104+
);
105+
}
106+
107+
async scaffold() {
108+
if (this.shouldExit()) return false;
109+
this._generateModels();
110+
this._generateControllers();
111+
}
112+
113+
_generateControllers() {
114+
const source = this.templatePath(
115+
'src/controllers/controller-template.ts.ejs',
116+
);
117+
for (const c of this.selectedControllers) {
118+
const controllerFile = c.fileName;
119+
if (debug.enabled) {
120+
debug(`Artifact output filename set to: ${controllerFile}`);
121+
}
122+
const dest = this.destinationPath(`src/controllers/${controllerFile}`);
123+
if (debug.enabled) {
124+
debug('Copying artifact to: %s', dest);
125+
}
126+
this.fs.copyTpl(source, dest, c, {}, {globOptions: {dot: true}});
127+
}
128+
}
129+
130+
_generateModels() {
131+
const modelSource = this.templatePath('src/models/model-template.ts.ejs');
132+
const typeSource = this.templatePath('src/models/type-template.ts.ejs');
133+
for (const m of this.modelSpecs) {
134+
if (!m.fileName) continue;
135+
const modelFile = m.fileName;
136+
if (debug.enabled) {
137+
debug(`Artifact output filename set to: ${modelFile}`);
138+
}
139+
const dest = this.destinationPath(`src/models/${modelFile}`);
140+
if (debug.enabled) {
141+
debug('Copying artifact to: %s', dest);
142+
}
143+
const source = m.kind === 'class' ? modelSource : typeSource;
144+
this.fs.copyTpl(source, dest, m, {}, {globOptions: {dot: true}});
145+
}
146+
}
147+
148+
async end() {
149+
await super.end();
150+
if (this.shouldExit()) return;
151+
const targetDir = this.destinationPath(`src/controllers`);
152+
for (const c of this.selectedControllers) {
153+
// Check all files being generated to ensure they succeeded
154+
const status = this.conflicter.generationStatus[c.fileName];
155+
if (status !== 'skip' && status !== 'identical') {
156+
await updateIndex(targetDir, c.fileName);
157+
}
158+
}
159+
}
160+
};

0 commit comments

Comments
 (0)