Repository: nestjs/swagger
Pull request #2835 (2024-02-13 00:18:11)

feat(introspectComments): Enhanced comment parsing for error response
## 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. -->
`@throws` tag will not add `ApiResponse` decorator

Issue Number: N/A


## What is the new behavior?
Add `ApiResponse` decorator if `@throws` exist.


## 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/plugin/utils/ast-utils.ts b/lib/plugin/utils/ast-utils.ts
index 5eeea754..ef3ecc5f 100644
--- a/lib/plugin/utils/ast-utils.ts
+++ b/lib/plugin/utils/ast-utils.ts
@@ -138,15 +138,18 @@ export function getDefaultTypeFormatFlags(enclosingNode: Node) {
   return formatFlags;
 }
 
-export function getMainCommentOfNode(
-  node: Node,
-  sourceFile: SourceFile
-): string {
+export function getDocComment(node: Node): DocComment {
   const tsdocParser: TSDocParser = new TSDocParser();
   const parserContext: ParserContext = tsdocParser.parseString(
     node.getFullText()
   );
-  const docComment: DocComment = parserContext.docComment;
+  return parserContext.docComment;
+}
+export function getMainCommentOfNode(
+  node: Node,
+  sourceFile: SourceFile
+): string {
+  const docComment = getDocComment(node);
   return renderDocNode(docComment.summarySection).trim();
 }
 
@@ -168,11 +171,7 @@ export function parseCommentDocValue(docValue: string, type: ts.Type) {
 }
 
 export function getTsDocTagsOfNode(node: Node, typeChecker: TypeChecker) {
-  const tsdocParser: TSDocParser = new TSDocParser();
-  const parserContext: ParserContext = tsdocParser.parseString(
-    node.getFullText()
-  );
-  const docComment: DocComment = parserContext.docComment;
+  const docComment = getDocComment(node);
 
   const tagDefinitions: {
     [key: string]: {
@@ -228,6 +227,36 @@ export function getTsDocTagsOfNode(node: Node, typeChecker: TypeChecker) {
   return tagResults;
 }
 
+export function getTsDocErrorsOfNode(node: Node) {
+  const tsdocParser: TSDocParser = new TSDocParser();
+  const parserContext: ParserContext = tsdocParser.parseString(
+    node.getFullText()
+  );
+  const docComment: DocComment = parserContext.docComment;
+
+  const tagResults = [];
+  const errorParsingRegex = /{(\d+)} (.*)/;
+
+  const introspectTsDocTags = (docComment: DocComment) => {
+    const blocks = docComment.customBlocks.filter(
+      (block) => block.blockTag.tagName === '@throws'
+    );
+
+    blocks.forEach((block) => {
+      try {
+        const docValue = renderDocNode(block.content).split('\n')[0].trim();
+        const match = docValue.match(errorParsingRegex);
+        tagResults.push({
+          status: match[1],
+          description: `"${match[2]}"`
+        });
+      } catch (err) {}
+    });
+  };
+  introspectTsDocTags(docComment);
+  return tagResults;
+}
+
 export function getDecoratorArguments(decorator: Decorator) {
   const callExpression = decorator.expression;
   return (callExpression && (callExpression as CallExpression).arguments) || [];
diff --git a/lib/plugin/visitors/controller-class.visitor.ts b/lib/plugin/visitors/controller-class.visitor.ts
index 68162c6e..4793bfd3 100644
--- a/lib/plugin/visitors/controller-class.visitor.ts
+++ b/lib/plugin/visitors/controller-class.visitor.ts
@@ -9,6 +9,7 @@ import {
   getDecoratorArguments,
   getDecoratorName,
   getMainCommentOfNode,
+  getTsDocErrorsOfNode,
   getTsDocTagsOfNode
 } from '../utils/ast-utils';
 import {
@@ -139,6 +140,17 @@ export class ControllerClassVisitor extends AbstractFileVisitor {
       typeChecker,
       metadata
     );
+
+    const apiResponseDecoratorsArray = this.createApiResponseDecorator(
+      factory,
+      compilerNode,
+      decorators,
+      options,
+      sourceFile,
+      typeChecker,
+      metadata
+    );
+
     const removeExistingApiOperationDecorator =
       apiOperationDecoratorsArray.length > 0;
 
@@ -160,6 +172,7 @@ export class ControllerClassVisitor extends AbstractFileVisitor {
     );
     const updatedDecorators = [
       ...apiOperationDecoratorsArray,
+      ...apiResponseDecoratorsArray,
       ...existingDecorators,
       factory.createDecorator(
         factory.createCallExpression(
@@ -302,6 +315,74 @@ export class ControllerClassVisitor extends AbstractFileVisitor {
     }
   }
 
+  createApiResponseDecorator(
+    factory: ts.NodeFactory,
+    node: ts.MethodDeclaration,
+    decorators: readonly ts.Decorator[],
+    options: PluginOptions,
+    sourceFile: ts.SourceFile,
+    typeChecker: ts.TypeChecker,
+    metadata: ClassMetadata
+  ) {
+    if (!options.introspectComments) {
+      return [];
+    }
+    const apiResponseDecorator = getDecoratorOrUndefinedByNames(
+      [ApiResponse.name],
+      decorators,
+      factory
+    );
+    let apiResponseExistingProps:
+      | ts.NodeArray<ts.PropertyAssignment>
+      | undefined = undefined;
+
+    if (apiResponseDecorator && !options.readonly) {
+      const apiResponseExpr = head(getDecoratorArguments(apiResponseDecorator));
+      if (apiResponseExpr) {
+        apiResponseExistingProps =
+          apiResponseExpr.properties as ts.NodeArray<ts.PropertyAssignment>;
+      }
+    }
+
+    const tags = getTsDocErrorsOfNode(node);
+    if (!tags.length) {
+      return [];
+    }
+
+    return tags.map((tag) => {
+      const properties = [
+        ...(apiResponseExistingProps ?? factory.createNodeArray())
+      ];
+      properties.push(
+        factory.createPropertyAssignment(
+          'status',
+          factory.createNumericLiteral(tag.status)
+        )
+      );
+      properties.push(
+        factory.createPropertyAssignment(
+          'description',
+          factory.createNumericLiteral(tag.description)
+        )
+      );
+      const objectLiteralExpr = factory.createObjectLiteralExpression(
+        compact(properties)
+      );
+      const methodKey = node.name.getText();
+      metadata[methodKey] = objectLiteralExpr;
+
+      const apiResponseDecoratorArguments: ts.NodeArray<ts.Expression> =
+        factory.createNodeArray([objectLiteralExpr]);
+      return factory.createDecorator(
+        factory.createCallExpression(
+          factory.createIdentifier(`${OPENAPI_NAMESPACE}.${ApiResponse.name}`),
+          undefined,
+          apiResponseDecoratorArguments
+        )
+      );
+    });
+  }
+
   createDecoratorObjectLiteralExpr(
     factory: ts.NodeFactory,
     node: ts.MethodDeclaration,


Test patch:
diff --git a/test/plugin/fixtures/app.controller.ts b/test/plugin/fixtures/app.controller.ts
index e407a9db..8b6c3583 100644
--- a/test/plugin/fixtures/app.controller.ts
+++ b/test/plugin/fixtures/app.controller.ts
@@ -11,6 +11,10 @@ export class AppController {
    * create a Cat
    * 
    * @remarks Creating a test cat
+   * 
+   * @throws {500} Something is wrong.
+   * @throws {400} Bad Request.
+   * @throws {400} Missing parameters.
    *
    * @returns {Promise<Cat>}
    * @memberof AppController
@@ -75,6 +79,10 @@ let AppController = exports.AppController = class AppController {
      *
      * @remarks Creating a test cat
      *
+     * @throws {500} Something is wrong.
+     * @throws {400} Bad Request.
+     * @throws {400} Missing parameters.
+     *
      * @returns {Promise<Cat>}
      * @memberof AppController
      */
@@ -109,6 +117,9 @@ let AppController = exports.AppController = class AppController {
 };
 __decorate([
     openapi.ApiOperation({ summary: \"create a Cat\", description: \"Creating a test cat\" }),
+    openapi.ApiResponse({ status: 500, description: "Something is wrong." }),
+    openapi.ApiResponse({ status: 400, description: "Bad Request." }),
+    openapi.ApiResponse({ status: 400, description: "Missing parameters." }),
     (0, common_1.Post)(),
     openapi.ApiResponse({ status: 201, type: Cat })
 ], AppController.prototype, \"create\", null);