diff --git a/docs/site/decorators/Decorators_openapi.md b/docs/site/decorators/Decorators_openapi.md index 767cc56c860c..ac322551161b 100644 --- a/docs/site/decorators/Decorators_openapi.md +++ b/docs/site/decorators/Decorators_openapi.md @@ -430,3 +430,61 @@ export class SomeController { 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. + +## Convenience Decorators + +While you can supply a fully valid OpenAPI specification for the class-level +`@api` decorator, and full operation OpenAPI specification for `@operation` and +the other convenience decorators, there are also a number of utility decorators +that allow you to supply specific OpenAPI information without requiring you to +use verbose JSON. + +## Shortcuts for the OpenAPI Spec (OAS) Objects + +All of the above are direct exports of `@loopback/openapi-v3`, but they are also +available under the `oas` namespace: + +```ts +import {oas} from '@loopback/openapi-v3'; + +@oas.api({}) +class MyController { + @oas.get('/greet/{id}') + public greet(@oas.param('id') id: string) {} +} +``` + +This namespace contains decorators that are specific to the OpenAPI +specification, but are also similar to other well-known decorators available, +such as `@deprecated()` + +### @oas.tags + +[API document](https://loopback.io/doc/en/lb4/apidocs.openapi-v3.tags.html), +[OpenAPI Operation Specification](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#operation-object) + +This decorator can be applied to a controller class and to controller class +methods. It will set the `tags` array string property of the Operation Object. +When applied to a class, it will mark all operation methods of that class with +those tags. Usage on both the class and method will combine the tags. + +```ts +@oas.tags('Foo', 'Bar') +class MyController { + @oas.get('/greet') + public async greet() { + // tags will be [Foo, Bar] + } + + @oas.tags('Baz') + @oas.get('/echo') + public async echo() { + // tags will be [Foo, Bar, Baz] + } +} +``` + +This decorator does not affect the top-level `tags` section defined in the +[OpenAPI Tag Object specification](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#tag-object). +This decorator only affects the spec partial generated at the class level. You +may find that your final tags also include a tag for the controller name. diff --git a/packages/openapi-v3/src/__tests__/unit/decorators/tags.decorator.unit.ts b/packages/openapi-v3/src/__tests__/unit/decorators/tags.decorator.unit.ts new file mode 100644 index 000000000000..a4a65ab61657 --- /dev/null +++ b/packages/openapi-v3/src/__tests__/unit/decorators/tags.decorator.unit.ts @@ -0,0 +1,140 @@ +// 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 {anOpenApiSpec} from '@loopback/openapi-spec-builder'; +import {expect} from '@loopback/testlab'; +import {api, get, getControllerSpec, oas} from '../../..'; + +describe('@oas.tags decorator', () => { + context('Without a top-level @api definition', () => { + it('Allows a class to not be decorated with @oas.tags at all', () => { + class MyController { + @get('/greet') + greet() { + return 'Hello world!'; + } + + @get('/echo') + echo() { + return 'Hello world!'; + } + } + + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.tags).to.be.undefined(); + }); + + it('Allows a class to decorate methods with @oas.tags', () => { + @oas.tags('Foo', 'Bar') + class MyController { + @get('/greet') + greet() { + return 'Hello world!'; + } + + @get('/echo') + echo() { + return 'Hello world!'; + } + } + + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.tags).to.eql(['Foo', 'Bar']); + expect(actualSpec.paths['/echo'].get.tags).to.eql(['Foo', 'Bar']); + }); + + it('Allows @oas.tags with options to append', () => { + @oas.tags('Foo') + class MyController { + @get('/greet') + greet() { + return 'Hello world!'; + } + + @get('/echo') + @oas.tags('Bar') + echo() { + return 'Hello world!'; + } + } + + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.tags).to.eql(['Foo']); + expect(actualSpec.paths['/echo'].get.tags).to.eql(['Foo', 'Bar']); + }); + + it('Does not allow a member variable to be decorated', () => { + const shouldThrow = () => { + class MyController { + @oas.tags('foo', 'bar') + public foo: string; + + @get('/greet') + greet() {} + } + + return getControllerSpec(MyController); + }; + + expect(shouldThrow).to.throw(/^\@oas.tags cannot be used on a property:/); + }); + }); + context('With a top-level @api definition', () => { + const expectedSpec = anOpenApiSpec() + .withOperationReturningString('get', '/greet', 'greet') + .build(); + expectedSpec.paths['/greet'].get.tags = ['Bin', 'Fill']; + + it('Allows a class to not be decorated with @oas.tags at all', () => { + @api(expectedSpec) + class MyController { + greet() { + return 'Hello world!'; + } + + echo() { + return 'Hello world!'; + } + } + + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.tags).to.eql(['Bin', 'Fill']); + }); + + it('Allows a class to decorate methods with @oas.tags', () => { + @api(expectedSpec) + @oas.tags('Foo', 'Bar') + class MyController { + greet() { + return 'Hello world!'; + } + } + + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.tags).to.containDeep([ + 'Foo', + 'Bar', + 'Bin', + 'Fill', + ]); + }); + }); + + it('Does not allow a member variable to be decorated', () => { + const shouldThrow = () => { + class MyController { + @oas.tags('foo', 'bar') + public foo: string; + + @get('/greet') + greet() {} + } + + return getControllerSpec(MyController); + }; + + expect(shouldThrow).to.throw(/^\@oas.tags cannot be used on a property:/); + }); +}); diff --git a/packages/openapi-v3/src/controller-spec.ts b/packages/openapi-v3/src/controller-spec.ts index a200e2a99da2..ac683d30a377 100644 --- a/packages/openapi-v3/src/controller-spec.ts +++ b/packages/openapi-v3/src/controller-spec.ts @@ -25,6 +25,7 @@ import { ResponseObject, SchemaObject, SchemasObject, + TagsDecoratorMetadata, } from './types'; const debug = require('debug')('loopback:openapi3:metadata:controller-spec'); @@ -77,6 +78,35 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { spec = {paths: {}}; } + const classTags = MetadataInspector.getClassMetadata( + OAI3Keys.TAGS_CLASS_KEY, + constructor, + ); + + if (classTags) { + debug(' using class-level @oas.tags()'); + } + + if (classTags) { + for (const path of Object.keys(spec.paths)) { + for (const method of Object.keys(spec.paths[path])) { + /* istanbul ignore else */ + if (classTags) { + if ( + spec.paths[path][method].tags && + spec.paths[path][method].tags.length + ) { + spec.paths[path][method].tags = spec.paths[path][ + method + ].tags.concat(classTags.tags); + } else { + spec.paths[path][method].tags = classTags.tags; + } + } + } + } + } + let endpoints = MetadataInspector.getAllMethodMetadata( OAI3Keys.METHODS_KEY, @@ -91,6 +121,14 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { const verb = endpoint.verb!; const path = endpoint.path!; + const methodTags = MetadataInspector.getMethodMetadata< + TagsDecoratorMetadata + >(OAI3Keys.TAGS_METHOD_KEY, constructor.prototype, op); + + if (methodTags) { + debug(' using method-level tags via @oas.tags()'); + } + let endpointName = ''; /* istanbul ignore if */ if (debug.enabled) { @@ -113,6 +151,19 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { }; endpoint.spec = operationSpec; } + + if (classTags && !operationSpec.tags) { + operationSpec.tags = classTags.tags; + } + + if (methodTags) { + if (operationSpec.tags && operationSpec.tags.length) { + operationSpec.tags = operationSpec.tags.concat(methodTags.tags); + } else { + operationSpec.tags = methodTags.tags; + } + } + debug(' operation for method %s: %j', op, endpoint); debug(' spec responses for method %s: %o', op, operationSpec.responses); diff --git a/packages/openapi-v3/src/decorators/index.ts b/packages/openapi-v3/src/decorators/index.ts index 947da4a6f0e4..4dffdcd45348 100644 --- a/packages/openapi-v3/src/decorators/index.ts +++ b/packages/openapi-v3/src/decorators/index.ts @@ -7,3 +7,23 @@ export * from './api.decorator'; export * from './operation.decorator'; export * from './parameter.decorator'; export * from './request-body.decorator'; +export * from './tags.decorator'; + +import {api} from './api.decorator'; +import {del, get, operation, patch, post, put} from './operation.decorator'; +import {param} from './parameter.decorator'; +import {requestBody} from './request-body.decorator'; +import {tags} from './tags.decorator'; + +export const oas = { + api, + operation, + get, + post, + del, + patch, + put, + param, + requestBody, + tags, +}; diff --git a/packages/openapi-v3/src/decorators/tags.decorator.ts b/packages/openapi-v3/src/decorators/tags.decorator.ts new file mode 100644 index 000000000000..584c9746918f --- /dev/null +++ b/packages/openapi-v3/src/decorators/tags.decorator.ts @@ -0,0 +1,46 @@ +// 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 { + ClassDecoratorFactory, + DecoratorFactory, + MethodDecoratorFactory, +} from '@loopback/core'; +import {OAI3Keys} from '../keys'; +import {TagsDecoratorMetadata} from '../types'; + +export function tags(...tagNames: string[]) { + return function tagsDecoratorForClassOrMethod( + // Class or a prototype + // eslint-disable-next-line @typescript-eslint/no-explicit-any + target: any, + method?: string, + // Use `any` to for `TypedPropertyDescriptor` + // See https://github.com/strongloop/loopback-next/pull/2704 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + methodDescriptor?: TypedPropertyDescriptor, + ) { + if (method && methodDescriptor) { + // Method + return MethodDecoratorFactory.createDecorator( + OAI3Keys.TAGS_METHOD_KEY, + {tags: tagNames}, + {decoratorName: '@oas.tags'}, + )(target, method, methodDescriptor); + } else if (typeof target === 'function' && !method && !methodDescriptor) { + // Class + return ClassDecoratorFactory.createDecorator( + OAI3Keys.TAGS_CLASS_KEY, + {tags: tagNames}, + {decoratorName: '@oas.tags'}, + )(target); + } else { + throw new Error( + '@oas.tags cannot be used on a property: ' + + DecoratorFactory.getTargetName(target, method, methodDescriptor), + ); + } + }; +} diff --git a/packages/openapi-v3/src/keys.ts b/packages/openapi-v3/src/keys.ts index a6d6cb7771ff..2af1531bef11 100644 --- a/packages/openapi-v3/src/keys.ts +++ b/packages/openapi-v3/src/keys.ts @@ -24,6 +24,22 @@ export namespace OAI3Keys { ParameterDecorator >('openapi-v3:parameters'); + /** + * Metadata key used to set or retrieve `@deprecated` metadata on a method. + */ + export const TAGS_METHOD_KEY = MetadataAccessor.create< + string[], + MethodDecorator + >('openapi-v3:methods:tags'); + + /** + * Metadata key used to set or retrieve `@deprecated` metadata on a class + */ + export const TAGS_CLASS_KEY = MetadataAccessor.create< + string[], + ClassDecorator + >('openapi-v3:class:tags'); + /** * Metadata key used to set or retrieve `@api` metadata */ diff --git a/packages/openapi-v3/src/types.ts b/packages/openapi-v3/src/types.ts index c420f4fbc7ec..a4d15b906c7a 100644 --- a/packages/openapi-v3/src/types.ts +++ b/packages/openapi-v3/src/types.ts @@ -28,3 +28,7 @@ export function createEmptyApiSpec(): OpenApiSpec { servers: [{url: '/'}], }; } + +export interface TagsDecoratorMetadata { + tags: string[]; +}