diff --git a/docs/site/decorators/Decorators_openapi.md b/docs/site/decorators/Decorators_openapi.md index ac322551161b..3f20e03f155d 100644 --- a/docs/site/decorators/Decorators_openapi.md +++ b/docs/site/decorators/Decorators_openapi.md @@ -458,6 +458,44 @@ 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.deprecated + +[API document](https://loopback.io/doc/en/lb4/apidocs.openapi-v3.deprecated.html), +[OpenAPI Operation Specification](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#operation-object) + +This decorator can currently be applied to class and a class method. It will set +the `deprecated` boolean property of the Operation Object. When applied to a +class, it will mark all operation methods of that class as deprecated, unless a +method overloads with `@oas.deprecated(false)`. + +This decorator does not currently support marking +(parameters)[https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#parameter-object] +as deprecated. + +```ts +@oas.deprecated() +class MyController { + @oas.get('/greet') + public async function greet() { + return 'Hello, World!' + } + + @oas.get('/greet-v2') + @oas.deprecated(false) + public async function greetV2() { + return 'Hello, World!' + } +} + +class MyOtherController { + @oas.get('/echo') + @oas.deprecated() + public async function echo() { + return 'Echo!' + } +} +``` + ### @oas.tags [API document](https://loopback.io/doc/en/lb4/apidocs.openapi-v3.tags.html), diff --git a/packages/openapi-v3/src/__tests__/unit/decorators/deprecated.decorator.unit.ts b/packages/openapi-v3/src/__tests__/unit/decorators/deprecated.decorator.unit.ts new file mode 100644 index 000000000000..1250f8da494f --- /dev/null +++ b/packages/openapi-v3/src/__tests__/unit/decorators/deprecated.decorator.unit.ts @@ -0,0 +1,112 @@ +// 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('deprecation decorator', () => { + it('Returns a spec with all the items decorated from the class level', () => { + const expectedSpec = anOpenApiSpec() + .withOperationReturningString('get', '/greet', 'greet') + .withOperationReturningString('get', '/echo', 'echo') + .build(); + + @api(expectedSpec) + @oas.deprecated() + class MyController { + greet() { + return 'Hello world!'; + } + echo() { + return 'Hello world!'; + } + } + + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.deprecated).to.eql(true); + expect(actualSpec.paths['/echo'].get.deprecated).to.eql(true); + }); + + it('Returns a spec where only one method is deprecated', () => { + class MyController { + @get('/greet') + greet() { + return 'Hello world!'; + } + + @get('/echo') + @oas.deprecated() + echo() { + return 'Hello world!'; + } + } + + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.deprecated).to.be.undefined(); + expect(actualSpec.paths['/echo'].get.deprecated).to.eql(true); + }); + + it('Allows a method to override the deprecation of a class', () => { + @oas.deprecated() + class MyController { + @get('/greet') + greet() { + return 'Hello world!'; + } + + @get('/echo') + echo() { + return 'Hello world!'; + } + + @get('/yell') + @oas.deprecated(false) + yell() { + return 'HELLO WORLD!'; + } + } + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.deprecated).to.eql(true); + expect(actualSpec.paths['/echo'].get.deprecated).to.eql(true); + expect(actualSpec.paths['/yell'].get.deprecated).to.be.undefined(); + }); + + it('Allows a class to not be decorated with @oas.deprecated 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.deprecated).to.be.undefined(); + expect(actualSpec.paths['/echo'].get.deprecated).to.be.undefined(); + }); + + it('Does not allow a member variable to be decorated', () => { + const shouldThrow = () => { + class MyController { + @oas.deprecated() + public foo: string; + + @get('/greet') + greet() {} + } + + return getControllerSpec(MyController); + }; + + expect(shouldThrow).to.throw( + /^\@oas.deprecated 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 ac683d30a377..2326007dc02a 100644 --- a/packages/openapi-v3/src/controller-spec.ts +++ b/packages/openapi-v3/src/controller-spec.ts @@ -78,6 +78,14 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { spec = {paths: {}}; } + const isClassDeprecated = MetadataInspector.getClassMetadata( + OAI3Keys.DEPRECATED_CLASS_KEY, + constructor, + ); + + if (isClassDeprecated) { + debug(' using class-level @deprecated()'); + } const classTags = MetadataInspector.getClassMetadata( OAI3Keys.TAGS_CLASS_KEY, constructor, @@ -87,9 +95,13 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { debug(' using class-level @oas.tags()'); } - if (classTags) { + if (classTags || isClassDeprecated) { for (const path of Object.keys(spec.paths)) { for (const method of Object.keys(spec.paths[path])) { + /* istanbul ignore else */ + if (isClassDeprecated) { + spec.paths[path][method].deprecated = true; + } /* istanbul ignore else */ if (classTags) { if ( @@ -121,6 +133,15 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { const verb = endpoint.verb!; const path = endpoint.path!; + const isMethodDeprecated = MetadataInspector.getMethodMetadata( + OAI3Keys.DEPRECATED_METHOD_KEY, + constructor.prototype, + op, + ); + if (isMethodDeprecated) { + debug(' using method-level deprecation via @deprecated()'); + } + const methodTags = MetadataInspector.getMethodMetadata< TagsDecoratorMetadata >(OAI3Keys.TAGS_METHOD_KEY, constructor.prototype, op); @@ -168,6 +189,17 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { debug(' spec responses for method %s: %o', op, operationSpec.responses); + // Prescedence: method decorator > class decorator > operationSpec > undefined + const deprecationSpec = + isMethodDeprecated ?? + isClassDeprecated ?? + operationSpec.deprecated ?? + false; + + if (deprecationSpec) { + operationSpec.deprecated = true; + } + for (const code in operationSpec.responses) { const responseObject: ResponseObject | ReferenceObject = operationSpec.responses[code]; diff --git a/packages/openapi-v3/src/decorators/deprecated.decorator.ts b/packages/openapi-v3/src/decorators/deprecated.decorator.ts new file mode 100644 index 000000000000..9c45ac34a8be --- /dev/null +++ b/packages/openapi-v3/src/decorators/deprecated.decorator.ts @@ -0,0 +1,87 @@ +// 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'; + +const debug = require('debug')( + 'loopback:openapi3:metadata:controller-spec:deprecated', +); + +/** + * Marks an api path as deprecated. When applied to a class, this decorator + * marks all paths as deprecated. + * + * You can optionally mark all controllers in a class as deprecated, but use + * `@deprecated(false)` on a specific method to ensure it is not marked + * as deprecated in the specification. + * + * @param isDeprecated - whether or not the path should be marked as deprecated. + * This is useful for marking a class as deprecated, but a method as + * not deprecated. + * + * @example + * ```ts + * @oas.deprecated() + * class MyController { + * @get('/greet') + * public async function greet() { + * return 'Hello, World!' + * } + * + * @get('/greet-v2') + * @oas.deprecated(false) + * public async function greetV2() { + * return 'Hello, World!' + * } + * } + * + * class MyOtherController { + * @get('/echo') + * public async function echo() { + * return 'Echo!' + * } + * } + * ``` + */ +export function deprecated(isDeprecated = true) { + return function deprecatedDecoratorForClassOrMethod( + // 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, + ) { + debug(target, method, methodDescriptor); + + if (method && methodDescriptor) { + // Method + return MethodDecoratorFactory.createDecorator( + OAI3Keys.DEPRECATED_METHOD_KEY, + isDeprecated, + {decoratorName: '@oas.deprecated'}, + )(target, method, methodDescriptor); + } else if (typeof target === 'function' && !method && !methodDescriptor) { + // Class + return ClassDecoratorFactory.createDecorator( + OAI3Keys.DEPRECATED_CLASS_KEY, + isDeprecated, + {decoratorName: '@oas.deprecated'}, + )(target); + } else { + throw new Error( + '@oas.deprecated cannot be used on a property: ' + + DecoratorFactory.getTargetName(target, method, methodDescriptor), + ); + } + }; +} diff --git a/packages/openapi-v3/src/decorators/index.ts b/packages/openapi-v3/src/decorators/index.ts index 4dffdcd45348..70661cdc8e51 100644 --- a/packages/openapi-v3/src/decorators/index.ts +++ b/packages/openapi-v3/src/decorators/index.ts @@ -4,12 +4,13 @@ // License text available at https://opensource.org/licenses/MIT export * from './api.decorator'; +export * from './deprecated.decorator'; export * from './operation.decorator'; export * from './parameter.decorator'; export * from './request-body.decorator'; -export * from './tags.decorator'; import {api} from './api.decorator'; +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'; @@ -18,12 +19,21 @@ import {tags} from './tags.decorator'; export const oas = { api, operation, + + // methods get, post, del, patch, put, + + //param param, + + // request body requestBody, + + // oas convenience decorators + deprecated, tags, }; diff --git a/packages/openapi-v3/src/keys.ts b/packages/openapi-v3/src/keys.ts index 2af1531bef11..a1e3f656fc66 100644 --- a/packages/openapi-v3/src/keys.ts +++ b/packages/openapi-v3/src/keys.ts @@ -16,6 +16,22 @@ export namespace OAI3Keys { MethodDecorator >('openapi-v3:methods'); + /** + * Metadata key used to set or retrieve `@deprecated` metadata on a method. + */ + export const DEPRECATED_METHOD_KEY = MetadataAccessor.create< + boolean, + MethodDecorator + >('openapi-v3:methods:deprecated'); + + /** + * Metadata key used to set or retrieve `@deprecated` metadata on a class + */ + export const DEPRECATED_CLASS_KEY = MetadataAccessor.create< + boolean, + ClassDecorator + >('openapi-v3:class:deprecated'); + /** * Metadata key used to set or retrieve `param` decorator metadata */