Repository: nestjs/swagger
Pull request #1173 (2021-02-03 14:35:03)

Support OpenAPI link objects
## PR Checklist
Please check if your PR fulfills the following requirements:

- [x] The commit message follows our guidelines: https://github.com/nestjs/nest/blob/master/CONTRIBUTING.md
- [x] Tests for the changes have been added (for bug fixes / features)
- [x] Docs have been added / updated (for bug fixes / features)


## PR Type
What kind of change does this PR introduce?

<!-- Please check the one that applies to this PR using "x". -->
```
[ ] Bugfix
[x] Feature
[ ] Code style update (formatting, local variables)
[ ] Refactoring (no functional changes, no api changes)
[ ] Build related changes
[ ] CI related changes
[ ] Other... Please describe:
```

## What is the current behavior?

Swagger link objects are not supported.

Issue Number: #1159


## What is the new behavior?

Swagger link objects are supported.

- `@ApiProperty` supports a new parameter `link` which contains a lazy function pointing to the linked type
- `@ApiDefaultGetter` was added to specify that a method of a controller is the default getter for a specific type. It is used together with `@ApiProperty({link: () => type])` to generate links
- `@ApiLink` was added to generate links that are more complicated than a direct getter

Here's an example:

```ts
class Human {
  @ApiProperty()
  id: string;

  @ApiProperty({ link: () => Human })
  @ApiPropertyOptional()
  spouseId?: string;

  @ApiProperty({ link: () => Human })
  parentIds: string[];
}

@Controller('')
class HumanController {
  @ApiDefaultGetter(Human, 'id')
  @Get(':id')
  @ApiOkResponse({
    type: Human,
    description: 'Human corresponding to `id`'
  })
  getHuman(@Param('id') id: string): Promise<Human> {
    const human = new Human();
    human.id = id;
    return Promise.resolve(human);
  }

  @Get(':id/children')
  @ApiLink({ from: Human, routeParam: 'id' })
  @ApiOkResponse({
    type: [Human],
    description: 'Children of human with id `id`'
  })
  getChildren(@Param('id') id: string): Promise<Human[]> {
    return Promise.resolve([]);
  }
}
```

links will be generated for both routes in the swagger document.

## Does this PR introduce a breaking change?
```
[ ] Yes
[x] No
```

## Other information

I based this PR off the commit in #1169 . I can rewrite the PR without the changes from the commit from #1169 if needed.

coyotte508: That was the last forcepush - I added the doc
coyotte508: I'm thinking about a more generic design - the current design only allows to link to direct getters. 

Eg:

```ts
class User {
  @ApiProperty()
  id

  @ApiProperty({link: () => ProjectGet)
  projectId
}
```

But what if there are multiple projects per user, and user does not contain `projectIds`?

E.g.

```ts
class User {
  @ApiProperty()
  id
}

class Project {
  @ApiProperty()
  id

  // this is already handled by the PR
  @ApiProperty({link: () => User})
  userId
}

@Controller('users')
class UserController {
  @Get(':id')
  @ApiDefaultGetter(User, 'id')
  getOne(@Param('id') id: string) {...}

  // How to create this link ?
  @Get(':id/projects')
  getProjects(@Param('id') id: string) {...]
}

@Controller('projects')
class ProjectController {
  @Get(':id')
  @ApiDefaultGetter(User, 'id')
  getOne(@Param('id') id: string) {...}
}
```

I'm going to think it over and update the PR when I find a solution (or if someone has an idea)
coyotte508: I added an additional decorator: `@ApiLink` and updated the PR description.
coyotte508: Is there anything I can do (API changes, ...) to improve this PR?
coyotte508: Rebased & changed base on upcoming 8.0.0 

Patch:
diff --git a/README.md b/README.md
index 57334ded..c747a270 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,11 @@ The following decorators have been changed/renamed:
 - `@ApiOperation({ title: 'test' })` is now `@ApiOperation({ summary: 'test' })`
 - `@ApiUseTags` is now `@ApiTags`
 
+The following decorators have been added:
+
+- `@ApiDefaultGetter` to generate [link objects](https://swagger.io/docs/specification/links/) together with `@ApiProperty({link: () => Type})`
+- `@ApiLink` to directly generate link objects
+
 `DocumentBuilder` breaking changes (updated method signatures):
 
 - `addTag`
diff --git a/lib/constants.ts b/lib/constants.ts
index 158de362..7bcb9c26 100644
--- a/lib/constants.ts
+++ b/lib/constants.ts
@@ -13,5 +13,7 @@ export const DECORATORS = {
   API_EXCLUDE_ENDPOINT: `${DECORATORS_PREFIX}/apiExcludeEndpoint`,
   API_EXCLUDE_CONTROLLER: `${DECORATORS_PREFIX}/apiExcludeController`,
   API_EXTRA_MODELS: `${DECORATORS_PREFIX}/apiExtraModels`,
-  API_EXTENSION: `${DECORATORS_PREFIX}/apiExtension`
+  API_EXTENSION: `${DECORATORS_PREFIX}/apiExtension`,
+  API_DEFAULT_GETTER: `${DECORATORS_PREFIX}/apiDefaultGetter`,
+  API_LINK: `${DECORATORS_PREFIX}/apiLink`
 };
diff --git a/lib/decorators/api-default-getter.decorator.ts b/lib/decorators/api-default-getter.decorator.ts
new file mode 100644
index 00000000..0a68c81c
--- /dev/null
+++ b/lib/decorators/api-default-getter.decorator.ts
@@ -0,0 +1,45 @@
+import { Type } from '@nestjs/common';
+import { DECORATORS } from '../constants';
+
+/**
+ * Set the default getter for the given type to the decorated method
+ *
+ * This is to be used in conjunction with `ApiProperty({link: () => Type})` to generate link objects
+ * in the swagger schema
+ *
+ * ```typescript
+ * @Controller('users')
+ * class UserController {
+ *   @Get(':userId')
+ *   @ApiDefaultGetter(UserGet, 'userId')
+ *   getUser(@Param('userId') userId: string) {
+ *     // ...
+ *   }
+ * }
+ * ```
+ *
+ * @param type The type for which the decorated function is the default getter
+ * @param parameter Name of the parameter in the route of the getter which corresponds to the id of the type
+ *
+ * @see [Swagger link objects](https://swagger.io/docs/specification/links/)
+ */
+export function ApiDefaultGetter(
+  type: Type<unknown> | Function,
+  parameter: string
+): MethodDecorator {
+  return (
+    prototype: object,
+    key: string | symbol,
+    descriptor: PropertyDescriptor
+  ) => {
+    if (type.prototype) {
+      Reflect.defineMetadata(
+        DECORATORS.API_DEFAULT_GETTER,
+        { getter: descriptor.value, parameter, prototype },
+        type.prototype
+      );
+    }
+
+    return descriptor;
+  };
+}
diff --git a/lib/decorators/api-link.decorator.ts b/lib/decorators/api-link.decorator.ts
new file mode 100644
index 00000000..5c39b19a
--- /dev/null
+++ b/lib/decorators/api-link.decorator.ts
@@ -0,0 +1,76 @@
+import { Type } from '@nestjs/common';
+import { DECORATORS } from '../constants';
+
+export interface ApiLinkOptions {
+  from: Type<unknown> | Function;
+  /**
+   * Field in the type `from` which is used as a parameter in the decorated route
+   *
+   * @default 'id'
+   */
+  fromField?: string;
+  /**
+   * Name of the parameter in the decorated route
+   */
+  routeParam: string;
+}
+
+/**
+ * Defines this route as a link between two types
+ *
+ * Typically used when the link between the types is not present in the `from` type,
+ * eg with the following
+ *
+ * ```typescript
+ * class User {
+ *   @ApiProperty()
+ *   id: string
+ *
+ *   // no field documentIds: string[]
+ * }
+ *
+ * class Document {
+ *   @ApiProperty()
+ *   id: string
+ * }
+ *
+ * @Controller()
+ * class UserController {
+ *   @Get(':userId/documents')
+ *   @ApiLink({from: User, fromField: 'id', routeParam: 'userId'})
+ *   getDocuments(@Param('userId') userId: string)): Promise<Documents[]>
+ * }
+ * ```
+ *
+ * @param type The type for which the decorated function is the default getter
+ * @param parameter Name of the parameter in the route of the getter which corresponds to the id of the type
+ *
+ * @see [Swagger link objects](https://swagger.io/docs/specification/links/)
+ */
+export function ApiLink({
+  from,
+  fromField = 'id',
+  routeParam
+}: ApiLinkOptions): MethodDecorator {
+  return (
+    controllerPrototype: object,
+    key: string | symbol,
+    descriptor: PropertyDescriptor
+  ) => {
+    const { prototype } = from;
+    if (prototype) {
+      const links = Reflect.getMetadata(DECORATORS.API_LINK, prototype) ?? [];
+
+      links.push({
+        method: descriptor.value,
+        prototype: controllerPrototype,
+        field: fromField,
+        parameter: routeParam
+      });
+
+      Reflect.defineMetadata(DECORATORS.API_LINK, links, prototype);
+    }
+
+    return descriptor;
+  };
+}
diff --git a/lib/decorators/api-property.decorator.ts b/lib/decorators/api-property.decorator.ts
index 9f4dbb0a..8b7c915f 100644
--- a/lib/decorators/api-property.decorator.ts
+++ b/lib/decorators/api-property.decorator.ts
@@ -1,3 +1,4 @@
+import { Type } from '@nestjs/common';
 import { DECORATORS } from '../constants';
 import { SchemaObjectMetadata } from '../interfaces/schema-object-metadata.interface';
 import { getEnumType, getEnumValues } from '../utils/enum.utils';
@@ -8,6 +9,16 @@ export interface ApiPropertyOptions
   name?: string;
   enum?: any[] | Record<string, any>;
   enumName?: string;
+  /**
+   * Lazy function returning the type for which the decorated property
+   * can be used as an id
+   *
+   * Use together with @ApiDefaultGetter on the getter route of the type
+   * to generate OpenAPI link objects
+   *
+   * @see [Swagger link objects](https://swagger.io/docs/specification/links/)
+   */
+  link?: () => Type<unknown> | Function;
 }
 
 const isEnumArray = (obj: ApiPropertyOptions): boolean =>
diff --git a/lib/decorators/index.ts b/lib/decorators/index.ts
index f507d072..97280bb8 100644
--- a/lib/decorators/index.ts
+++ b/lib/decorators/index.ts
@@ -3,11 +3,13 @@ export * from './api-bearer.decorator';
 export * from './api-body.decorator';
 export * from './api-consumes.decorator';
 export * from './api-cookie.decorator';
+export * from './api-default-getter.decorator';
 export * from './api-exclude-endpoint.decorator';
 export * from './api-exclude-controller.decorator';
 export * from './api-extra-models.decorator';
 export * from './api-header.decorator';
 export * from './api-hide-property.decorator';
+export * from './api-link.decorator';
 export * from './api-oauth2.decorator';
 export * from './api-operation.decorator';
 export * from './api-param.decorator';
diff --git a/lib/explorers/api-response.explorer.ts b/lib/explorers/api-response.explorer.ts
index 2eda5942..2c859f62 100644
--- a/lib/explorers/api-response.explorer.ts
+++ b/lib/explorers/api-response.explorer.ts
@@ -5,14 +5,18 @@ import { get, mapValues, omit } from 'lodash';
 import { DECORATORS } from '../constants';
 import { ApiResponseMetadata } from '../decorators';
 import { SchemaObject } from '../interfaces/open-api-spec.interface';
-import { ResponseObjectFactory } from '../services/response-object-factory';
+import {
+  FactoriesNeededByResponseFactory,
+  ResponseObjectFactory
+} from '../services/response-object-factory';
 import { mergeAndUniq } from '../utils/merge-and-uniq.util';
 
 const responseObjectFactory = new ResponseObjectFactory();
 
 export const exploreGlobalApiResponseMetadata = (
   schemas: Record<string, SchemaObject>,
-  metatype: Type<unknown>
+  metatype: Type<unknown>,
+  factories: FactoriesNeededByResponseFactory
 ) => {
   const responses: ApiResponseMetadata[] = Reflect.getMetadata(
     DECORATORS.API_RESPONSE,
@@ -21,13 +25,19 @@ export const exploreGlobalApiResponseMetadata = (
   const produces = Reflect.getMetadata(DECORATORS.API_PRODUCES, metatype);
   return responses
     ? {
-        responses: mapResponsesToSwaggerResponses(responses, schemas, produces)
+        responses: mapResponsesToSwaggerResponses(
+          responses,
+          schemas,
+          produces,
+          factories
+        )
       }
     : undefined;
 };
 
 export const exploreApiResponseMetadata = (
   schemas: Record<string, SchemaObject>,
+  factories: FactoriesNeededByResponseFactory,
   instance: object,
   prototype: Type<unknown>,
   method: Function
@@ -43,7 +53,12 @@ export const exploreApiResponseMetadata = (
       get(classProduces, 'produces'),
       methodProduces
     );
-    return mapResponsesToSwaggerResponses(responses, schemas, produces);
+    return mapResponsesToSwaggerResponses(
+      responses,
+      schemas,
+      produces,
+      factories
+    );
   }
   const status = getStatusCode(method);
   if (status) {
@@ -73,14 +88,15 @@ const omitParamType = (param: Record<string, any>) => omit(param, 'type');
 const mapResponsesToSwaggerResponses = (
   responses: ApiResponseMetadata[],
   schemas: Record<string, SchemaObject>,
-  produces: string[] = ['application/json']
+  produces: string[] = ['application/json'],
+  factories: FactoriesNeededByResponseFactory
 ) => {
   produces = isEmpty(produces) ? ['application/json'] : produces;
 
   const openApiResponses = mapValues(
     responses,
     (response: ApiResponseMetadata) =>
-      responseObjectFactory.create(response, produces, schemas)
+      responseObjectFactory.create(response, produces, schemas, factories)
   );
   return mapValues(openApiResponses, omitParamType);
 };
diff --git a/lib/interfaces/swagger-document-options.interface.ts b/lib/interfaces/swagger-document-options.interface.ts
index 041c6222..9a74cb04 100644
--- a/lib/interfaces/swagger-document-options.interface.ts
+++ b/lib/interfaces/swagger-document-options.interface.ts
@@ -25,4 +25,18 @@ export interface SwaggerDocumentOptions {
    * @default () => controllerKey_methodKey
    */
   operationIdFactory?: (controllerKey: string, methodKey: string) => string;
+
+  /**
+   * Custom linkNameFactory that will be used to generate the name of links
+   * in the `links` field of responses
+   *
+   * @see [Link objects](https://swagger.io/docs/specification/links/)
+   *
+   * @default () => `${controllerKey}_${methodKey}_from_${fieldKey}`
+   */
+  linkNameFactory?: (
+    controllerKey: string,
+    methodKey: string,
+    fieldKey: string
+  ) => string;
 }
diff --git a/lib/services/response-object-factory.ts b/lib/services/response-object-factory.ts
index cc189b94..99b8af18 100644
--- a/lib/services/response-object-factory.ts
+++ b/lib/services/response-object-factory.ts
@@ -1,6 +1,14 @@
-import { isFunction, omit } from 'lodash';
-import { ApiResponseMetadata, ApiResponseSchemaHost } from '../decorators';
-import { SchemaObject } from '../interfaces/open-api-spec.interface';
+import { isEmpty, isFunction, omit } from 'lodash';
+import { DECORATORS } from '../constants';
+import {
+  ApiPropertyOptions,
+  ApiResponseMetadata,
+  ApiResponseSchemaHost
+} from '../decorators';
+import {
+  LinksObject,
+  SchemaObject
+} from '../interfaces/open-api-spec.interface';
 import { isBuiltInType } from '../utils/is-built-in-type.util';
 import { MimetypeContentWrapper } from './mimetype-content-wrapper';
 import { ModelPropertiesAccessor } from './model-properties-accessor';
@@ -8,6 +16,14 @@ import { ResponseObjectMapper } from './response-object-mapper';
 import { SchemaObjectFactory } from './schema-object-factory';
 import { SwaggerTypesMapper } from './swagger-types-mapper';
 
+export type FactoriesNeededByResponseFactory = {
+  linkName: (
+    controllerKey: string,
+    methodKey: string,
+    fieldKey: string
+  ) => string;
+  operationId: (controllerKey: string, methodKey: string) => string;
+};
 export class ResponseObjectFactory {
   private readonly mimetypeContentWrapper = new MimetypeContentWrapper();
   private readonly modelPropertiesAccessor = new ModelPropertiesAccessor();
@@ -21,7 +37,8 @@ export class ResponseObjectFactory {
   create(
     response: ApiResponseMetadata,
     produces: string[],
-    schemas: Record<string, SchemaObject>
+    schemas: Record<string, SchemaObject>,
+    factories: FactoriesNeededByResponseFactory
   ) {
     const { type, isArray } = response as ApiResponseMetadata;
     response = omit(response, ['isArray']);
@@ -65,6 +82,90 @@ export class ResponseObjectFactory {
       type as Function,
       schemas
     );
+    if (isFunction(type) && type.prototype) {
+      const { prototype } = type;
+      const links: LinksObject = {};
+
+      const properties = this.modelPropertiesAccessor.getModelProperties(
+        prototype
+      );
+
+      const generateLink = (
+        controllerPrototype: { constructor: { name: string } },
+        method: Function,
+        parameter: string,
+        field: string
+      ) => {
+        const linkName = factories.linkName(
+          controllerPrototype.constructor.name,
+          method.name,
+          field
+        );
+
+        links[linkName] = {
+          operationId: factories.operationId(
+            controllerPrototype.constructor.name,
+            method.name
+          ),
+          parameters: {
+            [parameter]: `$response.body#/${field}`
+          }
+        };
+      };
+
+      // links defined by @ApiProperty({link: () => type}) and @ApiDefaultGetter
+      for (const key of properties) {
+        const metadata: ApiPropertyOptions =
+          Reflect.getMetadata(
+            DECORATORS.API_MODEL_PROPERTIES,
+            prototype,
+            key
+          ) ?? {};
+
+        if (!metadata.link) {
+          continue;
+        }
+
+        const linkedType = metadata.link();
+
+        const linkedGetterInfo = Reflect.getMetadata(
+          DECORATORS.API_DEFAULT_GETTER,
+          linkedType.prototype
+        );
+
+        if (!linkedGetterInfo) {
+          continue;
+        }
+
+        const {
+          getter,
+          parameter,
+          prototype: controllerPrototype
+        } = linkedGetterInfo;
+
+        generateLink(controllerPrototype, getter, parameter, key);
+      }
+
+      // links defind by @ApiLink
+      const customLinks = Reflect.getMetadata(DECORATORS.API_LINK, prototype);
+
+      for (const customLink of customLinks ?? []) {
+        const {
+          method,
+          parameter,
+          field,
+          prototype: controllerPrototype
+        } = customLink;
+
+        generateLink(controllerPrototype, method, parameter, field);
+      }
+
+      if (!isEmpty(links)) {
+        // merge links into the response links provided by the user - the provided links
+        // prevail against the new ones in case of conflict
+        response.links = Object.assign(links, response.links);
+      }
+    }
     if (isArray) {
       return this.responseObjectMapper.toArrayRefObject(
         response,
diff --git a/lib/services/schema-object-factory.ts b/lib/services/schema-object-factory.ts
index 3ce3f385..7a641b88 100644
--- a/lib/services/schema-object-factory.ts
+++ b/lib/services/schema-object-factory.ts
@@ -216,8 +216,10 @@ export class SchemaObjectFactory {
   ): SchemaObjectMetadata | ReferenceObject | ParameterObject {
     if (!metadata) {
       metadata =
-        Reflect.getMetadata(DECORATORS.API_MODEL_PROPERTIES, prototype, key) ||
-        {};
+        omit(
+          Reflect.getMetadata(DECORATORS.API_MODEL_PROPERTIES, prototype, key),
+          'link'
+        ) || {};
     }
     if (this.isLazyTypeFunc(metadata.type as Function)) {
       metadata.type = (metadata.type as Function)();
diff --git a/lib/swagger-explorer.ts b/lib/swagger-explorer.ts
index a4dccec5..d36de7be 100644
--- a/lib/swagger-explorer.ts
+++ b/lib/swagger-explorer.ts
@@ -70,6 +70,14 @@ export class SwaggerExplorer {
   private operationIdFactory = (controllerKey: string, methodKey: string) =>
     controllerKey ? `${controllerKey}_${methodKey}` : methodKey;
   private routePathFactory?: RoutePathFactory;
+  private linkNameFactory = (
+    controllerKey: string,
+    methodKey: string,
+    fieldKey: string
+  ) =>
+    controllerKey
+      ? `${controllerKey}_${methodKey}_from_${fieldKey}`
+      : `${methodKey}_from_${fieldKey}`;
 
   constructor(private readonly schemaObjectFactory: SchemaObjectFactory) {}
 
@@ -78,13 +86,21 @@ export class SwaggerExplorer {
     applicationConfig: ApplicationConfig,
     modulePath?: string | undefined,
     globalPrefix?: string | undefined,
-    operationIdFactory?: (controllerKey: string, methodKey: string) => string
+    operationIdFactory?: (controllerKey: string, methodKey: string) => string,
+    linkNameFactory?: (
+      controllerKey: string,
+      methodKey: string,
+      fieldKey: string
+    ) => string
   ) {
     this.routePathFactory = new RoutePathFactory(applicationConfig);
     if (operationIdFactory) {
       this.operationIdFactory = operationIdFactory;
     }
 
+    if (linkNameFactory) {
+      this.linkNameFactory = linkNameFactory;
+    }
     const { instance, metatype } = wrapper;
     const prototype = Object.getPrototypeOf(instance);
     const documentResolvers: DenormalizedDocResolvers = {
@@ -95,7 +111,12 @@ export class SwaggerExplorer {
       ],
       security: [exploreApiSecurityMetadata],
       tags: [exploreApiTagsMetadata],
-      responses: [exploreApiResponseMetadata.bind(null, this.schemas)]
+      responses: [
+        exploreApiResponseMetadata.bind(null, this.schemas, {
+          operationId: this.operationIdFactory,
+          linkName: this.linkNameFactory
+        })
+      ]
     };
     return this.generateDenormalizedDocument(
       metatype as Type<unknown>,
diff --git a/lib/swagger-scanner.ts b/lib/swagger-scanner.ts
index cda632c9..09f937b3 100644
--- a/lib/swagger-scanner.ts
+++ b/lib/swagger-scanner.ts
@@ -34,7 +34,8 @@ export class SwaggerScanner {
       include: includedModules = [],
       extraModels = [],
       ignoreGlobalPrefix = false,
-      operationIdFactory
+      operationIdFactory,
+      linkNameFactory
     } = options;
 
     const container = (app as any).container as NestContainer;
@@ -70,7 +71,8 @@ export class SwaggerScanner {
           modulePath,
           globalPrefix,
           internalConfigRef,
-          operationIdFactory
+          operationIdFactory,
+          linkNameFactory
         );
       }
     );
@@ -91,7 +93,12 @@ export class SwaggerScanner {
     modulePath: string | undefined,
     globalPrefix: string | undefined,
     applicationConfig: ApplicationConfig,
-    operationIdFactory?: (controllerKey: string, methodKey: string) => string
+    operationIdFactory?: (controllerKey: string, methodKey: string) => string,
+    linkNameFactory?: (
+      controllerKey: string,
+      methodKey: string,
+      fieldKey: string
+    ) => string
   ): Array<Omit<OpenAPIObject, 'openapi' | 'info'> & Record<'root', any>> {
     const denormalizedArray = [...routes.values()].map((ctrl) =>
       this.explorer.exploreController(
@@ -99,7 +106,8 @@ export class SwaggerScanner {
         applicationConfig,
         modulePath,
         globalPrefix,
-        operationIdFactory
+        operationIdFactory,
+        linkNameFactory
       )
     );
     return flatten(denormalizedArray) as any;


Test patch:
diff --git a/e2e/api-spec.json b/e2e/api-spec.json
index f7db09ec..ca03df1b 100644
--- a/e2e/api-spec.json
+++ b/e2e/api-spec.json
@@ -202,6 +202,14 @@
         "responses": {
           "200": {
             "description": "The found record",
+            "links": {
+              "GetCatByKittenIds": {
+                "operationId": "CatsController_findOne",
+                "parameters": {
+                  "id": "$response.body#/kittenIds"
+                }
+              }
+            },
             "content": {
               "application/json": {
                 "schema": {
@@ -450,6 +458,14 @@
         "responses": {
           "201": {
             "description": "The record has been successfully created.",
+            "links": {
+              "GetCatByKittenIds": {
+                "operationId": "CatsController_findOne",
+                "parameters": {
+                  "id": "$response.body#/kittenIds"
+                }
+              }
+            },
             "content": {
               "application/json": {
                 "schema": {
diff --git a/e2e/src/cats/cats.controller.ts b/e2e/src/cats/cats.controller.ts
index 1ea981fe..b9258d70 100644
--- a/e2e/src/cats/cats.controller.ts
+++ b/e2e/src/cats/cats.controller.ts
@@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
 import {
   ApiBearerAuth,
   ApiConsumes,
+  ApiDefaultGetter,
   ApiExtension,
   ApiHeader,
   ApiOperation,
@@ -51,6 +52,7 @@ export class CatsController {
     type: Cat
   })
   @ApiExtension('x-auth-type', 'NONE')
+  @ApiDefaultGetter(Cat, 'id')
   findOne(@Param('id') id: string): Cat {
     return this.catsService.findOne(+id);
   }
diff --git a/e2e/src/cats/classes/cat.class.ts b/e2e/src/cats/classes/cat.class.ts
index b135a4a6..7a844c54 100644
--- a/e2e/src/cats/classes/cat.class.ts
+++ b/e2e/src/cats/classes/cat.class.ts
@@ -53,4 +53,7 @@ export class Cat {
     isArray: true
   })
   enumArr: LettersEnum;
+
+  @ApiProperty({ type: [String], link: () => Cat })
+  kittenIds?: string[];
 }
diff --git a/test/explorer/swagger-explorer.spec.ts b/test/explorer/swagger-explorer.spec.ts
index 6313a4b8..03026569 100644
--- a/test/explorer/swagger-explorer.spec.ts
+++ b/test/explorer/swagger-explorer.spec.ts
@@ -10,20 +10,24 @@ import {
 } from '@nestjs/common';
 import { ApplicationConfig } from '@nestjs/core';
 import { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper';
+import { upperFirst } from 'lodash';
 import {
   ApiBadRequestResponse,
   ApiBody,
   ApiConsumes,
   ApiCreatedResponse,
+  ApiDefaultGetter,
   ApiDefaultResponse,
   ApiExcludeController,
   ApiExtraModels,
   ApiHeader,
+  ApiLink,
   ApiOkResponse,
   ApiOperation,
   ApiParam,
   ApiProduces,
   ApiProperty,
+  ApiPropertyOptional,
   ApiQuery
 } from '../../lib/decorators';
 import { DenormalizedDoc } from '../../lib/interfaces/denormalized-doc.interface';
@@ -1179,4 +1183,129 @@ describe('SwaggerExplorer', () => {
       expect(routes).toHaveLength(0);
     });
   });
+  describe('when links are defined', () => {
+    class Human {
+      @ApiProperty()
+      id: string;
+
+      @ApiProperty({
+        link: () => Human,
+        example: ['a33d0f4b-aec2-4b07-b407-45d8e70332b2']
+      })
+      @ApiPropertyOptional()
+      spouseId?: string;
+
+      @ApiProperty({
+        link: () => Human,
+        example: [
+          '5593519b-b830-4c5a-b5f6-3cbbfbecbd1b',
+          '8044bf32-5485-42c4-b481-d6ef1ae89163'
+        ]
+      })
+      parentIds: string[];
+    }
+
+    @Controller('')
+    class HumanController {
+      @ApiDefaultGetter(Human, 'id')
+      @Get(':id')
+      @ApiOkResponse({
+        type: Human,
+        description: 'Human corresponding to `id`'
+      })
+      getHuman(@Param('id') id: string): Promise<Human> {
+        const human = new Human();
+        human.id = id;
+        return Promise.resolve(human);
+      }
+
+      @Get(':id/children')
+      @ApiLink({ from: Human, routeParam: 'id' })
+      @ApiOkResponse({
+        type: [Human],
+        description: 'Children of human with id `id`'
+      })
+      getChildren(@Param('id') id: string): Promise<Human[]> {
+        return Promise.resolve([]);
+      }
+    }
+
+    it('should generate open api link objects', () => {
+      const explorer = new SwaggerExplorer(schemaObjectFactory);
+
+      const routes = explorer.exploreController(
+        {
+          instance: new HumanController(),
+          metatype: HumanController
+        } as InstanceWrapper<HumanController>,
+        new ApplicationConfig(),
+        'path'
+      );
+
+      expect((routes[0].responses['200'] as ResponseObject).links).toEqual({
+        HumanController_getHuman_from_spouseId: {
+          operationId: 'HumanController_getHuman',
+          parameters: {
+            id: '$response.body#/spouseId'
+          }
+        },
+        HumanController_getHuman_from_parentIds: {
+          operationId: 'HumanController_getHuman',
+          parameters: {
+            id: '$response.body#/parentIds'
+          }
+        },
+        HumanController_getChildren_from_id: {
+          operationId: 'HumanController_getChildren',
+          parameters: {
+            id: '$response.body#/id'
+          }
+        }
+      });
+
+      expect((routes[1].responses['200'] as ResponseObject).links).toEqual(
+        (routes[0].responses['200'] as ResponseObject).links
+      );
+    });
+
+    it('should generate open api link objects with custom linkNameFactory', () => {
+      const explorer = new SwaggerExplorer(schemaObjectFactory);
+
+      const routes = explorer.exploreController(
+        {
+          instance: new HumanController(),
+          metatype: HumanController
+        } as InstanceWrapper<HumanController>,
+        new ApplicationConfig(),
+        'path',
+        undefined,
+        undefined,
+        (controllerKey, methodKey, paramKey) =>
+          paramKey === 'id'
+            ? methodKey.replace(/^get/, '')
+            : upperFirst(paramKey.replace(/Id(s)?$/, '$1'))
+      );
+
+      expect((routes[0].responses['200'] as ResponseObject).links).toEqual({
+        Spouse: {
+          operationId: 'HumanController_getHuman',
+          parameters: {
+            id: '$response.body#/spouseId'
+          }
+        },
+        Parents: {
+          operationId: 'HumanController_getHuman',
+          parameters: {
+            id: '$response.body#/parentIds'
+          }
+        },
+        Children: {
+          operationId: 'HumanController_getChildren',
+          parameters: {
+            id: '$response.body#/id'
+          }
+        }
+      });
+    });
+  });
 });
diff --git a/test/services/schema-object-factory.spec.ts b/test/services/schema-object-factory.spec.ts
index f10ca163..d2df0820 100644
--- a/test/services/schema-object-factory.spec.ts
+++ b/test/services/schema-object-factory.spec.ts
@@ -206,6 +206,32 @@ describe('SchemaObjectFactory', () => {
       });
     });
 
+    it('should purge linked types from properties', () => {
+      class Human {
+        @ApiProperty()
+        id: string;
+
+        @ApiProperty({ link: () => Human })
+        spouseId: string;
+      }
+
+      const schemas: Record<string, SchemasObject> = {};
+
+      schemaObjectFactory.exploreModelSchema(Human, schemas);
+      expect(schemas[Human.name]).toEqual({
+        type: 'object',
+        properties: {
+          id: {
+            type: 'string'
+          },
+          spouseId: {
+            type: 'string'
+          }
+        },
+        required: ['id', 'spouseId']
+      });
+    });
+
     it('should override base class metadata', () => {
       class CreatUserDto {
         @ApiProperty({ minLength: 0, required: true })