Repository: lukeautry/tsoa (ts)

Issue:
Omitting the host property should make the host relative
<!--- Provide a general summary of the issue in the Title above -->
This is the v3 version for the compromise we made in https://github.com/lukeautry/tsoa/pull/547

## Sorting

* **I'm submitting a ...**
  - [ ] bug report
  - [X] feature request
  - [ ] support request

* I confirm that I
  - [X] used the [search](https://github.com/lukeautry/tsoa/search?type=Issues) to make sure that a similar issue hasn't already been submit

## Expected Behavior
<!--- Tell us what should happen -->
<!--- If this is related to swagger/OpenAPI files, then please include swagger.yaml that you wish tsoa had generated  -->
Assuming that the `host` property was not provided and the `basePath` is "v1" (correct me on this if I'm wrong @Eoksni / @jreusch-barco) but the desired output would be:
```yaml
"servers": [
		{
			"url": "/v1"
		}
	]
```
I'd like to be able to specify relative host, which is allowed by specification https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#fixed-fields-4.

## Current Behavior
Currently if I omit host field in the swagger config or use empty "host": "", it gets replaced with http://localhost:3000. Using "host": "/" also doesn't work.

## Possible Solution
The solutions were discussed in the v2 version of this change: https://github.com/lukeautry/tsoa/issues/540

## Detailed Description
<!--- Provide a detailed description of the change or addition you are proposing -->
We will accept that if someone doesn't specify a host, then it should not default (since tsoa couldn't possibly know which host to default to). It should be relative.

## Breaking change?
<!--- if this is a breaking change, then please describe how it breaks the existing libraries inputs/outputs -->
<!--- if this is a breaking change, then discuss how we can mitigate that -->
Yes, this should be part of the v3 branch.

Pull request description:
Change default behavior when no host is defined
In OpenAPI 3.0 definitions of an url can be absolute and contain an host, or
relative. This commit changes the bahavior of tsoa to generate a
relative url when no host is defined.

In version 2 of this lib when no host was defined it was as default set
to localhost:3000. The possibility to also generate relative urls
without breaking backwards compatability it was chosen to only generate
such urls when host is set tu 'null'.

For spec versoin 2 the urls where always relative, so this commit
realignes the behavior again.

Fixes #555

### 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?
* [ ] 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**

closes #555

### If this is a new feature submission:

Since this is changing the default behavior should we maybe mention it somewhere? (e.g. release notes?) and if so where?

Patch:
diff --git a/src/metadataGeneration/typeResolver.ts b/src/metadataGeneration/typeResolver.ts
index 72dada45..d819afb2 100644
--- a/src/metadataGeneration/typeResolver.ts
+++ b/src/metadataGeneration/typeResolver.ts
@@ -472,9 +472,8 @@ export class TypeResolver {
         .replace(/<|>/g, '_')
         .replace(/ /g, '')
         .replace(/,/g, '.')
-        .replace(/\'([^']*)\'|\"(^"*)\"/g, '$1')
-        .replace(/&/g, '~AND~')
-        .replace(/\|/g, '~OR~')
+        .replace(/\'(.*)\'|\"(.*)\'/g, '$1')
+        .replace(/&/g, '~AND')
         .replace(/\[\]/g, 'Array'),
     );
   }
diff --git a/src/routeGeneration/templateHelpers.ts b/src/routeGeneration/templateHelpers.ts
index f53c4f40..07d6854e 100644
--- a/src/routeGeneration/templateHelpers.ts
+++ b/src/routeGeneration/templateHelpers.ts
@@ -689,13 +689,13 @@ export class ValidationService {
         Object.keys(value).forEach((key: string) => {
           if (isAnExcessProperty(key)) {
             if (swaggerConfig.noImplicitAdditionalProperties === 'throw-on-extras') {
-              fieldErrors[parent + key] = {
+              fieldErrors[parent + '.' + key] = {
                 message: `"${key}" is an excess property and therefore is not allowed`,
                 value: key,
               };
             } else if (swaggerConfig.noImplicitAdditionalProperties === true) {
               warnAdditionalPropertiesDeprecation(swaggerConfig.noImplicitAdditionalProperties);
-              fieldErrors[parent + key] = {
+              fieldErrors[parent + '.' + key] = {
                 message: `"${key}" is an excess property and therefore is not allowed`,
                 value: key,
               };
@@ -718,7 +718,7 @@ export class ValidationService {
             if (validatedValue !== undefined) {
               value[key] = validatedValue;
             } else {
-              fieldErrors[parent + key] = {
+              fieldErrors[parent + '.' + key] = {
                 message: `No matching model found in additionalProperties to validate ${key}`,
                 value: key,
               };
diff --git a/src/swagger/specGenerator3.ts b/src/swagger/specGenerator3.ts
index 3a2e0706..dd9f088d 100644
--- a/src/swagger/specGenerator3.ts
+++ b/src/swagger/specGenerator3.ts
@@ -117,10 +117,10 @@ export class SpecGenerator3 extends SpecGenerator {
   private buildServers() {
     const basePath = normalisePath(this.config.basePath as string, '/', undefined, false);
     const scheme = this.config.schemes ? this.config.schemes[0] : 'https';
-    const host = this.config.host || 'localhost:3000';
+    const url = this.config.host ? `${scheme}://${this.config.host}${basePath}` : basePath;
     return [
       {
-        url: `${scheme}://${host}${basePath}`,
+        url,
       } as Swagger.Server,
     ];
   }
@@ -302,6 +302,10 @@ export class SpecGenerator3 extends SpecGenerator {
       parameter.schema.format = this.throwIfNotDataFormat(parameterType.format);
     }
 
+    if (parameter.in === 'query' && parameterType.type === 'array') {
+      (parameter as Swagger.QueryParameter).collectionFormat = 'multi';
+    }
+
     if (parameterType.$ref) {
       parameter.schema = parameterType as Swagger.Schema;
       return parameter;


Test patch:
diff --git a/tests/fixtures/testModel.ts b/tests/fixtures/testModel.ts
index 78975926..d90c2fb3 100644
--- a/tests/fixtures/testModel.ts
+++ b/tests/fixtures/testModel.ts
@@ -89,7 +89,7 @@ export interface TestModel extends Model {
     partial?: Partial<Account>;
     excludeToEnum?: Exclude<EnumUnion, EnumNumberValue>;
     excludeToAlias?: Exclude<ThreeOrFour, TypeAliasModel3>;
-    excludeLiteral?: Exclude<keyof TestClassModel, 'account' | 'defaultValue2'>;
+    excludeLiteral?: Exclude<keyof TestClassModel, 'account'>;
     excludeToInterface?: Exclude<OneOrTwo, TypeAliasModel1>;
     excludeTypeToPrimitive?: NonNullable<number | null>;
 
diff --git a/tests/integration/dynamic-controllers-express-server.spec.ts b/tests/integration/dynamic-controllers-express-server.spec.ts
index 47fcc52d..96211676 100644
--- a/tests/integration/dynamic-controllers-express-server.spec.ts
+++ b/tests/integration/dynamic-controllers-express-server.spec.ts
@@ -809,7 +809,7 @@ describe('Express Server', () => {
         data,
         (err, res) => {
           const body = JSON.parse(err.text);
-          expect(body.fields['map.key1'].message).to.eql('No matching model found in additionalProperties to validate key1');
+          expect(body.fields['map..key1'].message).to.eql('No matching model found in additionalProperties to validate key1');
         },
         400,
       );
diff --git a/tests/integration/express-server.spec.ts b/tests/integration/express-server.spec.ts
index 70565d9f..f5e9ea9f 100644
--- a/tests/integration/express-server.spec.ts
+++ b/tests/integration/express-server.spec.ts
@@ -837,7 +837,7 @@ describe('Express Server', () => {
         data,
         (err, res) => {
           const body = JSON.parse(err.text);
-          expect(body.fields['map.key1'].message).to.eql('No matching model found in additionalProperties to validate key1');
+          expect(body.fields['map..key1'].message).to.eql('No matching model found in additionalProperties to validate key1');
         },
         400,
       );
diff --git a/tests/integration/hapi-server.spec.ts b/tests/integration/hapi-server.spec.ts
index 3a862081..fe4c72de 100644
--- a/tests/integration/hapi-server.spec.ts
+++ b/tests/integration/hapi-server.spec.ts
@@ -782,7 +782,7 @@ describe('Hapi Server', () => {
         data,
         (err, res) => {
           const body = JSON.parse(err.text);
-          expect(body.fields['map.key1'].message).to.eql('No matching model found in additionalProperties to validate key1');
+          expect(body.fields['map..key1'].message).to.eql('No matching model found in additionalProperties to validate key1');
         },
         400,
       );
diff --git a/tests/integration/koa-server-no-additional-allowed.spec.ts b/tests/integration/koa-server-no-additional-allowed.spec.ts
index ebced983..0b68991e 100644
--- a/tests/integration/koa-server-no-additional-allowed.spec.ts
+++ b/tests/integration/koa-server-no-additional-allowed.spec.ts
@@ -25,7 +25,7 @@ describe('Koa Server (with noImplicitAdditionalProperties turned on)', () => {
       data,
       (err: any, res: any) => {
         const body = JSON.parse(err.text);
-        expect(body.fields['model.someExtraProperty'].message).to.eql('"someExtraProperty" is an excess property and therefore is not allowed');
+        expect(body.fields['model..someExtraProperty'].message).to.eql('"someExtraProperty" is an excess property and therefore is not allowed');
       },
       400,
     );
@@ -317,8 +317,8 @@ describe('Koa Server (with noImplicitAdditionalProperties turned on)', () => {
           // Although dictionaries/records allow additionalProperties they are still subject to their own validation
           const excessPropertyErrMessage = `"key1" is an excess property and therefore is not allowed`;
           const expectedErrorMessage = 'No matching model found in additionalProperties to validate key1';
-          expect(body.fields['map.key1'].message).not.to.eql(excessPropertyErrMessage);
-          expect(body.fields['map.key1'].message).to.eql(expectedErrorMessage);
+          expect(body.fields['map..key1'].message).not.to.eql(excessPropertyErrMessage);
+          expect(body.fields['map..key1'].message).to.eql(expectedErrorMessage);
         },
         400,
       );
diff --git a/tests/integration/koa-server.spec.ts b/tests/integration/koa-server.spec.ts
index 8c70fdfa..2f18f111 100644
--- a/tests/integration/koa-server.spec.ts
+++ b/tests/integration/koa-server.spec.ts
@@ -761,7 +761,7 @@ describe('Koa Server', () => {
         data,
         (err, res) => {
           const body = JSON.parse(err.text);
-          expect(body.fields['map.key1'].message).to.eql('No matching model found in additionalProperties to validate key1');
+          expect(body.fields['map..key1'].message).to.eql('No matching model found in additionalProperties to validate key1');
         },
         400,
       );
diff --git a/tests/integration/openapi3-express.spec.ts b/tests/integration/openapi3-express.spec.ts
index 89cbdd17..70ea960e 100644
--- a/tests/integration/openapi3-express.spec.ts
+++ b/tests/integration/openapi3-express.spec.ts
@@ -302,7 +302,7 @@ describe('OpenAPI3 Express Server', () => {
         expect(body.fields['body.typeAliases.nOLAlias'].message).to.equal('invalid object');
         expect(body.fields['body.typeAliases.genericAlias'].message).to.equal('invalid string value');
         expect(body.fields['body.typeAliases.genericAlias2.id'].message).to.equal("'id' is required");
-        expect(body.fields['body.typeAliases.genericAlias2.id2'].message).to.equal('"id2" is an excess property and therefore is not allowed');
+        expect(body.fields['body.typeAliases.genericAlias2..id2'].message).to.equal('"id2" is an excess property and therefore is not allowed');
         expect(body.fields['body.typeAliases.forwardGenericAlias'].message).to.contain('Could not match the union against any of the items.');
         expect(body.fields['body.typeAliases.intersectionAlias2'].message).to.equal(
           `Could not match the intersection against every type. Issues: [{"body.typeAliases.value3":{"message":"'value3' is required"}}]`,
diff --git a/tests/unit/swagger/definitionsGeneration/definitions.spec.ts b/tests/unit/swagger/definitionsGeneration/definitions.spec.ts
index 24d1abd3..cf3d844a 100644
--- a/tests/unit/swagger/definitionsGeneration/definitions.spec.ts
+++ b/tests/unit/swagger/definitionsGeneration/definitions.spec.ts
@@ -601,9 +601,9 @@ describe('Definition generation', () => {
                   partial: { $ref: '#/definitions/Partial_Account_' },
                   excludeToEnum: { $ref: '#/definitions/Exclude_EnumUnion.EnumNumberValue_' },
                   excludeToAlias: { $ref: '#/definitions/Exclude_ThreeOrFour.TypeAliasModel3_' },
-                  excludeLiteral: { $ref: '#/definitions/Exclude_keyofTestClassModel.account~OR~defaultValue2_' },
+                  excludeLiteral: { $ref: '#/definitions/Exclude_keyofTestClassModel.account_' },
                   excludeToInterface: { $ref: '#/definitions/Exclude_OneOrTwo.TypeAliasModel1_' },
-                  excludeTypeToPrimitive: { $ref: '#/definitions/NonNullable_number~OR~null_' },
+                  excludeTypeToPrimitive: { $ref: '#/definitions/NonNullable_number%7Cnull_' },
                   pick: { $ref: '#/definitions/Pick_ThingContainerWithTitle_string_.list_' },
                   readonlyClass: { $ref: '#/definitions/Readonly_TestClassModel_' },
                   defaultArgs: { $ref: '#/definitions/DefaultTestModel' },
@@ -689,12 +689,13 @@ describe('Definition generation', () => {
               `for definition linked by ${propertyName}`,
             );
 
-            const excludeLiteral = getValidatedDefinition('Exclude_keyofTestClassModel.account~OR~defaultValue2_', currentSpec);
+            const excludeLiteral = getValidatedDefinition('Exclude_keyofTestClassModel.account_', currentSpec);
             expect(excludeLiteral).to.deep.eq(
               {
                 type: 'string',
                 enum: [
                   'id',
+                  'defaultValue2',
                   'publicStringProperty',
                   'optionalPublicStringProperty',
                   'emailPattern',
@@ -722,7 +723,7 @@ describe('Definition generation', () => {
               `for definition linked by ${propertyName}`,
             );
 
-            const excludeTypeToPrimitive = getValidatedDefinition('NonNullable_number~OR~null_', currentSpec);
+            const excludeTypeToPrimitive = getValidatedDefinition('NonNullable_number%7Cnull_', currentSpec);
             expect(excludeTypeToPrimitive).to.deep.eq(
               {
                 type: 'number',
diff --git a/tests/unit/swagger/schemaDetails3.spec.ts b/tests/unit/swagger/schemaDetails3.spec.ts
index 6ab7ffed..45ea8398 100644
--- a/tests/unit/swagger/schemaDetails3.spec.ts
+++ b/tests/unit/swagger/schemaDetails3.spec.ts
@@ -74,6 +74,14 @@ describe('Definition generation for OpenAPI 3.0.0', () => {
       expect(specDefault.spec).to.not.have.property('basePath');
       expect(specDefault.spec.servers[0].url).to.match(/\/v1/);
     });
+
+    it('should have relative URL when no host is defined', () => {
+      const optionsWithNoHost = Object.assign<{}, SwaggerConfig>({}, defaultOptions);
+      delete optionsWithNoHost.host;
+
+      const spec: Swagger.Spec3 = new SpecGenerator3(metadata, optionsWithNoHost).GetSpec();
+      expect(spec.servers[0].url).to.equal('/v1');
+    });
   });
 
   describe('security', () => {
@@ -750,9 +758,9 @@ describe('Definition generation for OpenAPI 3.0.0', () => {
                   partial: { $ref: '#/components/schemas/Partial_Account_' },
                   excludeToEnum: { $ref: '#/components/schemas/Exclude_EnumUnion.EnumNumberValue_' },
                   excludeToAlias: { $ref: '#/components/schemas/Exclude_ThreeOrFour.TypeAliasModel3_' },
-                  excludeLiteral: { $ref: '#/components/schemas/Exclude_keyofTestClassModel.account~OR~defaultValue2_' },
+                  excludeLiteral: { $ref: '#/components/schemas/Exclude_keyofTestClassModel.account_' },
                   excludeToInterface: { $ref: '#/components/schemas/Exclude_OneOrTwo.TypeAliasModel1_' },
-                  excludeTypeToPrimitive: { $ref: '#/components/schemas/NonNullable_number~OR~null_' },
+                  excludeTypeToPrimitive: { $ref: '#/components/schemas/NonNullable_number%7Cnull_' },
                   pick: { $ref: '#/components/schemas/Pick_ThingContainerWithTitle_string_.list_' },
                   readonlyClass: { $ref: '#/components/schemas/Readonly_TestClassModel_' },
                   defaultArgs: { $ref: '#/components/schemas/DefaultTestModel' },
@@ -838,11 +846,12 @@ describe('Definition generation for OpenAPI 3.0.0', () => {
               `for a schema linked by property ${propertyName}`,
             );
 
-            const excludeLiteral = getComponentSchema('Exclude_keyofTestClassModel.account~OR~defaultValue2_', currentSpec);
+            const excludeLiteral = getComponentSchema('Exclude_keyofTestClassModel.account_', currentSpec);
             expect(excludeLiteral).to.deep.eq(
               {
                 oneOf: [
                   { type: 'string', enum: ['id'] },
+                  { type: 'string', enum: ['defaultValue2'] },
                   { type: 'string', enum: ['publicStringProperty'] },
                   { type: 'string', enum: ['optionalPublicStringProperty'] },
                   { type: 'string', enum: ['emailPattern'] },
@@ -870,7 +879,7 @@ describe('Definition generation for OpenAPI 3.0.0', () => {
               `for a schema linked by property ${propertyName}`,
             );
 
-            const excludeTypeToPrimitive = getComponentSchema('NonNullable_number~OR~null_', currentSpec);
+            const excludeTypeToPrimitive = getComponentSchema('NonNullable_number%7Cnull_', currentSpec);
             expect(excludeTypeToPrimitive).to.deep.eq(
               {
                 type: 'number',