Repository: lukeautry/tsoa
Pull request #602 (2020-03-03 12:52:39)

convert path params with colon delimiter to bracelet delimiter during spec generation
### All Submissions:

* [x] Have you followed the guidelines in our [Contributing](https://github.com/lukeautry/tsoa/tree/master/docs/CONTRIBUTING.md) document?
* [x] Have you checked to ensure there aren't other open [Pull Requests](https://github.com/lukeautry/tsoa/pulls) for the same update/change?
* [x] Have you written unit tests?
* [x] Have you written unit tests that cover the negative cases (i.e.: if bad data is submitted, does the library respond properly)?
* [x] This PR is associated with an existing issue?

**Closing issues**

Put `closes #XXXX`  (where XXXX is the issue number) in your comment to auto-close the issue that your PR fixes.

### If this is a new feature submission:

* [x] Has the issue had a maintainer respond to the issue and clarify that the feature is something that aligns with the [goals](https://github.com/lukeautry/tsoa#goal) and [philosophy](https://github.com/lukeautry/tsoa#philosophy) of the project?

**Potential Problems With The Approach**

<!-- if there are areas of the solution that you think might have limitations or potential "gotchas", then discuss them here -->
<!-- if you have many questions, then please consider discussing them in the issue thread (unless you need to share code to illustrate the questions) -->

**Test plan**

* I have wrote unit tests for the `convertColonPathParams` method in `pathUtils.ts`
* I have added route with colon delimiter path params to `paramaeterController.ts` and tested the resulted spec


Patch:
diff --git a/src/metadataGeneration/parameterGenerator.ts b/src/metadataGeneration/parameterGenerator.ts
index 71d7385a..3d34f92c 100644
--- a/src/metadataGeneration/parameterGenerator.ts
+++ b/src/metadataGeneration/parameterGenerator.ts
@@ -147,7 +147,7 @@ export class ParameterGenerator {
     if (!this.supportPathDataType(type)) {
       throw new GenerateMetadataError(`@Path('${parameterName}') Can't support '${type.dataType}' type.`);
     }
-    if (!this.path.includes(`{${pathName}}`)) {
+    if (!this.path.includes(`{${pathName}}`) && !this.path.includes(`:${pathName}`)) {
       throw new GenerateMetadataError(`@Path('${parameterName}') Can't match in URL: '${this.path}'.`);
     }
 
diff --git a/src/swagger/specGenerator2.ts b/src/swagger/specGenerator2.ts
index 15e83739..98fcc155 100644
--- a/src/swagger/specGenerator2.ts
+++ b/src/swagger/specGenerator2.ts
@@ -1,7 +1,7 @@
 import { Tsoa } from '../metadataGeneration/tsoa';
 import { assertNever } from '../utils/assertNever';
 import { SwaggerConfig } from './../config';
-import { normalisePath } from './../utils/pathUtils';
+import { convertColonPathParams, normalisePath } from './../utils/pathUtils';
 import { SpecGenerator } from './specGenerator';
 import { Swagger } from './swagger';
 
@@ -108,7 +108,8 @@ export class SpecGenerator2 extends SpecGenerator {
         .filter(method => !method.isHidden)
         .forEach(method => {
           const normalisedMethodPath = normalisePath(method.path, '/');
-          const path = normalisePath(`${normalisedControllerPath}${normalisedMethodPath}`, '/', '', false);
+          let path = normalisePath(`${normalisedControllerPath}${normalisedMethodPath}`, '/', '', false);
+          path = convertColonPathParams(path);
           paths[path] = paths[path] || {};
           this.buildMethod(controller.name, method, paths[path]);
         });
diff --git a/src/swagger/specGenerator3.ts b/src/swagger/specGenerator3.ts
index 03e85b41..6bc490ab 100644
--- a/src/swagger/specGenerator3.ts
+++ b/src/swagger/specGenerator3.ts
@@ -1,7 +1,7 @@
 import { Tsoa } from '../metadataGeneration/tsoa';
 import { assertNever } from '../utils/assertNever';
 import { SwaggerConfig } from './../config';
-import { normalisePath } from './../utils/pathUtils';
+import { convertColonPathParams, normalisePath } from './../utils/pathUtils';
 import { SpecGenerator } from './specGenerator';
 import { Swagger } from './swagger';
 
@@ -182,7 +182,8 @@ export class SpecGenerator3 extends SpecGenerator {
         .filter(method => !method.isHidden)
         .forEach(method => {
           const normalisedMethodPath = normalisePath(method.path, '/');
-          const path = normalisePath(`${normalisedControllerPath}${normalisedMethodPath}`, '/', '', false);
+          let path = normalisePath(`${normalisedControllerPath}${normalisedMethodPath}`, '/', '', false);
+          path = convertColonPathParams(path);
           paths[path] = paths[path] || {};
           this.buildMethod(controller.name, method, paths[path]);
         });
diff --git a/src/utils/pathUtils.ts b/src/utils/pathUtils.ts
index 27d1a442..38a3b196 100644
--- a/src/utils/pathUtils.ts
+++ b/src/utils/pathUtils.ts
@@ -2,6 +2,7 @@
  * Removes all '/', '\', and spaces from the beginning and end of the path
  * Replaces all '/', '\', and spaces between sections of the path
  * Adds prefix and suffix if supplied
+ * Replace ':pathParam' with '{pathParam}'
  */
 export function normalisePath(path: string, withPrefix?: string, withSuffix?: string, skipPrefixAndSuffixIfEmpty: boolean = true) {
   if ((!path || path === '/') && skipPrefixAndSuffixIfEmpty) {
@@ -18,3 +19,12 @@ export function normalisePath(path: string, withPrefix?: string, withSuffix?: st
   normalised = normalised.replace(/[/\\\s]+/g, '/');
   return normalised;
 }
+
+export function convertColonPathParams(path: string) {
+  if (!path || typeof path !== 'string') {
+    return path;
+  }
+
+  const normalised = path.replace(/:([^\/]+)/g, '{$1}');
+  return normalised;
+}


Test patch:
diff --git a/tests/fixtures/controllers/parameterController.ts b/tests/fixtures/controllers/parameterController.ts
index f89e7c8d..63b5edda 100644
--- a/tests/fixtures/controllers/parameterController.ts
+++ b/tests/fixtures/controllers/parameterController.ts
@@ -70,6 +70,37 @@ export class ParameterController {
     });
   }
 
+  /**
+   * Path test paramater
+   *
+   * @param {string} firstname Firstname description
+   * @param {string} lastname Lastname description
+   * @param {number} age Age description
+   * @param {number} weight Weight description
+   * @param {boolean} human Human description
+   * @param {Gender} gender Gender description
+   *
+   * @isInt age
+   * @isFloat weight
+   */
+  @Get('Path/:firstname/:last_name/:age/:weight/:human/:gender')
+  public async getPathColonDelimiter(
+    firstname: string,
+    @Path('last_name') lastname: string,
+    @Path() age: number,
+    @Path() weight: number,
+    @Path() human: boolean,
+    @Path() gender: Gender,
+  ): Promise<ParameterTestModel> {
+    return Promise.resolve<ParameterTestModel>({
+      age,
+      firstname,
+      gender,
+      human,
+      lastname,
+      weight,
+    });
+  }
   /**
    * Header test paramater
    *
diff --git a/tests/unit/swagger/common/paths.spec.ts b/tests/unit/swagger/common/paths.spec.ts
index 524a4395..d124989b 100644
--- a/tests/unit/swagger/common/paths.spec.ts
+++ b/tests/unit/swagger/common/paths.spec.ts
@@ -1,6 +1,6 @@
 import { expect } from 'chai';
 import 'mocha';
-import { normalisePath } from '../../../../src/utils/pathUtils';
+import { normalisePath, convertColonPathParams } from '../../../../src/utils/pathUtils';
 
 describe('Paths normalisation', () => {
   it('should remove all redundant symbols at the beginning and at the end', () => {
@@ -62,3 +62,29 @@ describe('Paths normalisation', () => {
     expect(normalisePath('', 'prefix', 'suffix', false)).to.equal('prefixsuffix');
   });
 });
+
+describe('Colon path params conversion', () => {
+  it('should not modify paths without colon', () => {
+    expect(convertColonPathParams('path1/path2')).to.equal('path1/path2');
+    expect(convertColonPathParams('path1/{pathParam}')).to.equal('path1/{pathParam}');
+  });
+
+  it('should replace ":param" with "{param}" in path', () => {
+    expect(convertColonPathParams(':pathParam')).to.equal('{pathParam}');
+    expect(convertColonPathParams('/path1/:pathParam')).to.equal('/path1/{pathParam}');
+    expect(convertColonPathParams('/path1/:pathParam/path2')).to.equal('/path1/{pathParam}/path2');
+  });
+
+  it('should handle empty path', () => {
+    expect(convertColonPathParams('')).to.equal('');
+  });
+
+  it('should ignore bad parameters', () => {
+    expect(convertColonPathParams(undefined as any)).to.equal(undefined);
+    expect(convertColonPathParams(null as any)).to.equal(null);
+    expect(convertColonPathParams(1 as any)).to.equal(1);
+    expect(convertColonPathParams('')).to.equal('');
+    const emptyObject = {};
+    expect(convertColonPathParams(emptyObject as any)).to.equal(emptyObject);
+  });
+});
diff --git a/tests/unit/swagger/definitionsGeneration/metadata.spec.ts b/tests/unit/swagger/definitionsGeneration/metadata.spec.ts
index 3043b7b3..0d82a8d9 100644
--- a/tests/unit/swagger/definitionsGeneration/metadata.spec.ts
+++ b/tests/unit/swagger/definitionsGeneration/metadata.spec.ts
@@ -363,6 +363,63 @@ describe('Metadata generation', () => {
       expect(genderParam.type.dataType).to.equal('enum');
     });
 
+    it('should generate an path parameter from colon delimiter path params', () => {
+      const method = controller.methods.find(m => m.name === 'getPathColonDelimiter');
+      if (!method) {
+        throw new Error('Method getPathColonDelimiter not defined!');
+      }
+
+      expect(method.parameters.length).to.equal(6);
+
+      const firstnameParam = method.parameters[0];
+      expect(firstnameParam.in).to.equal('path');
+      expect(firstnameParam.name).to.equal('firstname');
+      expect(firstnameParam.parameterName).to.equal('firstname');
+      expect(firstnameParam.description).to.equal('Firstname description');
+      expect(firstnameParam.required).to.be.true;
+      expect(firstnameParam.type.dataType).to.equal('string');
+
+      const lastnameParam = method.parameters[1];
+      expect(lastnameParam.in).to.equal('path');
+      expect(lastnameParam.name).to.equal('last_name');
+      expect(lastnameParam.parameterName).to.equal('lastname');
+      expect(lastnameParam.description).to.equal('Lastname description');
+      expect(lastnameParam.required).to.be.true;
+      expect(lastnameParam.type.dataType).to.equal('string');
+
+      const ageParam = method.parameters[2];
+      expect(ageParam.in).to.equal('path');
+      expect(ageParam.name).to.equal('age');
+      expect(ageParam.parameterName).to.equal('age');
+      expect(ageParam.description).to.equal('Age description');
+      expect(ageParam.required).to.be.true;
+      expect(ageParam.type.dataType).to.equal('integer');
+
+      const weightParam = method.parameters[3];
+      expect(weightParam.in).to.equal('path');
+      expect(weightParam.name).to.equal('weight');
+      expect(weightParam.parameterName).to.equal('weight');
+      expect(weightParam.description).to.equal('Weight description');
+      expect(weightParam.required).to.be.true;
+      expect(weightParam.type.dataType).to.equal('float');
+
+      const humanParam = method.parameters[4];
+      expect(humanParam.in).to.equal('path');
+      expect(humanParam.name).to.equal('human');
+      expect(humanParam.parameterName).to.equal('human');
+      expect(humanParam.description).to.equal('Human description');
+      expect(humanParam.required).to.be.true;
+      expect(humanParam.type.dataType).to.equal('boolean');
+
+      const genderParam = method.parameters[5];
+      expect(genderParam.in).to.equal('path');
+      expect(genderParam.name).to.equal('gender');
+      expect(genderParam.parameterName).to.equal('gender');
+      expect(genderParam.description).to.equal('Gender description');
+      expect(genderParam.required).to.be.true;
+      expect(genderParam.type.dataType).to.equal('enum');
+    });
+
     it('should generate an header parameter', () => {
       const method = controller.methods.find(m => m.name === 'getHeader');
       if (!method) {