Repository: lukeautry/tsoa
Pull request #277 (2018-09-13 08:20:01)

Custom OperationId
Does this look okay to you @redlock?


Patch:
diff --git a/README.MD b/README.MD
index dbeeb0ce..fe634b85 100644
--- a/README.MD
+++ b/README.MD
@@ -607,6 +607,19 @@ export class UserController {
 }
 ```
 
+### OperationId
+
+Set operationId parameter under operation's path.
+Useful for use with Swagger code generation tool since this parameter is used to name the function generated in the client SDK.
+
+```ts
+@Get()
+@OperationId('findDomain')
+public async find(): Promise<any> {
+
+}
+```
+
 ## Command Line Interface
 
 For information on the configuration object (tsoa.json), check out the following:
diff --git a/src/decorators/operationid.ts b/src/decorators/operationid.ts
new file mode 100644
index 00000000..0ddb0377
--- /dev/null
+++ b/src/decorators/operationid.ts
@@ -0,0 +1,5 @@
+import { MethodDecoratorReturn } from '../interfaces/decorator-return';
+
+export function OperationId(value: string): MethodDecoratorReturn {
+  return () => { return; };
+}
diff --git a/src/index.ts b/src/index.ts
index 7de42ea2..11343833 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -6,6 +6,7 @@ export * from './decorators/example';
 export * from './decorators/parameter';
 export * from './decorators/methods';
 export * from './decorators/tags';
+export * from './decorators/operationid';
 export * from './decorators/route';
 export * from './decorators/security';
 export * from './interfaces/controller';
diff --git a/src/metadataGeneration/methodGenerator.ts b/src/metadataGeneration/methodGenerator.ts
index 3c70f498..c9c3b3ac 100644
--- a/src/metadataGeneration/methodGenerator.ts
+++ b/src/metadataGeneration/methodGenerator.ts
@@ -44,6 +44,7 @@ export class MethodGenerator {
       isHidden: this.getIsHidden(),
       method: this.method,
       name: (this.node.name as ts.Identifier).text,
+      operationId: this.getOperationId(),
       parameters: this.buildParameters(),
       path: this.path,
       responses,
@@ -103,7 +104,7 @@ export class MethodGenerator {
   }
 
   private getMethodResponses(): Tsoa.Response[] {
-    const decorators = getDecorators(this.node, (identifier) => identifier.text === 'Response');
+    const decorators = this.getDecoratorsByIdentifier(this.node, 'Response');
     if (!decorators || !decorators.length) {
       return [];
     }
@@ -137,7 +138,7 @@ export class MethodGenerator {
   }
 
   private getMethodSuccessResponse(type: Tsoa.Type): Tsoa.Response {
-    const decorators = getDecorators(this.node, (identifier) => identifier.text === 'SuccessResponse');
+    const decorators = this.getDecoratorsByIdentifier(this.node, 'SuccessResponse');
     if (!decorators || !decorators.length) {
       return {
         description: type.dataType === 'void' ? 'No content' : 'Ok',
@@ -173,7 +174,7 @@ export class MethodGenerator {
   }
 
   private getMethodSuccessExamples() {
-    const exampleDecorators = getDecorators(this.node, (identifier) => identifier.text === 'Example');
+    const exampleDecorators = this.getDecoratorsByIdentifier(this.node, 'Example');
     if (!exampleDecorators || !exampleDecorators.length) {
       return undefined;
     }
@@ -200,8 +201,23 @@ export class MethodGenerator {
     return example;
   }
 
+  private getOperationId() {
+    const opDecorators = this.getDecoratorsByIdentifier(this.node, 'OperationId');
+    if (!opDecorators || !opDecorators.length) {
+      return undefined;
+    }
+    if (opDecorators.length > 1) {
+      throw new GenerateMetadataError(`Only one OperationId decorator allowed in '${this.getCurrentLocation}' method.`);
+    }
+
+    const decorator = opDecorators[0];
+    const expression = decorator.parent as ts.CallExpression;
+    const ops = expression.arguments.map((a: any) => a.text as string);
+    return ops[0];
+  }
+
   private getTags() {
-    const tagsDecorators = getDecorators(this.node, (identifier) => identifier.text === 'Tags');
+    const tagsDecorators = this.getDecoratorsByIdentifier(this.node, 'Tags');
     if (!tagsDecorators || !tagsDecorators.length) {
       return this.parentTags;
     }
@@ -219,7 +235,7 @@ export class MethodGenerator {
   }
 
   private getIsHidden() {
-    const hiddenDecorators = getDecorators(this.node, (identifier) => identifier.text === 'Hidden');
+    const hiddenDecorators = this.getDecoratorsByIdentifier(this.node, 'Hidden');
     if (!hiddenDecorators || !hiddenDecorators.length) {
       return false;
     }
@@ -231,7 +247,7 @@ export class MethodGenerator {
   }
 
   private getSecurity(): Tsoa.Security[] {
-    const securityDecorators = getDecorators(this.node, (identifier) => identifier.text === 'Security');
+    const securityDecorators = this.getDecoratorsByIdentifier(this.node, 'Security');
     if (!securityDecorators || !securityDecorators.length) {
       return this.parentSecurity || [];
     }
@@ -259,4 +275,8 @@ export class MethodGenerator {
 
     return securities;
   }
+
+  private getDecoratorsByIdentifier(node: ts.Node, id: string) {
+    return getDecorators(node, (identifier) => identifier.text === id);
+  }
 }
diff --git a/src/metadataGeneration/tsoa.ts b/src/metadataGeneration/tsoa.ts
index fbd16b60..0457c566 100644
--- a/src/metadataGeneration/tsoa.ts
+++ b/src/metadataGeneration/tsoa.ts
@@ -24,6 +24,7 @@ export namespace Tsoa {
     security: Security[];
     summary?: string;
     isHidden: boolean;
+    operationId?: string;
 
   }
 
diff --git a/src/swagger/specGenerator.ts b/src/swagger/specGenerator.ts
index 1a522723..602526df 100644
--- a/src/swagger/specGenerator.ts
+++ b/src/swagger/specGenerator.ts
@@ -103,6 +103,9 @@ export class SpecGenerator {
     pathMethod.summary = method.summary;
     pathMethod.tags = method.tags;
 
+    // Use operationId tag otherwise fallback to generated. Warning: This doesn't check uniqueness.
+    pathMethod.operationId = method.operationId || pathMethod.operationId;
+
     if (method.deprecated) {
       pathMethod.deprecated = method.deprecated;
     }


Test patch:
diff --git a/tests/fixtures/controllers/getController.ts b/tests/fixtures/controllers/getController.ts
index b60fd033..9758e912 100644
--- a/tests/fixtures/controllers/getController.ts
+++ b/tests/fixtures/controllers/getController.ts
@@ -2,6 +2,7 @@ import {
   Controller,
   Example,
   Get,
+  OperationId,
   Query,
   Request,
   Route,
@@ -142,6 +143,12 @@ export class GetTestController extends Controller {
     return new ModelService().getModel();
   }
 
+  @Get('CustomOperationId')
+  @OperationId('MyCustomOperationId')
+  public async getCustomOperationId(): Promise<TestModel> {
+    return new ModelService().getModel();
+  }
+
   @Get('HandleBufferType')
   public async getBuffer( @Query() buffer: Buffer): Promise<Buffer> {
     return new Buffer('testbuffer');
diff --git a/tests/fixtures/custom/routes.ts b/tests/fixtures/custom/routes.ts
index 648d0fa0..fa959344 100644
--- a/tests/fixtures/custom/routes.ts
+++ b/tests/fixtures/custom/routes.ts
@@ -455,6 +455,24 @@ export function RegisterRoutes(app: any) {
       const promise=controller.getGeneratesTags.apply(controller, validatedArgs);
       promiseHandler(controller, promise, response, next);
     });
+  app.get('/v1/GetTest/CustomOperationId',
+    function(request: any, response: any, next: any) {
+      const args={
+      };
+
+      let validatedArgs: any[]=[];
+      try {
+        validatedArgs=getValidatedArgs(args, request);
+      } catch (err) {
+        return next(err);
+      }
+
+      const controller=new GetTestController();
+
+
+      const promise=controller.getCustomOperationId.apply(controller, validatedArgs);
+      promiseHandler(controller, promise, response, next);
+    });
   app.get('/v1/GetTest/HandleBufferType',
     function(request: any, response: any, next: any) {
       const args={
diff --git a/tests/fixtures/express/routes.ts b/tests/fixtures/express/routes.ts
index 23ffbe3b..c39e1899 100644
--- a/tests/fixtures/express/routes.ts
+++ b/tests/fixtures/express/routes.ts
@@ -538,6 +538,24 @@ export function RegisterRoutes(app: any) {
       const promise=controller.getGeneratesTags.apply(controller, validatedArgs);
       promiseHandler(controller, promise, response, next);
     });
+  app.get('/v1/GetTest/CustomOperationId',
+    function(request: any, response: any, next: any) {
+      const args={
+      };
+
+      let validatedArgs: any[]=[];
+      try {
+        validatedArgs=getValidatedArgs(args, request);
+      } catch (err) {
+        return next(err);
+      }
+
+      const controller=new GetTestController();
+
+
+      const promise=controller.getCustomOperationId.apply(controller, validatedArgs);
+      promiseHandler(controller, promise, response, next);
+    });
   app.get('/v1/GetTest/HandleBufferType',
     function(request: any, response: any, next: any) {
       const args={
diff --git a/tests/fixtures/hapi/routes.ts b/tests/fixtures/hapi/routes.ts
index 1e6f7fa3..67ad7ccc 100644
--- a/tests/fixtures/hapi/routes.ts
+++ b/tests/fixtures/hapi/routes.ts
@@ -635,6 +635,30 @@ export function RegisterRoutes(server: any) {
       }
     }
   });
+  server.route({
+    method: 'get',
+    path: '/v1/GetTest/CustomOperationId',
+    options: {
+      handler: (request: any, h: any) => {
+        const args={
+        };
+
+        let validatedArgs: any[]=[];
+        try {
+          validatedArgs=getValidatedArgs(args, request);
+        } catch (err) {
+          return h
+            .response(err)
+            .code(err.status||500);
+        }
+
+        const controller=new GetTestController();
+
+        const promise=controller.getCustomOperationId.apply(controller, validatedArgs);
+        return promiseHandler(controller, promise, request, h);
+      }
+    }
+  });
   server.route({
     method: 'get',
     path: '/v1/GetTest/HandleBufferType',
diff --git a/tests/fixtures/koa/routes.ts b/tests/fixtures/koa/routes.ts
index 6df3227b..1790de85 100644
--- a/tests/fixtures/koa/routes.ts
+++ b/tests/fixtures/koa/routes.ts
@@ -554,6 +554,25 @@ export function RegisterRoutes(router: any) {
       const promise=controller.getGeneratesTags.apply(controller, validatedArgs);
       return promiseHandler(controller, promise, context, next);
     });
+  router.get('/v1/GetTest/CustomOperationId',
+    async (context, next) => {
+      const args={
+      };
+
+      let validatedArgs: any[]=[];
+      try {
+        validatedArgs=getValidatedArgs(args, context);
+      } catch (error) {
+        context.status=error.status||500;
+        context.body=error;
+        return next();
+      }
+
+      const controller=new GetTestController();
+
+      const promise=controller.getCustomOperationId.apply(controller, validatedArgs);
+      return promiseHandler(controller, promise, context, next);
+    });
   router.get('/v1/GetTest/HandleBufferType',
     async (context, next) => {
       const args={
diff --git a/tests/unit/swagger/pathGeneration/getRoutes.spec.ts b/tests/unit/swagger/pathGeneration/getRoutes.spec.ts
index d0efc715..35a6c7e4 100644
--- a/tests/unit/swagger/pathGeneration/getRoutes.spec.ts
+++ b/tests/unit/swagger/pathGeneration/getRoutes.spec.ts
@@ -56,6 +56,11 @@ describe('GET route generation', () => {
     expect(operation.tags).to.deep.equal(['test', 'test-two']);
   });
 
+  it('should generate a custom operation id for methods with that decorator', () => {
+    const operation = getValidatedGetOperation(`${baseRoute}/CustomOperationId`);
+    expect(operation.operationId).to.equal('MyCustomOperationId');
+  });
+
   it('should generate a path for a GET route with no controller path argument', () => {
     const pathlessMetadata = new MetadataGenerator('./tests/fixtures/controllers/pathlessGetController.ts').Generate();
     const pathlessSpec = new SpecGenerator(pathlessMetadata, getDefaultOptions()).GetSpec();