diff --git a/docs/site/decorators/Decorators_openapi.md b/docs/site/decorators/Decorators_openapi.md index 9dd0a85f676c..bc81a7e9e4ac 100644 --- a/docs/site/decorators/Decorators_openapi.md +++ b/docs/site/decorators/Decorators_openapi.md @@ -626,6 +626,47 @@ class MyOtherController { } ``` +### @oas.visibility + +[API document](https://loopback.io/doc/en/lb4/apidocs.openapi-v3.visibility.html) + +This decorator can be applied to class and/or a class method. It will set the +`x-visibility` property, which dictates if a class method appears in the OAS3 +spec. When applied to a class, it will mark all operation methods of that class, +unless a method overloads with `@oas.visibility()`. + +When not explicitly defined, `x-visibility` is `undefined`, which is identical +to `x-visibility: 'documented`. + +Currently, the supported values are `documented` or `undocumented`. + +This decorator does not currently support marking +(parameters)[https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#parameter-object]. + +```ts +@oas.visibility('undocumented') +class MyController { + @oas.get('/greet') + async function greet() { + return 'Hello, World!' + } + + @oas.get('/greet-v2') + @oas.visibility('documented') + async function greetV2() { + return 'Hello, World!' + } +} + +class MyOtherController { + @oas.get('/echo') + @oas.visibility('undocumented') + async function echo() { + return 'Echo!' + } +} +``` + ### @oas.response [API document](https://loopback.io/doc/en/lb4/apidocs.openapi-v3.oas.html#oas-variable), diff --git a/docs/site/extending/rest-api.md b/docs/site/extending/rest-api.md index bfc340aaf58e..e1d0af63f637 100644 --- a/docs/site/extending/rest-api.md +++ b/docs/site/extending/rest-api.md @@ -123,15 +123,16 @@ export class MyComponent implements Component { Sometimes it's desirable to treat the new endpoints as internal (undocumented) and leave them out from the OpenAPI document describing application's REST API. -This can be achieved using custom LoopBack extension `x-visibility`. +This can be achieved using +[`@oas.visibility(OperationVisibility.UNDOCUMENTED)`](../Decorators/Decorators_openapi.md#oasvisibility). ```ts class MyController { // constructor + @oas.visibility(OperationVisibility.UNDOCUMENTED) @get('/health', { responses: {}, - 'x-visibility': 'undocumented', }) health() { // ... diff --git a/packages/openapi-v3/src/__tests__/unit/decorators/visibility.decorator.unit.ts b/packages/openapi-v3/src/__tests__/unit/decorators/visibility.decorator.unit.ts new file mode 100644 index 000000000000..d3d0e358dab1 --- /dev/null +++ b/packages/openapi-v3/src/__tests__/unit/decorators/visibility.decorator.unit.ts @@ -0,0 +1,129 @@ +// Copyright IBM Corp. 2020. 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, OperationVisibility} from '../../..'; + +/** + * Verification of the Controller Spec uses string literals (e.g. + * 'documented', 'undocumented') instead of the `OperationVisibility` enum so + * as to verify that the enum itself did not change. + */ +describe('visibility 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.visibility(OperationVisibility.UNDOCUMENTED) + class MyController { + greet() { + return 'Hello world!'; + } + echo() { + return 'Hello world!'; + } + } + + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get['x-visibility']).to.eql( + 'undocumented', + ); + expect(actualSpec.paths['/echo'].get['x-visibility']).to.eql( + 'undocumented', + ); + }); + + it('Returns a spec where only one method is undocumented', () => { + class MyController { + @get('/greet') + greet() { + return 'Hello world!'; + } + + @get('/echo') + @oas.visibility(OperationVisibility.UNDOCUMENTED) + echo() { + return 'Hello world!'; + } + } + + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get['x-visibility']).to.be.undefined(); + expect(actualSpec.paths['/echo'].get['x-visibility']).to.eql( + 'undocumented', + ); + }); + + it('Allows a method to override the visibility of a class', () => { + @oas.visibility(OperationVisibility.UNDOCUMENTED) + class MyController { + @get('/greet') + greet() { + return 'Hello world!'; + } + + @get('/echo') + echo() { + return 'Hello world!'; + } + + @get('/yell') + @oas.visibility(OperationVisibility.DOCUMENTED) + yell() { + return 'HELLO WORLD!'; + } + } + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get['x-visibility']).to.eql( + 'undocumented', + ); + expect(actualSpec.paths['/echo'].get['x-visibility']).to.eql( + 'undocumented', + ); + expect(actualSpec.paths['/yell'].get['x-visibility']).to.be.eql( + 'documented', + ); + }); + + it('Allows a class to not be decorated with @oas.visibility 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['x-visibility']).to.be.undefined(); + expect(actualSpec.paths['/echo'].get['x-visibility']).to.be.undefined(); + }); + + it('Does not allow a member variable to be decorated', () => { + const shouldThrow = () => { + class MyController { + @oas.visibility(OperationVisibility.UNDOCUMENTED) + public foo: string; + + @get('/greet') + greet() {} + } + + return getControllerSpec(MyController); + }; + + expect(shouldThrow).to.throw( + /^\@oas.visibility 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 290b98343c43..ba1829e5490e 100644 --- a/packages/openapi-v3/src/controller-spec.ts +++ b/packages/openapi-v3/src/controller-spec.ts @@ -19,6 +19,7 @@ import { ISpecificationExtension, isReferenceObject, OperationObject, + OperationVisibility, ParameterObject, PathObject, ReferenceObject, @@ -93,17 +94,31 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { constructor, ); + const classVisibility = MetadataInspector.getClassMetadata< + OperationVisibility + >(OAI3Keys.VISIBILITY_CLASS_KEY, constructor); + + if (classVisibility) { + debug(` using class-level @oas.visibility(): '${classVisibility}'`); + } + if (classTags) { debug(' using class-level @oas.tags()'); } - if (classTags || isClassDeprecated) { + if (classTags || isClassDeprecated || classVisibility) { 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 (classVisibility) { + spec.paths[path][method]['x-visibility'] = classVisibility; + } + /* istanbul ignore else */ if (classTags) { if (spec.paths[path][method].tags?.length) { @@ -141,6 +156,16 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { debug(' using method-level deprecation via @deprecated()'); } + const methodVisibility = MetadataInspector.getMethodMetadata< + OperationVisibility + >(OAI3Keys.VISIBILITY_METHOD_KEY, constructor.prototype, op); + + if (methodVisibility) { + debug( + ` using method-level visibility via @visibility(): '${methodVisibility}'`, + ); + } + const methodTags = MetadataInspector.getMethodMetadata< TagsDecoratorMetadata >(OAI3Keys.TAGS_METHOD_KEY, constructor.prototype, op); @@ -202,7 +227,7 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { debug(' spec responses for method %s: %o', op, operationSpec.responses); - // Prescedence: method decorator > class decorator > operationSpec > undefined + // Precedence: method decorator > class decorator > operationSpec > undefined const deprecationSpec = isMethodDeprecated ?? isClassDeprecated ?? @@ -213,6 +238,14 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { operationSpec.deprecated = true; } + // Precedence: method decorator > class decorator > operationSpec > 'documented' + const visibilitySpec: OperationVisibility = + methodVisibility ?? classVisibility ?? operationSpec['x-visibility']; + + if (visibilitySpec) { + operationSpec['x-visibility'] = visibilitySpec; + } + for (const code in operationSpec.responses) { const responseObject: ResponseObject | ReferenceObject = operationSpec.responses[code]; diff --git a/packages/openapi-v3/src/decorators/index.ts b/packages/openapi-v3/src/decorators/index.ts index 5a90438b25fd..511841af8ded 100644 --- a/packages/openapi-v3/src/decorators/index.ts +++ b/packages/openapi-v3/src/decorators/index.ts @@ -8,6 +8,7 @@ export * from './deprecated.decorator'; export * from './operation.decorator'; export * from './parameter.decorator'; export * from './request-body.decorator'; +export * from './visibility.decorator'; import {api} from './api.decorator'; import {deprecated} from './deprecated.decorator'; @@ -16,6 +17,7 @@ import {param} from './parameter.decorator'; import {requestBody} from './request-body.decorator'; import {response} from './response.decorator'; import {tags} from './tags.decorator'; +import {visibility} from './visibility.decorator'; export const oas = { api, @@ -38,4 +40,5 @@ export const oas = { deprecated, response, tags, + visibility, }; diff --git a/packages/openapi-v3/src/decorators/visibility.decorator.ts b/packages/openapi-v3/src/decorators/visibility.decorator.ts new file mode 100644 index 000000000000..ab2d79ff5a0f --- /dev/null +++ b/packages/openapi-v3/src/decorators/visibility.decorator.ts @@ -0,0 +1,88 @@ +// Copyright IBM Corp. 2020. 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 {OperationVisibility} from '../types'; + +const debug = require('debug')( + 'loopback:openapi3:metadata:controller-spec:visibility', +); + +/** + * Marks an api path with the specfied visibility. When applied to a class, + * this decorator marks all paths with the specified visibility. + * + * You can optionally mark all controllers in a class with + * `@visibility('undocumented')`, but use `@visibility('documented')` + * on a specific method to ensure it is not marked as `undocumented`. + * + * @param visibilityTyoe - The visbility of the api path on the OAS3 spec. + * + * @example + * ```ts + * @oas.visibility('undocumented') + * class MyController { + * @get('/greet') + * async function greet() { + * return 'Hello, World!' + * } + * + * @get('/greet-v2') + * @oas.deprecated('documented') + * async function greetV2() { + * return 'Hello, World!' + * } + * } + * + * class MyOtherController { + * @get('/echo') + * async function echo() { + * return 'Echo!' + * } + * } + * ``` + */ +export function visibility(visibilityType: OperationVisibility) { + return function visibilityDecoratorForClassOrMethod( + // 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.VISIBILITY_METHOD_KEY, + visibilityType, + { + decoratorName: '@oas.visibility', + }, + )(target, method, methodDescriptor); + } else if (typeof target === 'function' && !method && !methodDescriptor) { + // Class + return ClassDecoratorFactory.createDecorator( + OAI3Keys.VISIBILITY_CLASS_KEY, + visibilityType, + {decoratorName: '@oas.visibility'}, + )(target); + } else { + throw new Error( + '@oas.visibility 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 81b56984c753..78c56df4c1ea 100644 --- a/packages/openapi-v3/src/keys.ts +++ b/packages/openapi-v3/src/keys.ts @@ -36,6 +36,22 @@ export namespace OAI3Keys { ClassDecorator >('openapi-v3:class:deprecated'); + /** + * Metadata key used to set or retrieve `@visibility` metadata on a method. + */ + export const VISIBILITY_METHOD_KEY = MetadataAccessor.create< + boolean, + MethodDecorator + >('openapi-v3:methods:visibility'); + + /** + * Metadata key used to set or retrieve `@visibility` metadata on a class + */ + export const VISIBILITY_CLASS_KEY = MetadataAccessor.create< + boolean, + ClassDecorator + >('openapi-v3:class:visibility'); + /* * Metadata key used to add to or retrieve an endpoint's responses */ diff --git a/packages/openapi-v3/src/types.ts b/packages/openapi-v3/src/types.ts index ce3874af71e8..6ab401d4db36 100644 --- a/packages/openapi-v3/src/types.ts +++ b/packages/openapi-v3/src/types.ts @@ -43,6 +43,11 @@ export interface TagsDecoratorMetadata { tags: string[]; } +export enum OperationVisibility { + DOCUMENTED = 'documented', + UNDOCUMENTED = 'undocumented', +} + export type ResponseModelOrSpec = | typeof Model | SchemaObject