Skip to content

Commit

Permalink
feat(openapi-v3): add OAS3 visibility decorator
Browse files Browse the repository at this point in the history
closes loopbackio#6392

Signed-off-by: Rifa Achrinza <[email protected]>
  • Loading branch information
achrinza committed Sep 21, 2020
1 parent 84a0e1d commit 33e21e5
Show file tree
Hide file tree
Showing 7 changed files with 308 additions and 1 deletion.
38 changes: 38 additions & 0 deletions docs/site/decorators/Decorators_openapi.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(<different visibility type>)`.

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),
Expand Down
Original file line number Diff line number Diff line change
@@ -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:/,
);
});
});
42 changes: 41 additions & 1 deletion packages/openapi-v3/src/controller-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
SchemaObject,
SchemasObject,
TagsDecoratorMetadata,
VisibilityDecoratorMetadata,
} from './types';

const debug = require('debug')('loopback:openapi3:metadata:controller-spec');
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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];
Expand Down
3 changes: 3 additions & 0 deletions packages/openapi-v3/src/decorators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -38,4 +40,5 @@ export const oas = {
deprecated,
response,
tags,
visibility,
};
86 changes: 86 additions & 0 deletions packages/openapi-v3/src/decorators/visibility.decorator.ts
Original file line number Diff line number Diff line change
@@ -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<any>,
) {
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<VisibilityDecoratorMetadata>(
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),
);
}
};
}
16 changes: 16 additions & 0 deletions packages/openapi-v3/src/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
2 changes: 2 additions & 0 deletions packages/openapi-v3/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export interface TagsDecoratorMetadata {
tags: string[];
}

export type VisibilityDecoratorMetadata = 'documented' | 'undocumented';

export type ResponseModelOrSpec =
| typeof Model
| SchemaObject
Expand Down

0 comments on commit 33e21e5

Please sign in to comment.