From 5e6ee27e1e545be0b36bc24cd3813d6acb288a52 Mon Sep 17 00:00:00 2001 From: jannyHou Date: Fri, 5 Jan 2018 16:09:10 -0500 Subject: [PATCH] feat: upgrade types --- packages/authentication/package.json | 2 +- .../test/acceptance/basic-auth.ts | 2 +- .../controllers/ping.controller.ts.template | 2 +- .../controller-rest-template.ts.template | 2 +- .../generators/project/templates/package.json | 2 +- .../project/templates/package.plain.json | 2 +- packages/cli/test/app.js | 2 +- packages/cli/test/project.js | 4 +- packages/example-getting-started/package.json | 2 +- .../src/controllers/todo.controller.ts | 21 +- .../src/models/todo.model.ts | 2 +- packages/openapi-spec-builder/package.json | 2 +- .../src/openapi-spec-builder.ts | 16 +- packages/openapi-spec-types/.gitignore | 3 + .../.npmrc | 0 .../CHANGELOG.md | 0 .../LICENSE | 0 .../README.md | 30 +- .../docs.json | 0 .../index.d.ts | 2 +- .../index.js | 2 +- .../index.ts | 2 +- .../openapi-spec-types/openai-3-migration.md | 195 +++++ .../package.json | 7 +- .../src/index.ts | 5 +- .../src/v2/openapi-v2-spec-types.ts} | 0 .../src/v3/openapi-v3-spec-types.ts | 395 ++++++++++ .../src/v3/openapi-v3-type-guard.ts | 25 + .../tsconfig.build.json | 0 packages/openapi-v2/src/controller-spec.ts | 743 ------------------ packages/openapi-v2/src/index.ts | 6 - .../param-decorators/param-body.test.ts | 171 ---- .../param-decorators/param-form-data.test.ts | 89 --- .../param-decorators/param-header.test.ts | 89 --- .../param-decorators/param-path.test.ts | 89 --- .../param-decorators/param-query.test.ts | 89 --- .../param-decorators/param.test.ts | 517 ------------ packages/openapi-v3/.gitignore | 3 + packages/{openapi-v2 => openapi-v3}/.npmrc | 0 .../{openapi-v2 => openapi-v3}/CHANGELOG.md | 0 packages/{openapi-v2 => openapi-v3}/LICENSE | 2 +- packages/{openapi-v2 => openapi-v3}/README.md | 39 +- packages/{openapi-v2 => openapi-v3}/docs.json | 0 .../{openapi-v2 => openapi-v3}/index.d.ts | 2 +- packages/{openapi-v2 => openapi-v3}/index.js | 2 +- packages/{openapi-v2 => openapi-v3}/index.ts | 2 +- .../{openapi-v2 => openapi-v3}/package.json | 19 +- packages/openapi-v3/src/controller-spec.ts | 322 ++++++++ packages/openapi-v3/src/generate-schema.ts | 52 ++ packages/openapi-v3/src/index.ts | 11 + packages/openapi-v3/src/json-to-schema.ts | 88 +++ packages/openapi-v3/src/keys.ts | 12 + .../openapi-v3/src/parameter-decorator.ts | 469 +++++++++++ .../openapi-v3/src/request-body-decorator.ts | 52 ++ .../test/integration/operation-spec.ts | 60 ++ .../controller-decorators.test.ts | 69 +- .../param-decorators/param.shortcut.test.ts | 65 ++ .../param-decorators/param.test.ts | 215 +++++ .../request-body.test.ts | 97 +++ .../request-body-decorators/shortcut.test.ts | 94 +++ .../json-to-schema-object.test.ts | 13 +- .../tsconfig.build.json | 0 .../src/build-schema.ts | 1 + packages/rest/index.ts | 1 + packages/rest/package.json | 11 +- packages/rest/src/http-handler.ts | 8 +- packages/rest/src/index.ts | 2 +- packages/rest/src/parser.ts | 59 +- packages/rest/src/rest-server.ts | 49 +- packages/rest/src/router/routing-table.ts | 4 +- packages/rest/src/utils/url-generator.ts | 61 ++ .../acceptance/routing/routing.acceptance.ts | 7 +- .../sequence/sequence.acceptance.ts | 2 +- .../integration/http-handler.integration.ts | 82 +- .../integration/rest-server.integration.ts | 73 +- .../test/unit/backward-compatibility.test.ts | 2 +- packages/rest/test/unit/parser.test.ts | 28 +- .../rest-server.open-api-spec.test.ts | 24 +- .../test/unit/router/routing-table.test.ts | 14 +- packages/testlab/package.json | 6 +- packages/testlab/src/validate-api-spec.ts | 25 +- 81 files changed, 2604 insertions(+), 2063 deletions(-) create mode 100644 packages/openapi-spec-types/.gitignore rename packages/{openapi-spec => openapi-spec-types}/.npmrc (100%) rename packages/{openapi-spec => openapi-spec-types}/CHANGELOG.md (100%) rename packages/{openapi-spec => openapi-spec-types}/LICENSE (100%) rename packages/{openapi-spec => openapi-spec-types}/README.md (54%) rename packages/{openapi-spec => openapi-spec-types}/docs.json (100%) rename packages/{openapi-spec => openapi-spec-types}/index.d.ts (81%) rename packages/{openapi-spec => openapi-spec-types}/index.js (82%) rename packages/{openapi-spec => openapi-spec-types}/index.ts (85%) create mode 100644 packages/openapi-spec-types/openai-3-migration.md rename packages/{openapi-spec => openapi-spec-types}/package.json (87%) rename packages/{openapi-spec => openapi-spec-types}/src/index.ts (55%) rename packages/{openapi-spec/src/openapi-spec-v2.ts => openapi-spec-types/src/v2/openapi-v2-spec-types.ts} (100%) create mode 100644 packages/openapi-spec-types/src/v3/openapi-v3-spec-types.ts create mode 100644 packages/openapi-spec-types/src/v3/openapi-v3-type-guard.ts rename packages/{openapi-spec => openapi-spec-types}/tsconfig.build.json (100%) delete mode 100644 packages/openapi-v2/src/controller-spec.ts delete mode 100644 packages/openapi-v2/src/index.ts delete mode 100644 packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param-body.test.ts delete mode 100644 packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param-form-data.test.ts delete mode 100644 packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param-header.test.ts delete mode 100644 packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param-path.test.ts delete mode 100644 packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param-query.test.ts delete mode 100644 packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param.test.ts create mode 100644 packages/openapi-v3/.gitignore rename packages/{openapi-v2 => openapi-v3}/.npmrc (100%) rename packages/{openapi-v2 => openapi-v3}/CHANGELOG.md (100%) rename packages/{openapi-v2 => openapi-v3}/LICENSE (97%) rename packages/{openapi-v2 => openapi-v3}/README.md (73%) rename packages/{openapi-v2 => openapi-v3}/docs.json (100%) rename packages/{openapi-v2 => openapi-v3}/index.d.ts (83%) rename packages/{openapi-v2 => openapi-v3}/index.js (84%) rename packages/{openapi-v2 => openapi-v3}/index.ts (87%) rename packages/{openapi-v2 => openapi-v3}/package.json (71%) create mode 100644 packages/openapi-v3/src/controller-spec.ts create mode 100644 packages/openapi-v3/src/generate-schema.ts create mode 100644 packages/openapi-v3/src/index.ts create mode 100644 packages/openapi-v3/src/json-to-schema.ts create mode 100644 packages/openapi-v3/src/keys.ts create mode 100644 packages/openapi-v3/src/parameter-decorator.ts create mode 100644 packages/openapi-v3/src/request-body-decorator.ts create mode 100644 packages/openapi-v3/test/integration/operation-spec.ts rename packages/{openapi-v2/test/unit/controller-spec => openapi-v3/test/unit/controller-spec/controller-decorators}/controller-decorators.test.ts (84%) create mode 100644 packages/openapi-v3/test/unit/controller-spec/controller-decorators/param-decorators/param.shortcut.test.ts create mode 100644 packages/openapi-v3/test/unit/controller-spec/controller-decorators/param-decorators/param.test.ts create mode 100644 packages/openapi-v3/test/unit/controller-spec/controller-decorators/request-body-decorators/request-body.test.ts create mode 100644 packages/openapi-v3/test/unit/controller-spec/controller-decorators/request-body-decorators/shortcut.test.ts rename packages/{openapi-v2 => openapi-v3}/test/unit/controller-spec/json-to-schema-object.test.ts (92%) rename packages/{openapi-v2 => openapi-v3}/tsconfig.build.json (100%) create mode 100644 packages/rest/src/utils/url-generator.ts diff --git a/packages/authentication/package.json b/packages/authentication/package.json index 746aea76a06d..ae49043118c2 100644 --- a/packages/authentication/package.json +++ b/packages/authentication/package.json @@ -23,7 +23,7 @@ "dependencies": { "@loopback/context": "^4.0.0-alpha.31", "@loopback/core": "^4.0.0-alpha.33", - "@loopback/openapi-v2": "^4.0.0-alpha.10", + "@loopback/openapi-v3": "^4.0.0-alpha.1", "@loopback/rest": "^4.0.0-alpha.25", "passport": "^0.4.0", "passport-strategy": "^1.0.0" diff --git a/packages/authentication/test/acceptance/basic-auth.ts b/packages/authentication/test/acceptance/basic-auth.ts index 74f8c2cd47f8..4848498afcc9 100644 --- a/packages/authentication/test/acceptance/basic-auth.ts +++ b/packages/authentication/test/acceptance/basic-auth.ts @@ -17,7 +17,7 @@ import { RestServer, RestComponent, } from '@loopback/rest'; -import {api, get} from '@loopback/openapi-v2'; +import {api, get} from '@loopback/openapi-v3'; import {Client, createClientForHandler} from '@loopback/testlab'; import {anOpenApiSpec} from '@loopback/openapi-spec-builder'; import {inject, Provider, ValueOrPromise} from '@loopback/context'; diff --git a/packages/cli/generators/app/templates/src/controllers/ping.controller.ts.template b/packages/cli/generators/app/templates/src/controllers/ping.controller.ts.template index 08f2ccd32019..32c92378131e 100644 --- a/packages/cli/generators/app/templates/src/controllers/ping.controller.ts.template +++ b/packages/cli/generators/app/templates/src/controllers/ping.controller.ts.template @@ -1,5 +1,5 @@ import {ServerRequest} from '@loopback/rest'; -import {get} from '@loopback/openapi-v2'; +import {get} from '@loopback/openapi-v3'; import {inject} from '@loopback/context'; /** diff --git a/packages/cli/generators/controller/templates/src/controllers/controller-rest-template.ts.template b/packages/cli/generators/controller/templates/src/controllers/controller-rest-template.ts.template index 803333101cda..8762dfeed993 100644 --- a/packages/cli/generators/controller/templates/src/controllers/controller-rest-template.ts.template +++ b/packages/cli/generators/controller/templates/src/controllers/controller-rest-template.ts.template @@ -1,5 +1,5 @@ import {Filter, Where} from '@loopback/repository'; -import {post, param, get, put, patch, del} from '@loopback/openapi-v2'; +import {post, param, get, put, patch, del} from '@loopback/openapi-v3'; import {inject} from '@loopback/context'; import {<%= modelName %>} from '../models'; import {<%= repositoryName %>} from '../repositories'; diff --git a/packages/cli/generators/project/templates/package.json b/packages/cli/generators/project/templates/package.json index 475ac79042e3..d346aa1c7d5f 100644 --- a/packages/cli/generators/project/templates/package.json +++ b/packages/cli/generators/project/templates/package.json @@ -61,7 +61,7 @@ <% if (project.projectType === 'application') { -%> "@loopback/core": ">=4.0.0-alpha.20", "@loopback/rest": ">=4.0.0-alpha.7", - "@loopback/openapi-v2": ">=4.0.0-alpha.2" + "@loopback/openapi-v3": ">=4.0.0-alpha.1" <% } else { -%> "@loopback/core": ">=4.0.0-alpha.20" <% } -%> diff --git a/packages/cli/generators/project/templates/package.plain.json b/packages/cli/generators/project/templates/package.plain.json index 083522a368af..72a0b152f750 100644 --- a/packages/cli/generators/project/templates/package.plain.json +++ b/packages/cli/generators/project/templates/package.plain.json @@ -61,7 +61,7 @@ <% if (project.projectType === 'application') { -%> "@loopback/core": ">=4.0.0-alpha.20", "@loopback/rest": ">=4.0.0-alpha.7", - "@loopback/openapi-v2": ">=4.0.0-alpha.2" + "@loopback/openapi-v3": ">=4.0.0-alpha.1" <% } else { -%> "@loopback/core": ">=4.0.0-alpha.20" <% } -%> diff --git a/packages/cli/test/app.js b/packages/cli/test/app.js index 920ed0421326..530fdcddc26c 100644 --- a/packages/cli/test/app.js +++ b/packages/cli/test/app.js @@ -49,7 +49,7 @@ describe('app-generator specfic files', () => { assert.fileContent('src/controllers/ping.controller.ts', /ping\(\)/); assert.fileContent( 'src/controllers/ping.controller.ts', - /\'\@loopback\/openapi\-v2\'/ + /\'\@loopback\/openapi\-v3\'/ ); assert.file; diff --git a/packages/cli/test/project.js b/packages/cli/test/project.js index 4e3ef7b50c9c..017aa67b978d 100644 --- a/packages/cli/test/project.js +++ b/packages/cli/test/project.js @@ -211,7 +211,7 @@ module.exports = function(projGenerator, props, projectType) { assert.fileContent('package.json', '"@loopback/core"'); assert.fileContent('package.json', '"@loopback/context"'); assert.fileContent('package.json', '"@loopback/rest"'); - assert.fileContent('package.json', '"@loopback/openapi-v2"'); + assert.fileContent('package.json', '"@loopback/openapi-v3"'); assert.jsonFileContent('package.json', { scripts: { start: 'npm run build && node .', @@ -222,7 +222,7 @@ module.exports = function(projGenerator, props, projectType) { assert.fileContent('package.json', '"@loopback/core"'); assert.fileContent('package.json', '"@loopback/context"'); assert.noFileContent('package.json', '"@loopback/rest"'); - assert.noFileContent('package.json', '"@loopback/openapi-v2"'); + assert.noFileContent('package.json', '"@loopback/openapi-v3"'); assert.noJsonFileContent('package.json', { start: 'npm run build && node .', }); diff --git a/packages/example-getting-started/package.json b/packages/example-getting-started/package.json index 0b5eabf0723e..25f0cb299602 100644 --- a/packages/example-getting-started/package.json +++ b/packages/example-getting-started/package.json @@ -28,7 +28,7 @@ "@loopback/context": "^4.0.0-alpha.31", "@loopback/core": "^4.0.0-alpha.33", "@loopback/openapi-spec": "^4.0.0-alpha.25", - "@loopback/openapi-v2": "^4.0.0-alpha.10", + "@loopback/openapi-v3": "^4.0.0-alpha.1", "@loopback/repository": "^4.0.0-alpha.29", "@loopback/rest": "^4.0.0-alpha.25" }, diff --git a/packages/example-getting-started/src/controllers/todo.controller.ts b/packages/example-getting-started/src/controllers/todo.controller.ts index 2e58030c68fa..3dd188a84162 100644 --- a/packages/example-getting-started/src/controllers/todo.controller.ts +++ b/packages/example-getting-started/src/controllers/todo.controller.ts @@ -1,4 +1,12 @@ -import {post, param, get, put, patch, del} from '@loopback/openapi-v2'; +import { + post, + param, + get, + put, + patch, + del, + requestBody, +} from '@loopback/openapi-v3'; import {HttpErrors} from '@loopback/rest'; import {TodoSchema, Todo} from '../models'; import {repository} from '@loopback/repository'; @@ -13,7 +21,10 @@ export class TodoController { ) {} @post('/todo') async createTodo( - @param.body('todo', TodoSchema) + // TODO(janny) The requestBody Spec will be empty after we + // 1. update to generate schema from bottom-up + // 2. decide the default content type + @requestBody({content: {'application/json': {schema: TodoSchema}}}) todo: Todo, ) { // TODO(bajtos) This should be handled by the framework @@ -40,7 +51,7 @@ export class TodoController { @put('/todo/{id}') async replaceTodo( @param.path.number('id') id: number, - @param.body('todo', TodoSchema) + @requestBody({content: {'application/json': {schema: TodoSchema}}}) todo: Todo, ): Promise { // REST adapter does not coerce parameter values coming from string sources @@ -48,14 +59,13 @@ export class TodoController { // ourselves. // See https://github.com/strongloop/loopback-next/issues/750 id = +id; - return await this.todoRepo.replaceById(id, todo); } @patch('/todo/{id}') async updateTodo( @param.path.number('id') id: number, - @param.body('todo', TodoSchema) + @requestBody({content: {'application/json': {schema: TodoSchema}}}) todo: Todo, ): Promise { // REST adapter does not coerce parameter values coming from string sources @@ -63,7 +73,6 @@ export class TodoController { // ourselves. // See https://github.com/strongloop/loopback-next/issues/750 id = +id; - return await this.todoRepo.updateById(id, todo); } diff --git a/packages/example-getting-started/src/models/todo.model.ts b/packages/example-getting-started/src/models/todo.model.ts index 43a092bd481a..9e2ed8b791c5 100644 --- a/packages/example-getting-started/src/models/todo.model.ts +++ b/packages/example-getting-started/src/models/todo.model.ts @@ -1,5 +1,5 @@ import {Entity, property, model} from '@loopback/repository'; -import {SchemaObject} from '@loopback/openapi-spec'; +import {SchemaObject} from '@loopback/openapi-spec-types'; @model() export class Todo extends Entity { diff --git a/packages/openapi-spec-builder/package.json b/packages/openapi-spec-builder/package.json index 4731f1980fd2..bafb32ace89c 100644 --- a/packages/openapi-spec-builder/package.json +++ b/packages/openapi-spec-builder/package.json @@ -23,7 +23,7 @@ "Testing" ], "dependencies": { - "@loopback/openapi-spec": "^4.0.0-alpha.25" + "@loopback/openapi-spec-types": "^4.0.0-alpha.1" }, "devDependencies": { "@loopback/build": "^4.0.0-alpha.13" diff --git a/packages/openapi-spec-builder/src/openapi-spec-builder.ts b/packages/openapi-spec-builder/src/openapi-spec-builder.ts index 39946535ac14..d62668f6c681 100644 --- a/packages/openapi-spec-builder/src/openapi-spec-builder.ts +++ b/packages/openapi-spec-builder/src/openapi-spec-builder.ts @@ -11,7 +11,8 @@ import { ResponseObject, ParameterObject, createEmptyApiSpec, -} from '@loopback/openapi-spec'; + RequestBodyObject, +} from '@loopback/openapi-spec-types'; /** * Create a new instance of OpenApiSpecBuilder. @@ -137,7 +138,13 @@ export class OperationSpecBuilder extends BuilderBase { withStringResponse(status: number | 'default' = 200): this { return this.withResponse(status, { description: 'The string result.', - schema: {type: 'string'}, + content: { + // TODO(janny) will change it to a default value + // after we figure out the plan for content type + '*/*': { + schema: {type: 'string'}, + }, + }, }); } @@ -154,6 +161,11 @@ export class OperationSpecBuilder extends BuilderBase { return this; } + withRequestBody(requestBodySpec: RequestBodyObject): this { + this._spec.requestBody = requestBodySpec; + return this; + } + /** * Define the operation name (controller method name). * diff --git a/packages/openapi-spec-types/.gitignore b/packages/openapi-spec-types/.gitignore new file mode 100644 index 000000000000..90a8d96cc3ff --- /dev/null +++ b/packages/openapi-spec-types/.gitignore @@ -0,0 +1,3 @@ +*.tgz +dist* +package diff --git a/packages/openapi-spec/.npmrc b/packages/openapi-spec-types/.npmrc similarity index 100% rename from packages/openapi-spec/.npmrc rename to packages/openapi-spec-types/.npmrc diff --git a/packages/openapi-spec/CHANGELOG.md b/packages/openapi-spec-types/CHANGELOG.md similarity index 100% rename from packages/openapi-spec/CHANGELOG.md rename to packages/openapi-spec-types/CHANGELOG.md diff --git a/packages/openapi-spec/LICENSE b/packages/openapi-spec-types/LICENSE similarity index 100% rename from packages/openapi-spec/LICENSE rename to packages/openapi-spec-types/LICENSE diff --git a/packages/openapi-spec/README.md b/packages/openapi-spec-types/README.md similarity index 54% rename from packages/openapi-spec/README.md rename to packages/openapi-spec-types/README.md index 073d8f45a90d..93af7cd00d74 100644 --- a/packages/openapi-spec/README.md +++ b/packages/openapi-spec-types/README.md @@ -7,18 +7,20 @@ TypeScript type definitions for OpenAPI Spec/Swagger documents. TypeScript definitions describing the schema of OpenAPI/Swagger documents, including LoopBack-specific extensions. +_@jannyHou: will add more doc here to specify the version of types after we decide how to support new versions/keep old versions_ + ## Installation ``` -$ npm install --save @loopback/openapi-spec +$ npm install --save @loopback/openapi-spec-types ``` ## Basic use -Use `OpenApiSpec` type in your function accepting a Swagger/OpenAPI document: +Use `OpenApiSpec` type in your function accepting an OpenAPI document: ```ts -import {OpenApiSpec} from '@loopback/openapi-spec'; +import {OpenApiSpec} from '@loopback/openapi-spec-types'; export function validateSpec(spec: OpenApiSpec) { // ... @@ -30,7 +32,7 @@ when constructing a spec argument value. ## Related resources -See https://www.openapis.org/ and [version 2.0](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md) +See https://www.openapis.org/ and [version 3.0.0](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md) of OpenAPI Specification. ## Contributions @@ -48,3 +50,23 @@ See [all contributors](https://github.com/strongloop/loopback-next/graphs/contri # License MIT + +# Question + +Do we want multiple repos for types? + +option 1 +- openapi-spec-types +- swagger-spec-types + +option 2 +- openapi-spec-types + - /v2 + - /v3 + + +Benefit of single repo: + +* easy to extract common suger interfaces +* less packages to maintain, clear layout to manage different versions +* if we only support one version, export types from one package, no need to update each dependant diff --git a/packages/openapi-spec/docs.json b/packages/openapi-spec-types/docs.json similarity index 100% rename from packages/openapi-spec/docs.json rename to packages/openapi-spec-types/docs.json diff --git a/packages/openapi-spec/index.d.ts b/packages/openapi-spec-types/index.d.ts similarity index 81% rename from packages/openapi-spec/index.d.ts rename to packages/openapi-spec-types/index.d.ts index dba88ee552d1..c7cb659f8a84 100644 --- a/packages/openapi-spec/index.d.ts +++ b/packages/openapi-spec-types/index.d.ts @@ -1,5 +1,5 @@ // Copyright IBM Corp. 2017,2018. All Rights Reserved. -// Node module: @loopback/openapi-spec +// Node module: @loopback/openapi-spec-types // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT diff --git a/packages/openapi-spec/index.js b/packages/openapi-spec-types/index.js similarity index 82% rename from packages/openapi-spec/index.js rename to packages/openapi-spec-types/index.js index 5326c63c1525..dcf3ae457598 100644 --- a/packages/openapi-spec/index.js +++ b/packages/openapi-spec-types/index.js @@ -1,5 +1,5 @@ // Copyright IBM Corp. 2017,2018. All Rights Reserved. -// Node module: @loopback/openapi-spec +// Node module: @loopback/openapi-spec-types // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT diff --git a/packages/openapi-spec/index.ts b/packages/openapi-spec-types/index.ts similarity index 85% rename from packages/openapi-spec/index.ts rename to packages/openapi-spec-types/index.ts index 6b8d6c7713b5..72ed78355f09 100644 --- a/packages/openapi-spec/index.ts +++ b/packages/openapi-spec-types/index.ts @@ -1,5 +1,5 @@ // Copyright IBM Corp. 2017,2018. All Rights Reserved. -// Node module: @loopback/openapi-spec +// Node module: @loopback/openapi-spec-types // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT diff --git a/packages/openapi-spec-types/openai-3-migration.md b/packages/openapi-spec-types/openai-3-migration.md new file mode 100644 index 000000000000..51023488b595 --- /dev/null +++ b/packages/openapi-spec-types/openai-3-migration.md @@ -0,0 +1,195 @@ +# Migration + +## @param + +### Remove method level @param + +Related discussion see https://github.com/strongloop/loopback-next/pull/940#discussion_r165409785 + +### parameter location + +In v3 the valid parameter locations is: 'query', 'path', 'header', 'cookie' + +## @requestBody + +Copied from official document: + +https://swagger.io/docs/specification/describing-request-body/ + +* Body and form parameters are replaced with requestBody. +* Operations can now consume both form data and other media types such as JSON. +* The consumes array is replaced with the requestBody.content map which maps the media types to their schemas. +* Schemas can vary by media type. + anyOf and oneOf can be used to specify alternate schemas. +* Form data can now contain objects, and you can specify the serialization strategy for objects and arrays. +* GET, DELETE and HEAD are no longer allowed to have request body because it does not have defined semantics as per RFC 7231. + +Our v3 decorator in this PR: + +```js +// v2 decorator + +@model() +class Note { + @property() message: string +} + +class MyController { + @post('/note') + create(@param.body() note: Note) { + + } +} +``` + +is refactorted to + +```js +// v3 decorator + +@model() +class Note { + @property() message: string +} + +class MyController { + @post('/note') + create(@requestBody() note: Note) { + + } +} +``` + +## content type + +### responses object supports multiple content type + +```js +// v2 response object +{ + '200': { + description: 'a response', + schema: {...schemaSpec} + } +} +``` + +is refactored to: + +```js +// v3 response object +{ + '200': { + content: { + '*/*': { + description: 'a response', + schema: {...schemaSpec} + } + } + } +} +``` + +### requestBody object supports multiple content type + +```js +// v3 requestBody object +{ + description: 'request body spec', + content: { + 'application/json': { + schema: {...schemaSpec} + } + } +} +``` + + +Discussion: + +If people decorate an argument with no input as `@requestBody() foo: Foo`, +I make `application/json` as the default content, is it ok? + + +## common name for OpenAPI data types + +reference link: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0md#data-types + +I created shortcut for `@param` accordingly, if we are good with them, +I will create same shortcuts for `@requestBody` too. + +### server + +```js +// v2 swagger spec +{ + basePath: '/', + port: '3000', + host: 'localhost' +} +``` + +is refactored to: + +```js +// v3 swagger spec +{ + servers: [ + {url: 'localhost:3000/'} + ] +} +``` + +### response spec + +see section #content-type + +### validator + +In package `testlab`, we switch to openapi 3 validator provided by + +[swagger2openapi/validate.js](https://github.com/Mermade/swagger2openapi/blob/master/validate.js) + + +### components/schemas + +```js +// v2 swagger spec +{ + definitions: { + Pet: {...PetSpec} + } +} +``` + +is refactored to: + +```js +// v3 swagger spec +{ + components: { + schemas: { + Pet: {...PetSpec} + } + } +} +``` + + +# Features + +## Parameter Serialization + +* Complex Serialization in Form Data +* Serialization in parameter + +## Security Schema + +## Restriction for GET, DELETE and HEAD + +They are no longer allowed to have request body because it does not have defined semantics as per RFC 7231. + +## additional properties + +Dictionaries, HashMaps and Associative Arrays +https://swagger.io/docs/specification/data-models/dictionaries/ diff --git a/packages/openapi-spec/package.json b/packages/openapi-spec-types/package.json similarity index 87% rename from packages/openapi-spec/package.json rename to packages/openapi-spec-types/package.json index 3b5a1ceef8bc..918c2f10ba2e 100644 --- a/packages/openapi-spec/package.json +++ b/packages/openapi-spec-types/package.json @@ -1,10 +1,13 @@ { - "name": "@loopback/openapi-spec", - "version": "4.0.0-alpha.25", + "name": "@loopback/openapi-spec-types", + "version": "4.0.0-alpha.1", "description": "TypeScript type definitions for OpenAPI Spec/Swagger documents.", "engines": { "node": ">=8" }, + "dependencies": { + "openapi3-ts": "^0.7.0" + }, "devDependencies": { "@loopback/build": "^4.0.0-alpha.13" }, diff --git a/packages/openapi-spec/src/index.ts b/packages/openapi-spec-types/src/index.ts similarity index 55% rename from packages/openapi-spec/src/index.ts rename to packages/openapi-spec-types/src/index.ts index 257cc5f582ec..3dd185daf674 100644 --- a/packages/openapi-spec/src/index.ts +++ b/packages/openapi-spec-types/src/index.ts @@ -1,6 +1,7 @@ // Copyright IBM Corp. 2017,2018. All Rights Reserved. -// Node module: @loopback/openapi-spec +// Node module: @loopback/openapi-spec-types // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -export * from './openapi-spec-v2'; +export * from './v3/openapi-v3-spec-types'; +export * from './v3/openapi-v3-type-guard'; diff --git a/packages/openapi-spec/src/openapi-spec-v2.ts b/packages/openapi-spec-types/src/v2/openapi-v2-spec-types.ts similarity index 100% rename from packages/openapi-spec/src/openapi-spec-v2.ts rename to packages/openapi-spec-types/src/v2/openapi-v2-spec-types.ts diff --git a/packages/openapi-spec-types/src/v3/openapi-v3-spec-types.ts b/packages/openapi-spec-types/src/v3/openapi-v3-spec-types.ts new file mode 100644 index 000000000000..63081ae35940 --- /dev/null +++ b/packages/openapi-spec-types/src/v3/openapi-v3-spec-types.ts @@ -0,0 +1,395 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-spec-types +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +/* + * OpenApiSpec - A typescript representation of OpenApi 3.0.0 + */ + +// tslint:disable:max-line-length + +import * as OAS3 from 'openapi3-ts'; +// Export spec interfaces from the community module if missing in our package +export * from 'openapi3-ts'; + +export type OpenApiSpec = OAS3.OpenAPIObject; +/** + * Custom extensions can use arbitrary type as the value, + * e.g. a string, an object or an array. + */ +// tslint:disable-next-line:no-any +export type ExtensionValue = any; + +/** + * The location of a parameter. + * Possible values are "query", "header", "path" or "body". + *

Specification: + * https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md + * search "The location of the parameter" + */ +export type ParameterLocation = 'query' | 'header' | 'path' | 'cookie'; + +/** + * The style of a parameter. + * Describes how the parameter value will be serialized. + * (serialization is not implemented yet) + *

Specification: + * https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#responseObject + */ +export type ParameterStyle = + | 'matrix' + | 'label' + | 'form' + | 'simple' + | 'spaceDelimited' + | 'pipeDelimited' + | 'deepObject'; + +/** + * The Schema Object allows the definition of input and output data types. + * the properties consiste of two parts: + * - taken directly from the JSON Schema, is described by interface `JSONType` + * - taken from the JSON Schema, but definitions were adjusted to the + * OpenAPI Specification, is described by interface `OAS3SchemaObject` + */ +export interface SchemaObject extends JSONType, OAS3SchemaObject {} + +/** + * Part of OpenAPI Schema Object, The following properties are taken from the + * JSON Schema definition but their definitions were adjusted to the OpenAPI + * Specification. + */ +export interface OAS3SchemaObject extends ISpecificationExtension { + nullable?: boolean; + discriminator?: DiscriminatorObject; + readOnly?: boolean; + writeOnly?: boolean; + xml?: XmlObject; + externalDocs?: ExternalDocumentationObject; + example?: ExtensionValue; + examples?: ExtensionValue[]; + deprecated?: boolean; + + type?: string; + allOf?: (SchemaObject | ReferenceObject)[]; + oneOf?: (SchemaObject | ReferenceObject)[]; + anyOf?: (SchemaObject | ReferenceObject)[]; + not?: SchemaObject | ReferenceObject; + items?: SchemaObject | ReferenceObject; + properties?: {[propertyName: string]: SchemaObject | ReferenceObject}; + additionalProperties?: SchemaObject | ReferenceObject; + description?: string; + format?: string; + default?: ExtensionValue; +} + +/** + * JSON type - This is part of the Schema object. + * The following properties are taken directly from the JSON Schema + * definition and follow the same specifications. + * See https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#schema-object + */ + +export type JSONType = { + title?: string; + multipleOf?: number; + maximum?: number; + exclusiveMaximum?: number; + minimum?: number; + exclusiveMinimum?: number; + maxLength?: number; + minLength?: number; + // (This string SHOULD be a valid regular expression, according to the ECMA 262 regular expression dialect) + pattern?: string; + maxItems?: number; + minItems?: number; + uniqueItems?: boolean; + maxProperties?: number; + minProperties?: number; + enum?: Array; +}; + +/** + * Describes a single request body. + *

Specification: + * https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#request-body-object + */ +export interface RequestBodyObject extends ISpecificationExtension { + description?: string; + content: ContentObject; + required?: boolean; +} + +/** + * Describes an object of multiple content types. + * For example: + * ```js + * { + * 'application/json': { + * schema: {...schemaObjectSpec} + * }, + * 'application/text': { + * schema: {...schemaObjectSpec} + * } + * } + * ``` + */ +export interface ContentObject { + [mediatype: string]: MediaTypeObject; +} + +/** + * Each Media Type Object provides schema and examples for the media type + * identified by its key. + *

Specification: + * https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#media-type-object + */ +export interface MediaTypeObject extends ISpecificationExtension { + schema?: SchemaObject | ReferenceObject; + examples?: [ExampleObject | ReferenceObject]; + example?: ExampleObject | ReferenceObject; + encoding?: EncodingObject; +} + +/** + * Describes an encoding object, copied from 'openapi3-ts' due to overriding + * its `ParameterObject` + */ +export interface EncodingObject extends ISpecificationExtension { + // [property: string]: EncodingPropertyObject; + [property: string]: EncodingPropertyObject | ExtensionValue; // Hack for allowing ISpecificationExtension +} + +/** + * Describes an encoding object, copied from 'openapi3-ts' due to overriding + * its `ParameterObject` + */ +export interface EncodingPropertyObject { + contentType?: string; + headers?: {[key: string]: HeaderObject | ReferenceObject}; + style?: string; + explode?: boolean; + allowReserved?: boolean; + [key: string]: ExtensionValue; // (any) = Hack for allowing ISpecificationExtension +} + +/** + * Describes an encoding object, copied from 'openapi3-ts' due to overriding + * its `ParameterObject` + */ +export interface HeaderObject extends ParameterObject {} + +/** + * Describes a single operation parameter. + * A unique parameter is defined by a combination of a name and location. + *

Specification + * https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#parameter-object + */ +export interface ParameterObject extends ISpecificationExtension { + name: string; + in: ParameterLocation; + description?: string; + required?: boolean; + deprecated?: boolean; + allowEmptyValue?: boolean; + + style?: ParameterStyle; + explode?: boolean; + allowReserved?: boolean; + schema?: SchemaObject | ReferenceObject; + examples?: {[param: string]: ExampleObject | ReferenceObject}; + example?: ExtensionValue; + content?: ContentObject; +} + +/** + * Describes an encoding object, copied from 'openapi3-ts' due to overriding + * its `ParameterObject` + */ +export interface ExampleObject { + summary?: string; + description?: string; + value?: ExtensionValue; + externalValue?: string; + [property: string]: ExtensionValue; // Hack for allowing ISpecificationExtension +} + +export interface ReferenceObject { + $ref: string; +} + +/** + * Describes an encoding object, copied from 'openapi3-ts' due to overriding + * its `SchemaObject` + */ +export interface DiscriminatorObject { + propertyName: string; + mapping?: {[key: string]: string}; +} + +/** + * Describes an encoding object, copied from 'openapi3-ts' due to overriding + * its `SchemaObject` + */ +export interface XmlObject extends ISpecificationExtension { + name?: string; + namespace?: string; + prefix?: string; + attribute?: boolean; + wrapped?: boolean; +} + +/** + * Describes an encoding object, copied from 'openapi3-ts' due to overriding + * its `SchemaObject` + */ +export interface ExternalDocumentationObject extends ISpecificationExtension { + description?: string; + url: string; +} + +// Specification Extensions +// ^x- +export interface ISpecificationExtension { + // Cannot constraint to "^x-" but can filter them later to access to them + [extensionName: string]: ExtensionValue; +} + +/** + * Maps names to a given type of values + */ +export interface MapObject { + /** + * Maps between a name and object + */ + [name: string]: T; +} + +/** + * Lists the available scopes for an OAuth2 security scheme. + */ +export interface ScopesObject + extends MapObject, + ISpecificationExtension { + /** + * Maps between a name of a scope to a short description of it (as the value + * of the property). + */ + [name: string]: string; +} + +/** + * Copied from swagger 2 spec, don't find our code use it. + * A declaration of the security schemes available to be used in the + * specification. This does not enforce the security schemes on the operations + * and only serves to provide the relevant details for each scheme. + */ +export interface SecurityDefinitionsObject + extends MapObject { + /** + * A single security scheme definition, mapping a "name" to the scheme it + * defines. + */ + [name: string]: OAS3.SecuritySchemeObject; +} + +/** + * Copied from swagger 2 spec, don't find our code uses it. + * An object to hold parameters to be reused across operations. Parameter + * definitions can be referenced to the ones defined here. + * + * This does not define global operation parameters. + */ +export interface ParametersDefinitionsObject + extends MapObject { + /** + * A single parameter definition, mapping a "name" to the parameter it + * defines. + */ + [name: string]: OAS3.ParameterObject; +} + +/** + * Copied from swagger 2 spec, don't find our code uses it. + * An object to hold responses to be reused across operations. Response + * definitions can be referenced to the ones defined here. + * + * This does not define global operation responses. + */ +export interface ResponsesDefinitionsObject + extends MapObject { + /** + * A single response definition, mapping a "name" to the response it defines. + */ + [name: string]: OAS3.ResponseObject; +} + +/** + * A container for the expected responses of an operation. + * The container maps a HTTP response code to the expected response. + * It is not expected from the documentation to necessarily cover all + * possible HTTP response codes, since they may not be known in advance. + * However, it is expected from the documentation to cover a successful + * operation response and any known errors. + *

The `default` can be used as the default response object for all + * HTTP codes that are not covered individually by the specification. + *

The `ResponsesObject` MUST contain at least one response code, + * and it SHOULD be the response for a successful operation call. + *

Specification: + * https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#responsesObject + */ +export interface ResponsesObject + extends MapObject, + ISpecificationExtension { + /** + * The documentation of responses other than the ones declared for specific + * HTTP response codes. It can be used to cover undeclared responses. + * Reference Object can be used to link to a response that is defined at + * the Swagger Object's responses section. + */ + default?: OAS3.ResponseObject | OAS3.ReferenceObject; +} + +/** + * Lists the headers that can be sent as part of a response. + */ +export interface HeadersObject extends MapObject { + /** + * The name of the property corresponds to the name of the header. The value + * describes the type of the header. + */ + [name: string]: OAS3.HeaderObject; +} + +/** + * Holds the relative paths to the individual endpoints. + * The path is appended to the basePath in order to construct the full URL. + * The Paths may be empty, due to ACL constraints. + *

Specification: + * https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#paths-object + */ +export interface PathsObject + extends MapObject< + OAS3.PathItemObject | OAS3.ReferenceObject | ExtensionValue + > { + [httpPathOrSwaggerExtension: string]: + | OAS3.PathItemObject + | OAS3.ReferenceObject + | ExtensionValue; +} + +/** + * Create an empty OpenApiSpec object that's still a valid openapi document. + */ +export function createEmptyApiSpec(): OpenApiSpec { + return { + openapi: '3.0.0', + info: { + title: 'LoopBack Application', + version: '1.0.0', + }, + paths: {}, + servers: [{url: '/'}], + }; +} diff --git a/packages/openapi-spec-types/src/v3/openapi-v3-type-guard.ts b/packages/openapi-spec-types/src/v3/openapi-v3-type-guard.ts new file mode 100644 index 000000000000..dbc505a1b6ba --- /dev/null +++ b/packages/openapi-spec-types/src/v3/openapi-v3-type-guard.ts @@ -0,0 +1,25 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-spec-types +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + SchemaObject, + ReferenceObject, + ExtensionValue, +} from './openapi-v3-spec-types'; + +/** + * Type guard for OpenAPI 3.0.0 schema object + * @param schema An OpenAPI 3.0.0 schema object + */ + +export function isSchemaObject( + schema: SchemaObject | ReferenceObject, +): schema is SchemaObject { + return !schema.hasOwnProperty('$ref'); +} + +export function isReferenceObject(obj: ExtensionValue): obj is ReferenceObject { + return obj.hasOwnProperty('$ref'); +} diff --git a/packages/openapi-spec/tsconfig.build.json b/packages/openapi-spec-types/tsconfig.build.json similarity index 100% rename from packages/openapi-spec/tsconfig.build.json rename to packages/openapi-spec-types/tsconfig.build.json diff --git a/packages/openapi-v2/src/controller-spec.ts b/packages/openapi-v2/src/controller-spec.ts deleted file mode 100644 index 1d9a885c742d..000000000000 --- a/packages/openapi-v2/src/controller-spec.ts +++ /dev/null @@ -1,743 +0,0 @@ -// Copyright IBM Corp. 2018. All Rights Reserved. -// Node module: @loopback/openapi-v2 -// This file is licensed under the MIT License. -// License text available at https://opensource.org/licenses/MIT - -import { - MetadataInspector, - ClassDecoratorFactory, - MethodDecoratorFactory, - ParameterDecoratorFactory, - DecoratorFactory, - MethodParameterDecoratorFactory, -} from '@loopback/context'; - -import { - OperationObject, - ParameterLocation, - ParameterObject, - SchemaObject, - ParameterType, - PathsObject, - ItemType, - ItemsObject, - DefinitionsObject, -} from '@loopback/openapi-spec'; - -import * as stream from 'stream'; -import {getJsonSchema, JsonDefinition} from '@loopback/repository-json-schema'; -import * as _ from 'lodash'; - -const debug = require('debug')('loopback:rest:router:metadata'); - -const REST_METHODS_KEY = 'rest:methods'; -const REST_METHODS_WITH_PARAMETERS_KEY = 'rest:methods:parameters'; -const REST_PARAMETERS_KEY = 'rest:parameters'; -const REST_CLASS_KEY = 'rest:class'; -const REST_CONTROLLER_SPEC_KEY = 'rest:controller-spec'; - -// tslint:disable:no-any - -export interface ControllerSpec { - /** - * The base path on which the Controller API is served. - * If it is not included, the API is served directly under the host. - * The value MUST start with a leading slash (/). - */ - basePath?: string; - - /** - * The available paths and operations for the API. - */ - paths: PathsObject; - - /** - * JSON Schema definitions of models used by the controller - */ - definitions?: DefinitionsObject; -} -/** - * Decorate the given Controller constructor with metadata describing - * the HTTP/REST API the Controller implements/provides. - * - * `@api` can be applied to controller classes. For example, - * ``` - * @api({basePath: '/my'}) - * class MyController { - * // ... - * } - * ``` - * - * @param spec OpenAPI specification describing the endpoints - * handled by this controller - * - * @decorator - */ -export function api(spec: ControllerSpec) { - return ClassDecoratorFactory.createDecorator( - REST_CLASS_KEY, - spec, - ); -} - -/** - * Data structure for REST related metadata - */ -interface RestEndpoint { - verb: string; - path: string; - spec?: OperationObject; -} - -/** - * Build the api spec from class and method level decorations - * @param constructor Controller class - */ -function resolveControllerSpec(constructor: Function): ControllerSpec { - debug(`Retrieving OpenAPI specification for controller ${constructor.name}`); - - let spec = MetadataInspector.getClassMetadata( - REST_CLASS_KEY, - constructor, - ); - if (spec) { - debug(' using class-level spec defined via @api()', spec); - spec = DecoratorFactory.cloneDeep(spec); - } else { - spec = {paths: {}}; - } - - let endpoints = - MetadataInspector.getAllMethodMetadata( - REST_METHODS_KEY, - constructor.prototype, - ) || {}; - - endpoints = DecoratorFactory.cloneDeep(endpoints); - for (const op in endpoints) { - debug(' processing method %s', op); - - const endpoint = endpoints[op]; - const verb = endpoint.verb!; - const path = endpoint.path!; - - let endpointName = ''; - /* istanbul ignore if */ - if (debug.enabled) { - const className = constructor.name || ''; - const fullMethodName = `${className}.${op}`; - endpointName = `${fullMethodName} (${verb} ${path})`; - } - - let operationSpec = endpoint.spec; - if (!operationSpec) { - // The operation was defined via @operation(verb, path) with no spec - operationSpec = { - responses: {}, - }; - endpoint.spec = operationSpec; - } - debug(' operation for method %s: %j', op, endpoint); - - debug(' processing parameters for method %s', op); - let params = MetadataInspector.getAllParameterMetadata( - REST_PARAMETERS_KEY, - constructor.prototype, - op, - ); - if (params == null) { - params = MetadataInspector.getMethodMetadata( - REST_METHODS_WITH_PARAMETERS_KEY, - constructor.prototype, - op, - ); - } - debug(' parameters for method %s: %j', op, params); - if (params != null) { - const bodyParams = params.filter(p => p && p.in === 'body'); - if (bodyParams.length > 1) { - throw new Error('More than one body parameters found: ' + bodyParams); - } - params = DecoratorFactory.cloneDeep(params); - /** - * If a controller method uses dependency injection, the parameters - * might be sparsed. For example, - * ```ts - * class MyController { - * greet( - * @inject('prefix') prefix: string, - * @param.query.string('name) name: string) { - * return `${prefix}`, ${name}`; - * } - * ``` - */ - operationSpec.parameters = params.filter(p => p != null); - } - operationSpec['x-operation-name'] = op; - - if (!spec.paths[path]) { - spec.paths[path] = {}; - } - - if (spec.paths[path][verb]) { - // Operations from subclasses override those from the base - debug(` Overriding ${endpointName} - endpoint was already defined`); - } - - debug(` adding ${endpointName}`, operationSpec); - spec.paths[path][verb] = operationSpec; - - debug(` inferring schema object for method %s`, op); - const paramTypes = MetadataInspector.getDesignTypeForMethod( - constructor.prototype, - op, - ).parameterTypes; - - const isComplexType = (ctor: Function) => - !_.includes([String, Number, Boolean, Array, Object], ctor) && - !isReadableStream(ctor); - - for (const p of paramTypes) { - if (isComplexType(p)) { - if (!spec.definitions) { - spec.definitions = {}; - } - const jsonSchema = getJsonSchema(p); - const openapiSchema = jsonToSchemaObject(jsonSchema); - - if (openapiSchema.definitions) { - for (const key in openapiSchema.definitions) { - spec.definitions[key] = openapiSchema.definitions[key]; - } - delete openapiSchema.definitions; - } - - spec.definitions[p.name] = openapiSchema; - break; - } - } - } - return spec; -} - -/** - * Get the controller spec for the given class - * @param constructor Controller class - */ -export function getControllerSpec(constructor: Function): ControllerSpec { - let spec = MetadataInspector.getClassMetadata( - REST_CONTROLLER_SPEC_KEY, - constructor, - {ownMetadataOnly: true}, - ); - if (!spec) { - spec = resolveControllerSpec(constructor); - MetadataInspector.defineMetadata( - REST_CONTROLLER_SPEC_KEY, - spec, - constructor, - ); - } - return spec; -} - -export function jsonToSchemaObject(jsonDef: JsonDefinition): SchemaObject { - const json = jsonDef as {[name: string]: any}; // gets around index signature error - const result: SchemaObject = {}; - const propsToIgnore = [ - 'anyOf', - 'oneOf', - 'additionalItems', - 'defaultProperties', - 'typeof', - ]; - for (const property in json) { - if (propsToIgnore.includes(property)) { - continue; - } - switch (property) { - case 'type': { - if (json.type === 'array' && !json.items) { - throw new Error( - '"items" property must be present if "type" is an array', - ); - } - result.type = Array.isArray(json.type) ? json.type[0] : json.type; - break; - } - case 'allOf': { - result.allOf = _.map(json.allOf, item => jsonToSchemaObject(item)); - break; - } - case 'definitions': { - result.definitions = _.mapValues(json.definitions, def => - jsonToSchemaObject(def), - ); - break; - } - case 'properties': { - result.properties = _.mapValues(json.properties, item => - jsonToSchemaObject(item), - ); - break; - } - case 'additionalProperties': { - if (typeof json.additionalProperties !== 'boolean') { - result.additionalProperties = jsonToSchemaObject( - json.additionalProperties as JsonDefinition, - ); - } - break; - } - case 'items': { - const items = Array.isArray(json.items) ? json.items[0] : json.items; - result.items = jsonToSchemaObject(items as JsonDefinition); - break; - } - case 'enum': { - const newEnum = []; - const primitives = ['string', 'number', 'boolean']; - for (const element of json.enum) { - if (primitives.includes(typeof element) || element === null) { - newEnum.push(element); - } else { - // if element is JsonDefinition, convert to SchemaObject - newEnum.push(jsonToSchemaObject(element as JsonDefinition)); - } - } - result.enum = newEnum; - - break; - } - default: { - result[property] = json[property]; - break; - } - } - } - - return result; -} - -/** - * Expose a Controller method as a REST API operation - * mapped to `GET` request method. - * - * @param path The URL path of this operation, e.g. `/product/{id}` - * @param spec The OpenAPI specification describing parameters and responses - * of this operation. - */ -export function get(path: string, spec?: OperationObject) { - return operation('get', path, spec); -} - -/** - * Expose a Controller method as a REST API operation - * mapped to `POST` request method. - * - * @param path The URL path of this operation, e.g. `/product/{id}` - * @param spec The OpenAPI specification describing parameters and responses - * of this operation. - */ -export function post(path: string, spec?: OperationObject) { - return operation('post', path, spec); -} - -/** - * Expose a Controller method as a REST API operation - * mapped to `PUT` request method. - * - * @param path The URL path of this operation, e.g. `/product/{id}` - * @param spec The OpenAPI specification describing parameters and responses - * of this operation. - */ -export function put(path: string, spec?: OperationObject) { - return operation('put', path, spec); -} - -/** - * Expose a Controller method as a REST API operation - * mapped to `PATCH` request method. - * - * @param path The URL path of this operation, e.g. `/product/{id}` - * @param spec The OpenAPI specification describing parameters and responses - * of this operation. - */ -export function patch(path: string, spec?: OperationObject) { - return operation('patch', path, spec); -} - -/** - * Expose a Controller method as a REST API operation - * mapped to `DELETE` request method. - * - * @param path The URL path of this operation, e.g. `/product/{id}` - * @param spec The OpenAPI specification describing parameters and responses - * of this operation. - */ -export function del(path: string, spec?: OperationObject) { - return operation('delete', path, spec); -} - -/** - * Expose a Controller method as a REST API operation. - * - * @param verb HTTP verb, e.g. `GET` or `POST`. - * @param path The URL path of this operation, e.g. `/product/{id}` - * @param spec The OpenAPI specification describing parameters and responses - * of this operation. - */ -export function operation(verb: string, path: string, spec?: OperationObject) { - return MethodDecoratorFactory.createDecorator>( - REST_METHODS_KEY, - { - verb, - path, - spec, - }, - ); -} - -const paramDecoratorStyle = Symbol('ParamDecoratorStyle'); - -/** - * Check if the given type is `stream.Readable` or a subclasses of - * `stream.Readable` - * @param type JavaScript type function - */ -function isReadableStream(type: Object): boolean { - if (typeof type !== 'function') return false; - if (type === stream.Readable) return true; - return isReadableStream(Object.getPrototypeOf(type)); -} - -/** - * Get openapi type name for a JavaScript type - * @param type JavaScript type - */ -function getTypeForNonBodyParam(type: Function): ParameterType { - if (type === String) { - return 'string'; - } else if (type === Number) { - return 'number'; - } else if (type === Boolean) { - return 'boolean'; - } else if (type === Array) { - return 'array'; - } else if (isReadableStream(type)) { - return 'file'; - } - return 'string'; -} - -/** - * Get openapi schema for a JavaScript type for a body parameter - * @param type JavaScript type - */ -function getSchemaForBodyParam(type: Function): SchemaObject { - const schema: SchemaObject = {}; - let typeName; - if (type === String) { - typeName = 'string'; - } else if (type === Number) { - typeName = 'number'; - } else if (type === Boolean) { - typeName = 'boolean'; - } else if (type === Array) { - // item type cannot be inspected - typeName = 'array'; - } else if (isReadableStream(type)) { - typeName = 'file'; - } else if (type === Object) { - typeName = 'object'; - } - if (typeName) { - schema.type = typeName; - } else { - schema.$ref = '#/definitions/' + type.name; - } - return schema; -} - -/** - * Describe an input parameter of a Controller method. - * - * `@param` can be applied to method itself or specific parameters. For example, - * ``` - * class MyController { - * @get('/') - * @param(offsetSpec) - * @param(pageSizeSpec) - * list(offset?: number, pageSize?: number) {} - * } - * ``` - * or - * ``` - * class MyController { - * @get('/') - * list( - * @param(offsetSpec) offset?: number, - * @param(pageSizeSpec) pageSize?: number, - * ) {} - * } - * ``` - * Please note mixed usage of `@param` at method/parameter level is not allowed. - * - * @param paramSpec Parameter specification. - */ -export function param(paramSpec: ParameterObject) { - return function( - target: Object, - member: string | symbol, - descriptorOrIndex: TypedPropertyDescriptor | number, - ) { - paramSpec = paramSpec || {}; - // Get the design time method parameter metadata - const methodSig = MetadataInspector.getDesignTypeForMethod(target, member); - const paramTypes = (methodSig && methodSig.parameterTypes) || []; - - const targetWithParamStyle = target as any; - if (typeof descriptorOrIndex === 'number') { - if (targetWithParamStyle[paramDecoratorStyle] === 'method') { - // This should not happen as parameter decorators are applied before - // the method decorator - /* istanbul ignore next */ - throw new Error( - 'Mixed usage of @param at method/parameter level' + - ' is not allowed.', - ); - } - // Map design-time parameter type to the OpenAPI param type - - let paramType = paramTypes[descriptorOrIndex]; - if (paramType) { - if (paramSpec.in !== 'body') { - if (!paramSpec.type) { - paramSpec.type = getTypeForNonBodyParam(paramType); - } - } else { - paramSpec.schema = Object.assign( - getSchemaForBodyParam(paramType), - paramSpec.schema, - ); - } - } - - if ( - paramSpec.type === 'array' || - (paramSpec.schema && paramSpec.schema.type === 'array') - ) { - paramType = paramTypes[descriptorOrIndex]; - // The design-time type is `Object` for `any` - if (paramType != null && paramType !== Object && paramType !== Array) { - throw new Error( - `The parameter type is set to 'array' but the JavaScript type is ${ - paramType.name - }`, - ); - } - } - targetWithParamStyle[paramDecoratorStyle] = 'parameter'; - ParameterDecoratorFactory.createDecorator( - REST_PARAMETERS_KEY, - paramSpec, - )(target, member, descriptorOrIndex); - } else { - if (targetWithParamStyle[paramDecoratorStyle] === 'parameter') { - throw new Error( - 'Mixed usage of @param at method/parameter level' + - ' is not allowed.', - ); - } - targetWithParamStyle[paramDecoratorStyle] = 'method'; - RestMethodParameterDecoratorFactory.createDecorator( - REST_METHODS_WITH_PARAMETERS_KEY, - paramSpec, - )(target, member, descriptorOrIndex); - } - }; -} - -class RestMethodParameterDecoratorFactory extends MethodParameterDecoratorFactory< - ParameterObject -> {} - -export namespace param { - export const query = { - /** - * Define a parameter of "string" type that's read from the query string. - * - * @param name Parameter name. - */ - string: createParamShortcut('query', 'string'), - - /** - * Define a parameter of "number" type that's read from the query string. - * - * @param name Parameter name. - */ - number: createParamShortcut('query', 'number'), - - /** - * Define a parameter of "integer" type that's read from the query string. - * - * @param name Parameter name. - */ - integer: createParamShortcut('query', 'integer'), - - /** - * Define a parameter of "boolean" type that's read from the query string. - * - * @param name Parameter name. - */ - boolean: createParamShortcut('query', 'boolean'), - }; - - export const header = { - /** - * Define a parameter of "string" type that's read from a request header. - * - * @param name Parameter name, it must match the header name - * (e.g. `Content-Type`). - */ - string: createParamShortcut('header', 'string'), - - /** - * Define a parameter of "number" type that's read from a request header. - * - * @param name Parameter name, it must match the header name - * (e.g. `Content-Length`). - */ - number: createParamShortcut('header', 'number'), - - /** - * Define a parameter of "integer" type that's read from a request header. - * - * @param name Parameter name, it must match the header name - * (e.g. `Content-Length`). - */ - integer: createParamShortcut('header', 'integer'), - - /** - * Define a parameter of "boolean" type that's read from a request header. - * - * @param name Parameter name, it must match the header name, - * (e.g. `DNT` or `X-Do-Not-Track`). - */ - boolean: createParamShortcut('header', 'boolean'), - }; - - export const path = { - /** - * Define a parameter of "string" type that's read from request path. - * - * @param name Parameter name matching one of the placeholders in the path - * string. - */ - string: createParamShortcut('path', 'string'), - - /** - * Define a parameter of "number" type that's read from request path. - * - * @param name Parameter name matching one of the placeholders in the path - * string. - */ - number: createParamShortcut('path', 'number'), - - /** - * Define a parameter of "integer" type that's read from request path. - * - * @param name Parameter name matching one of the placeholders in the path - * string. - */ - integer: createParamShortcut('path', 'integer'), - - /** - * Define a parameter of "boolean" type that's read from request path. - * - * @param name Parameter name matching one of the placeholders in the path - * string. - */ - boolean: createParamShortcut('path', 'boolean'), - }; - - export const formData = { - /** - * Define a parameter of "string" type that's read - * from a field in the request body. - * - * @param name Parameter name. - */ - string: createParamShortcut('formData', 'string'), - - /** - * Define a parameter of "number" type that's read - * from a field in the request body. - * - * @param name Parameter name. - */ - number: createParamShortcut('formData', 'number'), - - /** - * Define a parameter of "integer" type that's read - * from a field in the request body. - * - * @param name Parameter name. - */ - integer: createParamShortcut('formData', 'integer'), - - /** - * Define a parameter of "boolean" type that's read - * from a field in the request body. - * - * @param name Parameter name. - */ - boolean: createParamShortcut('formData', 'boolean'), - }; - - /** - * Define a parameter that's set to the full request body. - * - * @param name Parameter name - * @param schema The schema defining the type used for the body parameter. - */ - export const body = function(name: string, schema?: SchemaObject) { - return param({name, in: 'body', schema}); - }; - - /** - * Define a parameter of `array` type - * - * @example - * ```ts - * export class MyController { - * @get('/greet') - * greet(@param.array('names', 'query', 'string') names: string[]): string { - * return `Hello, ${names}`; - * } - * } - * ``` - * @param name Parameter name - * @param source Source of the parameter value - * @param itemSpec Item type for the array or the full item object - */ - export const array = function( - name: string, - source: ParameterLocation, - itemSpec: ItemType | ItemsObject, - ) { - const items = typeof itemSpec === 'string' ? {type: itemSpec} : itemSpec; - if (source !== 'body') { - return param({name, in: source, type: 'array', items}); - } else { - return param({name, in: source, schema: {type: 'array', items}}); - } - }; -} - -function createParamShortcut(source: ParameterLocation, type: ParameterType) { - // TODO(bajtos) @param.IN.TYPE('foo', {required: true}) - return (name: string) => { - return param({name, in: source, type}); - }; -} diff --git a/packages/openapi-v2/src/index.ts b/packages/openapi-v2/src/index.ts deleted file mode 100644 index 33f6ed6d2e05..000000000000 --- a/packages/openapi-v2/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright IBM Corp. 2018. All Rights Reserved. -// Node module: @loopback/openapi-v2 -// This file is licensed under the MIT License. -// License text available at https://opensource.org/licenses/MIT - -export * from './controller-spec'; diff --git a/packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param-body.test.ts b/packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param-body.test.ts deleted file mode 100644 index 15c0d6d4c0ac..000000000000 --- a/packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param-body.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -// Copyright IBM Corp. 2018. All Rights Reserved. -// Node module: @loopback/openapi-v2 -// This file is licensed under the MIT License. -// License text available at https://opensource.org/licenses/MIT - -import {post, param, getControllerSpec} from '../../../../..'; -import {expect} from '@loopback/testlab'; -import {model, property} from '@loopback/repository'; - -describe('Routing metadata for parameters', () => { - describe('@param.body', () => { - it('defines a parameter with in:body', () => { - class MyController { - @post('/greeting') - @param.body('data', {type: 'object'}) - greet(name: string) {} - } - - const actualSpec = getControllerSpec(MyController); - - expect(actualSpec.paths['/greeting']['post'].parameters).to.eql([ - { - name: 'data', - in: 'body', - schema: {type: 'object'}, - }, - ]); - }); - }); - - it('infers a complex parameter type with in:body', () => { - class MyData { - name: string; - } - class MyController { - @post('/greeting') - greet(@param.body('data') data: MyData) {} - } - - const actualSpec = getControllerSpec(MyController); - - expect(actualSpec.paths['/greeting']['post'].parameters).to.eql([ - { - name: 'data', - in: 'body', - schema: {$ref: '#/definitions/MyData'}, - }, - ]); - }); - - it('infers a complex parameter schema with in:body', () => { - @model() - class MyData { - @property() name: string; - } - class MyController { - @post('/greeting') - greet(@param.body('data') data: MyData) {} - } - - const actualSpec = getControllerSpec(MyController); - expect(actualSpec.definitions).to.deepEqual({ - MyData: { - title: 'MyData', - properties: { - name: { - type: 'string', - }, - }, - }, - }); - }); - - it('infers a string parameter type with in:body', () => { - class MyController { - @post('/greeting') - greet(@param.body('name') name: string) {} - } - - const actualSpec = getControllerSpec(MyController); - - expect(actualSpec.paths['/greeting']['post'].parameters).to.eql([ - { - name: 'name', - in: 'body', - schema: { - type: 'string', - }, - }, - ]); - }); - - it('infers a number parameter type with in:body', () => { - class MyController { - @post('/greeting') - greet(@param.body('count') name: number) {} - } - - const actualSpec = getControllerSpec(MyController); - - expect(actualSpec.paths['/greeting']['post'].parameters).to.eql([ - { - name: 'count', - in: 'body', - schema: { - type: 'number', - }, - }, - ]); - }); - - it('infers a boolean parameter type with in:body', () => { - class MyController { - @post('/greeting') - greet(@param.body('vip') vip: boolean) {} - } - - const actualSpec = getControllerSpec(MyController); - - expect(actualSpec.paths['/greeting']['post'].parameters).to.eql([ - { - name: 'vip', - in: 'body', - schema: { - type: 'boolean', - }, - }, - ]); - }); - - it('infers an array parameter type with in:body', () => { - class MyController { - @post('/greeting') - greet( - @param({name: 'names', in: 'body', schema: {items: {type: 'string'}}}) - names: string[], - ) {} - } - - const actualSpec = getControllerSpec(MyController); - - expect(actualSpec.paths['/greeting']['post'].parameters).to.eql([ - { - name: 'names', - in: 'body', - schema: { - type: 'array', - items: { - type: 'string', - }, - }, - }, - ]); - }); - - it('reports error if more than one body params are found for the same method', () => { - class MyController { - @post('/greeting') - greet( - @param.body('name', {type: 'string'}) - name: string, - @param.body('prefix', {type: 'string'}) - prefix: string, - ) {} - } - - expect(() => getControllerSpec(MyController)).to.throw( - /More than one body parameters found/, - ); - }); -}); diff --git a/packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param-form-data.test.ts b/packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param-form-data.test.ts deleted file mode 100644 index 355bb56b612e..000000000000 --- a/packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param-form-data.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright IBM Corp. 2018. All Rights Reserved. -// Node module: @loopback/openapi-v2 -// This file is licensed under the MIT License. -// License text available at https://opensource.org/licenses/MIT - -import {post, param, getControllerSpec} from '../../../../..'; -import {expect} from '@loopback/testlab'; - -describe('Routing metadata for parameters', () => { - describe('@param.formData.string', () => { - it('defines a parameter with in:formData type:string', () => { - class MyController { - @post('/greeting') - @param.formData.string('name') - greet(name: string) {} - } - - const actualSpec = getControllerSpec(MyController); - - expect(actualSpec.paths['/greeting']['post'].parameters).to.eql([ - { - name: 'name', - type: 'string', - in: 'formData', - }, - ]); - }); - }); - - describe('@param.formData.number', () => { - it('defines a parameter with in:formData type:number', () => { - class MyController { - @post('/greeting') - @param.formData.number('name') - greet(name: string) {} - } - - const actualSpec = getControllerSpec(MyController); - - expect(actualSpec.paths['/greeting']['post'].parameters).to.eql([ - { - name: 'name', - type: 'number', - in: 'formData', - }, - ]); - }); - }); - - describe('@param.formData.integer', () => { - it('defines a parameter with in:formData type:integer', () => { - class MyController { - @post('/greeting') - @param.formData.integer('name') - greet(name: string) {} - } - - const actualSpec = getControllerSpec(MyController); - - expect(actualSpec.paths['/greeting']['post'].parameters).to.eql([ - { - name: 'name', - type: 'integer', - in: 'formData', - }, - ]); - }); - }); - - describe('@param.formData.boolean', () => { - it('defines a parameter with in:formData type:boolean', () => { - class MyController { - @post('/greeting') - @param.formData.boolean('name') - greet(name: string) {} - } - - const actualSpec = getControllerSpec(MyController); - - expect(actualSpec.paths['/greeting']['post'].parameters).to.eql([ - { - name: 'name', - type: 'boolean', - in: 'formData', - }, - ]); - }); - }); -}); diff --git a/packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param-header.test.ts b/packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param-header.test.ts deleted file mode 100644 index 5c540b1d173b..000000000000 --- a/packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param-header.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright IBM Corp. 2018. All Rights Reserved. -// Node module: @loopback/openapi-v2 -// This file is licensed under the MIT License. -// License text available at https://opensource.org/licenses/MIT - -import {get, param, getControllerSpec} from '../../../../..'; -import {expect} from '@loopback/testlab'; - -describe('Routing metadata for parameters', () => { - describe('@param.header.string', () => { - it('defines a parameter with in:header type:string', () => { - class MyController { - @get('/greet') - @param.header.string('name') - greet(name: string) {} - } - - const actualSpec = getControllerSpec(MyController); - - expect(actualSpec.paths['/greet']['get'].parameters).to.eql([ - { - name: 'name', - type: 'string', - in: 'header', - }, - ]); - }); - }); - - describe('@param.header.number', () => { - it('defines a parameter with in:header type:number', () => { - class MyController { - @get('/greet') - @param.header.number('name') - greet(name: string) {} - } - - const actualSpec = getControllerSpec(MyController); - - expect(actualSpec.paths['/greet']['get'].parameters).to.eql([ - { - name: 'name', - type: 'number', - in: 'header', - }, - ]); - }); - }); - - describe('@param.header.integer', () => { - it('defines a parameter with in:header type:integer', () => { - class MyController { - @get('/greet') - @param.header.integer('name') - greet(name: string) {} - } - - const actualSpec = getControllerSpec(MyController); - - expect(actualSpec.paths['/greet']['get'].parameters).to.eql([ - { - name: 'name', - type: 'integer', - in: 'header', - }, - ]); - }); - }); - - describe('@param.header.boolean', () => { - it('defines a parameter with in:header type:boolean', () => { - class MyController { - @get('/greet') - @param.header.boolean('name') - greet(name: string) {} - } - - const actualSpec = getControllerSpec(MyController); - - expect(actualSpec.paths['/greet']['get'].parameters).to.eql([ - { - name: 'name', - type: 'boolean', - in: 'header', - }, - ]); - }); - }); -}); diff --git a/packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param-path.test.ts b/packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param-path.test.ts deleted file mode 100644 index b7b1afa38dd3..000000000000 --- a/packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param-path.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright IBM Corp. 2018. All Rights Reserved. -// Node module: @loopback/openapi-v2 -// This file is licensed under the MIT License. -// License text available at https://opensource.org/licenses/MIT - -import {get, param, getControllerSpec} from '../../../../..'; -import {expect} from '@loopback/testlab'; - -describe('Routing metadata for parameters', () => { - describe('@param.path.string', () => { - it('defines a parameter with in:path type:string', () => { - class MyController { - @get('/greet/{name}') - @param.path.string('name') - greet(name: string) {} - } - - const actualSpec = getControllerSpec(MyController); - - expect(actualSpec.paths['/greet/{name}']['get'].parameters).to.eql([ - { - name: 'name', - type: 'string', - in: 'path', - }, - ]); - }); - }); - - describe('@param.path.number', () => { - it('defines a parameter with in:path type:number', () => { - class MyController { - @get('/greet/{name}') - @param.path.number('name') - greet(name: string) {} - } - - const actualSpec = getControllerSpec(MyController); - - expect(actualSpec.paths['/greet/{name}']['get'].parameters).to.eql([ - { - name: 'name', - type: 'number', - in: 'path', - }, - ]); - }); - }); - - describe('@param.path.integer', () => { - it('defines a parameter with in:path type:integer', () => { - class MyController { - @get('/greet/{name}') - @param.path.integer('name') - greet(name: string) {} - } - - const actualSpec = getControllerSpec(MyController); - - expect(actualSpec.paths['/greet/{name}']['get'].parameters).to.eql([ - { - name: 'name', - type: 'integer', - in: 'path', - }, - ]); - }); - }); - - describe('@param.path.boolean', () => { - it('defines a parameter with in:path type:boolean', () => { - class MyController { - @get('/greet/{name}') - @param.path.boolean('name') - greet(name: string) {} - } - - const actualSpec = getControllerSpec(MyController); - - expect(actualSpec.paths['/greet/{name}']['get'].parameters).to.eql([ - { - name: 'name', - type: 'boolean', - in: 'path', - }, - ]); - }); - }); -}); diff --git a/packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param-query.test.ts b/packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param-query.test.ts deleted file mode 100644 index db33fb8df34b..000000000000 --- a/packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param-query.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright IBM Corp. 2018. All Rights Reserved. -// Node module: @loopback/openapi-v2 -// This file is licensed under the MIT License. -// License text available at https://opensource.org/licenses/MIT - -import {get, param, getControllerSpec} from '../../../../..'; -import {expect} from '@loopback/testlab'; - -describe('Routing metadata for parameters', () => { - describe('@param.query.string', () => { - it('defines a parameter with in:query type:string', () => { - class MyController { - @get('/greet') - @param.query.string('name') - greet(name: string) {} - } - - const actualSpec = getControllerSpec(MyController); - - expect(actualSpec.paths['/greet']['get'].parameters).to.eql([ - { - name: 'name', - type: 'string', - in: 'query', - }, - ]); - }); - }); - - describe('@param.query.number', () => { - it('defines a parameter with in:query type:number', () => { - class MyController { - @get('/greet') - @param.query.number('name') - greet(name: string) {} - } - - const actualSpec = getControllerSpec(MyController); - - expect(actualSpec.paths['/greet']['get'].parameters).to.eql([ - { - name: 'name', - type: 'number', - in: 'query', - }, - ]); - }); - }); - - describe('@param.query.integer', () => { - it('defines a parameter with in:query type:integer', () => { - class MyController { - @get('/greet') - @param.query.integer('name') - greet(name: string) {} - } - - const actualSpec = getControllerSpec(MyController); - - expect(actualSpec.paths['/greet']['get'].parameters).to.eql([ - { - name: 'name', - type: 'integer', - in: 'query', - }, - ]); - }); - }); - - describe('@param.query.boolean', () => { - it('defines a parameter with in:query type:boolean', () => { - class MyController { - @get('/greet') - @param.query.boolean('name') - greet(name: string) {} - } - - const actualSpec = getControllerSpec(MyController); - - expect(actualSpec.paths['/greet']['get'].parameters).to.eql([ - { - name: 'name', - type: 'boolean', - in: 'query', - }, - ]); - }); - }); -}); diff --git a/packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param.test.ts b/packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param.test.ts deleted file mode 100644 index 9a9f1defa454..000000000000 --- a/packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param.test.ts +++ /dev/null @@ -1,517 +0,0 @@ -// Copyright IBM Corp. 2018. All Rights Reserved. -// Node module: @loopback/openapi-v2 -// This file is licensed under the MIT License. -// License text available at https://opensource.org/licenses/MIT - -import { - get, - param, - getControllerSpec, - operation, - patch, - post, -} from '../../../../..'; -import { - OperationObject, - ParameterObject, - ResponsesObject, - DefinitionsObject, -} from '@loopback/openapi-spec'; -import {expect} from '@loopback/testlab'; -import {anOperationSpec} from '@loopback/openapi-spec-builder'; -import * as stream from 'stream'; -import {model, property} from '@loopback/repository'; - -describe('Routing metadata for parameters', () => { - describe('@param', () => { - it('defines a new parameter', () => { - const paramSpec: ParameterObject = { - name: 'name', - type: 'string', - in: 'query', - }; - - class MyController { - @get('/greet') - @param(paramSpec) - greet(name: string) {} - } - - const actualSpec = getControllerSpec(MyController); - - const expectedSpec = anOperationSpec() - .withOperationName('greet') - .withParameter(paramSpec) - .build(); - - expect(actualSpec.paths['/greet']['get']).to.eql(expectedSpec); - }); - - it('infers non-body parameter type', () => { - class MyController { - @patch('/update/{id}') - update( - @param({ - name: 'id', - in: 'path', - }) - id: string, - @param({ - name: 'name', - in: 'query', - }) - name: string, - @param({ - name: 'age', - in: 'query', - }) - age: number, - @param({ - name: 'vip', - in: 'query', - }) - vip: boolean, - @param.array('tags', 'query', {type: 'string'}) - tags: string[], - @param({ - name: 'picture', - in: 'body', - }) - picture: stream.Readable, - ) {} - } - - const actualSpec = getControllerSpec(MyController); - - const expectedSpec = anOperationSpec() - .withOperationName('update') - .withParameter({ - name: 'id', - type: 'string', - in: 'path', - }) - .withParameter({ - name: 'name', - type: 'string', - in: 'query', - }) - .withParameter({ - name: 'age', - type: 'number', - in: 'query', - }) - .withParameter({ - name: 'vip', - type: 'boolean', - in: 'query', - }) - .withParameter({ - name: 'tags', - type: 'array', - items: { - type: 'string', - }, - in: 'query', - }) - .withParameter({ - name: 'picture', - schema: { - type: 'file', - }, - in: 'body', - }) - .build(); - - expect(actualSpec.paths['/update/{id}']['patch']).to.eql(expectedSpec); - }); - - it('infers array non-body parameter type', () => { - class MyController { - @get('/greet') - greet( - @param.array('names', 'query', 'string') - names: string[], - ) {} - } - - const actualSpec = getControllerSpec(MyController); - - const expectedSpec = anOperationSpec() - .withOperationName('greet') - .withParameter({ - name: 'names', - type: 'array', - items: { - type: 'string', - }, - in: 'query', - }) - .build(); - - expect(actualSpec.paths['/greet']['get']).to.eql(expectedSpec); - }); - - it('infers array non-body parameter type without explict type', () => { - class MyController { - @get('/greet') - greet( - @param({name: 'names', in: 'query', items: {type: 'string'}}) - names: string[], - ) {} - } - - const actualSpec = getControllerSpec(MyController); - - const expectedSpec = anOperationSpec() - .withOperationName('greet') - .withParameter({ - name: 'names', - type: 'array', - items: { - type: 'string', - }, - in: 'query', - }) - .build(); - - expect(actualSpec.paths['/greet']['get']).to.eql(expectedSpec); - }); - - it('reports error if an array parameter type is not Array', () => { - expect.throws( - () => { - // tslint:disable-next-line:no-unused-variable - class MyController { - @get('/greet') - greet( - @param.array('names', 'query', 'string') - names: string, - ) {} - } - }, - Error, - `The parameter type is set to 'array' but the JavaScript type is String`, - ); - }); - - it('infers array parameter type with `any`', () => { - class MyController { - @get('/greet') - greet( - @param.array('names', 'query', 'string') - names: /* tslint:disable-next-line:no-any */ - any, - ) {} - } - - const actualSpec = getControllerSpec(MyController); - - const expectedSpec = anOperationSpec() - .withOperationName('greet') - .withParameter({ - name: 'names', - type: 'array', - items: { - type: 'string', - }, - in: 'query', - }) - .build(); - - expect(actualSpec.paths['/greet']['get']).to.eql(expectedSpec); - }); - - it('infers simple body parameter type', () => { - const paramSpec: ParameterObject = { - name: 'name', - in: 'body', - }; - - class MyController { - @get('/greet') - greet(@param(paramSpec) name: string) {} - } - - const actualSpec = getControllerSpec(MyController); - - const expectedSpec = anOperationSpec() - .withOperationName('greet') - .withParameter({ - name: 'name', - schema: { - type: 'string', - }, - in: 'body', - }) - .build(); - - expect(actualSpec.paths['/greet']['get']).to.eql(expectedSpec); - }); - - it('infers complex body parameter type', () => { - const paramSpec: ParameterObject = { - name: 'name', - in: 'body', - }; - - class MyBody { - name: string; - } - - class MyController { - @get('/greet') - greet(@param(paramSpec) name: MyBody) {} - } - - const actualSpec = getControllerSpec(MyController); - - const expectedSpec = anOperationSpec() - .withOperationName('greet') - .withParameter({ - name: 'name', - schema: { - $ref: '#/definitions/MyBody', - }, - in: 'body', - }) - .build(); - - expect(actualSpec.paths['/greet']['get']).to.eql(expectedSpec); - }); - - it('infers complex body parameter schema into the controller spec', () => { - const fooSpec: ParameterObject = { - name: 'foo', - in: 'body', - }; - const barSpec: ParameterObject = { - name: 'bar', - in: 'body', - }; - @model() - class Foo { - @property() price: number; - } - @model() - class Bar { - @property() name: string; - @property() foo: Foo; - } - class MyController { - @post('/foo') - foo(@param(fooSpec) foo: Foo) {} - - @post('/bar') - bar(@param(barSpec) bar: Bar) {} - } - - const defs = getControllerSpec(MyController) - .definitions as DefinitionsObject; - - // tslint:disable-next-line:no-any - expect(defs).to.have.keys('Foo', 'Bar'); - expect(defs.Foo).to.deepEqual({ - title: 'Foo', - properties: { - price: { - type: 'number', - }, - }, - }); - expect(defs.Bar).to.deepEqual({ - title: 'Bar', - properties: { - name: { - type: 'string', - }, - foo: { - $ref: '#/definitions/Foo', - }, - }, - }); - }); - - it('does not produce nested definitions', () => { - const paramSpec: ParameterObject = { - name: 'foo', - in: 'body', - }; - @model() - class Foo { - @property() bar: number; - } - @model() - class MyBody { - @property() name: string; - @property() foo: Foo; - } - class MyController { - @post('/foo') - foo(@param(paramSpec) body: MyBody) {} - } - - const defs = getControllerSpec(MyController) - .definitions as DefinitionsObject; - expect(defs).to.have.keys('MyBody', 'Foo'); - expect(defs.MyBody).to.not.have.key('definitions'); - }); - - it('infers no properties if no property metadata is present', () => { - const paramSpec: ParameterObject = { - name: 'foo', - in: 'body', - }; - @model() - class MyBody { - name: string; - } - class MyController { - @post('/foo') - foo(@param(paramSpec) foo: MyBody) {} - } - - const defs = getControllerSpec(MyController) - .definitions as DefinitionsObject; - - expect(defs).to.have.key('MyBody'); - expect(defs.MyBody).to.not.have.key('properties'); - }); - - it('does not infer definition if no class metadata is present', () => { - const paramSpec: ParameterObject = { - name: 'foo', - in: 'body', - }; - class MyBody { - @property() name: string; - } - class MyController { - @post('/foo') - foo(@param(paramSpec) foo: MyBody) {} - } - - const defs = getControllerSpec(MyController) - .definitions as DefinitionsObject; - - expect(defs).to.have.key('MyBody'); - expect(defs.MyBody).to.deepEqual({}); - }); - - it('can define multiple parameters in order', () => { - const offsetSpec: ParameterObject = { - name: 'offset', - type: 'number', - in: 'query', - }; - - const pageSizeSpec: ParameterObject = { - name: 'pageSize', - type: 'number', - in: 'query', - }; - - class MyController { - @get('/') - @param(offsetSpec) - @param(pageSizeSpec) - list(offset?: number, pageSize?: number) {} - } - - const actualSpec = getControllerSpec(MyController); - - expect(actualSpec.paths['/']['get'].parameters).to.eql([ - offsetSpec, - pageSizeSpec, - ]); - }); - - it('can define multiple parameters by arguments', () => { - const offsetSpec: ParameterObject = { - name: 'offset', - type: 'number', - in: 'query', - }; - - const pageSizeSpec: ParameterObject = { - name: 'pageSize', - type: 'number', - in: 'query', - }; - - class MyController { - @get('/') - list( - @param(offsetSpec) offset?: number, - @param(pageSizeSpec) pageSize?: number, - ) {} - } - - const actualSpec = getControllerSpec(MyController); - - expect(actualSpec.paths['/']['get'].parameters).to.eql([ - offsetSpec, - pageSizeSpec, - ]); - }); - // tslint:disable-next-line:max-line-length - it('throws an error if @param is used at both method and parameter level', () => { - expect(() => { - const offsetSpec: ParameterObject = { - name: 'offset', - type: 'number', - in: 'query', - }; - - const pageSizeSpec: ParameterObject = { - name: 'pageSize', - type: 'number', - in: 'query', - }; - // tslint:disable-next-line:no-unused-variable - class MyController { - @get('/') - @param(offsetSpec) - list(offset?: number, @param(pageSizeSpec) pageSize?: number) {} - } - }).to.throw( - /Mixed usage of @param at method\/parameter level is not allowed/, - ); - }); - - it('adds to existing spec provided via @operation', () => { - const offsetSpec: ParameterObject = { - name: 'offset', - type: 'number', - in: 'query', - }; - - const pageSizeSpec: ParameterObject = { - name: 'pageSize', - type: 'number', - in: 'query', - }; - - const responses: ResponsesObject = { - 200: { - schema: { - type: 'string', - }, - description: 'a string response', - }, - }; - - class MyController { - @operation('get', '/', {responses}) - @param(offsetSpec) - @param(pageSizeSpec) - list(offset?: number, pageSize?: number) {} - } - - const apiSpec = getControllerSpec(MyController); - const opSpec: OperationObject = apiSpec.paths['/']['get']; - - expect(opSpec.responses).to.eql(responses); - expect(opSpec.parameters).to.eql([offsetSpec, pageSizeSpec]); - }); - }); -}); diff --git a/packages/openapi-v3/.gitignore b/packages/openapi-v3/.gitignore new file mode 100644 index 000000000000..90a8d96cc3ff --- /dev/null +++ b/packages/openapi-v3/.gitignore @@ -0,0 +1,3 @@ +*.tgz +dist* +package diff --git a/packages/openapi-v2/.npmrc b/packages/openapi-v3/.npmrc similarity index 100% rename from packages/openapi-v2/.npmrc rename to packages/openapi-v3/.npmrc diff --git a/packages/openapi-v2/CHANGELOG.md b/packages/openapi-v3/CHANGELOG.md similarity index 100% rename from packages/openapi-v2/CHANGELOG.md rename to packages/openapi-v3/CHANGELOG.md diff --git a/packages/openapi-v2/LICENSE b/packages/openapi-v3/LICENSE similarity index 97% rename from packages/openapi-v2/LICENSE rename to packages/openapi-v3/LICENSE index 048fc725ba93..f301405944a1 100644 --- a/packages/openapi-v2/LICENSE +++ b/packages/openapi-v3/LICENSE @@ -1,5 +1,5 @@ Copyright (c) IBM Corp. 2018. All Rights Reserved. -Node module: @loopback/openapi-v2 +Node module: @loopback/openapi-v3 This project is licensed under the MIT License, full text below. -------- diff --git a/packages/openapi-v2/README.md b/packages/openapi-v3/README.md similarity index 73% rename from packages/openapi-v2/README.md rename to packages/openapi-v3/README.md index 13316e28726d..4b73f25892b7 100644 --- a/packages/openapi-v2/README.md +++ b/packages/openapi-v3/README.md @@ -1,9 +1,9 @@ -@loopback/openapi-v2 +@loopback/openapi-v3 This package contains: -- Decorators that describe LoopBack artifacts as OpenAPI v2 (Swagger) metadata. -- Utilities that transfer LoopBack metadata to OpenAPI v2 (Swagger) swagger specifications. +* Decorators that describe LoopBack artifacts as OpenAPI v2 (Swagger) metadata. +* Utilities that transfer LoopBack metadata to OpenAPI v2 (Swagger) swagger specifications. ## Overview @@ -15,7 +15,7 @@ Functions for more artifacts will be added when we need. ## Installation ``` -$ npm install --save @loopback/openapi-v2 +$ npm install --save @loopback/openapi-v3 ``` ## Basic use @@ -26,37 +26,38 @@ It generates swagger `paths` and `basePath` specs for a given decorated controll Here is an example of calling function `getControllerSpec` to generate the swagger spec: ```js -import {api, getControllerSpec} from '@loopback/openapi-v2'; +import {api, getControllerSpec} from '@loopback/openapi-v3'; @api(somePathSpec) class MyController { - greet() { - return 'Hello world!'; - } + greet() { + return 'Hello world!'; + } } const myControllerSpec = getControllerSpec(MyController); ``` then the `myControllerSpec` will be: + ```js { swagger: '2.0', basePath: '/', info: { title: 'LoopBack Application', version: '1.0.0' }, - paths: { - '/greet': { + paths: { + '/greet': { get: { - responses: { - '200': { - description: 'The string result.', - schema: { type: 'string' } - } + responses: { + '200': { + description: 'The string result.', + schema: { type: 'string' } + } }, - 'x-operation-name': 'greet' + 'x-operation-name': 'greet' } - } - } + } + } } ``` @@ -81,4 +82,4 @@ See [all contributors](https://github.com/strongloop/loopback-next/graphs/contri # License -MIT \ No newline at end of file +MIT diff --git a/packages/openapi-v2/docs.json b/packages/openapi-v3/docs.json similarity index 100% rename from packages/openapi-v2/docs.json rename to packages/openapi-v3/docs.json diff --git a/packages/openapi-v2/index.d.ts b/packages/openapi-v3/index.d.ts similarity index 83% rename from packages/openapi-v2/index.d.ts rename to packages/openapi-v3/index.d.ts index c1ab715d0c85..d8870b6ebd1c 100644 --- a/packages/openapi-v2/index.d.ts +++ b/packages/openapi-v3/index.d.ts @@ -1,5 +1,5 @@ // Copyright IBM Corp. 2018. All Rights Reserved. -// Node module: @loopback/openapi-v2 +// Node module: @loopback/openapi-v3 // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT diff --git a/packages/openapi-v2/index.js b/packages/openapi-v3/index.js similarity index 84% rename from packages/openapi-v2/index.js rename to packages/openapi-v3/index.js index c849a637c3a7..08a03353b9c9 100644 --- a/packages/openapi-v2/index.js +++ b/packages/openapi-v3/index.js @@ -1,5 +1,5 @@ // Copyright IBM Corp. 2018. All Rights Reserved. -// Node module: @loopback/openapi-v2 +// Node module: @loopback/openapi-v3 // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT diff --git a/packages/openapi-v2/index.ts b/packages/openapi-v3/index.ts similarity index 87% rename from packages/openapi-v2/index.ts rename to packages/openapi-v3/index.ts index be8e9c4afdab..0ee3acb423b7 100644 --- a/packages/openapi-v2/index.ts +++ b/packages/openapi-v3/index.ts @@ -1,5 +1,5 @@ // Copyright IBM Corp. 2018. All Rights Reserved. -// Node module: @loopback/openapi-v2 +// Node module: @loopback/openapi-v3 // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT diff --git a/packages/openapi-v2/package.json b/packages/openapi-v3/package.json similarity index 71% rename from packages/openapi-v2/package.json rename to packages/openapi-v3/package.json index 495e17e179aa..e601d9c8d87a 100644 --- a/packages/openapi-v2/package.json +++ b/packages/openapi-v3/package.json @@ -1,7 +1,7 @@ { - "name": "@loopback/openapi-v2", - "version": "4.0.0-alpha.10", - "description": "Processes openapi v2 related metadata", + "name": "@loopback/openapi-v3", + "version": "4.0.0-alpha.1", + "description": "Processes openapi v3 related metadata", "engines": { "node": ">=8" }, @@ -20,16 +20,15 @@ "prepublishOnly": "npm run build && npm run build:apidocs", "pretest": "npm run build", "test": "lb-mocha \"DIST/test/unit/**/*.js\"", - "verify": "npm pack && tar xf loopback-openapi-v2*.tgz && tree package && npm run clean" + "verify": + "npm pack && tar xf loopback-openapi-v2*.tgz && tree package && npm run clean", + "single": + "npm run build && lb-mocha \"DIST/test/unit/controller-spec/controller-decorators/**/**.js\"" }, "author": "IBM", "copyright.owner": "IBM Corp.", "license": "MIT", - "keywords": [ - "Swagger", - "OpenAPI Spec", - "TypeScript" - ], + "keywords": ["Swagger", "OpenAPI Spec", "TypeScript"], "files": [ "README.md", "index.js", @@ -47,7 +46,7 @@ }, "dependencies": { "@loopback/context": "^4.0.0-alpha.31", - "@loopback/openapi-spec": "^4.0.0-alpha.25", + "@loopback/openapi-spec-types": "^4.0.0-alpha.1", "@loopback/repository-json-schema": "^4.0.0-alpha.7", "debug": "^3.1.0", "lodash": "^4.17.4" diff --git a/packages/openapi-v3/src/controller-spec.ts b/packages/openapi-v3/src/controller-spec.ts new file mode 100644 index 000000000000..acefa36c3f4d --- /dev/null +++ b/packages/openapi-v3/src/controller-spec.ts @@ -0,0 +1,322 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + MetadataInspector, + ClassDecoratorFactory, + MethodDecoratorFactory, + DecoratorFactory, +} from '@loopback/context'; + +import {jsonToSchemaObject} from '../'; +import { + OperationObject, + ParameterObject, + PathsObject, + ComponentsObject, + RequestBodyObject, +} from '@loopback/openapi-spec-types'; +import {getJsonSchema} from '@loopback/repository-json-schema'; +import * as _ from 'lodash'; +import {OAS3} from './'; + +const debug = require('debug')('loopback:rest:router:metadata'); + +// tslint:disable:no-any + +export interface ControllerSpec { + /** + * The base path on which the Controller API is served. + * If it is not included, the API is served directly under the host. + * The value MUST start with a leading slash (/). + */ + basePath?: string; + + /** + * The available paths and operations for the API. + */ + paths: PathsObject; + + /** + * openapi components.schemas generated from model metadata + */ + components?: ComponentsObject; +} +/** + * Decorate the given Controller constructor with metadata describing + * the HTTP/REST API the Controller implements/provides. + * + * `@api` can be applied to controller classes. For example, + * ``` + * @api({basePath: '/my'}) + * class MyController { + * // ... + * } + * ``` + * + * @param spec OpenAPI specification describing the endpoints + * handled by this controller + * + * @decorator + */ +export function api(spec: ControllerSpec) { + return ClassDecoratorFactory.createDecorator( + OAS3.CLASS_KEY, + spec, + ); +} + +/** + * Data structure for REST related metadata + */ +interface RestEndpoint { + verb: string; + path: string; + spec?: OperationObject; +} + +/** + * Build the api spec from class and method level decorations + * @param constructor Controller class + */ +function resolveControllerSpec(constructor: Function): ControllerSpec { + debug(`Retrieving OpenAPI specification for controller ${constructor.name}`); + + let spec = MetadataInspector.getClassMetadata( + OAS3.CLASS_KEY, + constructor, + ); + if (spec) { + debug(' using class-level spec defined via @api()', spec); + spec = DecoratorFactory.cloneDeep(spec); + } else { + spec = {paths: {}}; + } + + let endpoints = + MetadataInspector.getAllMethodMetadata( + OAS3.METHODS_KEY, + constructor.prototype, + ) || {}; + + endpoints = DecoratorFactory.cloneDeep(endpoints); + for (const op in endpoints) { + debug(' processing method %s', op); + + const endpoint = endpoints[op]; + const verb = endpoint.verb!; + const path = endpoint.path!; + + let endpointName = ''; + /* istanbul ignore if */ + if (debug.enabled) { + const className = constructor.name || ''; + const fullMethodName = `${className}.${op}`; + endpointName = `${fullMethodName} (${verb} ${path})`; + } + + let operationSpec = endpoint.spec; + if (!operationSpec) { + // The operation was defined via @operation(verb, path) with no spec + operationSpec = { + responses: {}, + }; + endpoint.spec = operationSpec; + } + debug(' operation for method %s: %j', op, endpoint); + + debug(' processing parameters for method %s', op); + let params = MetadataInspector.getAllParameterMetadata( + OAS3.PARAMETERS_KEY, + constructor.prototype, + op, + ); + debug(' parameters for method %s: %j', op, params); + if (params != null) { + params = DecoratorFactory.cloneDeep(params); + /** + * If a controller method uses dependency injection, the parameters + * might be sparsed. For example, + * ```ts + * class MyController { + * greet( + * @inject('prefix') prefix: string, + * @param.query.string('name) name: string) { + * return `${prefix}`, ${name}`; + * } + * ``` + */ + operationSpec.parameters = params.filter(p => p != null); + } + debug(' processing requestBody for method %s', op); + let requestBodies = MetadataInspector.getAllParameterMetadata< + RequestBodyObject + >(OAS3.REQUEST_BODY_KEY, constructor.prototype, op); + if (requestBodies != null) + requestBodies = requestBodies.filter(p => p != null); + let requestBody: RequestBodyObject; + // workaround; + if (requestBodies) { + if (requestBodies.length > 1) + throw new Error( + 'An operation should only have one parameter decorated by @requestBody', + ); + requestBody = requestBodies[0]; + debug(' requestBody for method %s: %j', op, requestBody); + if (requestBody) { + operationSpec.requestBody = requestBody; + } + } + + operationSpec['x-operation-name'] = op; + + if (!spec.paths[path]) { + spec.paths[path] = {}; + } + + if (spec.paths[path][verb]) { + // Operations from subclasses override those from the base + debug(` Overriding ${endpointName} - endpoint was already defined`); + } + + debug(` adding ${endpointName}`, operationSpec); + spec.paths[path][verb] = operationSpec; + + debug(` inferring schema object for method %s`, op); + const opMetadata = MetadataInspector.getDesignTypeForMethod( + constructor.prototype, + op, + ); + const paramTypes = opMetadata.parameterTypes; + + const isComplexType = (ctor: Function) => + !_.includes([String, Number, Boolean, Array, Object], ctor); + + for (const p of paramTypes) { + if (isComplexType(p)) { + if (!spec.components) { + spec.components = {}; + } + if (!spec.components.schemas) { + spec.components.schemas = {}; + } + const jsonSchema = getJsonSchema(p); + const openapiSchema = jsonToSchemaObject(jsonSchema); + if (openapiSchema.definitions) { + for (const key in openapiSchema.definitions) { + spec.components.schemas[key] = openapiSchema.definitions[key]; + } + delete openapiSchema.definitions; + } + + spec.components.schemas[p.name] = openapiSchema; + break; + } + } + // const returnType = opMetadata.returnType; + // buildResponsesSpec(returnType, spec); + } + return spec; +} + +/** + * Get the controller spec for the given class + * @param constructor Controller class + */ +export function getControllerSpec(constructor: Function): ControllerSpec { + let spec = MetadataInspector.getClassMetadata( + OAS3.CONTROLLER_SPEC_KEY, + constructor, + {ownMetadataOnly: true}, + ); + if (!spec) { + spec = resolveControllerSpec(constructor); + MetadataInspector.defineMetadata( + OAS3.CONTROLLER_SPEC_KEY, + spec, + constructor, + ); + } + return spec; +} + +/** + * Expose a Controller method as a REST API operation + * mapped to `GET` request method. + * + * @param path The URL path of this operation, e.g. `/product/{id}` + * @param spec The OpenAPI specification describing parameters and responses + * of this operation. + */ +export function get(path: string, spec?: OperationObject) { + return operation('get', path, spec); +} + +/** + * Expose a Controller method as a REST API operation + * mapped to `POST` request method. + * + * @param path The URL path of this operation, e.g. `/product/{id}` + * @param spec The OpenAPI specification describing parameters and responses + * of this operation. + */ +export function post(path: string, spec?: OperationObject) { + return operation('post', path, spec); +} + +/** + * Expose a Controller method as a REST API operation + * mapped to `PUT` request method. + * + * @param path The URL path of this operation, e.g. `/product/{id}` + * @param spec The OpenAPI specification describing parameters and responses + * of this operation. + */ +export function put(path: string, spec?: OperationObject) { + return operation('put', path, spec); +} + +/** + * Expose a Controller method as a REST API operation + * mapped to `PATCH` request method. + * + * @param path The URL path of this operation, e.g. `/product/{id}` + * @param spec The OpenAPI specification describing parameters and responses + * of this operation. + */ +export function patch(path: string, spec?: OperationObject) { + return operation('patch', path, spec); +} + +/** + * Expose a Controller method as a REST API operation + * mapped to `DELETE` request method. + * + * @param path The URL path of this operation, e.g. `/product/{id}` + * @param spec The OpenAPI specification describing parameters and responses + * of this operation. + */ +export function del(path: string, spec?: OperationObject) { + return operation('delete', path, spec); +} + +/** + * Expose a Controller method as a REST API operation. + * + * @param verb HTTP verb, e.g. `GET` or `POST`. + * @param path The URL path of this operation, e.g. `/product/{id}` + * @param spec The OpenAPI specification describing parameters and responses + * of this operation. + */ +export function operation(verb: string, path: string, spec?: OperationObject) { + return MethodDecoratorFactory.createDecorator>( + OAS3.METHODS_KEY, + { + verb, + path, + spec, + }, + ); +} diff --git a/packages/openapi-v3/src/generate-schema.ts b/packages/openapi-v3/src/generate-schema.ts new file mode 100644 index 000000000000..5f756bd05818 --- /dev/null +++ b/packages/openapi-v3/src/generate-schema.ts @@ -0,0 +1,52 @@ +import {SchemaObject, isSchemaObject} from '@loopback/openapi-spec-types'; + +interface TypeAndFormat { + type?: string; + format?: string; +} +/** + * Generate the `type` and `format` property in a Schema Object according to a + * parameter's type. + * `type` and `format` will be preserved if provided in `schema` + * + * @param type The JavaScript type of a parameter + * @param schema The schema object provided in an parameter object + */ +export function getSchemaForParam( + type: Function, + schema: SchemaObject, +): SchemaObject { + // preserve `type` and `format` provided by user + if (isSchemaObject(schema) && schema.type && schema.format) return schema; + + let typeAndFormat: TypeAndFormat = {}; + if (type === String) { + typeAndFormat.type = 'string'; + } else if (type === Number) { + typeAndFormat.type = 'number'; + } else if (type === Boolean) { + typeAndFormat.type = 'boolean'; + } else if (type === Array) { + // item type cannot be inspected + typeAndFormat.type = 'array'; + } else if (type === Object) { + typeAndFormat.type = 'object'; + } + + if (typeAndFormat.type && !schema.type) schema.type = typeAndFormat.type; + if (typeAndFormat.format && !schema.format) + schema.format = typeAndFormat.format; + + return schema; +} + +/** + * Get OpenAPI Schema for a JavaScript type for a body parameter + * @param type The JavaScript type of an argument deccorated by @requestBody + */ +export function getSchemaForRequestBody(type: Function): SchemaObject { + let generatedSchema = getSchemaForParam(type, {}); + if (!generatedSchema.type) + generatedSchema.$ref = '#/components/schemas/' + type.name; + return generatedSchema; +} diff --git a/packages/openapi-v3/src/index.ts b/packages/openapi-v3/src/index.ts new file mode 100644 index 000000000000..78300395043c --- /dev/null +++ b/packages/openapi-v3/src/index.ts @@ -0,0 +1,11 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './controller-spec'; +export * from './json-to-schema'; +export * from './generate-schema'; +export * from './request-body-decorator'; +export * from './parameter-decorator'; +export * from './keys'; diff --git a/packages/openapi-v3/src/json-to-schema.ts b/packages/openapi-v3/src/json-to-schema.ts new file mode 100644 index 000000000000..93e265876423 --- /dev/null +++ b/packages/openapi-v3/src/json-to-schema.ts @@ -0,0 +1,88 @@ +import {JsonDefinition} from '@loopback/repository-json-schema'; +import {SchemaObject, ExtensionValue} from '@loopback/openapi-spec-types'; +import * as _ from 'lodash'; + +export function jsonToSchemaObject(jsonDef: JsonDefinition): SchemaObject { + const json = jsonDef as {[name: string]: ExtensionValue}; // gets around index signature error + const result: SchemaObject = {}; + const propsToIgnore = [ + 'anyOf', + 'oneOf', + 'additionalItems', + 'defaultProperties', + 'typeof', + ]; + for (const property in json) { + if (propsToIgnore.includes(property)) { + continue; + } + switch (property) { + case 'type': { + if (json.type === 'array' && !json.items) { + throw new Error( + '"items" property must be present if "type" is an array', + ); + } + result.type = Array.isArray(json.type) ? json.type[0] : json.type; + break; + } + case 'allOf': { + result.allOf = _.map(json.allOf, item => jsonToSchemaObject(item)); + break; + } + case 'definitions': { + result.definitions = _.mapValues(json.definitions, def => + jsonToSchemaObject(def), + ); + break; + } + case 'properties': { + result.properties = _.mapValues(json.properties, item => + jsonToSchemaObject(item), + ); + break; + } + case 'additionalProperties': { + if (typeof json.additionalProperties !== 'boolean') { + result.additionalProperties = jsonToSchemaObject( + json.additionalProperties as JsonDefinition, + ); + } + break; + } + case 'items': { + const items = Array.isArray(json.items) ? json.items[0] : json.items; + result.items = jsonToSchemaObject(items as JsonDefinition); + break; + } + case 'enum': { + const newEnum = []; + const primitives = ['string', 'number', 'boolean']; + for (const element of json.enum) { + if (primitives.includes(typeof element) || element === null) { + newEnum.push(element); + } else { + // if element is JsonDefinition, convert to SchemaObject + newEnum.push(jsonToSchemaObject(element as JsonDefinition)); + } + } + result.enum = newEnum; + + break; + } + case '$ref': { + result.$ref = json.$ref.replace( + '#/definitions', + '#/components/schemas', + ); + break; + } + default: { + result[property] = json[property]; + break; + } + } + } + + return result; +} diff --git a/packages/openapi-v3/src/keys.ts b/packages/openapi-v3/src/keys.ts new file mode 100644 index 000000000000..b0bbffbdf526 --- /dev/null +++ b/packages/openapi-v3/src/keys.ts @@ -0,0 +1,12 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export namespace OAS3 { + export const METHODS_KEY = 'rest:methods'; + export const PARAMETERS_KEY = 'rest:parameters'; + export const CLASS_KEY = 'rest:class'; + export const CONTROLLER_SPEC_KEY = 'rest:controller-spec'; + export const REQUEST_BODY_KEY = 'rest:request-body'; +} diff --git a/packages/openapi-v3/src/parameter-decorator.ts b/packages/openapi-v3/src/parameter-decorator.ts new file mode 100644 index 000000000000..fb0bf918811f --- /dev/null +++ b/packages/openapi-v3/src/parameter-decorator.ts @@ -0,0 +1,469 @@ +import { + isSchemaObject, + ParameterObject, + ParameterLocation, + ReferenceObject, + SchemaObject, +} from '@loopback/openapi-spec-types'; +import {MetadataInspector, ParameterDecoratorFactory} from '@loopback/context'; +import {getSchemaForParam, OAS3} from '../'; + +/** + * Describe an input parameter of a Controller method. + * + * `@param` must be applied to parameters. For example, + * ``` + * class MyController { + * @get('/') + * list( + * @param(offsetSpec) offset?: number, + * @param(pageSizeSpec) pageSize?: number, + * ) {} + * } + * ``` + * + * @param paramSpec Parameter specification. + */ +export function param(paramSpec: ParameterObject) { + return function( + target: Object, + member: string | symbol, + // deprecate method level decorator + index: number, + ) { + paramSpec = paramSpec || {}; + // Get the design time method parameter metadata + const methodSig = MetadataInspector.getDesignTypeForMethod(target, member); + const paramTypes = (methodSig && methodSig.parameterTypes) || []; + + // Map design-time parameter type to the OpenAPI param type + + let paramType = paramTypes[index]; + + if (paramType) { + if ( + !paramSpec.schema || + (isSchemaObject(paramSpec.schema) && !paramSpec.schema.type) + ) { + paramSpec.schema = getSchemaForParam(paramType, paramSpec.schema || {}); + } + } + + if ( + paramSpec.schema && + isSchemaObject(paramSpec.schema) && + paramSpec.schema.type === 'array' + ) { + // The design-time type is `Object` for `any` + if (paramType != null && paramType !== Object && paramType !== Array) { + throw new Error( + `The parameter type is set to 'array' but the JavaScript type is ${ + paramType.name + }`, + ); + } + } + + ParameterDecoratorFactory.createDecorator( + OAS3.PARAMETERS_KEY, + paramSpec, + )(target, member, index); + }; +} + +/** + * The `type` and `format` inferred by a common name of OpenAPI 3.0.0 data type + * reference link: + * https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#data-types + */ +const typeAndFormatMap = { + integer: {type: 'integer', format: 'int32'}, + long: {type: 'integer', format: 'int64'}, + float: {type: 'number', format: 'float'}, + double: {type: 'number', format: 'double'}, + byte: {type: 'string', format: 'byte'}, + binary: {type: 'string', format: 'binary'}, + date: {type: 'string', format: 'date'}, + dateTime: {type: 'string', format: 'date-time'}, + password: {type: 'string', format: 'password'}, +}; + +/** + * Shortcut parameter decorators + */ +export namespace param { + export const query = { + /** + * @param.query.string('paramName') + */ + string: createParamShortCut('query', 'string'), + /** + * @param.query.number('paramName') + */ + number: createParamShortCut('query', 'number'), + /** + * @param.query.boolean('paramName') + */ + boolean: createParamShortCut('query', 'boolean'), + /** + * @param.query.integer('paramName') + */ + integer: createParamShortCut( + 'query', + typeAndFormatMap.integer.type, + typeAndFormatMap.integer.format, + ), + /** + * @param.query.long('paramName') + */ + long: createParamShortCut( + 'query', + typeAndFormatMap.long.type, + typeAndFormatMap.long.format, + ), + /** + * @param.query.float('paramName') + */ + float: createParamShortCut( + 'query', + typeAndFormatMap.float.type, + typeAndFormatMap.float.format, + ), + /** + * @param.query.double('paramName') + */ + double: createParamShortCut( + 'query', + typeAndFormatMap.double.type, + typeAndFormatMap.double.format, + ), + /** + * @param.query.byte('paramName') + */ + byte: createParamShortCut( + 'query', + typeAndFormatMap.byte.type, + typeAndFormatMap.byte.format, + ), + /** + * @param.query.binary('paramName') + */ + binary: createParamShortCut( + 'query', + typeAndFormatMap.binary.type, + typeAndFormatMap.binary.format, + ), + /** + * @param.query.date('paramName') + */ + date: createParamShortCut( + 'query', + typeAndFormatMap.date.type, + typeAndFormatMap.date.format, + ), + /** + * @param.query.dateTime('paramName') + */ + dateTime: createParamShortCut( + 'query', + typeAndFormatMap.dateTime.type, + typeAndFormatMap.dateTime.format, + ), + /** + * @param.query.password('paramName') + */ + password: createParamShortCut( + 'query', + typeAndFormatMap.password.type, + typeAndFormatMap.password.format, + ), + }; + export const cookie = { + /** + * @param.cookie.string('paramName') + */ + string: createParamShortCut('cookie', 'string'), + /** + * @param.cookie.number('paramName') + */ + number: createParamShortCut('cookie', 'number'), + /** + * @param.cookie.boolean('paramName') + */ + boolean: createParamShortCut('cookie', 'boolean'), + /** + * @param.cookie.integer('paramName') + */ + integer: createParamShortCut( + 'cookie', + typeAndFormatMap.integer.type, + typeAndFormatMap.integer.format, + ), + /** + * @param.cookie.long('paramName') + */ + long: createParamShortCut( + 'cookie', + typeAndFormatMap.long.type, + typeAndFormatMap.long.format, + ), + /** + * @param.cookie.float('paramName') + */ + float: createParamShortCut( + 'cookie', + typeAndFormatMap.float.type, + typeAndFormatMap.float.format, + ), + /** + * @param.cookie.double('paramName') + */ + double: createParamShortCut( + 'cookie', + typeAndFormatMap.double.type, + typeAndFormatMap.double.format, + ), + /** + * @param.cookie.byte('paramName') + */ + byte: createParamShortCut( + 'cookie', + typeAndFormatMap.byte.type, + typeAndFormatMap.byte.format, + ), + /** + * @param.cookie.binary('paramName') + */ + binary: createParamShortCut( + 'cookie', + typeAndFormatMap.binary.type, + typeAndFormatMap.binary.format, + ), + /** + * @param.cookie.date('paramName') + */ + date: createParamShortCut( + 'cookie', + typeAndFormatMap.date.type, + typeAndFormatMap.date.format, + ), + /** + * @param.cookie.dateTime('paramName') + */ + dateTime: createParamShortCut( + 'cookie', + typeAndFormatMap.dateTime.type, + typeAndFormatMap.dateTime.format, + ), + /** + * @param.cookie.password('paramName') + */ + password: createParamShortCut( + 'cookie', + typeAndFormatMap.password.type, + typeAndFormatMap.password.format, + ), + }; + export const header = { + /** + * @param.header.string('paramName') + */ + string: createParamShortCut('header', 'string'), + /** + * @param.header.number('paramName') + */ + number: createParamShortCut('header', 'number'), + /** + * @param.header.boolean('paramName') + */ + boolean: createParamShortCut('header', 'boolean'), + /** + * @param.header.integer('paramName') + */ + integer: createParamShortCut( + 'header', + typeAndFormatMap.integer.type, + typeAndFormatMap.integer.format, + ), + /** + * @param.header.long('paramName') + */ + long: createParamShortCut( + 'header', + typeAndFormatMap.long.type, + typeAndFormatMap.long.format, + ), + /** + * @param.header.float('paramName') + */ + float: createParamShortCut( + 'header', + typeAndFormatMap.float.type, + typeAndFormatMap.float.format, + ), + /** + * @param.header.double('paramName') + */ + double: createParamShortCut( + 'header', + typeAndFormatMap.double.type, + typeAndFormatMap.double.format, + ), + /** + * @param.header.byte('paramName') + */ + byte: createParamShortCut( + 'header', + typeAndFormatMap.byte.type, + typeAndFormatMap.byte.format, + ), + /** + * @param.header.binary('paramName') + */ + binary: createParamShortCut( + 'header', + typeAndFormatMap.binary.type, + typeAndFormatMap.binary.format, + ), + /** + * @param.header.date('paramName') + */ + date: createParamShortCut( + 'header', + typeAndFormatMap.date.type, + typeAndFormatMap.date.format, + ), + /** + * @param.header.dateTime('paramName') + */ + dateTime: createParamShortCut( + 'header', + typeAndFormatMap.dateTime.type, + typeAndFormatMap.dateTime.format, + ), + /** + * @param.header.password('paramName') + */ + password: createParamShortCut( + 'header', + typeAndFormatMap.password.type, + typeAndFormatMap.password.format, + ), + }; + export const path = { + /** + * @param.path.string('paramName') + */ + string: createParamShortCut('path', 'string'), + /** + * @param.path.number('paramName') + */ + number: createParamShortCut('path', 'number'), + /** + * @param.path.boolean('paramName') + */ + boolean: createParamShortCut('path', 'boolean'), + /** + * @param.path.integer('paramName') + */ + integer: createParamShortCut( + 'path', + typeAndFormatMap.integer.type, + typeAndFormatMap.integer.format, + ), + /** + * @param.path.long('paramName') + */ + long: createParamShortCut( + 'path', + typeAndFormatMap.long.type, + typeAndFormatMap.long.format, + ), + /** + * @param.path.float('paramName') + */ + float: createParamShortCut( + 'path', + typeAndFormatMap.float.type, + typeAndFormatMap.float.format, + ), + /** + * @param.path.double('paramName') + */ + double: createParamShortCut( + 'path', + typeAndFormatMap.double.type, + typeAndFormatMap.double.format, + ), + /** + * @param.path.byte('paramName') + */ + byte: createParamShortCut( + 'path', + typeAndFormatMap.byte.type, + typeAndFormatMap.byte.format, + ), + /** + * @param.path.binary('paramName') + */ + binary: createParamShortCut( + 'path', + typeAndFormatMap.binary.type, + typeAndFormatMap.binary.format, + ), + /** + * @param.path.date('paramName') + */ + date: createParamShortCut( + 'path', + typeAndFormatMap.date.type, + typeAndFormatMap.date.format, + ), + /** + * @param.path.dateTime('paramName') + */ + dateTime: createParamShortCut( + 'path', + typeAndFormatMap.dateTime.type, + typeAndFormatMap.dateTime.format, + ), + /** + * @param.path.password('paramName') + */ + password: createParamShortCut( + 'path', + typeAndFormatMap.password.type, + typeAndFormatMap.password.format, + ), + }; + /** + * @param.array('paramName', 'query', {type: string}) + */ + export const array = function( + name: string, + source: ParameterLocation, + itemSpec: SchemaObject | ReferenceObject, + ) { + return param({ + name, + in: source, + schema: {type: 'array', items: itemSpec}, + }); + }; +} + +function createParamShortCut( + source: ParameterLocation, + type: string, + format?: string, +) { + if (format) { + return (name: string) => { + return param({name, in: source, schema: {type, format}}); + }; + } else { + return (name: string) => { + return param({name, in: source, schema: {type}}); + }; + } +} diff --git a/packages/openapi-v3/src/request-body-decorator.ts b/packages/openapi-v3/src/request-body-decorator.ts new file mode 100644 index 000000000000..cd204545a686 --- /dev/null +++ b/packages/openapi-v3/src/request-body-decorator.ts @@ -0,0 +1,52 @@ +import {RequestBodyObject} from '@loopback/openapi-spec-types'; +import {MetadataInspector, ParameterDecoratorFactory} from '@loopback/context'; +import {getSchemaForRequestBody, OAS3} from '../'; +import * as _ from 'lodash'; +import {inspect} from 'util'; + +const debug = require('debug')('loopback:rest:router:metadata'); +export const REQUEST_BODY_INDEX = 'x-parameter-index'; + +/** + * Describe the request body of a Controller method parameter. + * + * @param requestBodySpec The complete requestBody Object or partial of it. + * "partial" for allowing no `content` in spec, for example: + * ``` + * @requestBody({description: 'a request body'}) foo: Foo + * ``` + */ +export function requestBody(requestBodySpec?: Partial) { + return function(target: Object, member: string | symbol, index: number) { + // Use 'application/json' as default content if `requestBody` is undefined + requestBodySpec = requestBodySpec || {content: {}}; + + if (_.isEmpty(requestBodySpec.content)) + requestBodySpec.content = {'application/json': {}}; + + // Get the design time method parameter metadata + const methodSig = MetadataInspector.getDesignTypeForMethod(target, member); + const paramTypes = (methodSig && methodSig.parameterTypes) || []; + + let paramType = paramTypes[index]; + let schema = getSchemaForRequestBody(paramType); + requestBodySpec.content = _.mapValues(requestBodySpec.content, c => { + c.schema = c.schema || schema; + return c; + }); + + // The default position for request body argument is 0 + // if not, add extension 'x-parameter-index' to specify the position + if (index !== 0) { + requestBodySpec[REQUEST_BODY_INDEX] = index; + } + + debug('requestBody member: ', member); + debug('requestBody index: ', index); + debug('requestBody spec: ', inspect(requestBodySpec, {depth: null})); + ParameterDecoratorFactory.createDecorator( + OAS3.REQUEST_BODY_KEY, + requestBodySpec as RequestBodyObject, + )(target, member, index); + }; +} diff --git a/packages/openapi-v3/test/integration/operation-spec.ts b/packages/openapi-v3/test/integration/operation-spec.ts new file mode 100644 index 000000000000..65b4a2696a50 --- /dev/null +++ b/packages/openapi-v3/test/integration/operation-spec.ts @@ -0,0 +1,60 @@ +import {expect} from '@loopback/testlab'; +import {model, property} from '@loopback/repository'; +import {param, requestBody, getControllerSpec, post} from '../../'; + +describe('operation arguments', () => { + it('generate parameters and requestBody for operation', () => { + @model() + class User { + @property() name: string; + @property() password: number; + } + + class MyController { + @post('/users') + async createUser( + @param.query.string('type') type: string, + @param.header.string('token') token: string, + @param.cookie.string('session') session: string, + @param.path.string('location') location: string, + @requestBody() user: User, + ): Promise { + return; + } + } + + const expectedSpec = { + paths: { + '/users': { + post: { + responses: {}, + parameters: [ + {name: 'type', in: 'query', schema: {type: 'string'}}, + {name: 'token', in: 'header', schema: {type: 'string'}}, + {name: 'session', in: 'cookie', schema: {type: 'string'}}, + {name: 'location', in: 'path', schema: {type: 'string'}}, + ], + requestBody: { + content: { + 'application/json': { + schema: {$ref: '#/components/schemas/User'}, + }, + }, + 'x-parameter-index': 4, + }, + 'x-operation-name': 'createUser', + }, + }, + }, + components: { + schemas: { + User: { + title: 'User', + properties: {name: {type: 'string'}, password: {type: 'number'}}, + }, + }, + }, + }; + expect(getControllerSpec(MyController)).to.eql(expectedSpec); + }); +}); diff --git a/packages/openapi-v2/test/unit/controller-spec/controller-decorators.test.ts b/packages/openapi-v3/test/unit/controller-spec/controller-decorators/controller-decorators.test.ts similarity index 84% rename from packages/openapi-v2/test/unit/controller-spec/controller-decorators.test.ts rename to packages/openapi-v3/test/unit/controller-spec/controller-decorators/controller-decorators.test.ts index 1b7d3c7c01cc..ad777e901e39 100644 --- a/packages/openapi-v2/test/unit/controller-spec/controller-decorators.test.ts +++ b/packages/openapi-v3/test/unit/controller-spec/controller-decorators/controller-decorators.test.ts @@ -1,5 +1,5 @@ // Copyright IBM Corp. 2018. All Rights Reserved. -// Node module: @loopback/openapi-v2 +// Node module: @loopback/openapi-v3 // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT @@ -13,7 +13,9 @@ import { patch, del, param, -} from '../../..'; + requestBody, +} from '../../../..'; +import {model, property} from '@loopback/repository'; import {expect} from '@loopback/testlab'; import {anOpenApiSpec, anOperationSpec} from '@loopback/openapi-spec-builder'; @@ -129,7 +131,6 @@ describe('Routing metadata', () => { const operationSpec = anOperationSpec() .withStringResponse() .build(); - class MyController { @patch('/greeting', operationSpec) patchGreeting() {} @@ -371,4 +372,66 @@ describe('Routing metadata', () => { in: 'query', }); }); + + it('adds property schemas in components.schemas', () => { + @model() + class Bar { + @property() name: string; + } + + @model() + class Baz { + @property() name: string; + } + + @model() + class Foo { + @property() bar: Bar; + @property() baz: Baz; + } + + class FooController { + @post('/foo') + create( + @requestBody({name: 'foo', in: 'query'}) + foo: Foo, + ): void {} + } + + const expectedSpec = { + paths: { + '/foo': { + post: { + responses: {}, + requestBody: { + name: 'foo', + in: 'query', + content: { + 'application/json': { + schema: {$ref: '#/components/schemas/Foo'}, + }, + }, + }, + 'x-operation-name': 'create', + }, + }, + }, + components: { + schemas: { + Bar: {title: 'Bar', properties: {name: {type: 'string'}}}, + Baz: {title: 'Baz', properties: {name: {type: 'string'}}}, + Foo: { + // guarantee `definition` is deleted + title: 'Foo', + properties: { + bar: {$ref: '#/components/schemas/Bar'}, + baz: {$ref: '#/components/schemas/Baz'}, + }, + }, + }, + }, + }; + + expect(getControllerSpec(FooController)).to.eql(expectedSpec); + }); }); diff --git a/packages/openapi-v3/test/unit/controller-spec/controller-decorators/param-decorators/param.shortcut.test.ts b/packages/openapi-v3/test/unit/controller-spec/controller-decorators/param-decorators/param.shortcut.test.ts new file mode 100644 index 000000000000..f93abd632b7a --- /dev/null +++ b/packages/openapi-v3/test/unit/controller-spec/controller-decorators/param-decorators/param.shortcut.test.ts @@ -0,0 +1,65 @@ +import {expect} from '@loopback/testlab'; +import {post, param, getControllerSpec} from '../../../../../'; + +describe('param shortcut', () => { + it('javascript primitive types', () => { + class MyController { + @post('/users') + createUser( + @param.query.string('name') name: string, + @param.query.number('age') age: number, + @param.query.boolean('isNew') isNew: boolean, + ) {} + } + + const controllerSpec = getControllerSpec(MyController); + const params = controllerSpec.paths['/users'].post.parameters; + expect(params.length).to.eql(3); + + expect(params[0].schema.type).to.eql('string'); + expect(params[0].schema.format).to.eql(undefined); + expect(params[1].schema.type).to.eql('number'); + expect(params[1].schema.format).to.eql(undefined); + expect(params[2].schema.type).to.eql('boolean'); + expect(params[2].schema.format).to.eql(undefined); + }); + it('OpenAPI 3.0.0 primative types', () => { + class MyController { + @post('/users') + createUser( + @param.query.integer('age') age: number, + @param.query.long('SIN') SIN: number, + @param.query.float('height') height: number, + @param.query.double('foo') foo: number, + @param.query.byte('file') file: string, + @param.query.binary('image') image: string, + @param.query.date('createdDate') createdDate: string, + @param.query.dateTime('createdTime') createdTime: string, + @param.query.password('password') password: string, + ) {} + } + + const controllerSpec = getControllerSpec(MyController); + const params = controllerSpec.paths['/users'].post.parameters; + expect(params.length).to.eql(9); + + expect(params[0].schema.type).to.eql('integer'); + expect(params[0].schema.format).to.eql('int32'); + expect(params[1].schema.type).to.eql('integer'); + expect(params[1].schema.format).to.eql('int64'); + expect(params[2].schema.type).to.eql('number'); + expect(params[2].schema.format).to.eql('float'); + expect(params[3].schema.type).to.eql('number'); + expect(params[3].schema.format).to.eql('double'); + expect(params[4].schema.type).to.eql('string'); + expect(params[4].schema.format).to.eql('byte'); + expect(params[5].schema.type).to.eql('string'); + expect(params[5].schema.format).to.eql('binary'); + expect(params[6].schema.type).to.eql('string'); + expect(params[6].schema.format).to.eql('date'); + expect(params[7].schema.type).to.eql('string'); + expect(params[7].schema.format).to.eql('date-time'); + expect(params[8].schema.type).to.eql('string'); + expect(params[8].schema.format).to.eql('password'); + }); +}); diff --git a/packages/openapi-v3/test/unit/controller-spec/controller-decorators/param-decorators/param.test.ts b/packages/openapi-v3/test/unit/controller-spec/controller-decorators/param-decorators/param.test.ts new file mode 100644 index 000000000000..8eacaf5e4212 --- /dev/null +++ b/packages/openapi-v3/test/unit/controller-spec/controller-decorators/param-decorators/param.test.ts @@ -0,0 +1,215 @@ +import { + ParameterObject, + ResponsesObject, + OperationObject, +} from '@loopback/openapi-spec-types'; +import {param, get, patch, operation, getControllerSpec} from '../../../../../'; +import {anOperationSpec} from '@loopback/openapi-spec-builder'; +import {expect} from '@loopback/testlab'; + +describe('Routing metadata for parameters', () => { + describe('@param', () => { + it('defines a new parameter', () => { + const paramSpec: ParameterObject = { + name: 'name', + schema: { + type: 'string', + }, + in: 'query', + }; + + class MyController { + @get('/greet') + greet(@param(paramSpec) name: string) {} + } + + const actualSpec = getControllerSpec(MyController); + + const expectedSpec = anOperationSpec() + .withOperationName('greet') + .withParameter(paramSpec) + .build(); + expect(actualSpec.paths['/greet']['get']).to.eql(expectedSpec); + }); + it('infers ts primitive types', () => { + class MyController { + @patch('/update/{id}') + update( + @param({ + name: 'id', + in: 'path', + }) + id: string, + @param({ + name: 'name', + in: 'query', + }) + name: string, + @param({ + name: 'age', + in: 'query', + }) + age: number, + @param({ + name: 'vip', + in: 'query', + }) + vip: boolean, + @param.array('tags', 'query', {type: 'string'}) + tags: string[], + @param({ + name: 'address', + in: 'query', + }) + address: object, + ) {} + } + + const actualSpec = getControllerSpec(MyController); + + const expectedSpec = anOperationSpec() + .withOperationName('update') + .withParameter({ + name: 'id', + schema: { + type: 'string', + }, + in: 'path', + }) + .withParameter({ + name: 'name', + schema: { + type: 'string', + }, + in: 'query', + }) + .withParameter({ + name: 'age', + schema: { + type: 'number', + }, + in: 'query', + }) + .withParameter({ + name: 'vip', + schema: { + type: 'boolean', + }, + in: 'query', + }) + .withParameter({ + name: 'tags', + schema: { + type: 'array', + items: { + type: 'string', + }, + }, + in: 'query', + }) + .withParameter({ + name: 'address', + schema: { + type: 'object', + }, + in: 'query', + }) + .build(); + + expect(actualSpec.paths['/update/{id}']['patch']).to.eql(expectedSpec); + }); + it('infers array type without explicit type', () => { + class MyController { + @get('/greet') + greet( + @param({ + name: 'names', + in: 'query', + schema: {items: {type: 'string'}}, + }) + names: string[], + ) {} + } + + const actualSpec = getControllerSpec(MyController); + + const expectedSpec = anOperationSpec() + .withOperationName('greet') + .withParameter({ + name: 'names', + schema: { + type: 'array', + items: { + type: 'string', + }, + }, + in: 'query', + }) + .build(); + + expect(actualSpec.paths['/greet']['get']).to.eql(expectedSpec); + }); + it('reports error if an array parameter type is not Array', () => { + expect.throws( + () => { + // tslint:disable-next-line:no-unused-variable + class MyController { + @get('/greet') + greet( + @param.array('names', 'query', {type: 'string'}) + names: string, + ) {} + } + }, + Error, + `The parameter type is set to 'array' but the JavaScript type is String`, + ); + }); + it('adds to existing spec provided via @operation', () => { + const offsetSpec: ParameterObject = { + name: 'offset', + in: 'query', + schema: { + type: 'number', + }, + }; + + const pageSizeSpec: ParameterObject = { + name: 'pageSize', + in: 'query', + schema: { + type: 'number', + }, + }; + + const responses: ResponsesObject = { + 200: { + content: { + '*/*': { + schema: { + type: 'string', + }, + }, + }, + description: 'a string response', + }, + }; + + class MyController { + @operation('get', '/', {responses}) + list( + @param({name: 'offset', in: 'query'}) + offset?: number, + @param({name: 'pageSize', in: 'query'}) + pageSize?: number, + ) {} + } + + const apiSpec = getControllerSpec(MyController); + const opSpec: OperationObject = apiSpec.paths['/']['get']; + + expect(opSpec.responses).to.eql(responses); + expect(opSpec.parameters).to.eql([offsetSpec, pageSizeSpec]); + }); + }); +}); diff --git a/packages/openapi-v3/test/unit/controller-spec/controller-decorators/request-body-decorators/request-body.test.ts b/packages/openapi-v3/test/unit/controller-spec/controller-decorators/request-body-decorators/request-body.test.ts new file mode 100644 index 000000000000..d11f604c1e5b --- /dev/null +++ b/packages/openapi-v3/test/unit/controller-spec/controller-decorators/request-body-decorators/request-body.test.ts @@ -0,0 +1,97 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {post, requestBody, getControllerSpec} from '../../../../..'; +import {expect} from '@loopback/testlab'; +import {model, property} from '@loopback/repository'; + +describe('Routing metadata for request body', () => { + describe('@requestBody', () => { + context('can build a correct "RequestBody" spec and', () => { + it('persists "description" and "required" into the generated schema', () => { + const requestSpec = { + description: 'A sample request body', + required: true, + }; + class MyController { + @post('/greeting') + greet(@requestBody(requestSpec) name: string) {} + } + + const r = getControllerSpec(MyController).paths['/greeting']['post'] + .requestBody; + expect(r.description).to.eql('A sample request body'); + expect(r.required).to.eql(true); + }); + it('defaults content-type to "application/json"', () => { + const requestSpec = { + description: 'A sample request body', + required: true, + }; + class MyController { + @post('/greeting') + greet(@requestBody(requestSpec) name: string) {} + } + + const r = getControllerSpec(MyController).paths['/greeting']['post'] + .requestBody; + expect(r.content).to.have.key('application/json'); + }); + it('infers request body with complex type', () => { + const expectedContent = { + 'application/text': { + schema: {$ref: '#/components/schemas/MyModel'}, + }, + }; + @model() + class MyModel { + @property() name: string; + } + + class MyController { + @post('/MyModel') + createMyModel( + @requestBody({content: {'application/text': {}}}) + inst: MyModel, + ) {} + } + + const r = getControllerSpec(MyController).paths['/MyModel']['post'] + .requestBody; + expect(r.content).to.deepEqual(expectedContent); + }); + it('schema in requestBody overrides the generated schema', () => { + const expectedContent = { + 'application/json': { + schema: {type: 'object'}, + }, + }; + + class MyModel {} + + class MyController { + @post('/MyModel') + createMyModel( + @requestBody({content: expectedContent}) + inst: MyModel, + ) {} + } + + const r = getControllerSpec(MyController).paths['/MyModel']['post'] + .requestBody; + expect(r.content).to.deepEqual(expectedContent); + }); + it('reports error if more than one requestBody are found for the same method', () => { + class MyController { + @post('/greeting') + greet(@requestBody() name: string, @requestBody() foo: number) {} + } + expect(() => getControllerSpec(MyController)).to.throwError( + /An operation should only have one parameter decorated by @requestBody/, + ); + }); + }); + }); +}); diff --git a/packages/openapi-v3/test/unit/controller-spec/controller-decorators/request-body-decorators/shortcut.test.ts b/packages/openapi-v3/test/unit/controller-spec/controller-decorators/request-body-decorators/shortcut.test.ts new file mode 100644 index 000000000000..7ebde2f33f0d --- /dev/null +++ b/packages/openapi-v3/test/unit/controller-spec/controller-decorators/request-body-decorators/shortcut.test.ts @@ -0,0 +1,94 @@ +import {post, requestBody, getControllerSpec} from '../../../../../'; +import {expect} from '@loopback/testlab'; + +// Just tired to create shortcuts for @requestBody after writing +// them for @param +// I will add shortcut decorators if we think they are valuable +// considering we are changing to DI soon +describe('requestBody shortcut', () => { + it('infers request body with primative types', () => { + class MyController { + @post('/greetingWithString') + greetWithString(@requestBody() name: string) {} + @post('/greetingWithNumber') + greetWithNumber(@requestBody() name: number) {} + @post('/greetingWithBoolean') + greetWithBoolean(@requestBody() name: boolean) {} + @post('/greetingWithArray') + greetWithArray(@requestBody() name: string[]) {} + @post('/greetingWithObject') + greetWitObejct(@requestBody() name: object) {} + // @post('/greetingWithInteger') + // greetWithInteger(@requestBody.integer() name: number) {} + // @post('/greetingWithLong') + // greetWithLong(@requestBody.long() name: number) {} + // @post('/greetingWithFloat') + // greetWithFloat(@requestBody.float() name: number) {} + // @post('/greetingWithDouble') + // greetWithDouble(@requestBody.double() name: number) {} + // @post('/greetingWithByte') + // greetWithByte(@requestBody.byte() name: string) {} + // @post('/greetingWithBinary') + // greetWithBinary(@requestBody.binary() name: string) {} + // @post('/greetingWithDate') + // greetWithDate(@requestBody.date() name: string) {} + // @post('/greetingWithDateTime') + // greetWithDateTime(@requestBody.dateTime() name: string) {} + // @post('/greetingWithPassword') + // greetWithPassword(@requestBody.password() name: string) {} + } + + const actualSpec = getControllerSpec(MyController); + const expectedContentWithString = { + 'application/json': { + schema: { + type: 'string', + }, + }, + }; + const expectedContentWithNumber = { + 'application/json': { + schema: { + type: 'number', + }, + }, + }; + const expectedContentWithBoolean = { + 'application/json': { + schema: { + type: 'boolean', + }, + }, + }; + const expectedContentWithArray = { + 'application/json': { + schema: { + type: 'array', + }, + }, + }; + const expectedContentWithObject = { + 'application/json': { + schema: { + type: 'object', + }, + }, + }; + + expect( + actualSpec.paths['/greetingWithString']['post'].requestBody.content, + ).to.eql(expectedContentWithString); + expect( + actualSpec.paths['/greetingWithNumber']['post'].requestBody.content, + ).to.eql(expectedContentWithNumber); + expect( + actualSpec.paths['/greetingWithBoolean']['post'].requestBody.content, + ).to.eql(expectedContentWithBoolean); + expect( + actualSpec.paths['/greetingWithArray']['post'].requestBody.content, + ).to.eql(expectedContentWithArray); + expect( + actualSpec.paths['/greetingWithObject']['post'].requestBody.content, + ).to.eql(expectedContentWithObject); + }); +}); diff --git a/packages/openapi-v2/test/unit/controller-spec/json-to-schema-object.test.ts b/packages/openapi-v3/test/unit/controller-spec/json-to-schema-object.test.ts similarity index 92% rename from packages/openapi-v2/test/unit/controller-spec/json-to-schema-object.test.ts rename to packages/openapi-v3/test/unit/controller-spec/json-to-schema-object.test.ts index 97bf3952bfb6..37ae83ed41c7 100644 --- a/packages/openapi-v2/test/unit/controller-spec/json-to-schema-object.test.ts +++ b/packages/openapi-v3/test/unit/controller-spec/json-to-schema-object.test.ts @@ -5,7 +5,8 @@ import {expect} from '@loopback/testlab'; import {JsonDefinition} from '@loopback/repository-json-schema'; -import {SchemaObject} from '@loopback/openapi-spec'; + +import {SchemaObject} from '@loopback/openapi-spec-types'; import {jsonToSchemaObject} from '../../../index'; describe('jsonToSchemaObject', () => { @@ -42,16 +43,6 @@ describe('jsonToSchemaObject', () => { propertyConversionTest(allOfDef, expectedAllOf); }); - it('converts definitions', () => { - const definitionsDef: JsonDefinition = { - definitions: {foo: typeDef, bar: typeDef}, - }; - const expectedDef: SchemaObject = { - definitions: {foo: expectedType, bar: expectedType}, - }; - propertyConversionTest(definitionsDef, expectedDef); - }); - it('converts properties', () => { const propertyDef: JsonDefinition = { properties: { diff --git a/packages/openapi-v2/tsconfig.build.json b/packages/openapi-v3/tsconfig.build.json similarity index 100% rename from packages/openapi-v2/tsconfig.build.json rename to packages/openapi-v3/tsconfig.build.json diff --git a/packages/repository-json-schema/src/build-schema.ts b/packages/repository-json-schema/src/build-schema.ts index a8564684be6c..971ba1e7d038 100644 --- a/packages/repository-json-schema/src/build-schema.ts +++ b/packages/repository-json-schema/src/build-schema.ts @@ -29,6 +29,7 @@ export interface JsonDefinition extends Definition { additionalProperties?: JsonDefinition | boolean; definitions?: {[definition: string]: JsonDefinition}; properties?: {[property: string]: JsonDefinition}; + $ref?: string; } /** diff --git a/packages/rest/index.ts b/packages/rest/index.ts index e6accd3805ab..bc044f17f36d 100644 --- a/packages/rest/index.ts +++ b/packages/rest/index.ts @@ -6,3 +6,4 @@ // DO NOT EDIT THIS FILE // Add any aditional (re)exports to src/index.ts instead. export * from './src'; +export * from '@loopback/openapi-v3'; diff --git a/packages/rest/package.json b/packages/rest/package.json index ba082a0b419a..b57453b3485e 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -13,9 +13,11 @@ "prepublishOnly": "npm run build && npm run build:apidocs", "pretest": "npm run build", "integration": "lb-mocha \"DIST/test/integration/**/*.js\"", - "test": "lb-mocha \"DIST/test/unit/**/*.js\" \"DIST/test/integration/**/*.js\" \"DIST/test/acceptance/**/*.js\"", + "test": + "lb-mocha \"DIST/test/unit/**/*.js\" \"DIST/test/integration/**/*.js\" \"DIST/test/acceptance/**/*.js\"", "unit": "lb-mocha \"DIST/test/unit/**/*.js\"", - "verify": "npm pack && tar xf loopback-rest*.tgz && tree package && npm run clean" + "verify": + "npm pack && tar xf loopback-rest*.tgz && tree package && npm run clean" }, "author": "IBM", "copyright.owner": "IBM Corp.", @@ -23,11 +25,12 @@ "dependencies": { "@loopback/context": "^4.0.0-alpha.31", "@loopback/core": "^4.0.0-alpha.33", - "@loopback/openapi-spec": "^4.0.0-alpha.25", - "@loopback/openapi-v2": "^4.0.0-alpha.10", + "@loopback/openapi-spec-types": "^4.0.0-alpha.1", + "@loopback/openapi-v3": "^4.0.0-alpha.1", "@types/http-errors": "^1.6.1", "@types/node": "^8.5.9", "body": "^5.1.0", + "cookie": "^0.3.1", "debug": "^3.1.0", "http-errors": "^1.6.1", "js-yaml": "^3.9.1", diff --git a/packages/rest/src/http-handler.ts b/packages/rest/src/http-handler.ts index 700e1ca3f7f7..13391a784181 100644 --- a/packages/rest/src/http-handler.ts +++ b/packages/rest/src/http-handler.ts @@ -4,9 +4,9 @@ // License text available at https://opensource.org/licenses/MIT import {Context} from '@loopback/context'; -import {PathsObject, DefinitionsObject} from '@loopback/openapi-spec'; +import {PathsObject, SchemaObject} from '@loopback/openapi-spec-types'; import {ServerRequest, ServerResponse} from 'http'; -import {ControllerSpec} from '@loopback/openapi-v2'; +import {ControllerSpec} from '@loopback/openapi-v3'; import {SequenceHandler} from './sequence'; import { @@ -22,7 +22,7 @@ import {RestBindings} from './keys'; export class HttpHandler { protected _routes: RoutingTable = new RoutingTable(); - protected _apiDefinitions: DefinitionsObject; + protected _apiDefinitions: {[keys: string]: SchemaObject}; public handleRequest: ( request: ServerRequest, @@ -41,7 +41,7 @@ export class HttpHandler { this._routes.registerRoute(route); } - registerApiDefinitions(defs: DefinitionsObject) { + registerApiDefinitions(defs: SchemaObject) { this._apiDefinitions = Object.assign({}, this._apiDefinitions, defs); } diff --git a/packages/rest/src/index.ts b/packages/rest/src/index.ts index 339f47f08e43..31b0a9dc5279 100644 --- a/packages/rest/src/index.ts +++ b/packages/rest/src/index.ts @@ -37,4 +37,4 @@ export * from './rest-server'; export * from './sequence'; // Re-export decorators like @get for easier use -export * from '@loopback/openapi-v2'; +export * from '@loopback/openapi-v3'; diff --git a/packages/rest/src/parser.ts b/packages/rest/src/parser.ts index 8c6696d4d022..7ced22c62298 100644 --- a/packages/rest/src/parser.ts +++ b/packages/rest/src/parser.ts @@ -5,14 +5,20 @@ import {ServerRequest} from 'http'; import * as HttpErrors from 'http-errors'; -import {OperationObject, ParameterObject} from '@loopback/openapi-spec'; import {promisify} from 'util'; +import { + OperationObject, + ParameterObject, + RequestBodyObject, +} from '@loopback/openapi-spec-types'; import { OperationArgs, ParsedRequest, PathParameterValues, } from './internal-types'; import {ResolvedRoute} from './router/routing-table'; +import {REQUEST_BODY_INDEX} from '@loopback/openapi-v3'; +const cookieParser = require('cookie').parse; type HttpError = HttpErrors.HttpError; // tslint:disable-next-line:no-any @@ -76,15 +82,7 @@ function loadRequestBodyIfNeeded( } function hasArgumentsFromBody(operationSpec: OperationObject): boolean { - if (!operationSpec.parameters || !operationSpec.parameters.length) - return false; - - for (const paramSpec of operationSpec.parameters) { - if ('$ref' in paramSpec) continue; - const source = (paramSpec as ParameterObject).in; - if (source === 'formData' || source === 'body') return true; - } - return false; + return !!operationSpec.requestBody; } function buildOperationArguments( @@ -95,34 +93,59 @@ function buildOperationArguments( ): OperationArgs { const args: OperationArgs = []; + let requestBodyIndex: number = -1; + if (hasArgumentsFromBody(operationSpec)) { + const i = (operationSpec.requestBody)[ + REQUEST_BODY_INDEX + ]; + requestBodyIndex = i ? i : 0; + } + + const paramArgs: OperationArgs = []; for (const paramSpec of operationSpec.parameters || []) { if ('$ref' in paramSpec) { // TODO(bajtos) implement $ref parameters // See https://github.com/strongloop/loopback-next/issues/435 throw new Error('$ref parameters are not supported yet.'); } + const spec = paramSpec as ParameterObject; switch (spec.in) { case 'query': - args.push(request.query[spec.name]); + paramArgs.push(request.query[spec.name]); break; case 'path': - args.push(pathParams[spec.name]); + paramArgs.push(pathParams[spec.name]); break; case 'header': - args.push(request.headers[spec.name.toLowerCase()]); + paramArgs.push(request.headers[spec.name.toLowerCase()]); break; - case 'formData': - args.push(body ? body[spec.name] : undefined); - break; - case 'body': - args.push(body); + case 'cookie': + // For details about cookie format, please check: + // https://www.npmjs.com/package/cookie + let cookies = cookieParser(request.headers.cookie || ''); + paramArgs.push(cookies[spec.name]); break; + // 'file' is not supported yet default: throw new HttpErrors.NotImplemented( 'Parameters with "in: ' + spec.in + '" are not supported yet.', ); } } + + insertRequestBody(); return args; + + function insertRequestBody() { + if (paramArgs.length === 0 && requestBodyIndex > -1) { + args.push(body); + return; + } + for (const arg of paramArgs) { + if (paramArgs.indexOf(arg) === requestBodyIndex) args.push(body); + args.push(arg); + } + if (paramArgs.length === requestBodyIndex) args.push(body); + } } diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 328cbf7cb32b..3e877e6a1510 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -4,7 +4,6 @@ // License text available at https://opensource.org/licenses/MIT import {AssertionError} from 'assert'; -const swagger2openapi = require('swagger2openapi'); import {safeDump} from 'js-yaml'; import {Binding, Context, Constructor, inject} from '@loopback/context'; import {Route, ControllerRoute, RouteEntry} from './router/routing-table'; @@ -13,11 +12,11 @@ import { OpenApiSpec, createEmptyApiSpec, OperationObject, -} from '@loopback/openapi-spec'; +} from '@loopback/openapi-spec-types'; import {ServerRequest, ServerResponse, createServer} from 'http'; import * as Http from 'http'; import {Application, CoreBindings, Server} from '@loopback/core'; -import {getControllerSpec} from '@loopback/openapi-v2'; +import {getControllerSpec} from '@loopback/openapi-v3'; import {HttpHandler} from './http-handler'; import {DefaultSequence, SequenceHandler, SequenceFunction} from './sequence'; import { @@ -29,6 +28,7 @@ import { } from './internal-types'; import {ControllerClass} from './router/routing-table'; import {RestBindings} from './keys'; +import {createServerAsUrl, ServerOptions} from './utils/url-generator'; const SequenceActions = RestBindings.SequenceActions; @@ -215,8 +215,8 @@ export class RestServer extends Context implements Server { // controller methods are specified through app.api() spec continue; } - if (apiSpec.definitions) { - this._httpHandler.registerApiDefinitions(apiSpec.definitions); + if (apiSpec.components && apiSpec.components.schemas) { + this._httpHandler.registerApiDefinitions(apiSpec.components.schemas); } this._httpHandler.registerController(ctor, apiSpec); } @@ -282,10 +282,14 @@ export class RestServer extends Context implements Server { response: ServerResponse, options?: OpenApiSpecOptions, ) { - options = options || {version: '2.0', format: 'json'}; + options = options || {version: '3.0.0', format: 'json'}; let specObj = this.getApiSpec(); - if (options.version === '3.0.0') { - specObj = await swagger2openapi.convertObj(specObj, {direct: true}); + + if (options.version !== '3.0.0') { + throw new Error( + 'Swagger2 spec is not supported in rest server, ' + + 'please upgrade it to OpenAPI3', + ); } if (options.format === 'json') { const spec = JSON.stringify(specObj, null, 2); @@ -451,7 +455,8 @@ export class RestServer extends Context implements Server { // accidentally modifying our internal routing data spec.paths = cloneDeep(this.httpHandler.describeApiPaths()); if (defs) { - spec.definitions = cloneDeep(defs); + spec.components = spec.components || {}; + spec.components.schemas = cloneDeep(defs); } return spec; } @@ -515,6 +520,23 @@ export class RestServer extends Context implements Server { this.sequence(SequenceFromFunction); } + private _addServerSpec(opts: ServerOptions) { + const spec = this.getSync(RestBindings.API_SPEC); + spec.servers = [ + createServerAsUrl({ + protocal: 'http', + hostname: opts.hostname || 'localhost', + port: opts.port || 3000, + // hardcoded now, about allowing configured `basePath`, + // see issue https://github.com/strongloop/loopback-next/issues/914 + // is it '/' or '/api'? + basePath: '/', + description: 'A LoopBack rest server', + }), + ]; + this.api(spec); + } + /** * Start this REST API's HTTP/HTTPS server. * @@ -524,10 +546,15 @@ export class RestServer extends Context implements Server { async start(): Promise { // Setup the HTTP handler so that we can verify the configuration // of API spec, controllers and routes at startup time. - this._setupHandlerIfNeeded(); - const httpPort = await this.get(RestBindings.PORT); const httpHost = await this.get(RestBindings.HOST); + // TBD: add integration test for it. + // get api after start + // Verify the explorer endpoints work + this._addServerSpec({hostname: httpHost, port: httpPort}); + + this._setupHandlerIfNeeded(); + this._httpServer = createServer(this.handleHttp); const httpServer = this._httpServer; diff --git a/packages/rest/src/router/routing-table.ts b/packages/rest/src/router/routing-table.ts index b121a3a6ee88..323fd9575564 100644 --- a/packages/rest/src/router/routing-table.ts +++ b/packages/rest/src/router/routing-table.ts @@ -7,7 +7,7 @@ import { OperationObject, ParameterObject, PathsObject, -} from '@loopback/openapi-spec'; +} from '@loopback/openapi-spec-types'; import { Context, Constructor, @@ -24,7 +24,7 @@ import { OperationRetval, } from '../internal-types'; -import {ControllerSpec} from '@loopback/openapi-v2'; +import {ControllerSpec} from '@loopback/openapi-v3'; import * as assert from 'assert'; import * as url from 'url'; diff --git a/packages/rest/src/utils/url-generator.ts b/packages/rest/src/utils/url-generator.ts new file mode 100644 index 000000000000..afc63f20be4f --- /dev/null +++ b/packages/rest/src/utils/url-generator.ts @@ -0,0 +1,61 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {ServerObject} from '@loopback/openapi-spec-types'; + +/** + * Describe the options for generating an OpenAPI 3.0.0 standard server url + */ +export interface ServerOptions { + basePath?: string; + description?: string; + hostname?: string; + protocal?: string; + port?: number; +} + +/** + * Function to generate an OpenAPI 3.0.0 standard server object + * as a string url. + * @param serverOptions The options that match interface `ServerOptions` + */ +export function createServerAsUrl(serverOptions: ServerOptions): ServerObject { + return { + url: `${serverOptions.protocal}://${serverOptions.hostname}:${ + serverOptions.port + }${serverOptions.basePath}`, + description: `${serverOptions.description}` || 'A LoopBack rest server.', + }; +} + +// unused function, add it here for discussion: +// Which format of Url do we want now? +// I vote for the simple url since the rest server doesn't support multiple server ATM. +export function createServerAsTemplate( + serverOptions: ServerOptions, +): ServerObject { + return { + url: '{protocal}://{hostname}:{port}{basePath}', + description: 'The default LoopBack rest server', + variables: { + protocal: { + default: 'http', + }, + basePath: { + default: (serverOptions && serverOptions.basePath) || '/', + }, + port: { + default: + (serverOptions && + serverOptions.port && + serverOptions.port.toString()) || + '3000', + }, + hostname: { + default: (serverOptions && serverOptions.hostname) || 'localhost', + }, + }, + }; +} diff --git a/packages/rest/test/acceptance/routing/routing.acceptance.ts b/packages/rest/test/acceptance/routing/routing.acceptance.ts index f0c25fdf1b7a..387c9dcba3fe 100644 --- a/packages/rest/test/acceptance/routing/routing.acceptance.ts +++ b/packages/rest/test/acceptance/routing/routing.acceptance.ts @@ -12,7 +12,7 @@ import { RestComponent, } from '../../..'; -import {api, get, param} from '@loopback/openapi-v2'; +import {api, get, param} from '@loopback/openapi-v3'; import {Application} from '@loopback/core'; @@ -20,7 +20,7 @@ import { ParameterObject, OperationObject, ResponseObject, -} from '@loopback/openapi-spec'; +} from '@loopback/openapi-spec-types'; import {expect, Client, createClientForHandler} from '@loopback/testlab'; import {anOpenApiSpec, anOperationSpec} from '@loopback/openapi-spec-builder'; @@ -103,8 +103,7 @@ describe('Routing', () => { it('allows controllers to define params via decorators', async () => { class MyController { @get('/greet') - @param.query.string('name') - greet(name: string) { + greet(@param.query.string('name') name: string) { return `hello ${name}`; } } diff --git a/packages/rest/test/acceptance/sequence/sequence.acceptance.ts b/packages/rest/test/acceptance/sequence/sequence.acceptance.ts index 8f36d2484c54..a1d94f40b79d 100644 --- a/packages/rest/test/acceptance/sequence/sequence.acceptance.ts +++ b/packages/rest/test/acceptance/sequence/sequence.acceptance.ts @@ -17,7 +17,7 @@ import { RestServer, RestComponent, } from '../../..'; -import {api} from '@loopback/openapi-v2'; +import {api} from '@loopback/openapi-v3'; import {Application} from '@loopback/core'; import {expect, Client, createClientForHandler} from '@loopback/testlab'; import {anOpenApiSpec} from '@loopback/openapi-spec-builder'; diff --git a/packages/rest/test/integration/http-handler.integration.ts b/packages/rest/test/integration/http-handler.integration.ts index b02b0bda416e..7de1ba7866f4 100644 --- a/packages/rest/test/integration/http-handler.integration.ts +++ b/packages/rest/test/integration/http-handler.integration.ts @@ -14,12 +14,12 @@ import { InvokeMethodProvider, RejectProvider, } from '../..'; -import {ControllerSpec, get} from '@loopback/openapi-v2'; +import {ControllerSpec, get} from '@loopback/openapi-v3'; import {Context} from '@loopback/context'; import {Client, createClientForHandler} from '@loopback/testlab'; import * as HttpErrors from 'http-errors'; import * as debugModule from 'debug'; -import {ParameterObject} from '@loopback/openapi-spec'; +import {ParameterObject, RequestBodyObject} from '@loopback/openapi-spec-types'; import {anOpenApiSpec, anOperationSpec} from '@loopback/openapi-spec-builder'; const debug = debugModule('loopback:rest:test'); @@ -233,42 +233,32 @@ describe('HttpHandler', () => { } }); - context('with a formData-parameter route', () => { - beforeEach(givenFormDataParamController); + context('with a cookie-parameter route', () => { + beforeEach(givenCookieParamController); - it('returns the value sent in json-encoded body', () => { - return client - .post('/show-formdata') - .send({key: 'value'}) - .expect(200, 'value'); - }); - - it('rejects url-encoded request body', () => { - logErrorsExcept(415); + it('returns the value sent in the cookie', () => { return client - .post('/show-formdata') - .send('key=value') - .expect(415); + .post('/login') + .set('Cookie', 'username=loopback; password=l00pback') + .expect({username: 'loopback', password: 'l00pback'}); }); - it('returns 400 for malformed JSON body', () => { - logErrorsExcept(400); - return client - .post('/show-formdata') - .set('content-type', 'application/json') - .send('malformed-json') - .expect(400); - }); - - function givenFormDataParamController() { + function givenCookieParamController() { const spec = anOpenApiSpec() - .withOperation('post', '/show-formdata', { - 'x-operation-name': 'showFormData', + .withOperation('post', '/login', { + 'x-operation-name': 'login', parameters: [ { - name: 'key', - in: 'formData', - description: 'Any value.', + name: 'username', + in: 'cookie', + description: 'username for login', + required: true, + type: 'string', + }, + { + name: 'password', + in: 'cookie', + description: 'password for login', required: true, type: 'string', }, @@ -285,8 +275,8 @@ describe('HttpHandler', () => { .build(); class RouteParamController { - async showFormData(key: string): Promise { - return key; + async login(username: string, password: string): Promise { + return {username, password}; } } @@ -294,7 +284,7 @@ describe('HttpHandler', () => { } }); - context('with a body-parameter route', () => { + context('with a body request route', () => { beforeEach(givenBodyParamController); it('returns the value sent in json-encoded body', () => { @@ -329,19 +319,23 @@ describe('HttpHandler', () => { const spec = anOpenApiSpec() .withOperation('post', '/show-body', { 'x-operation-name': 'showBody', - parameters: [ - { - name: 'data', - in: 'body', - description: 'Any object value.', - required: true, - schema: {type: 'object'}, + requestBody: { + description: 'Any object value.', + required: true, + content: { + 'application/json': { + schema: {type: 'object'}, + }, }, - ], + }, responses: { 200: { - schema: { - type: 'object', + content: { + 'application/json': { + schema: { + type: 'object', + }, + }, }, description: '', }, diff --git a/packages/rest/test/integration/rest-server.integration.ts b/packages/rest/test/integration/rest-server.integration.ts index 4fd0d155ec07..e9902750dd0d 100644 --- a/packages/rest/test/integration/rest-server.integration.ts +++ b/packages/rest/test/integration/rest-server.integration.ts @@ -37,77 +37,12 @@ describe('RestServer (integration)', () => { .expect(500); }); - it('exposes "GET /swagger.json" endpoint', async () => { - const server = await givenAServer({rest: {port: 0}}); - const greetSpec = { - responses: { - 200: { - schema: {type: 'string'}, - description: 'greeting of the day', - }, - }, - }; - server.route(new Route('get', '/greet', greetSpec, function greet() {})); - - const response = await createClientForHandler(server.handleHttp).get( - '/swagger.json', - ); - expect(response.body).to.containDeep({ - basePath: '/', - paths: { - '/greet': { - get: greetSpec, - }, - }, - }); - expect(response.get('Access-Control-Allow-Origin')).to.equal('*'); - expect(response.get('Access-Control-Allow-Credentials')).to.equal('true'); - expect(response.get('Access-Control-Allow-Max-Age')).to.equal('86400'); - }); - - it('exposes "GET /swagger.yaml" endpoint', async () => { - const server = await givenAServer({rest: {port: 0}}); - const greetSpec = { - responses: { - 200: { - schema: {type: 'string'}, - description: 'greeting of the day', - }, - }, - }; - server.route(new Route('get', '/greet', greetSpec, function greet() {})); - - const response = await createClientForHandler(server.handleHttp).get( - '/swagger.yaml', - ); - const expected = yaml.safeLoad(` -swagger: '2.0' -basePath: / -info: - title: LoopBack Application - version: 1.0.0 -paths: - /greet: - get: - responses: - '200': - schema: - type: string - description: greeting of the day - `); - // Use json for comparison to tolerate textual diffs - expect(yaml.safeLoad(response.text)).to.eql(expected); - expect(response.get('Access-Control-Allow-Origin')).to.equal('*'); - expect(response.get('Access-Control-Allow-Credentials')).to.equal('true'); - expect(response.get('Access-Control-Allow-Max-Age')).to.equal('86400'); - }); - it('exposes "GET /openapi.json" endpoint', async () => { const server = await givenAServer({rest: {port: 0}}); const greetSpec = { responses: { 200: { - schema: {type: 'string'}, + content: {'*/*': {schema: {type: 'string'}}}, description: 'greeting of the day', }, }, @@ -148,7 +83,11 @@ paths: const greetSpec = { responses: { 200: { - schema: {type: 'string'}, + content: { + '*/*': { + schema: {type: 'string'}, + }, + }, description: 'greeting of the day', }, }, diff --git a/packages/rest/test/unit/backward-compatibility.test.ts b/packages/rest/test/unit/backward-compatibility.test.ts index 4de5c57cecce..7d4d84af65de 100644 --- a/packages/rest/test/unit/backward-compatibility.test.ts +++ b/packages/rest/test/unit/backward-compatibility.test.ts @@ -6,7 +6,7 @@ import {get} from '../..'; describe('backward-compatibility', () => { - it('exports functions from @loopback/openapi-v2', async () => { + it('exports functions from @loopback/openapi-v3', async () => { /* tslint:disable-next-line:no-unused-variable */ class Test { // Make sure the decorators are exported diff --git a/packages/rest/test/unit/parser.test.ts b/packages/rest/test/unit/parser.test.ts index fde346371f75..27b7eb00e063 100644 --- a/packages/rest/test/unit/parser.test.ts +++ b/packages/rest/test/unit/parser.test.ts @@ -12,7 +12,11 @@ import { createResolvedRoute, } from '../..'; import {expect, ShotRequest, ShotRequestOptions} from '@loopback/testlab'; -import {OperationObject, ParameterObject} from '@loopback/openapi-spec'; +import { + OperationObject, + ParameterObject, + RequestBodyObject, +} from '@loopback/openapi-spec-types'; describe('operationArgsParser', () => { it('parses path parameters', async () => { @@ -37,15 +41,15 @@ describe('operationArgsParser', () => { payload: {key: 'value'}, }); - const spec = givenOperationWithParameters([ - { - name: 'data', - schema: {type: 'object'}, - in: 'body', + const spec = givenOperationWithRequestBody({ + description: 'data', + content: { + 'application/json': { + schema: {type: 'object'}, + }, }, - ]); + }); const route = givenResolvedRoute(spec); - const args = await parseOperationArgs(req, route); expect(args).to.eql([{key: 'value'}]); @@ -59,6 +63,14 @@ describe('operationArgsParser', () => { }; } + function givenOperationWithRequestBody(requestBody?: RequestBodyObject) { + return { + 'x-operation-name': 'testOp', + requestBody: requestBody, + responses: {}, + }; + } + function givenRequest(options?: ShotRequestOptions): ParsedRequest { return parseRequestUrl(new ShotRequest(options || {url: '/'})); } diff --git a/packages/rest/test/unit/rest-server/rest-server.open-api-spec.test.ts b/packages/rest/test/unit/rest-server/rest-server.open-api-spec.test.ts index 3d990d99215b..11beb95b67bb 100644 --- a/packages/rest/test/unit/rest-server/rest-server.open-api-spec.test.ts +++ b/packages/rest/test/unit/rest-server/rest-server.open-api-spec.test.ts @@ -6,7 +6,7 @@ import {expect, validateApiSpec} from '@loopback/testlab'; import {Application} from '@loopback/core'; import {RestServer, Route, RestComponent} from '../../..'; -import {get, post, param} from '@loopback/openapi-v2'; +import {get, post, requestBody} from '@loopback/openapi-v3'; import {anOpenApiSpec} from '@loopback/openapi-spec-builder'; import {model, property} from '@loopback/repository'; @@ -21,26 +21,32 @@ describe('RestServer.getApiSpec()', () => { it('honours API defined via app.api()', () => { server.api({ - swagger: '2.0', + openapi: '3.0.0', info: { title: 'Test API', version: '1.0.0', }, - host: 'example.com:8080', - basePath: '/api', + servers: [ + { + url: 'http://example.com:8080/api', + }, + ], paths: {}, 'x-foo': 'bar', }); const spec = server.getApiSpec(); expect(spec).to.deepEqual({ - swagger: '2.0', + openapi: '3.0.0', info: { title: 'Test API', version: '1.0.0', }, - host: 'example.com:8080', - basePath: '/api', + servers: [ + { + url: 'http://example.com:8080/api', + }, + ], paths: {}, 'x-foo': 'bar', }); @@ -127,12 +133,12 @@ describe('RestServer.getApiSpec()', () => { } class MyController { @post('/foo') - createFoo(@param.body('foo') foo: MyModel) {} + createFoo(@requestBody() foo: MyModel) {} } app.controller(MyController); const spec = server.getApiSpec(); - expect(spec.definitions).to.deepEqual({ + expect(spec.components && spec.components.schemas).to.deepEqual({ MyModel: { title: 'MyModel', properties: { diff --git a/packages/rest/test/unit/router/routing-table.test.ts b/packages/rest/test/unit/router/routing-table.test.ts index 8903692288f9..b6d00d525c6f 100644 --- a/packages/rest/test/unit/router/routing-table.test.ts +++ b/packages/rest/test/unit/router/routing-table.test.ts @@ -9,7 +9,7 @@ import { RoutingTable, ControllerRoute, } from '../../..'; -import {getControllerSpec, param, get} from '@loopback/openapi-v2'; +import {getControllerSpec, param, get} from '@loopback/openapi-v3'; import {expect, ShotRequestOptions, ShotRequest} from '@loopback/testlab'; import {anOpenApiSpec} from '@loopback/openapi-spec-builder'; @@ -34,7 +34,11 @@ describe('RoutingTable', () => { it('does not fail if some of the parameters are not decorated', () => { class TestController { @get('/greet') - greet(prefix: string, @param.query.string('message') message: string) { + greet( + prefix: string, + @param({name: 'message', in: 'query'}) + message: string, + ) { return prefix + ': ' + message; } } @@ -47,7 +51,7 @@ describe('RoutingTable', () => { expect(params[0]).to.have.properties({ name: 'message', in: 'query', - type: 'string', + schema: {type: 'string'}, }); }); @@ -81,6 +85,10 @@ describe('RoutingTable', () => { .withOperationReturningString('get', '/hello', 'greet') .build(); + // @jannyHou: please note ` anOpenApiSpec()` returns an openapi spec, + // not controller spec, should be FIXED + // the routing table test expects an empty spec for + // interface `ControllerSpec` spec.basePath = '/my'; class TestController {} diff --git a/packages/testlab/package.json b/packages/testlab/package.json index 3b3d4c74ed51..2720c62171af 100644 --- a/packages/testlab/package.json +++ b/packages/testlab/package.json @@ -17,18 +17,20 @@ "copyright.owner": "IBM Corp.", "license": "MIT", "dependencies": { - "@loopback/openapi-spec": "^4.0.0-alpha.25", "@types/fs-extra": "^5.0.0", + "@types/node": "^9.3.0", "@types/shot": "^3.4.0", "@types/sinon": "^4.1.3", "@types/supertest": "^2.0.0", "@types/swagger-parser": "^4.0.1", "fs-extra": "^5.0.0", + "@loopback/openapi-spec-types": "^4.0.0-alpha.1", "shot": "^4.0.3", "should": "^13.1.3", "sinon": "^4.1.2", "supertest": "^3.0.0", - "swagger-parser": "^4.0.1" + "swagger-parser": "^4.0.1", + "swagger2openapi": "^2.11.10" }, "devDependencies": { "@loopback/build": "^4.0.0-alpha.13" diff --git a/packages/testlab/src/validate-api-spec.ts b/packages/testlab/src/validate-api-spec.ts index ba89aa5b3257..5de4f5450f13 100644 --- a/packages/testlab/src/validate-api-spec.ts +++ b/packages/testlab/src/validate-api-spec.ts @@ -3,20 +3,15 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import * as SwaggerParser from 'swagger-parser'; -import {OpenApiSpec} from '@loopback/openapi-spec'; +import {OpenApiSpec} from '@loopback/openapi-spec-types'; +const validator = require('swagger2openapi/validate.js'); +import * as util from 'util'; +const promisify = util.promisify; +const promisifiedValidator = promisify(validator.validate); export async function validateApiSpec(spec: OpenApiSpec): Promise { - const opts: SwaggerParser.Options = { - $refs: { - internal: false, - external: false, - }, - } as SwaggerParser.Options; - - // workaround for unhelpful message returned by SwaggerParser - // TODO(bajtos) contribute these improvements to swagger-parser - if (!spec.swagger) { + const opts = {}; + if (!spec.openapi) { throw new Error('Missing required property: swagger at #/'); } @@ -28,5 +23,9 @@ export async function validateApiSpec(spec: OpenApiSpec): Promise { throw new Error('Missing required property: paths at #/'); } - await SwaggerParser.validate(spec, opts); + try { + await promisifiedValidator(spec, opts); + } catch (err) { + throw new Error(err); + } }