From 84d2827c68398abda714135cef5862e751b2c20b Mon Sep 17 00:00:00 2001 From: matt Date: Wed, 22 Jan 2020 09:42:38 -0800 Subject: [PATCH] feat: adds @response decorator --- docs/site/decorators/Decorators_openapi.md | 180 ++++++++++++++++ packages/openapi-v3/package-lock.json | 15 ++ packages/openapi-v3/package.json | 2 + .../decorators/response.decorator.unit.ts | 204 ++++++++++++++++++ .../src/build-responses-from-metadata.ts | 150 +++++++++++++ packages/openapi-v3/src/controller-spec.ts | 24 ++- packages/openapi-v3/src/decorators/index.ts | 2 + .../src/decorators/response.decorator.ts | 83 +++++++ packages/openapi-v3/src/keys.ts | 14 +- packages/openapi-v3/src/types.ts | 24 ++- 10 files changed, 691 insertions(+), 7 deletions(-) create mode 100644 packages/openapi-v3/src/__tests__/unit/decorators/response.decorator.unit.ts create mode 100644 packages/openapi-v3/src/build-responses-from-metadata.ts create mode 100644 packages/openapi-v3/src/decorators/response.decorator.ts diff --git a/docs/site/decorators/Decorators_openapi.md b/docs/site/decorators/Decorators_openapi.md index 9706b647bb60..e6971c743be5 100644 --- a/docs/site/decorators/Decorators_openapi.md +++ b/docs/site/decorators/Decorators_openapi.md @@ -542,6 +542,186 @@ 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`, `ContentObjects`, 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/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..76e71918d9b2 100644 --- a/packages/openapi-v3/package.json +++ b/packages/openapi-v3/package.json @@ -9,6 +9,7 @@ "@loopback/core": "^1.12.4", "@loopback/repository-json-schema": "^1.12.2", "debug": "^4.1.1", + "http-status": "^1.4.2", "json-merge-patch": "^0.2.3", "lodash": "^4.17.15", "openapi3-ts": "^1.3.0" @@ -20,6 +21,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 2312fca26cd7..b3fffc5909a3 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 f39511ec3238..5a90438b25fd 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..7014f96b109c --- /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 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 eb6e8cd4c7e8..81b56984c753 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 20ed393efe65..fe52c3f0fc24 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[];