Repository: nestjs/swagger
Pull request #2459 (2023-06-05 10:15:41)

feat(plugin): introduce readonly visitors
## 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
- [ ] Tests for the changes have been added (for bug fixes / features)
- [ ] 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?
<!-- Please describe the current behavior that you are modifying, or link to a relevant issue. -->

Issue Number: N/A


## What is the new behavior?


## Does this PR introduce a breaking change?
- [ ] Yes
- [x] No

<!-- If this PR contains a breaking change, please describe the impact and migration path for existing applications below. -->


## Other information



Patch:
diff --git a/lib/decorators/api-operation.decorator.ts b/lib/decorators/api-operation.decorator.ts
index d95cb8f8..9add4351 100644
--- a/lib/decorators/api-operation.decorator.ts
+++ b/lib/decorators/api-operation.decorator.ts
@@ -9,7 +9,10 @@ const defaultOperationOptions: ApiOperationOptions = {
   summary: ''
 };
 
-export function ApiOperation(options: ApiOperationOptions): MethodDecorator {
+export function ApiOperation(
+  options: ApiOperationOptions,
+  { overrideExisting } = { overrideExisting: true }
+): MethodDecorator {
   return createMethodDecorator(
     DECORATORS.API_OPERATION,
     pickBy(
@@ -18,6 +21,7 @@ export function ApiOperation(options: ApiOperationOptions): MethodDecorator {
         ...options
       } as ApiOperationOptions,
       negate(isUndefined)
-    )
+    ),
+    { overrideExisting }
   );
 }
diff --git a/lib/decorators/api-response.decorator.ts b/lib/decorators/api-response.decorator.ts
index 98339fda..e89c7291 100644
--- a/lib/decorators/api-response.decorator.ts
+++ b/lib/decorators/api-response.decorator.ts
@@ -2,9 +2,9 @@ import { HttpStatus, Type } from '@nestjs/common';
 import { omit } from 'lodash';
 import { DECORATORS } from '../constants';
 import {
+  ReferenceObject,
   ResponseObject,
-  SchemaObject,
-  ReferenceObject
+  SchemaObject
 } from '../interfaces/open-api-spec.interface';
 import { getTypeIsArrayTuple } from './helpers';
 
@@ -26,7 +26,8 @@ export interface ApiResponseSchemaHost
 export type ApiResponseOptions = ApiResponseMetadata | ApiResponseSchemaHost;
 
 export function ApiResponse(
-  options: ApiResponseOptions
+  options: ApiResponseOptions,
+  { overrideExisting } = { overrideExisting: true }
 ): MethodDecorator & ClassDecorator {
   const [type, isArray] = getTypeIsArrayTuple(
     (options as ApiResponseMetadata).type,
@@ -46,8 +47,14 @@ export function ApiResponse(
     descriptor?: TypedPropertyDescriptor<any>
   ): any => {
     if (descriptor) {
-      const responses =
-        Reflect.getMetadata(DECORATORS.API_RESPONSE, descriptor.value) || {};
+      const responses = Reflect.getMetadata(
+        DECORATORS.API_RESPONSE,
+        descriptor.value
+      );
+
+      if (responses && !overrideExisting) {
+        return descriptor;
+      }
       Reflect.defineMetadata(
         DECORATORS.API_RESPONSE,
         {
@@ -58,8 +65,10 @@ export function ApiResponse(
       );
       return descriptor;
     }
-    const responses =
-      Reflect.getMetadata(DECORATORS.API_RESPONSE, target) || {};
+    const responses = Reflect.getMetadata(DECORATORS.API_RESPONSE, target);
+    if (responses && !overrideExisting) {
+      return descriptor;
+    }
     Reflect.defineMetadata(
       DECORATORS.API_RESPONSE,
       {
diff --git a/lib/decorators/helpers.ts b/lib/decorators/helpers.ts
index e700a6be..d81f8b69 100644
--- a/lib/decorators/helpers.ts
+++ b/lib/decorators/helpers.ts
@@ -4,13 +4,26 @@ import { METADATA_FACTORY_NAME } from '../plugin/plugin-constants';
 
 export function createMethodDecorator<T = any>(
   metakey: string,
-  metadata: T
+  metadata: T,
+  { overrideExisting } = { overrideExisting: true }
 ): MethodDecorator {
   return (
     target: object,
     key: string | symbol,
     descriptor: PropertyDescriptor
   ) => {
+    if (typeof metadata === 'object') {
+      const prevValue = Reflect.getMetadata(metakey, descriptor.value);
+      if (prevValue && !overrideExisting) {
+        return descriptor;
+      }
+      Reflect.defineMetadata(
+        metakey,
+        { ...prevValue, ...metadata },
+        descriptor.value
+      );
+      return descriptor;
+    }
     Reflect.defineMetadata(metakey, metadata, descriptor.value);
     return descriptor;
   };
diff --git a/lib/explorers/api-operation.explorer.ts b/lib/explorers/api-operation.explorer.ts
index 37e8a82c..f3e77032 100644
--- a/lib/explorers/api-operation.explorer.ts
+++ b/lib/explorers/api-operation.explorer.ts
@@ -1,8 +1,54 @@
 import { Type } from '@nestjs/common';
 import { DECORATORS } from '../constants';
+import { ApiOperation } from '../decorators/api-operation.decorator';
+import { METADATA_FACTORY_NAME } from '../plugin/plugin-constants';
 
 export const exploreApiOperationMetadata = (
   instance: object,
   prototype: Type<unknown>,
   method: object
-) => Reflect.getMetadata(DECORATORS.API_OPERATION, method);
+) => {
+  applyMetadataFactory(prototype);
+  return Reflect.getMetadata(DECORATORS.API_OPERATION, method);
+};
+
+function applyMetadataFactory(prototype: Type<unknown>) {
+  const classPrototype = prototype;
+  do {
+    if (!prototype.constructor) {
+      return;
+    }
+    if (!prototype.constructor[METADATA_FACTORY_NAME]) {
+      continue;
+    }
+    const metadata = prototype.constructor[METADATA_FACTORY_NAME]();
+    const methodKeys = Object.keys(metadata);
+    methodKeys.forEach((key) => {
+      const operationMeta = {};
+      const { summary, deprecated, tags } = metadata[key];
+
+      applyIfNotNil(operationMeta, 'summary', summary);
+      applyIfNotNil(operationMeta, 'deprecated', deprecated);
+      applyIfNotNil(operationMeta, 'tags', tags);
+
+      if (Object.keys(operationMeta).length === 0) {
+        return;
+      }
+      ApiOperation(operationMeta, { overrideExisting: false })(
+        classPrototype,
+        key,
+        Object.getOwnPropertyDescriptor(classPrototype, key)
+      );
+    });
+  } while (
+    (prototype = Reflect.getPrototypeOf(prototype) as Type<any>) &&
+    prototype !== Object.prototype &&
+    prototype
+  );
+}
+
+function applyIfNotNil(target: Record<string, any>, key: string, value: any) {
+  if (value !== undefined && value !== null) {
+    target[key] = value;
+  }
+}
diff --git a/lib/explorers/api-response.explorer.ts b/lib/explorers/api-response.explorer.ts
index 2eda5942..1079cf5e 100644
--- a/lib/explorers/api-response.explorer.ts
+++ b/lib/explorers/api-response.explorer.ts
@@ -3,8 +3,9 @@ import { HTTP_CODE_METADATA, METHOD_METADATA } from '@nestjs/common/constants';
 import { isEmpty } from '@nestjs/common/utils/shared.utils';
 import { get, mapValues, omit } from 'lodash';
 import { DECORATORS } from '../constants';
-import { ApiResponseMetadata } from '../decorators';
+import { ApiResponse, ApiResponseMetadata } from '../decorators';
 import { SchemaObject } from '../interfaces/open-api-spec.interface';
+import { METADATA_FACTORY_NAME } from '../plugin/plugin-constants';
 import { ResponseObjectFactory } from '../services/response-object-factory';
 import { mergeAndUniq } from '../utils/merge-and-uniq.util';
 
@@ -32,6 +33,8 @@ export const exploreApiResponseMetadata = (
   prototype: Type<unknown>,
   method: Function
 ) => {
+  applyMetadataFactory(prototype);
+
   const responses = Reflect.getMetadata(DECORATORS.API_RESPONSE, method);
   if (responses) {
     const classProduces = Reflect.getMetadata(
@@ -84,3 +87,36 @@ const mapResponsesToSwaggerResponses = (
   );
   return mapValues(openApiResponses, omitParamType);
 };
+
+function applyMetadataFactory(prototype: Type<unknown>) {
+  const classPrototype = prototype;
+  do {
+    if (!prototype.constructor) {
+      return;
+    }
+    if (!prototype.constructor[METADATA_FACTORY_NAME]) {
+      continue;
+    }
+    const metadata = prototype.constructor[METADATA_FACTORY_NAME]();
+    const methodKeys = Object.keys(metadata);
+    methodKeys.forEach((key) => {
+      const { summary, deprecated, tags, ...meta } = metadata[key];
+
+      if (Object.keys(meta).length === 0) {
+        return;
+      }
+      if (meta.status === undefined) {
+        meta.status = getStatusCode(classPrototype[key]);
+      }
+      ApiResponse(meta, { overrideExisting: false })(
+        classPrototype,
+        key,
+        Object.getOwnPropertyDescriptor(classPrototype, key)
+      );
+    });
+  } while (
+    (prototype = Reflect.getPrototypeOf(prototype) as Type<any>) &&
+    prototype !== Object.prototype &&
+    prototype
+  );
+}
diff --git a/lib/extra/swagger-shim.ts b/lib/extra/swagger-shim.ts
index b0f8b367..93cb5757 100644
--- a/lib/extra/swagger-shim.ts
+++ b/lib/extra/swagger-shim.ts
@@ -167,3 +167,9 @@ export function PickType() {
 export function getSchemaPath() {
   return () => '';
 }
+export function before() {
+  return () => '';
+}
+export function ReadonlyVisitor() {
+  return class {};
+}
diff --git a/lib/interfaces/swagger-custom-options.interface.ts b/lib/interfaces/swagger-custom-options.interface.ts
index 5853e5fa..f9fc3228 100644
--- a/lib/interfaces/swagger-custom-options.interface.ts
+++ b/lib/interfaces/swagger-custom-options.interface.ts
@@ -1,6 +1,4 @@
 import { SwaggerUiOptions } from './swagger-ui-options.interface';
-import { SwaggerDocumentOptions } from './swagger-document-options.interface';
-import { OpenAPIObject } from './open-api-spec.interface';
 
 export interface SwaggerCustomOptions {
   useGlobalPrefix?: boolean;
diff --git a/lib/plugin/compiler-plugin.ts b/lib/plugin/compiler-plugin.ts
index 84ae76c4..f62048b5 100644
--- a/lib/plugin/compiler-plugin.ts
+++ b/lib/plugin/compiler-plugin.ts
@@ -1,12 +1,11 @@
 import * as ts from 'typescript';
 import { mergePluginOptions } from './merge-options';
+import { isFilenameMatched } from './utils/is-filename-matched.util';
 import { ControllerClassVisitor } from './visitors/controller-class.visitor';
 import { ModelClassVisitor } from './visitors/model-class.visitor';
 
 const modelClassVisitor = new ModelClassVisitor();
 const controllerClassVisitor = new ControllerClassVisitor();
-const isFilenameMatched = (patterns: string[], filename: string) =>
-  patterns.some((path) => filename.includes(path));
 
 export const before = (options?: Record<string, any>, program?: ts.Program) => {
   options = mergePluginOptions(options);
diff --git a/lib/plugin/index.ts b/lib/plugin/index.ts
index 2a513e23..f980bda5 100644
--- a/lib/plugin/index.ts
+++ b/lib/plugin/index.ts
@@ -1,1 +1,2 @@
 export * from './compiler-plugin';
+export * from './visitors/readonly.visitor';
diff --git a/lib/plugin/merge-options.ts b/lib/plugin/merge-options.ts
index 51aded4d..06e60b68 100644
--- a/lib/plugin/merge-options.ts
+++ b/lib/plugin/merge-options.ts
@@ -7,6 +7,8 @@ export interface PluginOptions {
   dtoKeyOfComment?: string;
   controllerKeyOfComment?: string;
   introspectComments?: boolean;
+  readonly?: boolean;
+  pathToSource?: string;
 }
 
 const defaultOptions: PluginOptions = {
@@ -15,7 +17,8 @@ const defaultOptions: PluginOptions = {
   classValidatorShim: true,
   dtoKeyOfComment: 'description',
   controllerKeyOfComment: 'description',
-  introspectComments: false
+  introspectComments: false,
+  readonly: false
 };
 
 export const mergePluginOptions = (
diff --git a/lib/plugin/metadata-loader.ts b/lib/plugin/metadata-loader.ts
new file mode 100644
index 00000000..80768978
--- /dev/null
+++ b/lib/plugin/metadata-loader.ts
@@ -0,0 +1,28 @@
+import { METADATA_FACTORY_NAME } from './plugin-constants';
+
+export class MetadataLoader {
+  async load(metadata: Record<string, any>) {
+    const pkgMetadata = metadata['@nestjs/swagger'];
+    if (!pkgMetadata) {
+      return;
+    }
+    const { models, controllers } = pkgMetadata;
+    if (models) {
+      await this.applyMetadata(models);
+    }
+    if (controllers) {
+      await this.applyMetadata(controllers);
+    }
+  }
+
+  private async applyMetadata(meta: Record<string, any>) {
+    const loadPromises = meta.map(async ([fileImport, fileMeta]) => {
+      const fileRef = await fileImport;
+      Object.keys(fileMeta).map((key) => {
+        const clsRef = fileRef[key];
+        clsRef[METADATA_FACTORY_NAME] = () => fileMeta[key];
+      });
+    });
+    await Promise.all(loadPromises);
+  }
+}
diff --git a/lib/plugin/utils/is-filename-matched.util.ts b/lib/plugin/utils/is-filename-matched.util.ts
new file mode 100644
index 00000000..ef5139c7
--- /dev/null
+++ b/lib/plugin/utils/is-filename-matched.util.ts
@@ -0,0 +1,2 @@
+export const isFilenameMatched = (patterns: string[], filename: string) =>
+  patterns.some((path) => filename.includes(path));
diff --git a/lib/plugin/utils/plugin-utils.ts b/lib/plugin/utils/plugin-utils.ts
index 9c10ac2b..154cc45e 100644
--- a/lib/plugin/utils/plugin-utils.ts
+++ b/lib/plugin/utils/plugin-utils.ts
@@ -1,6 +1,7 @@
 import { head } from 'lodash';
 import { isAbsolute, posix } from 'path';
 import * as ts from 'typescript';
+import { PluginOptions } from '../merge-options';
 import {
   getDecoratorName,
   getText,
@@ -117,7 +118,11 @@ export function hasPropertyKey(
     .some((item) => item.name.getText() === key);
 }
 
-export function replaceImportPath(typeReference: string, fileName: string) {
+export function replaceImportPath(
+  typeReference: string,
+  fileName: string,
+  options: PluginOptions
+) {
   if (!typeReference.includes('import')) {
     return typeReference;
   }
@@ -135,7 +140,10 @@ export function replaceImportPath(typeReference: string, fileName: string) {
     require.resolve(importPath);
     return typeReference.replace('import', 'require');
   } catch (_error) {
-    let relativePath = posix.relative(posix.dirname(fileName), importPath);
+    const from = options?.readonly
+      ? options.pathToSource
+      : posix.dirname(fileName);
+    let relativePath = posix.relative(from, importPath);
     relativePath = relativePath[0] !== '.' ? './' + relativePath : relativePath;
 
     const nodeModulesText = 'node_modules';
diff --git a/lib/plugin/visitors/controller-class.visitor.ts b/lib/plugin/visitors/controller-class.visitor.ts
index e71b1b51..76745f31 100644
--- a/lib/plugin/visitors/controller-class.visitor.ts
+++ b/lib/plugin/visitors/controller-class.visitor.ts
@@ -1,4 +1,5 @@
 import { compact, head } from 'lodash';
+import { posix } from 'path';
 import * as ts from 'typescript';
 import { ApiOperation, ApiResponse } from '../../decorators';
 import { PluginOptions } from '../merge-options';
@@ -18,7 +19,31 @@ import {
 } from '../utils/plugin-utils';
 import { AbstractFileVisitor } from './abstract.visitor';
 
+type ClassMetadata = Record<string, ts.ObjectLiteralExpression>;
+
 export class ControllerClassVisitor extends AbstractFileVisitor {
+  private readonly _collectedMetadata: Record<
+    string,
+    Record<string, ClassMetadata>
+  > = {};
+
+  get collectedMetadata(): Array<
+    [ts.CallExpression, Record<string, ClassMetadata>]
+  > {
+    const metadataWithImports = [];
+    Object.keys(this._collectedMetadata).forEach((filePath) => {
+      const metadata = this._collectedMetadata[filePath];
+      const path = filePath.replace(/\.[jt]s$/, '');
+      const importExpr = ts.factory.createCallExpression(
+        ts.factory.createToken(ts.SyntaxKind.ImportKeyword) as ts.Expression,
+        undefined,
+        [ts.factory.createStringLiteral(path)]
+      );
+      metadataWithImports.push([importExpr, metadata]);
+    });
+    return metadataWithImports;
+  }
+
   visit(
     sourceFile: ts.SourceFile,
     ctx: ts.TransformationContext,
@@ -26,24 +51,59 @@ export class ControllerClassVisitor extends AbstractFileVisitor {
     options: PluginOptions
   ) {
     const typeChecker = program.getTypeChecker();
-    sourceFile = this.updateImports(sourceFile, ctx.factory, program);
+    if (!options.readonly) {
+      sourceFile = this.updateImports(sourceFile, ctx.factory, program);
+    }
 
     const visitNode = (node: ts.Node): ts.Node => {
       if (ts.isMethodDeclaration(node)) {
         try {
-          return this.addDecoratorToNode(
+          const metadata: ClassMetadata = {};
+          const updatedNode = this.addDecoratorToNode(
             ctx.factory,
             node,
             typeChecker,
             options,
-            sourceFile.fileName,
-            sourceFile
+            sourceFile,
+            metadata
           );
+          if (!options.readonly) {
+            return updatedNode;
+          } else {
+            const filePath = this.normalizeImportPath(
+              options.pathToSource,
+              sourceFile.fileName
+            );
+
+            if (!this._collectedMetadata[filePath]) {
+              this._collectedMetadata[filePath] = {};
+            }
+
+            const parent = node.parent as ts.ClassDeclaration;
+            const clsName = parent.name?.getText();
+
+            if (clsName) {
+              if (!this._collectedMetadata[filePath][clsName]) {
+                this._collectedMetadata[filePath][clsName] = {};
+              }
+              Object.assign(
+                this._collectedMetadata[filePath][clsName],
+                metadata
+              );
+            }
+          }
         } catch {
-          return node;
+          if (!options.readonly) {
+            return node;
+          }
         }
       }
-      return ts.visitEachChild(node, visitNode, ctx);
+
+      if (options.readonly) {
+        ts.forEachChild(node, visitNode);
+      } else {
+        return ts.visitEachChild(node, visitNode, ctx);
+      }
     };
     return ts.visitNode(sourceFile, visitNode);
   }
@@ -53,11 +113,13 @@ export class ControllerClassVisitor extends AbstractFileVisitor {
     compilerNode: ts.MethodDeclaration,
     typeChecker: ts.TypeChecker,
     options: PluginOptions,
-    hostFilename: string,
-    sourceFile: ts.SourceFile
+    sourceFile: ts.SourceFile,
+    metadata: ClassMetadata
   ): ts.MethodDeclaration {
+    const hostFilename = sourceFile.fileName;
     const decorators =
       ts.canHaveDecorators(compilerNode) && ts.getDecorators(compilerNode);
+
     if (!decorators) {
       return compilerNode;
     }
@@ -68,7 +130,8 @@ export class ControllerClassVisitor extends AbstractFileVisitor {
       decorators,
       options,
       sourceFile,
-      typeChecker
+      typeChecker,
+      metadata
     );
     const removeExistingApiOperationDecorator =
       apiOperationDecoratorsArray.length > 0;
@@ -80,6 +143,15 @@ export class ControllerClassVisitor extends AbstractFileVisitor {
       : decorators;
 
     const modifiers = ts.getModifiers(compilerNode) ?? [];
+    const objectLiteralExpr = this.createDecoratorObjectLiteralExpr(
+      factory,
+      compilerNode,
+      typeChecker,
+      factory.createNodeArray(),
+      hostFilename,
+      metadata,
+      options
+    );
     const updatedDecorators = [
       ...apiOperationDecoratorsArray,
       ...existingDecorators,
@@ -87,30 +159,26 @@ export class ControllerClassVisitor extends AbstractFileVisitor {
         factory.createCallExpression(
           factory.createIdentifier(`${OPENAPI_NAMESPACE}.${ApiResponse.name}`),
           undefined,
-          [
-            this.createDecoratorObjectLiteralExpr(
-              factory,
-              compilerNode,
-              typeChecker,
-              factory.createNodeArray(),
-              hostFilename
-            )
-          ]
+          [factory.createObjectLiteralExpression(objectLiteralExpr.properties)]
         )
       )
     ];
 
-    return factory.updateMethodDeclaration(
-      compilerNode,
-      [...updatedDecorators, ...modifiers],
-      compilerNode.asteriskToken,
-      compilerNode.name,
-      compilerNode.questionToken,
-      compilerNode.typeParameters,
-      compilerNode.parameters,
-      compilerNode.type,
-      compilerNode.body
-    );
+    if (!options.readonly) {
+      return factory.updateMethodDeclaration(
+        compilerNode,
+        [...updatedDecorators, ...modifiers],
+        compilerNode.asteriskToken,
+        compilerNode.name,
+        compilerNode.questionToken,
+        compilerNode.typeParameters,
+        compilerNode.parameters,
+        compilerNode.type,
+        compilerNode.body
+      );
+    } else {
+      return compilerNode;
+    }
   }
 
   createApiOperationDecorator(
@@ -119,7 +187,8 @@ export class ControllerClassVisitor extends AbstractFileVisitor {
     decorators: readonly ts.Decorator[],
     options: PluginOptions,
     sourceFile: ts.SourceFile,
-    typeChecker: ts.TypeChecker
+    typeChecker: ts.TypeChecker,
+    metadata: ClassMetadata
   ) {
     if (!options.introspectComments) {
       return [];
@@ -163,10 +232,27 @@ export class ControllerClassVisitor extends AbstractFileVisitor {
       properties.push(deprecatedPropertyAssignment);
     }
 
+    const objectLiteralExpr = factory.createObjectLiteralExpression(
+      compact(properties)
+    );
     const apiOperationDecoratorArguments: ts.NodeArray<ts.Expression> =
-      factory.createNodeArray([
-        factory.createObjectLiteralExpression(compact(properties))
+      factory.createNodeArray([objectLiteralExpr]);
+
+    const methodKey = node.name.getText();
+    if (metadata[methodKey]) {
+      const existingObjectLiteralExpr = metadata[methodKey];
+      const existingProperties = existingObjectLiteralExpr.properties;
+      const updatedProperties = factory.createNodeArray([
+        ...existingProperties,
+        ...compact(properties)
       ]);
+      const updatedObjectLiteralExpr =
+        factory.createObjectLiteralExpression(updatedProperties);
+      metadata[methodKey] = updatedObjectLiteralExpr;
+    } else {
+      metadata[methodKey] = objectLiteralExpr;
+    }
+
     if (apiOperationDecorator) {
       const expr = apiOperationDecorator.expression as any as ts.CallExpression;
       const updatedCallExpr = factory.updateCallExpression(
@@ -196,20 +282,46 @@ export class ControllerClassVisitor extends AbstractFileVisitor {
     node: ts.MethodDeclaration,
     typeChecker: ts.TypeChecker,
     existingProperties: ts.NodeArray<ts.PropertyAssignment> = factory.createNodeArray(),
-    hostFilename: string
+    hostFilename: string,
+    metadata: ClassMetadata,
+    options: PluginOptions
   ): ts.ObjectLiteralExpression {
-    const properties = [
-      ...existingProperties,
-      this.createStatusPropertyAssignment(factory, node, existingProperties),
+    let properties = [];
+    if (!options.readonly) {
+      properties = properties.concat(
+        existingProperties,
+        this.createStatusPropertyAssignment(factory, node, existingProperties)
+      );
+    }
+    properties = properties.concat([
       this.createTypePropertyAssignment(
         factory,
         node,
         typeChecker,
         existingProperties,
-        hostFilename
+        hostFilename,
+        options
       )
-    ];
-    return factory.createObjectLiteralExpression(compact(properties));
+    ]);
+    const objectLiteralExpr = factory.createObjectLiteralExpression(
+      compact(properties)
+    );
+
+    const methodKey = node.name.getText();
+    const existingExprOrUndefined = metadata[methodKey];
+    if (existingExprOrUndefined) {
+      const existingProperties = existingExprOrUndefined.properties;
+      const updatedProperties = factory.createNodeArray([
+        ...existingProperties,
+        ...compact(properties)
+      ]);
+      const updatedObjectLiteralExpr =
+        factory.createObjectLiteralExpression(updatedProperties);
+      metadata[methodKey] = updatedObjectLiteralExpr;
+    } else {
+      metadata[methodKey] = objectLiteralExpr;
+    }
+    return objectLiteralExpr;
   }
 
   createTypePropertyAssignment(
@@ -217,7 +329,8 @@ export class ControllerClassVisitor extends AbstractFileVisitor {
     node: ts.MethodDeclaration,
     typeChecker: ts.TypeChecker,
     existingProperties: ts.NodeArray<ts.PropertyAssignment>,
-    hostFilename: string
+    hostFilename: string,
+    options: PluginOptions
   ) {
     if (hasPropertyKey('type', existingProperties)) {
       return undefined;
@@ -234,7 +347,7 @@ export class ControllerClassVisitor extends AbstractFileVisitor {
     if (typeReference.includes('node_modules')) {
       return undefined;
     }
-    typeReference = replaceImportPath(typeReference, hostFilename);
+    typeReference = replaceImportPath(typeReference, hostFilename, options);
     return factory.createPropertyAssignment(
       'type',
       factory.createIdentifier(typeReference)
@@ -276,4 +389,10 @@ export class ControllerClassVisitor extends AbstractFileVisitor {
     }
     return factory.createIdentifier('200');
   }
+
+  private normalizeImportPath(pathToSource: string, path: string) {
+    let relativePath = posix.relative(pathToSource, path);
+    relativePath = relativePath[0] !== '.' ? './' + relativePath : relativePath;
+    return relativePath;
+  }
 }
diff --git a/lib/plugin/visitors/model-class.visitor.ts b/lib/plugin/visitors/model-class.visitor.ts
index d66d0807..599ce411 100644
--- a/lib/plugin/visitors/model-class.visitor.ts
+++ b/lib/plugin/visitors/model-class.visitor.ts
@@ -1,4 +1,5 @@
 import { compact, flatten, head } from 'lodash';
+import { posix } from 'path';
 import * as ts from 'typescript';
 import { PropertyAssignment, factory } from 'typescript';
 import { ApiHideProperty } from '../../decorators';
@@ -28,6 +29,24 @@ import { AbstractFileVisitor } from './abstract.visitor';
 type ClassMetadata = Record<string, ts.ObjectLiteralExpression>;
 
 export class ModelClassVisitor extends AbstractFileVisitor {
+  private readonly _collectedMetadata: Record<string, ClassMetadata> = {};
+
+  get collectedMetadata(): Array<
+    [ts.CallExpression, Record<string, ClassMetadata>]
+  > {
+    const metadataWithImports = [];
+    Object.keys(this._collectedMetadata).forEach((filePath) => {
+      const metadata = this._collectedMetadata[filePath];
+      const importExpr = ts.factory.createCallExpression(
+        ts.factory.createIdentifier('require'),
+        undefined,
+        [ts.factory.createStringLiteral(filePath)]
+      );
+      metadataWithImports.push([importExpr, metadata]);
+    });
+    return metadataWithImports;
+  }
+
   visit(
     sourceFile: ts.SourceFile,
     ctx: ts.TransformationContext,
@@ -40,66 +59,107 @@ export class ModelClassVisitor extends AbstractFileVisitor {
     const propertyNodeVisitorFactory =
       (metadata: ClassMetadata) =>
       (node: ts.Node): ts.Node => {
-        if (ts.isPropertyDeclaration(node)) {
-          const decorators =
-            ts.canHaveDecorators(node) && ts.getDecorators(node);
-
-          const hidePropertyDecorator = getDecoratorOrUndefinedByNames(
-            [ApiHideProperty.name],
-            decorators,
-            factory
-          );
-          if (hidePropertyDecorator) {
-            return node;
-          }
-
-          const isPropertyStatic = (node.modifiers || []).some(
-            (modifier: ts.Modifier) =>
-              modifier.kind === ts.SyntaxKind.StaticKeyword
-          );
-          if (isPropertyStatic) {
-            return node;
-          }
-          try {
-            this.inspectPropertyDeclaration(
-              ctx.factory,
+        const visit = () => {
+          if (ts.isPropertyDeclaration(node)) {
+            this.visitPropertyNodeDeclaration(
               node,
+              ctx,
               typeChecker,
               options,
-              sourceFile.fileName,
               sourceFile,
               metadata
             );
-          } catch (err) {
-            return node;
           }
+          return node;
+        };
+        const visitedNode = visit();
+        if (!options.readonly) {
+          return visitedNode;
         }
-        return node;
       };
 
     const visitClassNode = (node: ts.Node): ts.Node => {
       if (ts.isClassDeclaration(node)) {
         const metadata: ClassMetadata = {};
-        node = ts.visitEachChild(
-          node,
-          propertyNodeVisitorFactory(metadata),
-          ctx
-        );
-        return this.addMetadataFactory(
+
+        if (options.readonly) {
+          ts.forEachChild(node, propertyNodeVisitorFactory(metadata));
+        } else {
+          node = ts.visitEachChild(
+            node,
+            propertyNodeVisitorFactory(metadata),
+            ctx
+          );
+        }
+
+        const declaration = this.addMetadataFactory(
           ctx.factory,
           node as ts.ClassDeclaration,
-          metadata
+          metadata,
+          sourceFile,
+          options
         );
+
+        if (!options.readonly) {
+          return declaration;
+        }
+      }
+
+      if (options.readonly) {
+        ts.forEachChild(node, visitClassNode);
+      } else {
+        return ts.visitEachChild(node, visitClassNode, ctx);
       }
-      return ts.visitEachChild(node, visitClassNode, ctx);
     };
     return ts.visitNode(sourceFile, visitClassNode);
   }
 
+  visitPropertyNodeDeclaration(
+    node: ts.PropertyDeclaration,
+    ctx: ts.TransformationContext,
+    typeChecker: ts.TypeChecker,
+    options: PluginOptions,
+    sourceFile: ts.SourceFile,
+    metadata: ClassMetadata
+  ) {
+    const decorators = ts.canHaveDecorators(node) && ts.getDecorators(node);
+
+    const hidePropertyDecorator = getDecoratorOrUndefinedByNames(
+      [ApiHideProperty.name],
+      decorators,
+      factory
+    );
+    if (hidePropertyDecorator) {
+      return node;
+    }
+
+    const isPropertyStatic = (node.modifiers || []).some(
+      (modifier: ts.Modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword
+    );
+    if (isPropertyStatic) {
+      return node;
+    }
+    try {
+      this.inspectPropertyDeclaration(
+        ctx.factory,
+        node,
+        typeChecker,
+        options,
+        sourceFile.fileName,
+        sourceFile,
+        metadata
+      );
+    } catch (err) {
+      return node;
+    }
+  }
+
   addMetadataFactory(
     factory: ts.NodeFactory,
     node: ts.ClassDeclaration,
-    classMetadata: ClassMetadata
+    classMetadata: ClassMetadata,
+    sourceFile: ts.SourceFile,
+    options: PluginOptions
   ) {
     const returnValue = factory.createObjectLiteralExpression(
       Object.keys(classMetadata).map((key) =>
@@ -109,6 +169,20 @@ export class ModelClassVisitor extends AbstractFileVisitor {
         )
       )
     );
+
+    if (options.readonly) {
+      const filePath = this.normalizeImportPath(
+        options.pathToSource,
+        sourceFile.fileName
+      );
+      if (!this._collectedMetadata[filePath]) {
+        this._collectedMetadata[filePath] = {};
+      }
+      const attributeKey = node.name.getText();
+      this._collectedMetadata[filePath][attributeKey] = returnValue;
+      return;
+    }
+
     const method = factory.createMethodDeclaration(
       [factory.createModifier(ts.SyntaxKind.StaticKeyword)],
       undefined,
@@ -179,7 +253,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
         node.type,
         typeChecker,
         existingProperties,
-        hostFilename
+        hostFilename,
+        options
       ),
       ...this.createDescriptionAndTsDocTagPropertyAssigments(
         factory,
@@ -195,7 +270,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
         node,
         typeChecker,
         existingProperties,
-        hostFilename
+        hostFilename,
+        options
       )
     ];
     if (options.classValidatorShim) {
@@ -217,7 +293,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
     node: ts.TypeNode,
     typeChecker: ts.TypeChecker,
     existingProperties: ts.NodeArray<ts.PropertyAssignment>,
-    hostFilename: string
+    hostFilename: string,
+    options: PluginOptions
   ): ts.PropertyAssignment[] {
     const key = 'type';
     if (hasPropertyKey(key, existingProperties)) {
@@ -274,7 +351,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
             remainingTypes[0],
             typeChecker,
             existingProperties,
-            hostFilename
+            hostFilename,
+            options
           );
 
           const resultArray = new Array<ts.PropertyAssignment>(
@@ -300,7 +378,7 @@ export class ModelClassVisitor extends AbstractFileVisitor {
     if (!typeReference) {
       return [];
     }
-    typeReference = replaceImportPath(typeReference, hostFilename);
+    typeReference = replaceImportPath(typeReference, hostFilename, options);
     return [
       factory.createPropertyAssignment(
         key,
@@ -321,7 +399,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
     node: ts.PropertyDeclaration | ts.PropertySignature,
     typeChecker: ts.TypeChecker,
     existingProperties: ts.NodeArray<ts.PropertyAssignment>,
-    hostFilename: string
+    hostFilename: string,
+    options: PluginOptions
   ) {
     const key = 'enum';
     if (hasPropertyKey(key, existingProperties)) {
@@ -358,7 +437,11 @@ export class ModelClassVisitor extends AbstractFileVisitor {
       isArrayType = typeIsArrayTuple.isArray;
       type = typeIsArrayTuple.type;
     }
-    const enumRef = replaceImportPath(getText(type, typeChecker), hostFilename);
+    const enumRef = replaceImportPath(
+      getText(type, typeChecker),
+      hostFilename,
+      options
+    );
     const enumProperty = factory.createPropertyAssignment(
       key,
       factory.createIdentifier(enumRef)
@@ -523,8 +606,14 @@ export class ModelClassVisitor extends AbstractFileVisitor {
         const argument: ts.Expression = head(
           getDecoratorArguments(decoratorRef)
         );
-        if (argument) {
-          return [factory.createPropertyAssignment(propertyKey, argument)];
+        const assignment = ts.isNumericLiteral(argument)
+          ? ts.factory.createNumericLiteral(argument.text)
+          : ts.isStringLiteral(argument)
+          ? ts.factory.createStringLiteral(argument.text)
+          : argument;
+
+        if (assignment) {
+          return [factory.createPropertyAssignment(propertyKey, assignment)];
         }
         return [];
       }
@@ -624,4 +713,10 @@ export class ModelClassVisitor extends AbstractFileVisitor {
 
     return propertyAssignments;
   }
+
+  private normalizeImportPath(pathToSource: string, path: string) {
+    let relativePath = posix.relative(pathToSource, path);
+    relativePath = relativePath[0] !== '.' ? './' + relativePath : relativePath;
+    return relativePath;
+  }
 }
diff --git a/lib/plugin/visitors/readonly.visitor.ts b/lib/plugin/visitors/readonly.visitor.ts
new file mode 100644
index 00000000..bd9e1d5f
--- /dev/null
+++ b/lib/plugin/visitors/readonly.visitor.ts
@@ -0,0 +1,50 @@
+import * as ts from 'typescript';
+import { PluginOptions, mergePluginOptions } from '../merge-options';
+import { isFilenameMatched } from '../utils/is-filename-matched.util';
+import { ControllerClassVisitor } from './controller-class.visitor';
+import { ModelClassVisitor } from './model-class.visitor';
+
+export class ReadonlyVisitor {
+  public readonly key = '@nestjs/swagger';
+  private readonly modelClassVisitor = new ModelClassVisitor();
+  private readonly controllerClassVisitor = new ControllerClassVisitor();
+
+  constructor(private readonly options: PluginOptions) {
+    options.readonly = true;
+
+    if (!options.pathToSource) {
+      throw new Error(`"pathToSource" must be defined in plugin options`);
+    }
+  }
+
+  visit(program: ts.Program, sf: ts.SourceFile) {
+    const factoryHost = { factory: ts.factory } as any;
+    const parsedOptions: Record<string, any> = mergePluginOptions(this.options);
+
+    if (isFilenameMatched(parsedOptions.dtoFileNameSuffix, sf.fileName)) {
+      return this.modelClassVisitor.visit(
+        sf,
+        factoryHost,
+        program,
+        parsedOptions
+      );
+    }
+    if (
+      isFilenameMatched(parsedOptions.controllerFileNameSuffix, sf.fileName)
+    ) {
+      return this.controllerClassVisitor.visit(
+        sf,
+        factoryHost,
+        program,
+        parsedOptions
+      );
+    }
+  }
+
+  collect() {
+    return {
+      models: this.modelClassVisitor.collectedMetadata,
+      controllers: this.controllerClassVisitor.collectedMetadata
+    };
+  }
+}
diff --git a/lib/swagger-module.ts b/lib/swagger-module.ts
index 9fe676d2..3e73014a 100644
--- a/lib/swagger-module.ts
+++ b/lib/swagger-module.ts
@@ -1,6 +1,5 @@
 import { INestApplication } from '@nestjs/common';
 import { HttpServer } from '@nestjs/common/interfaces/http/http-server.interface';
-import { isFunction } from '@nestjs/common/utils/shared.utils';
 import { NestExpressApplication } from '@nestjs/platform-express';
 import { NestFastifyApplication } from '@nestjs/platform-fastify';
 import * as jsyaml from 'js-yaml';
@@ -9,6 +8,7 @@ import {
   SwaggerCustomOptions,
   SwaggerDocumentOptions
 } from './interfaces';
+import { MetadataLoader } from './plugin/metadata-loader';
 import { SwaggerScanner } from './swagger-scanner';
 import {
   buildSwaggerHTML,
@@ -17,11 +17,13 @@ import {
 } from './swagger-ui';
 import { assignTwoLevelsDeep } from './utils/assign-two-levels-deep';
 import { getGlobalPrefix } from './utils/get-global-prefix';
-import { validatePath } from './utils/validate-path.util';
 import { normalizeRelPath } from './utils/normalize-rel-path';
 import { validateGlobalPrefix } from './utils/validate-global-prefix.util';
+import { validatePath } from './utils/validate-path.util';
 
 export class SwaggerModule {
+  private static readonly metadataLoader = new MetadataLoader();
+
   public static createDocument(
     app: INestApplication,
     config: Omit<OpenAPIObject, 'paths'>,
@@ -44,6 +46,10 @@ export class SwaggerModule {
     };
   }
 
+  public static async loadPluginMetadata(metadata: Record<string, any>) {
+    return this.metadataLoader.load(metadata);
+  }
+
   private static serveStatic(finalPath: string, app: INestApplication) {
     const httpAdapter = app.getHttpAdapter();
     const swaggerAssetsAbsoluteFSPath = getSwaggerAssetsAbsoluteFSPath();
@@ -66,39 +72,21 @@ export class SwaggerModule {
     finalPath: string,
     urlLastSubdirectory: string,
     httpAdapter: HttpServer,
-    documentOrFactory: OpenAPIObject | (() => OpenAPIObject),
+    swaggerInitJS: string,
     options: {
+      html: string;
+      yamlDocument: string;
+      jsonDocument: string;
       jsonDocumentUrl: string;
       yamlDocumentUrl: string;
-      swaggerOptions: SwaggerCustomOptions
-    },
-  ) {
-    let document: OpenAPIObject;
-
-    const lazyBuildDocument = () => {
-      return typeof documentOrFactory === 'function' ? documentOrFactory() : documentOrFactory;
     }
-
-    const baseUrlForSwaggerUI = normalizeRelPath(`./${urlLastSubdirectory}/`);
-
-    let html: string;
-    let swaggerInitJS: string;
-
+  ) {
     httpAdapter.get(
       normalizeRelPath(`${finalPath}/swagger-ui-init.js`),
       (req, res) => {
         res.type('application/javascript');
-
-        if (!document) {
-          document = lazyBuildDocument();
-        }
-
-        if (!swaggerInitJS) {
-          swaggerInitJS = buildSwaggerInitJS(document, options);
-        }
-
         res.send(swaggerInitJS);
-      },
+      }
     );
 
     /**
@@ -108,21 +96,12 @@ export class SwaggerModule {
     try {
       httpAdapter.get(
         normalizeRelPath(
-          `${finalPath}/${urlLastSubdirectory}/swagger-ui-init.js`,
+          `${finalPath}/${urlLastSubdirectory}/swagger-ui-init.js`
         ),
         (req, res) => {
           res.type('application/javascript');
-
-          if (!document) {
-            document = lazyBuildDocument();
-          }
-
-          if (!swaggerInitJS) {
-            swaggerInitJS = buildSwaggerInitJS(document, options);
-          }
-
           res.send(swaggerInitJS);
-        },
+        }
       );
     } catch (err) {
       /**
@@ -133,32 +112,14 @@ export class SwaggerModule {
 
     httpAdapter.get(finalPath, (req, res) => {
       res.type('text/html');
-
-      if (!document) {
-        document = lazyBuildDocument();
-      }
-
-      if (!html) {
-        html = buildSwaggerHTML(baseUrlForSwaggerUI, document, options);
-      }
-
-      res.send(html);
+      res.send(options.html);
     });
 
     // fastify doesn't resolve 'routePath/' -> 'routePath', that's why we handle it manually
     try {
       httpAdapter.get(normalizeRelPath(`${finalPath}/`), (req, res) => {
         res.type('text/html');
-
-        if (!document) {
-          document = lazyBuildDocument();
-        }
-
-        if (!html) {
-          html = buildSwaggerHTML(baseUrlForSwaggerUI, document, options);
-        }
-
-        res.send(html);
+        res.send(options.html);
       });
     } catch (err) {
       /**
@@ -169,51 +130,33 @@ export class SwaggerModule {
        */
     }
 
-    let yamlDocument: string;
-    let jsonDocument: string;
-
     httpAdapter.get(normalizeRelPath(options.jsonDocumentUrl), (req, res) => {
       res.type('application/json');
-
-      if (!document) {
-         document = lazyBuildDocument();
-      }
-
-      if (!jsonDocument) {
-         jsonDocument = JSON.stringify(document);
-      }
-
-      res.send(jsonDocument);
+      res.send(options.jsonDocument);
     });
 
     httpAdapter.get(normalizeRelPath(options.yamlDocumentUrl), (req, res) => {
       res.type('text/yaml');
-
-      if (!document) {
-        document = lazyBuildDocument();
-      }
-
-      if (!yamlDocument) {
-         yamlDocument = jsyaml.dump(document, { skipInvalid: true });
-      }
-
-      res.send(yamlDocument);
+      res.send(options.yamlDocument);
     });
   }
 
   public static setup(
     path: string,
     app: INestApplication,
-    documentOrFactory: OpenAPIObject | (() => OpenAPIObject),
+    document: OpenAPIObject,
     options?: SwaggerCustomOptions
   ) {
     const globalPrefix = getGlobalPrefix(app);
     const finalPath = validatePath(
       options?.useGlobalPrefix && validateGlobalPrefix(globalPrefix)
         ? `${globalPrefix}${validatePath(path)}`
-        : path,
+        : path
     );
-    const urlLastSubdirectory = finalPath.split('/').slice(-1).pop() || '';
+    const urlLastSubdirectory = finalPath.split('/').slice(-1).pop();
+
+    const yamlDocument = jsyaml.dump(document, { skipInvalid: true });
+    const jsonDocument = JSON.stringify(document);
 
     const validatedGlobalPrefix =
       options?.useGlobalPrefix && validateGlobalPrefix(globalPrefix)
@@ -228,18 +171,24 @@ export class SwaggerModule {
       ? `${validatedGlobalPrefix}${validatePath(options.yamlDocumentUrl)}`
       : `${finalPath}-yaml`;
 
+    const baseUrlForSwaggerUI = normalizeRelPath(`./${urlLastSubdirectory}/`);
+
+    const html = buildSwaggerHTML(baseUrlForSwaggerUI, document, options);
+    const swaggerInitJS = buildSwaggerInitJS(document, options);
     const httpAdapter = app.getHttpAdapter();
 
     SwaggerModule.serveDocuments(
       finalPath,
       urlLastSubdirectory,
       httpAdapter,
-      documentOrFactory,
+      swaggerInitJS,
       {
+        html,
+        yamlDocument,
+        jsonDocument,
         jsonDocumentUrl: finalJSONDocumentPath,
-        yamlDocumentUrl: finalYAMLDocumentPath,
-        swaggerOptions: options || {},
-      },
+        yamlDocumentUrl: finalYAMLDocumentPath
+      }
     );
 
     SwaggerModule.serveStatic(finalPath, app);
diff --git a/package.json b/package.json
index 0a787379..096da813 100644
--- a/package.json
+++ b/package.json
@@ -64,8 +64,8 @@
   },
   "peerDependencies": {
     "@fastify/static": "^6.0.0",
-    "@nestjs/common": "^9.0.0 || ^10.0.0",
-    "@nestjs/core": "^9.0.0 || ^10.0.0",
+    "@nestjs/common": "^9.0.0",
+    "@nestjs/core": "^9.0.0",
     "class-transformer": "*",
     "class-validator": "*",
     "reflect-metadata": "^0.1.12"
diff --git a/tsconfig.json b/tsconfig.json
index 34e05106..c40afb94 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -7,7 +7,7 @@
     "noLib": false,
     "emitDecoratorMetadata": true,
     "experimentalDecorators": true,
-    "target": "ES2021",
+    "target": "es6",
     "sourceMap": false,
     "outDir": "./dist",
     "rootDir": "./lib",


Test patch:
diff --git a/e2e/api-spec.json b/e2e/api-spec.json
index 2423a5ba..5cf27e9b 100644
--- a/e2e/api-spec.json
+++ b/e2e/api-spec.json
@@ -586,6 +586,7 @@
     "/api/cats/bulk": {
       "get": {
         "operationId": "CatsController_findAllBulk",
+        "summary": "Find all cats in bulk",
         "parameters": [
           {
             "name": "header",
@@ -607,7 +608,17 @@
         ],
         "responses": {
           "200": {
-            "description": ""
+            "description": "",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "array",
+                  "items": {
+                    "$ref": "#/components/schemas/Cat"
+                  }
+                }
+              }
+            }
           }
         },
         "tags": [
@@ -992,6 +1003,7 @@
         "type": "object",
         "properties": {
           "name": {
+            "description": "Name of the cat",
             "type": "string"
           },
           "age": {
@@ -1080,6 +1092,11 @@
             "description": "The breed of the Cat"
           },
           "_tags": {
+            "description": "Tags of the cat",
+            "example": [
+              "tag1",
+              "tag2"
+            ],
             "type": "array",
             "items": {
               "type": "string"
diff --git a/e2e/validate-schema.e2e-spec.ts b/e2e/validate-schema.e2e-spec.ts
index 5482cf69..ca514e0c 100644
--- a/e2e/validate-schema.e2e-spec.ts
+++ b/e2e/validate-schema.e2e-spec.ts
@@ -1,6 +1,7 @@
 import { INestApplication } from '@nestjs/common';
 import { NestFactory } from '@nestjs/core';
 import { writeFileSync } from 'fs';
+import { OpenAPIV3 } from 'openapi-types';
 import { join } from 'path';
 import * as SwaggerParser from 'swagger-parser';
 import {
@@ -12,7 +13,6 @@ import {
 import { ApplicationModule } from './src/app.module';
 import { Cat } from './src/cats/classes/cat.class';
 import { TagDto } from './src/cats/dto/tag.dto';
-import { OpenAPIV3 } from 'openapi-types';
 
 describe('Validate OpenAPI schema', () => {
   let app: INestApplication;
@@ -49,6 +49,48 @@ describe('Validate OpenAPI schema', () => {
   });
 
   it('should produce a valid OpenAPI 3.0 schema', async () => {
+    SwaggerModule.loadPluginMetadata({
+      metadata: {
+        '@nestjs/swagger': {
+          models: [
+            [
+              require('./src/cats/classes/cat.class'),
+              {
+                Cat: {
+                  tags: {
+                    description: 'Tags of the cat',
+                    example: ['tag1', 'tag2']
+                  }
+                }
+              }
+            ],
+            [
+              require('./src/cats/dto/create-cat.dto'),
+              {
+                CreateCatDto: {
+                  name: {
+                    description: 'Name of the cat'
+                  }
+                }
+              }
+            ]
+          ],
+          controllers: [
+            [
+              require('./src/cats/cats.controller'),
+              {
+                CatsController: {
+                  findAllBulk: {
+                    type: [require('./src/cats/classes/cat.class').Cat],
+                    summary: 'Find all cats in bulk'
+                  }
+                }
+              }
+            ]
+          ]
+        }
+      }
+    });
     const document = SwaggerModule.createDocument(app, options);
 
     const doc = JSON.stringify(document, null, 2);
diff --git a/test/explorer/swagger-explorer.spec.ts b/test/explorer/swagger-explorer.spec.ts
index 84645560..c1b6a1fb 100644
--- a/test/explorer/swagger-explorer.spec.ts
+++ b/test/explorer/swagger-explorer.spec.ts
@@ -9,7 +9,7 @@ import {
   Version,
   VersioningType
 } from '@nestjs/common';
-import { VersionValue, VERSION_NEUTRAL } from '@nestjs/common/interfaces';
+import { VERSION_NEUTRAL, VersionValue } from '@nestjs/common/interfaces';
 import { ApplicationConfig } from '@nestjs/core';
 import { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper';
 import {
@@ -30,11 +30,12 @@ import {
 } from '../../lib/decorators';
 import { DenormalizedDoc } from '../../lib/interfaces/denormalized-doc.interface';
 import { ResponseObject } from '../../lib/interfaces/open-api-spec.interface';
+import { METADATA_FACTORY_NAME } from '../../lib/plugin/plugin-constants';
 import { ModelPropertiesAccessor } from '../../lib/services/model-properties-accessor';
 import { SchemaObjectFactory } from '../../lib/services/schema-object-factory';
 import { SwaggerTypesMapper } from '../../lib/services/swagger-types-mapper';
-import { SwaggerExplorer } from '../../lib/swagger-explorer';
 import { GlobalParametersStorage } from '../../lib/storages/global-parameters.storage';
+import { SwaggerExplorer } from '../../lib/swagger-explorer';
 
 describe('SwaggerExplorer', () => {
   const schemaObjectFactory = new SchemaObjectFactory(
@@ -124,6 +125,57 @@ describe('SwaggerExplorer', () => {
       validateRoutes(routes, operationPrefix);
     });
 
+    @Controller('')
+    class FooWithMetadataController {
+      @Post('foos')
+      @ApiCreatedResponse({
+        type: Foo,
+        description: 'Newly created Foo object'
+      })
+      create(
+        @Body() createFoo: CreateFoo,
+        @Query() listEntities: ListEntitiesDto
+      ): Promise<Foo> {
+        return Promise.resolve({});
+      }
+
+      @Get(['foos/:objectId', 'foo/:objectId'])
+      find(
+        @Param('objectId') objectId: string,
+        @Query('page') q: string
+      ): Promise<Foo[]> {
+        return Promise.resolve([]);
+      }
+
+      static [METADATA_FACTORY_NAME]() {
+        return {
+          create: {
+            summary: 'Create foo'
+          },
+          find: {
+            summary: 'List all Foos',
+            type: [Foo]
+          }
+        };
+      }
+    }
+
+    it('sees two controller operations and their responses (metadata cache)', () => {
+      const explorer = new SwaggerExplorer(schemaObjectFactory);
+      const routes = explorer.exploreController(
+        {
+          instance: new FooWithMetadataController(),
+          metatype: FooWithMetadataController
+        } as unknown as InstanceWrapper<FooWithMetadataController>,
+        new ApplicationConfig(),
+        'modulePath',
+        'globalPrefix'
+      );
+      const operationPrefix = 'FooWithMetadataController_';
+
+      validateRoutes(routes, operationPrefix);
+    });
+
     it('sees two controller operations and their responses with custom operationIdFactory to return methodKey', () => {
       const explorer = new SwaggerExplorer(schemaObjectFactory);
       const routes = explorer.exploreController(
@@ -1466,12 +1518,10 @@ describe('SwaggerExplorer', () => {
       });
     });
   });
-  
-  describe('when @All(...) is used', () => {
 
+  describe('when @All(...) is used', () => {
     @Controller('')
     class AllController {
-
       @All('*')
       all(): Promise<void> {
         return Promise.resolve();
@@ -1491,13 +1541,26 @@ describe('SwaggerExplorer', () => {
       );
 
       expect(routes.length).toEqual(7);
-      expect(['get', 'post', 'put', 'delete', 'patch', 'options', 'head'].every((method) => routes.find((route) => route.root.method === method))).toBe(true);
-      expect(routes.find((route) => route.root.method === 'all')).toBe(undefined);
+      expect(
+        ['get', 'post', 'put', 'delete', 'patch', 'options', 'head'].every(
+          (method) => routes.find((route) => route.root.method === method)
+        )
+      ).toBe(true);
+      expect(routes.find((route) => route.root.method === 'all')).toBe(
+        undefined
+      );
       // check if all routes are equal except for method
-      expect(routes.filter((v, i, a) => a.findIndex(v2 => ['path', 'parameter'].every(k => v2[k] === v[k])) === i).length).toEqual(1);
+      expect(
+        routes.filter(
+          (v, i, a) =>
+            a.findIndex((v2) =>
+              ['path', 'parameter'].every((k) => v2[k] === v[k])
+            ) === i
+        ).length
+      ).toEqual(1);
     });
   });
-  
+
   describe('when global paramters are defined', () => {
     class Foo {}