diff --git a/docs/site/decorators/Decorators_openapi.md b/docs/site/decorators/Decorators_openapi.md index 9dd0a85f676c..20c8e5b1803e 100644 --- a/docs/site/decorators/Decorators_openapi.md +++ b/docs/site/decorators/Decorators_openapi.md @@ -626,6 +626,44 @@ 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()`. + +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/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..9d9799fd3440 --- /dev/null +++ b/packages/openapi-v3/src/__tests__/unit/decorators/visibility.decorator.unit.ts @@ -0,0 +1,122 @@ +// 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} from '../../..'; + +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('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('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('undocumented') + class MyController { + @get('/greet') + greet() { + return 'Hello world!'; + } + + @get('/echo') + echo() { + return 'Hello world!'; + } + + @get('/yell') + @oas.visibility('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.undefined(); + }); + + 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('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..f72795ee905e 100644 --- a/packages/openapi-v3/src/controller-spec.ts +++ b/packages/openapi-v3/src/controller-spec.ts @@ -28,6 +28,7 @@ import { SchemaObject, SchemasObject, TagsDecoratorMetadata, + VisibilityDecoratorMetadata, } from './types'; const debug = require('debug')('loopback:openapi3:metadata:controller-spec'); @@ -93,17 +94,35 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { constructor, ); + const classVisibility = MetadataInspector.getClassMetadata< + VisibilityDecoratorMetadata + >(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 && classVisibility !== 'documented') + ) { 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 !== 'documented') { + spec.paths[path][method]['x-visibility'] = classVisibility; + } + /* istanbul ignore else */ if (classTags) { if (spec.paths[path][method].tags?.length) { @@ -141,6 +160,16 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { debug(' using method-level deprecation via @deprecated()'); } + const methodVisibility = MetadataInspector.getMethodMetadata< + VisibilityDecoratorMetadata + >(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); @@ -213,6 +242,17 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { operationSpec.deprecated = true; } + // Prescedence: method decorator > class decorator > operationSpec > 'documented' + const visibilitySpec: VisibilityDecoratorMetadata = + methodVisibility ?? + classVisibility ?? + operationSpec['x-visibility'] ?? + 'documented'; + + if (visibilitySpec !== 'documented') { + 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..a5624ba9ff32 --- /dev/null +++ b/packages/openapi-v3/src/decorators/visibility.decorator.ts @@ -0,0 +1,86 @@ +// 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 {VisibilityDecoratorMetadata} 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: VisibilityDecoratorMetadata) { + 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< + VisibilityDecoratorMetadata + >(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..285effcf0b7b 100644 --- a/packages/openapi-v3/src/types.ts +++ b/packages/openapi-v3/src/types.ts @@ -43,6 +43,8 @@ export interface TagsDecoratorMetadata { tags: string[]; } +export type VisibilityDecoratorMetadata = 'documented' | 'undocumented'; + export type ResponseModelOrSpec = | typeof Model | SchemaObject