Repository: nestjs/swagger
Pull request #1720 (2021-12-12 18:42:56)

feat(): add support for additional TSDoc tags
## 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)
- [ ] 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: https://github.com/nestjs/swagger/issues/1695

Currently, nestjs/swagger only supports `@example` TSDoc tags.

## What is the new behavior?

This PR adds support for the [`@deprecated`](https://tsdoc.org/pages/tags/deprecated/) TSDoc tag, in addition to the existing support for [`@example`](https://tsdoc.org/pages/tags/example/) tag. It also defines a more solid interface for adding additional tag support in the future, such as support for [`@param`](https://tsdoc.org/pages/tags/param/) as discussed in #1695. 

Note that this PR doesn't yet add support for `@param`, since there likely has to be a larger discussion about how it should be implemented (e.g. what happens when the symbol name differs from the property name, like `@Query('pageLimit') limit: number`).

## Does this PR introduce a breaking change?
- [ ] Yes
- [x] No

## Other information

I tried to build this in a way that could be easily extendable in the future for additional tags. If anyone has suggestions on how this could be improve, please let me know!


Patch:
diff --git a/lib/plugin/utils/ast-utils.ts b/lib/plugin/utils/ast-utils.ts
index 8be48ba5..00314d14 100644
--- a/lib/plugin/utils/ast-utils.ts
+++ b/lib/plugin/utils/ast-utils.ts
@@ -113,12 +113,10 @@ export function getDefaultTypeFormatFlags(enclosingNode: Node) {
   return formatFlags;
 }
 
-export function getMainCommentAndExamplesOfNode(
+export function getMainCommentOfNode(
   node: Node,
-  sourceFile: SourceFile,
-  typeChecker: TypeChecker,
-  includeExamples?: boolean
-): [string, string[]] {
+  sourceFile: SourceFile
+): string {
   const sourceText = sourceFile.getFullText();
   // in case we decide to include "// comments"
   const replaceRegex =
@@ -126,37 +124,97 @@ export function getMainCommentAndExamplesOfNode(
   //const replaceRegex = /^ *\** *@.*$|^ *\/\*+ *|^ *\/+ *|^ *\*+ *| +$| *\**\/ *$/gim;
 
   const commentResult = [];
-  const examplesResult = [];
-  const introspectCommentsAndExamples = (comments?: CommentRange[]) =>
+  const introspectComments = (comments?: CommentRange[]) =>
     comments?.forEach((comment) => {
       const commentSource = sourceText.substring(comment.pos, comment.end);
       const oneComment = commentSource.replace(replaceRegex, '').trim();
       if (oneComment) {
         commentResult.push(oneComment);
       }
-      if (includeExamples) {
-        const regexOfExample =
-          /@example *((['"](?<exampleAsString>.+?)['"])|(?<exampleAsBooleanOrNumber>[^ ]+?)|(?<exampleAsArray>(\[.+?\]))) *$/gim;
+    });
+
+  const leadingCommentRanges = getLeadingCommentRanges(
+    sourceText,
+    node.getFullStart()
+  );
+  introspectComments(leadingCommentRanges);
+  if (!commentResult.length) {
+    const trailingCommentRanges = getTrailingCommentRanges(
+      sourceText,
+      node.getFullStart()
+    );
+    introspectComments(trailingCommentRanges);
+  }
+  return commentResult.join('\n');
+}
+
+export function getTsDocTagsOfNode(
+  node: Node,
+  sourceFile: SourceFile,
+  typeChecker: TypeChecker
+) {
+  const sourceText = sourceFile.getFullText();
+
+  const tagDefinitions: {
+    [key: string]: {
+      regex: RegExp;
+      hasProperties: boolean;
+      repeatable: boolean;
+    };
+  } = {
+    example: {
+      regex:
+        /@example *((['"](?<string>.+?)['"])|(?<booleanOrNumber>[^ ]+?)|(?<array>(\[.+?\]))) *$/gim,
+      hasProperties: true,
+      repeatable: true
+    },
+    deprecated: {
+      regex: /@deprecated */gim,
+      hasProperties: false,
+      repeatable: false
+    }
+  };
+
+  const tagResults: any = {};
+  const introspectTsDocTags = (comments?: CommentRange[]) =>
+    comments?.forEach((comment) => {
+      const commentSource = sourceText.substring(comment.pos, comment.end);
+
+      for (const tag in tagDefinitions) {
+        const { regex, hasProperties, repeatable } = tagDefinitions[tag];
+
+        let value: any;
+
         let execResult: RegExpExecArray;
         while (
-          (execResult = regexOfExample.exec(commentSource)) &&
-          execResult.length > 1
+          (execResult = regex.exec(commentSource)) &&
+          (!hasProperties || execResult.length > 1)
         ) {
-          const example =
-            execResult.groups?.exampleAsString ??
-            execResult.groups?.exampleAsBooleanOrNumber ??
-            (execResult.groups?.exampleAsArray &&
-              execResult.groups.exampleAsArray.replace(/'/g, '"'));
-
-          const type = typeChecker.getTypeAtLocation(node);
-          if (type && isString(type)) {
-            examplesResult.push(example);
-          } else {
-            try {
-              examplesResult.push(JSON.parse(example));
-            } catch {
-              examplesResult.push(example);
+          if (repeatable && !tagResults[tag]) tagResults[tag] = [];
+
+          if (hasProperties) {
+            const docValue =
+              execResult.groups?.string ??
+              execResult.groups?.booleanOrNumber ??
+              (execResult.groups?.array &&
+                execResult.groups.array.replace(/'/g, '"'));
+
+            const type = typeChecker.getTypeAtLocation(node);
+
+            value = docValue;
+            if (!type || !isString(type)) {
+              try {
+                value = JSON.parse(value);
+              } catch {}
             }
+          } else {
+            value = true;
+          }
+
+          if (repeatable) {
+            tagResults[tag].push(value);
+          } else {
+            tagResults[tag] = value;
           }
         }
       }
@@ -166,15 +224,8 @@ export function getMainCommentAndExamplesOfNode(
     sourceText,
     node.getFullStart()
   );
-  introspectCommentsAndExamples(leadingCommentRanges);
-  if (!commentResult.length) {
-    const trailingCommentRanges = getTrailingCommentRanges(
-      sourceText,
-      node.getFullStart()
-    );
-    introspectCommentsAndExamples(trailingCommentRanges);
-  }
-  return [commentResult.join('\n'), examplesResult];
+  introspectTsDocTags(leadingCommentRanges);
+  return tagResults;
 }
 
 export function getDecoratorArguments(decorator: Decorator) {
@@ -242,3 +293,14 @@ export function createPrimitiveLiteral(factory: ts.NodeFactory, item: unknown) {
       return factory.createStringLiteral(item as string);
   }
 }
+
+export function createLiteralFromAnyValue(
+  factory: ts.NodeFactory,
+  item: unknown
+) {
+  return Array.isArray(item)
+    ? factory.createArrayLiteralExpression(
+        item.map((item) => createLiteralFromAnyValue(factory, item))
+      )
+    : createPrimitiveLiteral(factory, item);
+}
diff --git a/lib/plugin/visitors/controller-class.visitor.ts b/lib/plugin/visitors/controller-class.visitor.ts
index cd90dde4..a948ad15 100644
--- a/lib/plugin/visitors/controller-class.visitor.ts
+++ b/lib/plugin/visitors/controller-class.visitor.ts
@@ -4,9 +4,11 @@ import { ApiOperation, ApiResponse } from '../../decorators';
 import { PluginOptions } from '../merge-options';
 import { OPENAPI_NAMESPACE } from '../plugin-constants';
 import {
+  createLiteralFromAnyValue,
   getDecoratorArguments,
   getDecoratorName,
-  getMainCommentAndExamplesOfNode
+  getMainCommentOfNode,
+  getTsDocTagsOfNode
 } from '../utils/ast-utils';
 import {
   getDecoratorOrUndefinedByNames,
@@ -132,59 +134,55 @@ export class ControllerClassVisitor extends AbstractFileVisitor {
       apiOperationExpr &&
       (apiOperationExpr.properties as ts.NodeArray<ts.PropertyAssignment>);
 
-    if (
-      !apiOperationDecorator ||
-      !apiOperationExpr ||
-      !apiOperationExprProperties ||
-      !hasPropertyKey(keyToGenerate, apiOperationExprProperties)
-    ) {
-      const [extractedComments] = getMainCommentAndExamplesOfNode(
-        node,
-        sourceFile,
-        typeChecker
+    const extractedComments = getMainCommentOfNode(node, sourceFile);
+    const tags = getTsDocTagsOfNode(node, sourceFile, typeChecker);
+
+    const properties = [
+      factory.createPropertyAssignment(
+        keyToGenerate,
+        factory.createStringLiteral(extractedComments)
+      ),
+      ...(apiOperationExprProperties ?? factory.createNodeArray())
+    ];
+
+    const hasDeprecatedKey = hasPropertyKey(
+      'deprecated',
+      factory.createNodeArray(apiOperationExprProperties)
+    );
+    if (!hasDeprecatedKey && tags.deprecated) {
+      const deprecatedPropertyAssignment = factory.createPropertyAssignment(
+        'deprecated',
+        createLiteralFromAnyValue(factory, tags.deprecated)
       );
-      if (!extractedComments) {
-        // Node does not have any comments
-        return [];
-      }
-      const properties = [
-        factory.createPropertyAssignment(
-          keyToGenerate,
-          factory.createStringLiteral(extractedComments)
-        ),
-        ...(apiOperationExprProperties ?? factory.createNodeArray())
-      ];
-      const apiOperationDecoratorArguments: ts.NodeArray<ts.Expression> =
-        factory.createNodeArray([
-          factory.createObjectLiteralExpression(compact(properties))
-        ]);
-      if (apiOperationDecorator) {
-        const expr =
-          apiOperationDecorator.expression as any as ts.CallExpression;
-        const updatedCallExpr = factory.updateCallExpression(
-          expr,
-          expr.expression,
-          undefined,
-          apiOperationDecoratorArguments
-        );
-        return [
-          factory.updateDecorator(apiOperationDecorator, updatedCallExpr)
-        ];
-      } else {
-        return [
-          factory.createDecorator(
-            factory.createCallExpression(
-              factory.createIdentifier(
-                `${OPENAPI_NAMESPACE}.${ApiOperation.name}`
-              ),
-              undefined,
-              apiOperationDecoratorArguments
-            )
+      properties.push(deprecatedPropertyAssignment);
+    }
+
+    const apiOperationDecoratorArguments: ts.NodeArray<ts.Expression> =
+      factory.createNodeArray([
+        factory.createObjectLiteralExpression(compact(properties))
+      ]);
+    if (apiOperationDecorator) {
+      const expr = apiOperationDecorator.expression as any as ts.CallExpression;
+      const updatedCallExpr = factory.updateCallExpression(
+        expr,
+        expr.expression,
+        undefined,
+        apiOperationDecoratorArguments
+      );
+      return [factory.updateDecorator(apiOperationDecorator, updatedCallExpr)];
+    } else {
+      return [
+        factory.createDecorator(
+          factory.createCallExpression(
+            factory.createIdentifier(
+              `${OPENAPI_NAMESPACE}.${ApiOperation.name}`
+            ),
+            undefined,
+            apiOperationDecoratorArguments
           )
-        ];
-      }
+        )
+      ];
     }
-    return [];
   }
 
   createDecoratorObjectLiteralExpr(
diff --git a/lib/plugin/visitors/model-class.visitor.ts b/lib/plugin/visitors/model-class.visitor.ts
index e11031ac..e34bf964 100644
--- a/lib/plugin/visitors/model-class.visitor.ts
+++ b/lib/plugin/visitors/model-class.visitor.ts
@@ -5,10 +5,12 @@ import { PluginOptions } from '../merge-options';
 import { METADATA_FACTORY_NAME } from '../plugin-constants';
 import {
   createBooleanLiteral,
+  createLiteralFromAnyValue,
   createPrimitiveLiteral,
   getDecoratorArguments,
-  getMainCommentAndExamplesOfNode,
+  getMainCommentOfNode,
   getText,
+  getTsDocTagsOfNode,
   isEnum
 } from '../utils/ast-utils';
 import {
@@ -178,7 +180,7 @@ export class ModelClassVisitor extends AbstractFileVisitor {
         existingProperties,
         hostFilename
       ),
-      ...this.createDescriptionAndExamplePropertyAssigments(
+      ...this.createDescriptionAndTsDocTagPropertyAssigments(
         factory,
         node,
         typeChecker,
@@ -558,7 +560,7 @@ export class ModelClassVisitor extends AbstractFileVisitor {
     metadata[propertyName] = objectLiteral;
   }
 
-  createDescriptionAndExamplePropertyAssigments(
+  createDescriptionAndTsDocTagPropertyAssigments(
     factory: ts.NodeFactory,
     node: ts.PropertyDeclaration | ts.PropertySignature,
     typeChecker: ts.TypeChecker,
@@ -570,12 +572,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
       return [];
     }
     const propertyAssignments = [];
-    const [comments, examples] = getMainCommentAndExamplesOfNode(
-      node,
-      sourceFile,
-      typeChecker,
-      true
-    );
+    const comments = getMainCommentOfNode(node, sourceFile);
+    const tags = getTsDocTagsOfNode(node, sourceFile, typeChecker);
 
     const keyOfComment = options.dtoKeyOfComment;
     if (!hasPropertyKey(keyOfComment, existingProperties) && comments) {
@@ -589,30 +587,31 @@ export class ModelClassVisitor extends AbstractFileVisitor {
     const hasExampleOrExamplesKey =
       hasPropertyKey('example', existingProperties) ||
       hasPropertyKey('examples', existingProperties);
-
-    if (!hasExampleOrExamplesKey && examples.length) {
-      if (examples.length === 1) {
+    if (!hasExampleOrExamplesKey && tags.example?.length) {
+      if (tags.example.length === 1) {
         const examplePropertyAssignment = factory.createPropertyAssignment(
           'example',
-          this.createLiteralFromAnyValue(factory, examples[0])
+          createLiteralFromAnyValue(factory, tags.example[0])
         );
         propertyAssignments.push(examplePropertyAssignment);
       } else {
         const examplesPropertyAssignment = factory.createPropertyAssignment(
           'examples',
-          this.createLiteralFromAnyValue(factory, examples)
+          createLiteralFromAnyValue(factory, tags.example)
         );
         propertyAssignments.push(examplesPropertyAssignment);
       }
     }
-    return propertyAssignments;
-  }
 
-  private createLiteralFromAnyValue(factory: ts.NodeFactory, item: unknown) {
-    return Array.isArray(item)
-      ? factory.createArrayLiteralExpression(
-          item.map((item) => this.createLiteralFromAnyValue(factory, item))
-        )
-      : createPrimitiveLiteral(factory, item);
+    const hasDeprecatedKey = hasPropertyKey('deprecated', existingProperties);
+    if (!hasDeprecatedKey && tags.deprecated) {
+      const deprecatedPropertyAssignment = factory.createPropertyAssignment(
+        'deprecated',
+        createLiteralFromAnyValue(factory, tags.deprecated)
+      );
+      propertyAssignments.push(deprecatedPropertyAssignment);
+    }
+
+    return propertyAssignments;
   }
 }


Test patch:
diff --git a/test/plugin/fixtures/app.controller.ts b/test/plugin/fixtures/app.controller.ts
index 5d871510..5069697c 100644
--- a/test/plugin/fixtures/app.controller.ts
+++ b/test/plugin/fixtures/app.controller.ts
@@ -16,6 +16,27 @@ export class AppController {
   @Post()
   async create(): Promise<Cat> {}
 
+  /**
+   * create a test Cat
+   *
+   * @deprecated Use create instead
+   * @returns {Promise<Cat>}
+   * @memberof AppController
+   */
+  @Post()
+  async testCreate(): Promise<Cat> {}
+
+  /**
+   * create a test Cat, not actually deprecated
+   *
+   * @deprecated
+   * @returns {Promise<Cat>}
+   * @memberof AppController
+   */
+  @ApiOperation({ deprecated: false })
+  @Post()
+  async testCreate2(): Promise<Cat> {}
+
   /**
    * find a Cat
    */
@@ -54,6 +75,22 @@ let AppController = class AppController {
      * @memberof AppController
      */
     async create() { }
+    /**
+     * create a test Cat
+     *
+     * @deprecated Use create instead
+     * @returns {Promise<Cat>}
+     * @memberof AppController
+     */
+    async testCreate() { }
+    /**
+     * create a test Cat, not actually deprecated
+     *
+     * @deprecated
+     * @returns {Promise<Cat>}
+     * @memberof AppController
+     */
+    async testCreate2() { }
     /**
      * find a Cat
      */
@@ -71,6 +108,16 @@ __decorate([
     (0, common_1.Post)(),
     openapi.ApiResponse({ status: 201, type: Cat })
 ], AppController.prototype, \"create\", null);
+__decorate([
+    openapi.ApiOperation({ summary: "create a test Cat", deprecated: true }),
+    (0, common_1.Post)(),
+    openapi.ApiResponse({ status: 201, type: Cat })
+], AppController.prototype, \"testCreate\", null);
+__decorate([
+    (0, swagger_1.ApiOperation)({ summary: "create a test Cat, not actually deprecated", deprecated: false }),
+    (0, common_1.Post)(),
+    openapi.ApiResponse({ status: 201, type: Cat })
+], AppController.prototype, \"testCreate2\", null);
 __decorate([
     (0, swagger_1.ApiOperation)({ summary: "find a Cat" }),
     Get(),
diff --git a/test/plugin/fixtures/create-cat-alt2.dto.ts b/test/plugin/fixtures/create-cat-alt2.dto.ts
index 9774d464..8f502a4a 100644
--- a/test/plugin/fixtures/create-cat-alt2.dto.ts
+++ b/test/plugin/fixtures/create-cat-alt2.dto.ts
@@ -15,7 +15,7 @@ export abstract class Audit {
 
   /**
    * test
-   * version 
+   * version
    * @example 'version 123'
    * @example ignore this
    * @memberof Audit
@@ -25,16 +25,27 @@ export abstract class Audit {
 
   /**
    * testVersion
-   *  
+   *
    * @example '0.0.1'
    * @example '0.0.2'
+   * @deprecated
    * @memberof Audit
    */
   testVersion;
 
+  /**
+   * testVersion2
+   *
+   * @example '0.0.1'
+   * @example '0.0.2'
+   * @deprecated Use version instead
+   * @memberof Audit
+   */
+  testVersion2;
+
   /**
    * testVersionArray
-   *  
+   *
    * @example ['0.0.1', '0.0.2']
    * @memberof Audit
    */
@@ -42,7 +53,7 @@ export abstract class Audit {
 
   /**
    * testVersionArray
-   *  
+   *
    * @example ['version 123', 'version 321']
    * @memberof Audit
    */
@@ -50,7 +61,7 @@ export abstract class Audit {
 
   /**
    * testVersionArray
-   *  
+   *
    * @example [123, 321]
    * @memberof Audit
    */
@@ -58,14 +69,14 @@ export abstract class Audit {
 
   /**
    * testBoolean
-   *  
+   *
    * @example true
    */
   testBoolean: boolean;
 
   /**
    * testNumber
-   *  
+   *
    * @example 1.0
    * @example 5
    */
@@ -79,7 +90,7 @@ const openapi = __require("@nestjs/swagger");
 import { CreateDateColumn, UpdateDateColumn, VersionColumn } from 'typeorm';
 export class Audit {
     static _OPENAPI_METADATA_FACTORY() {
-        return { createdAt: { required: true, type: () => Object, description: "test on createdAt" }, updatedAt: { required: true, type: () => Object }, version: { required: true, type: () => String, description: "test\\nversion", example: "version 123" }, testVersion: { required: true, type: () => Object, description: "testVersion", examples: ["0.0.1", "0.0.2"] }, testVersionArray: { required: true, type: () => [String], description: "testVersionArray", example: ["0.0.1", "0.0.2"] }, testVersionArray2: { required: true, type: () => [String], description: "testVersionArray", example: ["version 123", "version 321"] }, testVersionArray3: { required: true, type: () => [Number], description: "testVersionArray", example: [123, 321] }, testBoolean: { required: true, type: () => Boolean, description: "testBoolean", example: true }, testNumber: { required: true, type: () => Number, description: "testNumber", examples: [1, 5] } };
+        return { createdAt: { required: true, type: () => Object, description: "test on createdAt" }, updatedAt: { required: true, type: () => Object }, version: { required: true, type: () => String, description: "test\\nversion", example: "version 123" }, testVersion: { required: true, type: () => Object, description: "testVersion", examples: ["0.0.1", "0.0.2"], deprecated: true }, testVersion2: { required: true, type: () => Object, description: "testVersion2", examples: ["0.0.1", "0.0.2"], deprecated: true }, testVersionArray: { required: true, type: () => [String], description: "testVersionArray", example: ["0.0.1", "0.0.2"] }, testVersionArray2: { required: true, type: () => [String], description: "testVersionArray", example: ["version 123", "version 321"] }, testVersionArray3: { required: true, type: () => [Number], description: "testVersionArray", example: [123, 321] }, testBoolean: { required: true, type: () => Boolean, description: "testBoolean", example: true }, testNumber: { required: true, type: () => Number, description: "testNumber", examples: [1, 5] } };
     }
 }
 __decorate([