Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions docs/site/decorators/Decorators_openapi.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
@@ -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:/);
});
});
51 changes: 51 additions & 0 deletions packages/openapi-v3/src/controller-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
ResponseObject,
SchemaObject,
SchemasObject,
TagsDecoratorMetadata,
} from './types';

const debug = require('debug')('loopback:openapi3:metadata:controller-spec');
Expand Down Expand Up @@ -77,6 +78,35 @@ function resolveControllerSpec(constructor: Function): ControllerSpec {
spec = {paths: {}};
}

const classTags = MetadataInspector.getClassMetadata<TagsDecoratorMetadata>(
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<RestEndpoint>(
OAI3Keys.METHODS_KEY,
Expand All @@ -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) {
Expand All @@ -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);
Expand Down
20 changes: 20 additions & 0 deletions packages/openapi-v3/src/decorators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
46 changes: 46 additions & 0 deletions packages/openapi-v3/src/decorators/tags.decorator.ts
Original file line number Diff line number Diff line change
@@ -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<any>,
) {
if (method && methodDescriptor) {
// Method
return MethodDecoratorFactory.createDecorator<TagsDecoratorMetadata>(
OAI3Keys.TAGS_METHOD_KEY,
{tags: tagNames},
{decoratorName: '@oas.tags'},
)(target, method, methodDescriptor);
} else if (typeof target === 'function' && !method && !methodDescriptor) {
// Class
return ClassDecoratorFactory.createDecorator<TagsDecoratorMetadata>(
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),
);
}
};
}
16 changes: 16 additions & 0 deletions packages/openapi-v3/src/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
4 changes: 4 additions & 0 deletions packages/openapi-v3/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,7 @@ export function createEmptyApiSpec(): OpenApiSpec {
servers: [{url: '/'}],
};
}

export interface TagsDecoratorMetadata {
tags: string[];
}