diff --git a/docs/site/decorators/Decorators_openapi.md b/docs/site/decorators/Decorators_openapi.md index 9706b647bb60..f467150c9ba2 100644 --- a/docs/site/decorators/Decorators_openapi.md +++ b/docs/site/decorators/Decorators_openapi.md @@ -427,52 +427,6 @@ export class SomeController { } ``` -#### anyOf, allOf, oneOf, not - -The `x-ts-type` extention is also valid as a value in `allOf`, `anyOf`, `oneOf`, -and `not` schema keys. - -```ts -@model -class FooModel extends Model { - @property() - foo: string; -} - -@model -class BarModel extends Model { - @property() - bar: string; -} - -@model -class BazModel extends Model { - @property() - baz: string; -} - -class MyController { - @get('/some-value', { - responses: { - '200': { - description: 'returns a union of two values', - content: { - 'application/json': { - schema: { - not: {'x-ts-type': BazModel}, - allOf: [{'x-ts-type': FooModel}, {'x-ts-type': BarModel}], - }, - }, - }, - }, - }, - }) - getSomeValue() { - return {foo: 'foo', bar: 'bar'}; - } -} -``` - When the OpenAPI spec is generated, the `xs-ts-type` is mapped to `{$ref: '#/components/schemas/MyModel'}` and a corresponding schema is added to `components.schemas.MyModel` of the spec. @@ -542,6 +496,187 @@ class MyOtherController { } ``` +### @oas.response + +[API document](https://loopback.io/doc/en/lb4/apidocs.openapi-v3.oas.response.html), +[OpenAPI Response Specification](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#response-object) + +This decorator lets you easily add response specifications using `Models` from +`@loopback/repository`. The convenience decorator sets the `content-type` to +`application/json`, and the response description to the string value in the +`http-status` module. The models become references through the `x-ts-type` +schema extention. + +```ts +@model() +class SuccessModel extends Model { + constructor(err: Partial) { + super(err); + } + @property({default: 'Hi there!'}) + message: string; +} + +class GenericError extends Model { + @property() + message: string; +} + +class MyController { + @oas.get('/greet', + @oas.response(200, SuccessModel) + @oas.response(500, GenericError) + greet() { + return new SuccessModel({message: 'Hello, world!'}); + } +} +``` + +```json +{ + "paths": { + "/greet": { + "get": { + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessModel" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenericError" + } + } + } + } + } + } + } + } +} +``` + +#### Using many models + +For a given response code, it's possible to have a path that could return one of +many errors. The `@oas.response` decorator lets you pass multiple Models as +arguments. They're combined using an `anyOf` keyword. + +```ts +class FooNotFound extends Model { + @property() + message: string; +} + +class BarNotFound extends Model { + @property() + message: string; +} + +class BazNotFound extends Model { + @property() + message: string; +} + +class MyController { + @oas.get('/greet/{foo}/{bar}', + @oas.response(404, FooNotFound, BarNotFound) + @oas.response(404, BazNotFound) + greet() { + return new SuccessModel({message: 'Hello, world!'}); + } +} +``` + +```json +{ + "paths": { + "/greet": { + "get": { + "responses": { + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "anyOf": [ + {"$ref": "#/components/schemas/FooNotFound"}, + {"$ref": "#/components/schemas/BarNotFound"}, + {"$ref": "#/components/schemas/BazNotFound"} + ] + } + } + } + } + } + } + } + } +} +``` + +#### Using ReferenceObject, ResponseObjects, ContentObjects + +You don't have to use loopback `Models` to use this convenience decorator. Valid +ReferenceObjects, ContenObjects, and ResponseObjects are also valid. + +```ts +class MyController { + + // this is a valid SchemaObject + @oas.get('/schema-object', + @oas.response(200, { + type: 'object', + properties: { + message: 'string' + }, + required: 'string' + }) + returnFromSchemaObject() { + return {message: 'Hello, world!'}; + } + + // this is a valid ResponseObject + @oas.get('/response-object', + @oas.response(200, { + content: { + 'application/pdf': { + schema: { + type: 'string', + format: 'base64' + } + } + } + }) + returnFromResponseObject() { + return {message: 'Hello, world!'}; + } + + // this is a valid ResponseObject + @oas.get('/reference-object', + @oas.response(200, { $ref: '#/path/to/schema' }) + returnFromResponseObject() { + return {message: 'Hello, world!'}; + } +} +``` + +#### Using more options + +The `@oas.response` convenience decorator makes some assumptions for you in +order to provide a level of convenience. The `@operation` decorator and the +method convenience decorators let you write a full, complete, and completely +valid `OperationObject`. + ### @oas.tags [API document](https://loopback.io/doc/en/lb4/apidocs.openapi-v3.tags.html), diff --git a/package-lock.json b/package-lock.json index ba489e80ec61..c7b64b227c50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3749,12 +3749,6 @@ "flat-cache": "^2.0.1" } }, - "file-type": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-11.1.0.tgz", - "integrity": "sha512-rM0UO7Qm9K7TWTtA6AShI/t7H5BPjDeGVDaNyg9BjHAj3PysKy7+8C8D137R88jnR3rFJZQB/tFgydl5sN5m7g==", - "dev": true - }, "fill-range": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", @@ -5146,12 +5140,6 @@ "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", "dev": true }, - "is-wsl": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz", - "integrity": "sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0=", - "dev": true - }, "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -6081,58 +6069,6 @@ "mimic-fn": "^1.0.0" } }, - "open": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/open/-/open-6.4.0.tgz", - "integrity": "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg==", - "dev": true, - "requires": { - "is-wsl": "^1.1.0" - } - }, - "open-cli": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/open-cli/-/open-cli-5.0.0.tgz", - "integrity": "sha512-Y2KQDS6NqNtk+PSXzSgwH41vTDMRndwFgVWsfgMhXv7lNe1cImLCe19Vo8oKwMsL7WeNsGmmbX7Ml74Ydj61Cg==", - "dev": true, - "requires": { - "file-type": "^11.0.0", - "get-stdin": "^7.0.0", - "meow": "^5.0.0", - "open": "^6.3.0", - "temp-write": "^4.0.0" - }, - "dependencies": { - "is-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", - "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==", - "dev": true - }, - "make-dir": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.0.0.tgz", - "integrity": "sha512-grNJDhb8b1Jm1qeqW5R/O63wUo4UXo2v2HMic6YT9i/HBlF93S8jkMgH7yugvY9ABDShH4VZMn8I+U8+fCNegw==", - "dev": true, - "requires": { - "semver": "^6.0.0" - } - }, - "temp-write": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/temp-write/-/temp-write-4.0.0.tgz", - "integrity": "sha512-HIeWmj77uOOHb0QX7siN3OtwV3CTntquin6TNVg6SHOqCP3hYKmox90eeFOGaY1MqJ9WYDDjkyZrW6qS5AWpbw==", - "dev": true, - "requires": { - "graceful-fs": "^4.1.15", - "is-stream": "^2.0.0", - "make-dir": "^3.0.0", - "temp-dir": "^1.0.0", - "uuid": "^3.3.2" - } - } - } - }, "opencollective-postinstall": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.2.tgz", diff --git a/package.json b/package.json index e73f372c6009..9545abfd2573 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ "fs-extra": "^8.1.0", "husky": "^3.1.0", "lerna": "^3.20.2", - "open-cli": "^5.0.0", "typescript": "~3.7.5" }, "scripts": { @@ -43,7 +42,7 @@ "tsdocs": "lerna run --scope @loopback/tsdocs build:tsdocs", "coverage:ci": "node packages/build/bin/run-nyc report --reporter=text-lcov | coveralls", "precoverage": "npm test", - "coverage": "open-cli coverage/index.html", + "coverage": "open coverage/index.html", "lint": "npm run prettier:check && npm run eslint && node bin/check-package-locks", "lint:fix": "npm run eslint:fix && npm run prettier:fix", "eslint": "node packages/build/bin/run-eslint --report-unused-disable-directives --cache .", diff --git a/packages/openapi-v3/package-lock.json b/packages/openapi-v3/package-lock.json index 804ae0713640..0ac776d65577 100644 --- a/packages/openapi-v3/package-lock.json +++ b/packages/openapi-v3/package-lock.json @@ -10,6 +10,15 @@ "integrity": "sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ==", "dev": true }, + "@types/http-status": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@types/http-status/-/http-status-1.1.2.tgz", + "integrity": "sha512-GodZPvF3xPVglUx4q7s0plKu1cdW0mMLADpWi5PMMx3n/2xGB6GoKqQxQxp6A5xuARtZpQ9/6p3WDFwCpPaN8A==", + "dev": true, + "requires": { + "http-status": "*" + } + }, "@types/lodash": { "version": "4.14.149", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.149.tgz", @@ -97,6 +106,12 @@ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==" }, + "http-status": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/http-status/-/http-status-1.4.2.tgz", + "integrity": "sha512-mBnIohUwRw9NyXMEMMv8/GANnzEYUj0Y8d3uL01zDWFkxUjYyZ6rgCaAI2zZ1Wb34Oqtbx/nFZolPRDc8Xlm5A==", + "dev": true + }, "is-arguments": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.0.4.tgz", diff --git a/packages/openapi-v3/package.json b/packages/openapi-v3/package.json index 0baa03e45e83..35aacf5c49a7 100644 --- a/packages/openapi-v3/package.json +++ b/packages/openapi-v3/package.json @@ -20,6 +20,7 @@ "@loopback/repository": "^1.19.1", "@loopback/testlab": "^1.10.3", "@types/debug": "^4.1.5", + "@types/http-status": "^1.1.2", "@types/lodash": "^4.14.149", "@types/node": "^10.17.14" }, diff --git a/packages/openapi-v3/src/__tests__/unit/decorators/response.decorator.unit.ts b/packages/openapi-v3/src/__tests__/unit/decorators/response.decorator.unit.ts new file mode 100644 index 000000000000..4885cc2830f2 --- /dev/null +++ b/packages/openapi-v3/src/__tests__/unit/decorators/response.decorator.unit.ts @@ -0,0 +1,204 @@ +// Copyright IBM Corp. 2019. 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 {Model, model, property} from '@loopback/repository'; +import {expect} from '@loopback/testlab'; +import * as httpStatus from 'http-status'; +import {ResponseObject} from 'openapi3-ts'; +import {get, getControllerSpec, oas} from '../../..'; + +describe('@oas.response decorator', () => { + it('allows a class to not be decorated with @oas.response at all', () => { + class MyController { + @get('/greet') + greet() { + return 'Hello world!'; + } + } + + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.responses['200'].description).to.eql( + 'Return value of MyController.greet', + ); + }); + + context('with response models', () => { + @model() + class SuccessModel extends Model { + constructor(err: Partial) { + super(err); + } + @property({default: 'ok'}) + message: string; + } + + @model() + class FooError extends Model { + constructor(err: Partial) { + super(err); + } + @property({default: 'foo'}) + foo: string; + } + + @model() + class BarError extends Model { + constructor(err: Partial) { + super(err); + } + @property({default: 'bar'}) + bar: string; + } + + const successSchema: ResponseObject = { + description: httpStatus['200'], + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/SuccessModel', + }, + }, + }, + }; + + const fooBarSchema: ResponseObject = { + description: httpStatus['404'], + content: { + 'application/json': { + schema: { + anyOf: [ + {$ref: '#/components/schemas/BarError'}, + {$ref: '#/components/schemas/FooError'}, + ], + }, + }, + }, + }; + + it('supports a single @oas.response decorator', () => { + class MyController { + @get('/greet') + @oas.response(200, SuccessModel) + greet() { + return new SuccessModel({message: 'Hello, world'}); + } + } + + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.responses[200]).to.eql( + successSchema, + ); + expect( + actualSpec.components?.schemas?.SuccessModel, + ).to.not.be.undefined(); + }); + + it('supports multiple @oas.response decorators on a method', () => { + class MyController { + @get('/greet') + @oas.response(200, SuccessModel) + @oas.response(404, FooError) + @oas.response(404, BarError) + greet() { + throw new FooError({foo: 'bar'}); + } + } + + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.responses[404]).to.eql( + fooBarSchema, + ); + expect(actualSpec.components?.schemas?.FooError).to.not.be.undefined(); + expect(actualSpec.components?.schemas?.BarError).to.not.be.undefined(); + expect( + actualSpec.components?.schemas?.SuccessModel, + ).to.not.be.undefined(); + }); + it('supports multiple @oas.response decorators with an array of models', () => { + class MyController { + @get('/greet') + @oas.response(200, SuccessModel) + @oas.response(404, BarError, FooError) + greet() { + throw new BarError({bar: 'baz'}); + } + } + + const actualSpec = getControllerSpec(MyController); + + expect(actualSpec.paths['/greet'].get.responses[404]).to.eql( + fooBarSchema, + ); + expect(actualSpec.components?.schemas?.FooError).to.not.be.undefined(); + expect(actualSpec.components?.schemas?.BarError).to.not.be.undefined(); + expect( + actualSpec.components?.schemas?.SuccessModel, + ).to.not.be.undefined(); + }); + + context('with complex responses', () => { + const FIRST_SCHEMA = { + type: 'object', + properties: { + x: { + type: 'int', + default: 1, + }, + y: { + type: 'string', + default: '2', + }, + }, + }; + + class MyController { + @get('/greet', { + responses: { + 200: { + description: 'Unknown', + content: { + 'application/jsonc': {schema: FIRST_SCHEMA}, + }, + }, + }, + }) + @oas.response(200, SuccessModel, { + content: { + 'application/pdf': {schema: {type: 'string', format: 'base64'}}, + }, + }) + @oas.response(404, FooError, BarError) + greet() { + return new SuccessModel({message: 'Hello, world!'}); + } + } + + const actualSpec = getControllerSpec(MyController); + expect( + actualSpec.paths['/greet'].get.responses[200].content[ + 'application/jsonc' + ], + ).to.not.be.undefined(); + + expect( + actualSpec.paths['/greet'].get.responses[200].content[ + 'application/json' + ], + ).to.not.be.undefined(); + + expect( + actualSpec.paths['/greet'].get.responses[200].content[ + 'application/pdf' + ], + ).to.not.be.undefined(); + + expect( + actualSpec.paths['/greet'].get.responses[200].content[ + 'application/json' + ].schema, + ).to.eql({$ref: '#/components/schemas/SuccessModel'}); + }); + }); +}); diff --git a/packages/openapi-v3/src/build-responses-from-metadata.ts b/packages/openapi-v3/src/build-responses-from-metadata.ts new file mode 100644 index 000000000000..6a3f743a2104 --- /dev/null +++ b/packages/openapi-v3/src/build-responses-from-metadata.ts @@ -0,0 +1,150 @@ +import {DecoratorFactory} from '@loopback/core'; +import {Model} from '@loopback/repository'; +import { + ContentObject, + OperationObject, + ResponseDecoratorMetadata, + ResponseModelOrSpec, + ResponseObject, +} from './types'; + +declare type ContentMap = Map; +declare type ResponseMap = Map< + number, + {description: string; content: ContentMap} +>; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function isModel(c: any): c is T { + return c && c.prototype instanceof Model; +} + +/** + * Reducer which builds the operation responses + */ +function reduceSpecContent( + specContents: ContentObject, + [contentType, modelOrSpecs]: [string, ResponseModelOrSpec[]], +): ContentObject { + if (Array.isArray(modelOrSpecs) && modelOrSpecs.length > 1) { + specContents[contentType] = { + schema: { + anyOf: modelOrSpecs.map(m => { + if (isModel(m)) { + return {'x-ts-type': m}; + } else { + return m; + } + }), + }, + }; + } else { + const modelOrSpec = Array.isArray(modelOrSpecs) + ? modelOrSpecs[0] + : modelOrSpecs; + if (isModel(modelOrSpec)) { + specContents[contentType] = { + schema: {'x-ts-type': modelOrSpec}, + }; + } else { + specContents[contentType] = { + schema: modelOrSpec, + }; + } + } + return specContents; +} + +/** + * Reducer which builds the content sections of the operation responses + */ +function reduceSpecResponses( + specResponses: ResponseObject, + [responseCode, c]: [number, {description: string; content: ContentMap}], +): ResponseObject { + const responseContent = c.content; + // check if there is an existing block, from something like an inhered @op spec + if (Object.prototype.hasOwnProperty.call(specResponses, responseCode)) { + // we might need to merge + const content = Array.from(responseContent).reduce( + reduceSpecContent, + specResponses[responseCode].content as ContentObject, + ); + + specResponses[responseCode] = { + description: c.description, + content, + }; + } else { + const content = Array.from(responseContent).reduce( + reduceSpecContent, + {} as ContentObject, + ); + + specResponses[responseCode] = { + description: c.description, + content, + }; + } + return specResponses; +} + +/** + * This function takes an array of flat-ish data: + * ``` + * [ + * { responseCode, contentType, description, modelOrSpec }, + * { responseCode, contentType, description, modelOrSpec }, + * ] + * ``` + * and turns it into a multi-map structure that more closely aligns with + * the final json + * ``` + * Map{ [code, Map{[contentType, modelOrSpec], [contentType, modelOrSpec]}]} + * ``` + */ +function buildMapsFromMetadata( + metadata: ResponseDecoratorMetadata, +): ResponseMap { + const builder: ResponseMap = new Map(); + metadata.forEach(r => { + if (builder.has(r.responseCode)) { + const responseRef = builder.get(r.responseCode); + const codeRef = responseRef?.content; + + if (codeRef?.has(r.contentType)) { + // eslint-disable-next-line no-unused-expressions + codeRef.get(r.contentType)?.push(r.responseModelOrSpec); + } else { + // eslint-disable-next-line no-unused-expressions + codeRef?.set(r.contentType, [r.responseModelOrSpec]); + } + } else { + const codeRef = new Map(); + codeRef.set(r.contentType, [r.responseModelOrSpec]); + builder.set(r.responseCode, { + description: r.description, + content: codeRef, + }); + } + }); + return builder; +} +export function buildResponsesFromMetadata( + metadata: ResponseDecoratorMetadata, + existingOperation?: OperationObject, +): OperationObject { + const builder = buildMapsFromMetadata(metadata); + const base = existingOperation + ? DecoratorFactory.cloneDeep(existingOperation.responses) + : {}; + // Now, mega-reduce. + const responses: ResponseObject = Array.from(builder).reduce( + reduceSpecResponses, + base as ResponseObject, + ); + + return { + responses, + }; +} diff --git a/packages/openapi-v3/src/controller-spec.ts b/packages/openapi-v3/src/controller-spec.ts index 2326007dc02a..59d3abf77d44 100644 --- a/packages/openapi-v3/src/controller-spec.ts +++ b/packages/openapi-v3/src/controller-spec.ts @@ -10,6 +10,7 @@ import { JsonSchemaOptions, } from '@loopback/repository-json-schema'; import {includes} from 'lodash'; +import {buildResponsesFromMetadata} from './build-responses-from-metadata'; import {resolveSchema} from './generate-schema'; import {jsonToSchemaObject, SchemaRef} from './json-to-schema'; import {OAI3Keys} from './keys'; @@ -22,6 +23,7 @@ import { PathObject, ReferenceObject, RequestBodyObject, + ResponseDecoratorMetadata, ResponseObject, SchemaObject, SchemasObject, @@ -165,12 +167,26 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { }; let operationSpec = endpoint.spec; + + const decoratedResponses = MetadataInspector.getMethodMetadata< + ResponseDecoratorMetadata + >(OAI3Keys.RESPONSE_METHOD_KEY, constructor.prototype, op); + if (!operationSpec) { - // The operation was defined via @operation(verb, path) with no spec - operationSpec = { - responses: defaultResponse, - }; + if (decoratedResponses) { + operationSpec = buildResponsesFromMetadata(decoratedResponses); + } else { + // The operation was defined via @operation(verb, path) with no spec + operationSpec = { + responses: defaultResponse, + }; + } endpoint.spec = operationSpec; + } else if (decoratedResponses) { + operationSpec = buildResponsesFromMetadata( + decoratedResponses, + operationSpec, + ); } if (classTags && !operationSpec.tags) { diff --git a/packages/openapi-v3/src/decorators/index.ts b/packages/openapi-v3/src/decorators/index.ts index 70661cdc8e51..4a59325525c4 100644 --- a/packages/openapi-v3/src/decorators/index.ts +++ b/packages/openapi-v3/src/decorators/index.ts @@ -14,6 +14,7 @@ import {deprecated} from './deprecated.decorator'; import {del, get, operation, patch, post, put} from './operation.decorator'; import {param} from './parameter.decorator'; import {requestBody} from './request-body.decorator'; +import {response} from './response.decorator'; import {tags} from './tags.decorator'; export const oas = { @@ -35,5 +36,6 @@ export const oas = { // oas convenience decorators deprecated, + response, tags, }; diff --git a/packages/openapi-v3/src/decorators/response.decorator.ts b/packages/openapi-v3/src/decorators/response.decorator.ts new file mode 100644 index 000000000000..364b23a3fa72 --- /dev/null +++ b/packages/openapi-v3/src/decorators/response.decorator.ts @@ -0,0 +1,83 @@ +// Copyright IBM Corp. 2018,2019. 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 {MethodMultiDecoratorFactory} from '@loopback/core'; +import * as httpStatus from 'http-status'; +import { + ContentObject, + ReferenceObject, + ResponseObject, + SchemaObject, +} from 'openapi3-ts'; +import {OAI3Keys} from '../keys'; +import {ResponseDecoratorMetadata, ResponseModelOrSpec} from '../types'; + +// overloading the definition because content cannot be undefined in this case. +interface ResponseWithContent extends ResponseObject { + content: ContentObject; +} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function isResponseObject(c: any): c is ResponseWithContent { + // eslint-disable-next-line no-prototype-builtins + return !!c && c.hasOwnProperty('content') && !!c.content; +} + +function buildDecoratorReducer( + responseCode: number, + description: string, + contentType = 'application/json', +) { + const decoratorItemReducer = ( + r: ResponseDecoratorMetadata, + m: ResponseModelOrSpec, + ) => { + // allow { content: { 'application/json': {...}}} + if (isResponseObject(m)) { + Object.keys(m.content ?? {}).forEach(ct => { + r.push({ + responseCode, + responseModelOrSpec: m.content[ct].schema as + | SchemaObject + | ReferenceObject, + contentType: ct, + description, + }); + }); + } else { + r.push({ + responseCode, + responseModelOrSpec: m, + // we're defaulting these for convenience for now. + contentType, + description, + }); + } + return r; + }; + return decoratorItemReducer; +} + +/** + * class MyController { + * @oas.response(200, FirstModel) + * @oas.response(404, OneError, { $ref: '#/definition...'}) + * @oas.response(403, SecondError, { schema: ... }) + * } + * + */ +export function response( + responseCode: number, + ...responseModelOrSpec: ResponseModelOrSpec[] +) { + const messageKey = String(responseCode) as keyof httpStatus.HttpStatus; + + return MethodMultiDecoratorFactory.createDecorator( + OAI3Keys.RESPONSE_METHOD_KEY, + responseModelOrSpec.reduce( + buildDecoratorReducer(responseCode, httpStatus[messageKey] as string), + [], + ), + {decoratorName: '@response', allowInheritance: false}, + ); +} diff --git a/packages/openapi-v3/src/keys.ts b/packages/openapi-v3/src/keys.ts index a1e3f656fc66..eb33b17b9432 100644 --- a/packages/openapi-v3/src/keys.ts +++ b/packages/openapi-v3/src/keys.ts @@ -5,7 +5,11 @@ import {MetadataAccessor} from '@loopback/core'; import {ControllerSpec, RestEndpoint} from './controller-spec'; -import {ParameterObject, RequestBodyObject} from './types'; +import { + ParameterObject, + RequestBodyObject, + ResponseDecoratorMetadata, +} from './types'; export namespace OAI3Keys { /** @@ -32,6 +36,14 @@ export namespace OAI3Keys { ClassDecorator >('openapi-v3:class:deprecated'); + /* + * Metadata key used to add to or retrieve an endpoint's responses + */ + export const RESPONSE_METHOD_KEY = MetadataAccessor.create< + ResponseDecoratorMetadata, + MethodDecorator + >('openapi-v3:methods:response'); + /** * Metadata key used to set or retrieve `param` decorator metadata */ diff --git a/packages/openapi-v3/src/types.ts b/packages/openapi-v3/src/types.ts index a4d15b906c7a..edfd78ea5201 100644 --- a/packages/openapi-v3/src/types.ts +++ b/packages/openapi-v3/src/types.ts @@ -2,8 +2,13 @@ // Node module: @loopback/openapi-v3 // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT - -import {OpenAPIObject} from 'openapi3-ts'; +import {Model} from '@loopback/repository'; +import { + OpenAPIObject, + ReferenceObject, + ResponseObject, + SchemaObject, +} from 'openapi3-ts'; /* * OpenApiSpec - A typescript representation of OpenApi 3.0.0 */ @@ -32,3 +37,18 @@ export function createEmptyApiSpec(): OpenApiSpec { export interface TagsDecoratorMetadata { tags: string[]; } + +export type ResponseModelOrSpec = + | typeof Model + | SchemaObject + | ResponseObject + | ReferenceObject; + +export interface ResponseDecoratorMetadataItem { + responseCode: number; + contentType: string; + responseModelOrSpec: ResponseModelOrSpec; + description: string; +} + +export type ResponseDecoratorMetadata = ResponseDecoratorMetadataItem[];