From 2fcafc90604897cdb7a4028bad8c1ba5055b4467 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Wed, 20 May 2020 21:53:16 -0700 Subject: [PATCH] feat(cli): add `lb4 openapi-spec` command to save openapi spec to json or yaml --- docs/site/OpenAPI-spec-generator.md | 47 + docs/site/tables/lb4-artifact-commands.html | 8 +- packages/cli/.yo-rc.json | 72 + packages/cli/generators/openapi-spec/index.js | 79 + packages/cli/lib/cli.js | 4 + packages/cli/package.json | 1 + .../cli/cli.integration.snapshots.js | 74 + .../openapi-spec.integration.snapshots.js | 1594 +++++++++++++++++ .../cli/test/fixtures/openapi-spec/index.js | 14 + .../test/fixtures/openapi-spec/index.js.txt | 16 + .../test/fixtures/openapi-spec/openapi.json | 362 ++++ .../generators/openapi-spec.integration.js | 85 + 12 files changed, 2355 insertions(+), 1 deletion(-) create mode 100644 docs/site/OpenAPI-spec-generator.md create mode 100644 packages/cli/generators/openapi-spec/index.js create mode 100644 packages/cli/snapshots/integration/generators/openapi-spec.integration.snapshots.js create mode 100644 packages/cli/test/fixtures/openapi-spec/index.js create mode 100644 packages/cli/test/fixtures/openapi-spec/index.js.txt create mode 100644 packages/cli/test/fixtures/openapi-spec/openapi.json create mode 100644 packages/cli/test/integration/generators/openapi-spec.integration.js diff --git a/docs/site/OpenAPI-spec-generator.md b/docs/site/OpenAPI-spec-generator.md new file mode 100644 index 000000000000..558e00e76117 --- /dev/null +++ b/docs/site/OpenAPI-spec-generator.md @@ -0,0 +1,47 @@ +--- +lang: en +title: 'OpenAPI spec generator' +keywords: LoopBack 4.0, LoopBack 4 +sidebar: lb4_sidebar +permalink: /doc/en/lb4/OpenAPI-spec-generator.html +--- + +{% include content/generator-create-app.html lang=page.lang %} + +### Synopsis + +Saves OpenAPI spec to a json or yaml file. + +```sh +lb4 openapi-spec [options] +``` + +### Options + +`--out` or `-o` : File name for the OpenAPI spec + +{% include_relative includes/CLI-std-options.md %} + +### Interactive Prompts + +The tool will prompt you for: + +- **File name for the OpenAPI spec.** _(outFile)_ If the `--out` had been + supplied from the command line, the prompt is skipped. + +### Output + +Once all the prompts have been answered, the CLI will do the following: + +- Start the application without listening on an Http port. +- Load the OpenAPI spec +- Save the spec to the file in json or yaml format depending on the extension + +``` +lb4 openapi-spec + +? File name for the OpenAPI spec: dist/openapi.json +Server is running at undefined +create dist/openapi.json +OpenAPI spec is written to dist/openapi.json. +``` diff --git a/docs/site/tables/lb4-artifact-commands.html b/docs/site/tables/lb4-artifact-commands.html index c8a02a383181..df42328caaa6 100644 --- a/docs/site/tables/lb4-artifact-commands.html +++ b/docs/site/tables/lb4-artifact-commands.html @@ -54,7 +54,7 @@ lb4 openapi - Generate controllers and models from OpenAPI specs + Generate controllers, service proxies, and models from OpenAPI specs OpenAPI generator @@ -81,5 +81,11 @@ Generate rest configs for model endpoints Model endpoint generator + + + lb4 openapi-spec + Save OpenAPI spec to a json or yaml file + OpenAPI spec generator + diff --git a/packages/cli/.yo-rc.json b/packages/cli/.yo-rc.json index 59a3d36be430..c955ae2ba038 100644 --- a/packages/cli/.yo-rc.json +++ b/packages/cli/.yo-rc.json @@ -1643,6 +1643,78 @@ }, "arguments": [], "name": "copyright" + }, + "openapi-spec": { + "options": { + "help": { + "name": "help", + "type": "Boolean", + "alias": "h", + "description": "Print the generator's options and usage" + }, + "skip-cache": { + "name": "skip-cache", + "type": "Boolean", + "description": "Do not remember prompt answers", + "default": false + }, + "skip-install": { + "name": "skip-install", + "type": "Boolean", + "description": "Do not automatically install dependencies", + "default": false + }, + "force-install": { + "name": "force-install", + "type": "Boolean", + "description": "Fail on install dependencies error", + "default": false + }, + "ask-answered": { + "type": "Boolean", + "description": "Show prompts for already configured options", + "default": false, + "name": "ask-answered", + "hide": false + }, + "out": { + "alias": "o", + "type": "String", + "required": false, + "description": "File name for the OpenAPI spec to be written", + "name": "out", + "hide": false + }, + "config": { + "type": "String", + "alias": "c", + "description": "JSON file name or value to configure options", + "name": "config", + "hide": false + }, + "yes": { + "type": "Boolean", + "alias": "y", + "description": "Skip all confirmation prompts with default or provided value", + "name": "yes", + "hide": false + }, + "format": { + "type": "Boolean", + "description": "Format generated code using npm run lint:fix", + "name": "format", + "hide": false + }, + "packageManager": { + "type": "String", + "description": "Change the default package manager", + "alias": "pm", + "name": "packageManager", + "hide": false + } + }, + "arguments": [], + "name": "openapi-spec" } } } diff --git a/packages/cli/generators/openapi-spec/index.js b/packages/cli/generators/openapi-spec/index.js new file mode 100644 index 000000000000..d694bd816197 --- /dev/null +++ b/packages/cli/generators/openapi-spec/index.js @@ -0,0 +1,79 @@ +// Copyright IBM Corp. 2019,2020. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +const BaseGenerator = require('../../lib/base-generator'); +const g = require('../../lib/globalize'); +const {dump} = require('js-yaml'); + +module.exports = class OpenApiSpecGenerator extends BaseGenerator { + // Note: arguments and options should be defined in the constructor. + constructor(args, opts) { + super(args, opts); + } + + _setupGenerator() { + this.option('out', { + alias: 'o', + type: String, + required: false, + description: g.f('File name for the OpenAPI spec to be written'), + }); + return super._setupGenerator(); + } + + setOptions() { + return super.setOptions(); + } + + checkLoopBackProject() { + if (this.shouldExit()) return; + return super.checkLoopBackProject(); + } + + async promptFileName() { + if (this.shouldExit()) return; + if (this.options.out) { + this.outFile = this.options.out; + return; + } + const prompts = [ + { + type: 'input', + name: 'outFile', + message: g.f('File name for the OpenAPI spec:'), + default: 'dist/openapi.json', + }, + ]; + const answers = await this.prompt(prompts); + this.outFile = answers && answers.outFile; + } + + async saveSpec() { + if (this.shouldExit()) return; + this.log('Loading application %s...', this.packageJson.name); + // Load the `main` function + const main = require(this.destinationRoot()).main; + const app = await main({rest: {listenOnStart: false}}); + + // Get the OpenAPI spec + const spec = await app.restServer.getApiSpec(); + const outFile = this.outFile || 'dist/openapi.json'; + const fileName = this.outFile.toLowerCase(); + if (fileName.endsWith('.yaml') || fileName.endsWith('.yml')) { + const yaml = dump(spec); + this.fs.write(outFile, yaml); + } else { + this.fs.writeJSON(outFile, spec, null, 2); + } + await app.stop(); + } + + async end() { + this.log('The OpenAPI spec has been saved to %s.', this.outFile); + await super.end(); + } +}; diff --git a/packages/cli/lib/cli.js b/packages/cli/lib/cli.js index 3c1c59b2f832..830d5d5c4674 100644 --- a/packages/cli/lib/cli.js +++ b/packages/cli/lib/cli.js @@ -116,6 +116,10 @@ function setupGenerators() { path.join(__dirname, '../generators/copyright'), PREFIX + 'copyright', ); + env.register( + path.join(__dirname, '../generators/openapi-spec'), + PREFIX + 'openapi-spec', + ); return env; } diff --git a/packages/cli/package.json b/packages/cli/package.json index 1afdd6ffffd2..acf1f7e72a71 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -53,6 +53,7 @@ "fs-extra": "^9.0.0", "glob": "^7.1.6", "inquirer-autocomplete-prompt": "^1.0.2", + "js-yaml": "^3.13.1", "json5": "^2.1.3", "latest-version": "^5.1.0", "lodash": "^4.17.15", diff --git a/packages/cli/snapshots/integration/cli/cli.integration.snapshots.js b/packages/cli/snapshots/integration/cli/cli.integration.snapshots.js index e363653a1b7b..f81178adf021 100644 --- a/packages/cli/snapshots/integration/cli/cli.integration.snapshots.js +++ b/packages/cli/snapshots/integration/cli/cli.integration.snapshots.js @@ -26,6 +26,7 @@ Available commands: lb4 update lb4 rest-crud lb4 copyright + lb4 openapi-spec lb4 install-completion lb4 uninstall-completion `; @@ -50,6 +51,7 @@ Available commands: lb4 update lb4 rest-crud lb4 copyright + lb4 openapi-spec lb4 install-completion lb4 uninstall-completion `; @@ -1701,6 +1703,78 @@ exports[`cli saves command metadata to .yo-rc.json 1`] = ` }, "arguments": [], "name": "copyright" + }, + "openapi-spec": { + "options": { + "help": { + "name": "help", + "type": "Boolean", + "alias": "h", + "description": "Print the generator's options and usage" + }, + "skip-cache": { + "name": "skip-cache", + "type": "Boolean", + "description": "Do not remember prompt answers", + "default": false + }, + "skip-install": { + "name": "skip-install", + "type": "Boolean", + "description": "Do not automatically install dependencies", + "default": false + }, + "force-install": { + "name": "force-install", + "type": "Boolean", + "description": "Fail on install dependencies error", + "default": false + }, + "ask-answered": { + "type": "Boolean", + "description": "Show prompts for already configured options", + "default": false, + "name": "ask-answered", + "hide": false + }, + "out": { + "alias": "o", + "type": "String", + "required": false, + "description": "File name for the OpenAPI spec to be written", + "name": "out", + "hide": false + }, + "config": { + "type": "String", + "alias": "c", + "description": "JSON file name or value to configure options", + "name": "config", + "hide": false + }, + "yes": { + "type": "Boolean", + "alias": "y", + "description": "Skip all confirmation prompts with default or provided value", + "name": "yes", + "hide": false + }, + "format": { + "type": "Boolean", + "description": "Format generated code using npm run lint:fix", + "name": "format", + "hide": false + }, + "packageManager": { + "type": "String", + "description": "Change the default package manager", + "alias": "pm", + "name": "packageManager", + "hide": false + } + }, + "arguments": [], + "name": "openapi-spec" } } } diff --git a/packages/cli/snapshots/integration/generators/openapi-spec.integration.snapshots.js b/packages/cli/snapshots/integration/generators/openapi-spec.integration.snapshots.js new file mode 100644 index 000000000000..a7f50178bb5f --- /dev/null +++ b/packages/cli/snapshots/integration/generators/openapi-spec.integration.snapshots.js @@ -0,0 +1,1594 @@ +// IMPORTANT +// This snapshot file is auto-generated, but designed for humans. +// It should be checked into source control and tracked carefully. +// Re-generate by setting UPDATE_SNAPSHOTS=1 and running tests. +// Make sure to inspect the changes in the snapshots below. +// Do not ignore changes! + +'use strict'; + +exports[`lb4 openapi-spec generates json spec to dist/openapi.json 1`] = ` +{ + "openapi": "3.0.0", + "info": { + "title": "@loopback/example-todo", + "version": "3.4.1", + "description": "Tutorial example on how to build an application with LoopBack 4.", + "contact": { + "name": "IBM Corp." + } + }, + "paths": { + "/todos/{id}": { + "put": { + "x-controller-name": "TodoController", + "x-operation-name": "replaceTodo", + "tags": [ + "TodoController" + ], + "responses": { + "204": { + "description": "Todo PUT success" + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "number" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "x-parameter-index": 1 + }, + "operationId": "TodoController.replaceTodo" + }, + "patch": { + "x-controller-name": "TodoController", + "x-operation-name": "updateTodo", + "tags": [ + "TodoController" + ], + "responses": { + "204": { + "description": "Todo PATCH success" + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "number" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoPartial" + } + } + }, + "x-parameter-index": 1 + }, + "operationId": "TodoController.updateTodo" + }, + "get": { + "x-controller-name": "TodoController", + "x-operation-name": "findTodoById", + "tags": [ + "TodoController" + ], + "responses": { + "200": { + "description": "Todo model instance", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "number" + }, + "required": true + }, + { + "name": "items", + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "operationId": "TodoController.findTodoById" + }, + "delete": { + "x-controller-name": "TodoController", + "x-operation-name": "deleteTodo", + "tags": [ + "TodoController" + ], + "responses": { + "204": { + "description": "Todo DELETE success" + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "number" + }, + "required": true + } + ], + "operationId": "TodoController.deleteTodo" + } + }, + "/todos": { + "post": { + "x-controller-name": "TodoController", + "x-operation-name": "createTodo", + "tags": [ + "TodoController" + ], + "responses": { + "200": { + "description": "Todo model instance", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewTodo" + } + } + } + }, + "operationId": "TodoController.createTodo" + }, + "get": { + "x-controller-name": "TodoController", + "x-operation-name": "findTodos", + "tags": [ + "TodoController" + ], + "responses": { + "200": { + "description": "Array of Todo model instances", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + } + }, + "parameters": [ + { + "name": "filter", + "in": "query", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Todo.Filter" + } + } + } + } + ], + "operationId": "TodoController.findTodos" + } + } + }, + "servers": [ + { + "url": "/" + } + ], + "components": { + "schemas": { + "Todo": { + "title": "Todo", + "properties": { + "id": { + "type": "number" + }, + "title": { + "type": "string" + }, + "desc": { + "type": "string" + }, + "isComplete": { + "type": "boolean" + }, + "remindAtAddress": { + "type": "string" + }, + "remindAtGeo": { + "type": "string" + }, + "tag": {} + }, + "required": [ + "title" + ], + "additionalProperties": false + }, + "NewTodo": { + "title": "NewTodo", + "description": "(tsType: Omit, schemaOptions: { title: 'NewTodo', exclude: [ 'id' ] })", + "properties": { + "title": { + "type": "string" + }, + "desc": { + "type": "string" + }, + "isComplete": { + "type": "boolean" + }, + "remindAtAddress": { + "type": "string" + }, + "remindAtGeo": { + "type": "string" + }, + "tag": {} + }, + "required": [ + "title" + ], + "additionalProperties": false, + "x-typescript-type": "Omit" + }, + "TodoPartial": { + "title": "TodoPartial", + "description": "(tsType: Partial, schemaOptions: { partial: true })", + "properties": { + "id": { + "type": "number" + }, + "title": { + "type": "string" + }, + "desc": { + "type": "string" + }, + "isComplete": { + "type": "boolean" + }, + "remindAtAddress": { + "type": "string" + }, + "remindAtGeo": { + "type": "string" + }, + "tag": {} + }, + "additionalProperties": false, + "x-typescript-type": "Partial" + }, + "Todo.Fields": { + "title": "Todo.Fields", + "type": "object", + "properties": { + "id": { + "type": "boolean" + }, + "title": { + "type": "boolean" + }, + "desc": { + "type": "boolean" + }, + "isComplete": { + "type": "boolean" + }, + "remindAtAddress": { + "type": "boolean" + }, + "remindAtGeo": { + "type": "boolean" + }, + "tag": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "Todo.Filter": { + "type": "object", + "title": "Todo.Filter", + "properties": { + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "example": 100 + }, + "skip": { + "type": "integer", + "minimum": 0 + }, + "order": { + "type": "array", + "items": { + "type": "string" + } + }, + "where": { + "title": "Todo.WhereFilter", + "type": "object", + "additionalProperties": true + }, + "fields": { + "$ref": "#/components/schemas/Todo.Fields" + } + }, + "additionalProperties": false, + "x-typescript-type": "@loopback/repository#Filter" + } + } + } +} + +`; + + +exports[`lb4 openapi-spec generates json spec with --out 1`] = ` +{ + "openapi": "3.0.0", + "info": { + "title": "@loopback/example-todo", + "version": "3.4.1", + "description": "Tutorial example on how to build an application with LoopBack 4.", + "contact": { + "name": "IBM Corp." + } + }, + "paths": { + "/todos/{id}": { + "put": { + "x-controller-name": "TodoController", + "x-operation-name": "replaceTodo", + "tags": [ + "TodoController" + ], + "responses": { + "204": { + "description": "Todo PUT success" + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "number" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "x-parameter-index": 1 + }, + "operationId": "TodoController.replaceTodo" + }, + "patch": { + "x-controller-name": "TodoController", + "x-operation-name": "updateTodo", + "tags": [ + "TodoController" + ], + "responses": { + "204": { + "description": "Todo PATCH success" + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "number" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoPartial" + } + } + }, + "x-parameter-index": 1 + }, + "operationId": "TodoController.updateTodo" + }, + "get": { + "x-controller-name": "TodoController", + "x-operation-name": "findTodoById", + "tags": [ + "TodoController" + ], + "responses": { + "200": { + "description": "Todo model instance", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "number" + }, + "required": true + }, + { + "name": "items", + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "operationId": "TodoController.findTodoById" + }, + "delete": { + "x-controller-name": "TodoController", + "x-operation-name": "deleteTodo", + "tags": [ + "TodoController" + ], + "responses": { + "204": { + "description": "Todo DELETE success" + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "number" + }, + "required": true + } + ], + "operationId": "TodoController.deleteTodo" + } + }, + "/todos": { + "post": { + "x-controller-name": "TodoController", + "x-operation-name": "createTodo", + "tags": [ + "TodoController" + ], + "responses": { + "200": { + "description": "Todo model instance", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewTodo" + } + } + } + }, + "operationId": "TodoController.createTodo" + }, + "get": { + "x-controller-name": "TodoController", + "x-operation-name": "findTodos", + "tags": [ + "TodoController" + ], + "responses": { + "200": { + "description": "Array of Todo model instances", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + } + }, + "parameters": [ + { + "name": "filter", + "in": "query", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Todo.Filter" + } + } + } + } + ], + "operationId": "TodoController.findTodos" + } + } + }, + "servers": [ + { + "url": "/" + } + ], + "components": { + "schemas": { + "Todo": { + "title": "Todo", + "properties": { + "id": { + "type": "number" + }, + "title": { + "type": "string" + }, + "desc": { + "type": "string" + }, + "isComplete": { + "type": "boolean" + }, + "remindAtAddress": { + "type": "string" + }, + "remindAtGeo": { + "type": "string" + }, + "tag": {} + }, + "required": [ + "title" + ], + "additionalProperties": false + }, + "NewTodo": { + "title": "NewTodo", + "description": "(tsType: Omit, schemaOptions: { title: 'NewTodo', exclude: [ 'id' ] })", + "properties": { + "title": { + "type": "string" + }, + "desc": { + "type": "string" + }, + "isComplete": { + "type": "boolean" + }, + "remindAtAddress": { + "type": "string" + }, + "remindAtGeo": { + "type": "string" + }, + "tag": {} + }, + "required": [ + "title" + ], + "additionalProperties": false, + "x-typescript-type": "Omit" + }, + "TodoPartial": { + "title": "TodoPartial", + "description": "(tsType: Partial, schemaOptions: { partial: true })", + "properties": { + "id": { + "type": "number" + }, + "title": { + "type": "string" + }, + "desc": { + "type": "string" + }, + "isComplete": { + "type": "boolean" + }, + "remindAtAddress": { + "type": "string" + }, + "remindAtGeo": { + "type": "string" + }, + "tag": {} + }, + "additionalProperties": false, + "x-typescript-type": "Partial" + }, + "Todo.Fields": { + "title": "Todo.Fields", + "type": "object", + "properties": { + "id": { + "type": "boolean" + }, + "title": { + "type": "boolean" + }, + "desc": { + "type": "boolean" + }, + "isComplete": { + "type": "boolean" + }, + "remindAtAddress": { + "type": "boolean" + }, + "remindAtGeo": { + "type": "boolean" + }, + "tag": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "Todo.Filter": { + "type": "object", + "title": "Todo.Filter", + "properties": { + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "example": 100 + }, + "skip": { + "type": "integer", + "minimum": 0 + }, + "order": { + "type": "array", + "items": { + "type": "string" + } + }, + "where": { + "title": "Todo.WhereFilter", + "type": "object", + "additionalProperties": true + }, + "fields": { + "$ref": "#/components/schemas/Todo.Fields" + } + }, + "additionalProperties": false, + "x-typescript-type": "@loopback/repository#Filter" + } + } + } +} + +`; + + +exports[`lb4 openapi-spec generates json spec with outFile prompt 1`] = ` +{ + "openapi": "3.0.0", + "info": { + "title": "@loopback/example-todo", + "version": "3.4.1", + "description": "Tutorial example on how to build an application with LoopBack 4.", + "contact": { + "name": "IBM Corp." + } + }, + "paths": { + "/todos/{id}": { + "put": { + "x-controller-name": "TodoController", + "x-operation-name": "replaceTodo", + "tags": [ + "TodoController" + ], + "responses": { + "204": { + "description": "Todo PUT success" + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "number" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "x-parameter-index": 1 + }, + "operationId": "TodoController.replaceTodo" + }, + "patch": { + "x-controller-name": "TodoController", + "x-operation-name": "updateTodo", + "tags": [ + "TodoController" + ], + "responses": { + "204": { + "description": "Todo PATCH success" + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "number" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoPartial" + } + } + }, + "x-parameter-index": 1 + }, + "operationId": "TodoController.updateTodo" + }, + "get": { + "x-controller-name": "TodoController", + "x-operation-name": "findTodoById", + "tags": [ + "TodoController" + ], + "responses": { + "200": { + "description": "Todo model instance", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "number" + }, + "required": true + }, + { + "name": "items", + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "operationId": "TodoController.findTodoById" + }, + "delete": { + "x-controller-name": "TodoController", + "x-operation-name": "deleteTodo", + "tags": [ + "TodoController" + ], + "responses": { + "204": { + "description": "Todo DELETE success" + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "number" + }, + "required": true + } + ], + "operationId": "TodoController.deleteTodo" + } + }, + "/todos": { + "post": { + "x-controller-name": "TodoController", + "x-operation-name": "createTodo", + "tags": [ + "TodoController" + ], + "responses": { + "200": { + "description": "Todo model instance", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewTodo" + } + } + } + }, + "operationId": "TodoController.createTodo" + }, + "get": { + "x-controller-name": "TodoController", + "x-operation-name": "findTodos", + "tags": [ + "TodoController" + ], + "responses": { + "200": { + "description": "Array of Todo model instances", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + } + }, + "parameters": [ + { + "name": "filter", + "in": "query", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Todo.Filter" + } + } + } + } + ], + "operationId": "TodoController.findTodos" + } + } + }, + "servers": [ + { + "url": "/" + } + ], + "components": { + "schemas": { + "Todo": { + "title": "Todo", + "properties": { + "id": { + "type": "number" + }, + "title": { + "type": "string" + }, + "desc": { + "type": "string" + }, + "isComplete": { + "type": "boolean" + }, + "remindAtAddress": { + "type": "string" + }, + "remindAtGeo": { + "type": "string" + }, + "tag": {} + }, + "required": [ + "title" + ], + "additionalProperties": false + }, + "NewTodo": { + "title": "NewTodo", + "description": "(tsType: Omit, schemaOptions: { title: 'NewTodo', exclude: [ 'id' ] })", + "properties": { + "title": { + "type": "string" + }, + "desc": { + "type": "string" + }, + "isComplete": { + "type": "boolean" + }, + "remindAtAddress": { + "type": "string" + }, + "remindAtGeo": { + "type": "string" + }, + "tag": {} + }, + "required": [ + "title" + ], + "additionalProperties": false, + "x-typescript-type": "Omit" + }, + "TodoPartial": { + "title": "TodoPartial", + "description": "(tsType: Partial, schemaOptions: { partial: true })", + "properties": { + "id": { + "type": "number" + }, + "title": { + "type": "string" + }, + "desc": { + "type": "string" + }, + "isComplete": { + "type": "boolean" + }, + "remindAtAddress": { + "type": "string" + }, + "remindAtGeo": { + "type": "string" + }, + "tag": {} + }, + "additionalProperties": false, + "x-typescript-type": "Partial" + }, + "Todo.Fields": { + "title": "Todo.Fields", + "type": "object", + "properties": { + "id": { + "type": "boolean" + }, + "title": { + "type": "boolean" + }, + "desc": { + "type": "boolean" + }, + "isComplete": { + "type": "boolean" + }, + "remindAtAddress": { + "type": "boolean" + }, + "remindAtGeo": { + "type": "boolean" + }, + "tag": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "Todo.Filter": { + "type": "object", + "title": "Todo.Filter", + "properties": { + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "example": 100 + }, + "skip": { + "type": "integer", + "minimum": 0 + }, + "order": { + "type": "array", + "items": { + "type": "string" + } + }, + "where": { + "title": "Todo.WhereFilter", + "type": "object", + "additionalProperties": true + }, + "fields": { + "$ref": "#/components/schemas/Todo.Fields" + } + }, + "additionalProperties": false, + "x-typescript-type": "@loopback/repository#Filter" + } + } + } +} + +`; + + +exports[`lb4 openapi-spec generates yaml spec with --out 1`] = ` +openapi: 3.0.0 +info: + title: '@loopback/example-todo' + version: 3.4.1 + description: Tutorial example on how to build an application with LoopBack 4. + contact: + name: IBM Corp. +paths: + '/todos/{id}': + put: + x-controller-name: TodoController + x-operation-name: replaceTodo + tags: + - TodoController + responses: + '204': + description: Todo PUT success + parameters: + - name: id + in: path + schema: + type: number + required: true + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Todo' + x-parameter-index: 1 + operationId: TodoController.replaceTodo + patch: + x-controller-name: TodoController + x-operation-name: updateTodo + tags: + - TodoController + responses: + '204': + description: Todo PATCH success + parameters: + - name: id + in: path + schema: + type: number + required: true + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TodoPartial' + x-parameter-index: 1 + operationId: TodoController.updateTodo + get: + x-controller-name: TodoController + x-operation-name: findTodoById + tags: + - TodoController + responses: + '200': + description: Todo model instance + content: + application/json: + schema: + $ref: '#/components/schemas/Todo' + parameters: + - name: id + in: path + schema: + type: number + required: true + - name: items + in: query + schema: + type: boolean + operationId: TodoController.findTodoById + delete: + x-controller-name: TodoController + x-operation-name: deleteTodo + tags: + - TodoController + responses: + '204': + description: Todo DELETE success + parameters: + - name: id + in: path + schema: + type: number + required: true + operationId: TodoController.deleteTodo + /todos: + post: + x-controller-name: TodoController + x-operation-name: createTodo + tags: + - TodoController + responses: + '200': + description: Todo model instance + content: + application/json: + schema: + $ref: '#/components/schemas/Todo' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/NewTodo' + operationId: TodoController.createTodo + get: + x-controller-name: TodoController + x-operation-name: findTodos + tags: + - TodoController + responses: + '200': + description: Array of Todo model instances + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Todo' + parameters: + - name: filter + in: query + content: + application/json: + schema: + $ref: '#/components/schemas/Todo.Filter' + operationId: TodoController.findTodos +servers: + - url: / +components: + schemas: + Todo: + title: Todo + properties: + id: + type: number + title: + type: string + desc: + type: string + isComplete: + type: boolean + remindAtAddress: + type: string + remindAtGeo: + type: string + tag: {} + required: + - title + additionalProperties: false + NewTodo: + title: NewTodo + description: >- + (tsType: Omit, schemaOptions: { title: 'NewTodo', exclude: [ + 'id' ] }) + properties: + title: + type: string + desc: + type: string + isComplete: + type: boolean + remindAtAddress: + type: string + remindAtGeo: + type: string + tag: {} + required: + - title + additionalProperties: false + x-typescript-type: 'Omit' + TodoPartial: + title: TodoPartial + description: '(tsType: Partial, schemaOptions: { partial: true })' + properties: + id: + type: number + title: + type: string + desc: + type: string + isComplete: + type: boolean + remindAtAddress: + type: string + remindAtGeo: + type: string + tag: {} + additionalProperties: false + x-typescript-type: Partial + Todo.Fields: + title: Todo.Fields + type: object + properties: + id: + type: boolean + title: + type: boolean + desc: + type: boolean + isComplete: + type: boolean + remindAtAddress: + type: boolean + remindAtGeo: + type: boolean + tag: + type: boolean + additionalProperties: false + Todo.Filter: + type: object + title: Todo.Filter + properties: + offset: + type: integer + minimum: 0 + limit: + type: integer + minimum: 1 + example: 100 + skip: + type: integer + minimum: 0 + order: + type: array + items: + type: string + where: + title: Todo.WhereFilter + type: object + additionalProperties: true + fields: + $ref: '#/components/schemas/Todo.Fields' + additionalProperties: false + x-typescript-type: '@loopback/repository#Filter' + +`; + + +exports[`lb4 openapi-spec generates yml spec with --out 1`] = ` +openapi: 3.0.0 +info: + title: '@loopback/example-todo' + version: 3.4.1 + description: Tutorial example on how to build an application with LoopBack 4. + contact: + name: IBM Corp. +paths: + '/todos/{id}': + put: + x-controller-name: TodoController + x-operation-name: replaceTodo + tags: + - TodoController + responses: + '204': + description: Todo PUT success + parameters: + - name: id + in: path + schema: + type: number + required: true + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Todo' + x-parameter-index: 1 + operationId: TodoController.replaceTodo + patch: + x-controller-name: TodoController + x-operation-name: updateTodo + tags: + - TodoController + responses: + '204': + description: Todo PATCH success + parameters: + - name: id + in: path + schema: + type: number + required: true + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TodoPartial' + x-parameter-index: 1 + operationId: TodoController.updateTodo + get: + x-controller-name: TodoController + x-operation-name: findTodoById + tags: + - TodoController + responses: + '200': + description: Todo model instance + content: + application/json: + schema: + $ref: '#/components/schemas/Todo' + parameters: + - name: id + in: path + schema: + type: number + required: true + - name: items + in: query + schema: + type: boolean + operationId: TodoController.findTodoById + delete: + x-controller-name: TodoController + x-operation-name: deleteTodo + tags: + - TodoController + responses: + '204': + description: Todo DELETE success + parameters: + - name: id + in: path + schema: + type: number + required: true + operationId: TodoController.deleteTodo + /todos: + post: + x-controller-name: TodoController + x-operation-name: createTodo + tags: + - TodoController + responses: + '200': + description: Todo model instance + content: + application/json: + schema: + $ref: '#/components/schemas/Todo' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/NewTodo' + operationId: TodoController.createTodo + get: + x-controller-name: TodoController + x-operation-name: findTodos + tags: + - TodoController + responses: + '200': + description: Array of Todo model instances + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Todo' + parameters: + - name: filter + in: query + content: + application/json: + schema: + $ref: '#/components/schemas/Todo.Filter' + operationId: TodoController.findTodos +servers: + - url: / +components: + schemas: + Todo: + title: Todo + properties: + id: + type: number + title: + type: string + desc: + type: string + isComplete: + type: boolean + remindAtAddress: + type: string + remindAtGeo: + type: string + tag: {} + required: + - title + additionalProperties: false + NewTodo: + title: NewTodo + description: >- + (tsType: Omit, schemaOptions: { title: 'NewTodo', exclude: [ + 'id' ] }) + properties: + title: + type: string + desc: + type: string + isComplete: + type: boolean + remindAtAddress: + type: string + remindAtGeo: + type: string + tag: {} + required: + - title + additionalProperties: false + x-typescript-type: 'Omit' + TodoPartial: + title: TodoPartial + description: '(tsType: Partial, schemaOptions: { partial: true })' + properties: + id: + type: number + title: + type: string + desc: + type: string + isComplete: + type: boolean + remindAtAddress: + type: string + remindAtGeo: + type: string + tag: {} + additionalProperties: false + x-typescript-type: Partial + Todo.Fields: + title: Todo.Fields + type: object + properties: + id: + type: boolean + title: + type: boolean + desc: + type: boolean + isComplete: + type: boolean + remindAtAddress: + type: boolean + remindAtGeo: + type: boolean + tag: + type: boolean + additionalProperties: false + Todo.Filter: + type: object + title: Todo.Filter + properties: + offset: + type: integer + minimum: 0 + limit: + type: integer + minimum: 1 + example: 100 + skip: + type: integer + minimum: 0 + order: + type: array + items: + type: string + where: + title: Todo.WhereFilter + type: object + additionalProperties: true + fields: + $ref: '#/components/schemas/Todo.Fields' + additionalProperties: false + x-typescript-type: '@loopback/repository#Filter' + +`; diff --git a/packages/cli/test/fixtures/openapi-spec/index.js b/packages/cli/test/fixtures/openapi-spec/index.js new file mode 100644 index 000000000000..f0072dcf4cdf --- /dev/null +++ b/packages/cli/test/fixtures/openapi-spec/index.js @@ -0,0 +1,14 @@ +const fs = require('fs'); + +exports.SANDBOX_FILES = [ + { + path: '.', + file: 'index.js', + content: fs.readFileSync(require.resolve('./index.js.txt')), + }, + { + path: '.', + file: 'openapi.json', + content: fs.readFileSync(require.resolve('./openapi.json')), + }, +]; diff --git a/packages/cli/test/fixtures/openapi-spec/index.js.txt b/packages/cli/test/fixtures/openapi-spec/index.js.txt new file mode 100644 index 000000000000..819f5433776a --- /dev/null +++ b/packages/cli/test/fixtures/openapi-spec/index.js.txt @@ -0,0 +1,16 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/example-rest-crud +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +exports.main = function () { + const app = { + restServer: { + getApiSpec() { + return require('./openapi.json'); + }, + }, + stop() {}, + }; + return app; +}; diff --git a/packages/cli/test/fixtures/openapi-spec/openapi.json b/packages/cli/test/fixtures/openapi-spec/openapi.json new file mode 100644 index 000000000000..406430c5b395 --- /dev/null +++ b/packages/cli/test/fixtures/openapi-spec/openapi.json @@ -0,0 +1,362 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "@loopback/example-todo", + "version": "3.4.1", + "description": "Tutorial example on how to build an application with LoopBack 4.", + "contact": { + "name": "IBM Corp." + } + }, + "paths": { + "/todos/{id}": { + "put": { + "x-controller-name": "TodoController", + "x-operation-name": "replaceTodo", + "tags": [ + "TodoController" + ], + "responses": { + "204": { + "description": "Todo PUT success" + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "number" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "x-parameter-index": 1 + }, + "operationId": "TodoController.replaceTodo" + }, + "patch": { + "x-controller-name": "TodoController", + "x-operation-name": "updateTodo", + "tags": [ + "TodoController" + ], + "responses": { + "204": { + "description": "Todo PATCH success" + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "number" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoPartial" + } + } + }, + "x-parameter-index": 1 + }, + "operationId": "TodoController.updateTodo" + }, + "get": { + "x-controller-name": "TodoController", + "x-operation-name": "findTodoById", + "tags": [ + "TodoController" + ], + "responses": { + "200": { + "description": "Todo model instance", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "number" + }, + "required": true + }, + { + "name": "items", + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "operationId": "TodoController.findTodoById" + }, + "delete": { + "x-controller-name": "TodoController", + "x-operation-name": "deleteTodo", + "tags": [ + "TodoController" + ], + "responses": { + "204": { + "description": "Todo DELETE success" + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "number" + }, + "required": true + } + ], + "operationId": "TodoController.deleteTodo" + } + }, + "/todos": { + "post": { + "x-controller-name": "TodoController", + "x-operation-name": "createTodo", + "tags": [ + "TodoController" + ], + "responses": { + "200": { + "description": "Todo model instance", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewTodo" + } + } + } + }, + "operationId": "TodoController.createTodo" + }, + "get": { + "x-controller-name": "TodoController", + "x-operation-name": "findTodos", + "tags": [ + "TodoController" + ], + "responses": { + "200": { + "description": "Array of Todo model instances", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + } + } + } + }, + "parameters": [ + { + "name": "filter", + "in": "query", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Todo.Filter" + } + } + } + } + ], + "operationId": "TodoController.findTodos" + } + } + }, + "servers": [ + { + "url": "/" + } + ], + "components": { + "schemas": { + "Todo": { + "title": "Todo", + "properties": { + "id": { + "type": "number" + }, + "title": { + "type": "string" + }, + "desc": { + "type": "string" + }, + "isComplete": { + "type": "boolean" + }, + "remindAtAddress": { + "type": "string" + }, + "remindAtGeo": { + "type": "string" + }, + "tag": {} + }, + "required": [ + "title" + ], + "additionalProperties": false + }, + "NewTodo": { + "title": "NewTodo", + "description": "(tsType: Omit, schemaOptions: { title: 'NewTodo', exclude: [ 'id' ] })", + "properties": { + "title": { + "type": "string" + }, + "desc": { + "type": "string" + }, + "isComplete": { + "type": "boolean" + }, + "remindAtAddress": { + "type": "string" + }, + "remindAtGeo": { + "type": "string" + }, + "tag": {} + }, + "required": [ + "title" + ], + "additionalProperties": false, + "x-typescript-type": "Omit" + }, + "TodoPartial": { + "title": "TodoPartial", + "description": "(tsType: Partial, schemaOptions: { partial: true })", + "properties": { + "id": { + "type": "number" + }, + "title": { + "type": "string" + }, + "desc": { + "type": "string" + }, + "isComplete": { + "type": "boolean" + }, + "remindAtAddress": { + "type": "string" + }, + "remindAtGeo": { + "type": "string" + }, + "tag": {} + }, + "additionalProperties": false, + "x-typescript-type": "Partial" + }, + "Todo.Fields": { + "title": "Todo.Fields", + "type": "object", + "properties": { + "id": { + "type": "boolean" + }, + "title": { + "type": "boolean" + }, + "desc": { + "type": "boolean" + }, + "isComplete": { + "type": "boolean" + }, + "remindAtAddress": { + "type": "boolean" + }, + "remindAtGeo": { + "type": "boolean" + }, + "tag": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "Todo.Filter": { + "type": "object", + "title": "Todo.Filter", + "properties": { + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "example": 100 + }, + "skip": { + "type": "integer", + "minimum": 0 + }, + "order": { + "type": "array", + "items": { + "type": "string" + } + }, + "where": { + "title": "Todo.WhereFilter", + "type": "object", + "additionalProperties": true + }, + "fields": { + "$ref": "#/components/schemas/Todo.Fields" + } + }, + "additionalProperties": false, + "x-typescript-type": "@loopback/repository#Filter" + } + } + } +} diff --git a/packages/cli/test/integration/generators/openapi-spec.integration.js b/packages/cli/test/integration/generators/openapi-spec.integration.js new file mode 100644 index 000000000000..be1ffc11e151 --- /dev/null +++ b/packages/cli/test/integration/generators/openapi-spec.integration.js @@ -0,0 +1,85 @@ +// Copyright IBM Corp. 2018,2020. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +const path = require('path'); +const testlab = require('@loopback/testlab'); + +const TestSandbox = testlab.TestSandbox; + +const generator = path.join(__dirname, '../../../generators/openapi-spec'); +const SANDBOX_FILES = require('../../fixtures/openapi-spec').SANDBOX_FILES; +const testUtils = require('../../test-utils'); +const {expectFileToMatchSnapshot} = require('../../snapshots'); + +// Test Sandbox +const sandbox = new TestSandbox(path.resolve(__dirname, '../.sandbox')); + +describe('lb4 openapi-spec', () => { + beforeEach('reset sandbox', async () => { + await sandbox.reset(); + }); + + it('generates json spec with --out', async () => { + await testUtils + .executeGenerator(generator) + .inDir(sandbox.path, () => + testUtils.givenLBProject(sandbox.path, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withOptions({out: 'spec.json'}); + expectFileToMatchSnapshot('spec.json'); + }); + + it('generates json spec with outFile prompt', async () => { + await testUtils + .executeGenerator(generator) + .inDir(sandbox.path, () => + testUtils.givenLBProject(sandbox.path, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withPrompts({outFile: 'spec.json'}); + expectFileToMatchSnapshot('spec.json'); + }); + + it('generates json spec to dist/openapi.json', async () => { + await testUtils + .executeGenerator(generator) + .inDir(sandbox.path, () => + testUtils.givenLBProject(sandbox.path, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withPrompts({}); + expectFileToMatchSnapshot('dist/openapi.json'); + }); + + it('generates yaml spec with --out', async () => { + await testUtils + .executeGenerator(generator) + .inDir(sandbox.path, () => + testUtils.givenLBProject(sandbox.path, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withOptions({out: 'spec.yaml'}); + expectFileToMatchSnapshot('spec.yaml'); + }); + + it('generates yml spec with --out', async () => { + await testUtils + .executeGenerator(generator) + .inDir(sandbox.path, () => + testUtils.givenLBProject(sandbox.path, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withOptions({out: 'spec.yml'}); + expectFileToMatchSnapshot('spec.yml'); + }); +});