Repository: lukeautry/tsoa
Pull request #91 (2017-04-19 15:51:36)

Swagger specification fields
Hi,

I have added parameters definition, like minLength and maxLength, in generated swagger file from decorators.
I followed https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#definitionsObject

Thank you

rmendonca: Hi,

Did you have time to look at my pull request? Are you interested in incorporating the changes I have proposed or you are planning to release these features soon?

Thank you

lukeautry: Thanks for the reminder @rmendonca. This looks great to me; I'll merge it in for a future release.

Patch:
diff --git a/src/decorators/validations.ts b/src/decorators/validations.ts
index 0ebacf31..0249acef 100644
--- a/src/decorators/validations.ts
+++ b/src/decorators/validations.ts
@@ -11,7 +11,7 @@ export interface NumberOption {
 export interface StringOption {
   minLength?: number;
   maxLength?: number;
-  pattern?: number;
+  pattern?: string;
 }
 
 export interface ArrayOption {
diff --git a/src/metadataGeneration/metadataGenerator.ts b/src/metadataGeneration/metadataGenerator.ts
index bec3609c..e4c2bf6a 100644
--- a/src/metadataGeneration/metadataGenerator.ts
+++ b/src/metadataGeneration/metadataGenerator.ts
@@ -92,6 +92,16 @@ export interface Parameter {
   name: string;
   required: boolean;
   type: Type;
+  minLength?: number;
+  maxLength?: number;
+  pattern?: string;
+  minimum?: number;
+  maximum?: number;
+  minItems?: number;
+  maxItems?: number;
+  uniqueItems?: boolean;
+  minDate?: Date;
+  maxDate?: Date;
 }
 
 export interface Security {
@@ -129,4 +139,14 @@ export interface Property {
   name: string;
   type: Type;
   required: boolean;
+  minLength?: number;
+  maxLength?: number;
+  pattern?: string;
+  minimum?: number;
+  maximum?: number;
+  minItems?: number;
+  maxItems?: number;
+  uniqueItems?: boolean;
+  minDate?: Date;
+  maxDate?: Date;
 }
diff --git a/src/metadataGeneration/methodGenerator.ts b/src/metadataGeneration/methodGenerator.ts
index 09b78cc3..2d695556 100644
--- a/src/metadataGeneration/methodGenerator.ts
+++ b/src/metadataGeneration/methodGenerator.ts
@@ -3,7 +3,7 @@ import { Method, ResponseType, Type } from './metadataGenerator';
 import { ResolveType } from './resolveType';
 import { ParameterGenerator } from './parameterGenerator';
 import { getJSDocDescription, getJSDocTag, isExistJSDocTag } from './../utils/jsDocUtils';
-import { getDecorators } from './../utils/decoratorUtils';
+import { getDecorators, getInitializerValue } from './../utils/decoratorUtils';
 
 export class MethodGenerator {
   private method: string;
@@ -179,7 +179,7 @@ export class MethodGenerator {
   private getExamplesValue(argument: any) {
     const example: any = {};
     argument.properties.forEach((p: any) => {
-      example[p.name.text] = this.getInitializerValue(p.initializer);
+      example[p.name.text] = getInitializerValue(p.initializer);
     });
     return example;
   }
@@ -212,30 +212,4 @@ export class MethodGenerator {
       scopes: expression.arguments[1] ? (expression.arguments[1] as any).elements.map((e: any) => e.text) : undefined
     };
   }
-
-  private getInitializerValue(initializer: any) {
-    switch (initializer.kind as ts.SyntaxKind) {
-      case ts.SyntaxKind.ArrayLiteralExpression:
-        return initializer.elements.map((e: any) => this.getInitializerValue(e));
-      case ts.SyntaxKind.StringLiteral:
-        return initializer.text;
-      case ts.SyntaxKind.TrueKeyword:
-        return true;
-      case ts.SyntaxKind.FalseKeyword:
-        return false;
-      case ts.SyntaxKind.NumberKeyword:
-      case ts.SyntaxKind.FirstLiteralToken:
-        return parseInt(initializer.text, 10);
-      case ts.SyntaxKind.ObjectLiteralExpression:
-        const nestedObject: any = {};
-
-        initializer.properties.forEach((p: any) => {
-          nestedObject[p.name.text] = this.getInitializerValue(p.initializer);
-        });
-
-        return nestedObject;
-      default:
-        return undefined;
-    }
-  }
 }
diff --git a/src/metadataGeneration/parameterGenerator.ts b/src/metadataGeneration/parameterGenerator.ts
index 47e0df03..2ab48a7e 100644
--- a/src/metadataGeneration/parameterGenerator.ts
+++ b/src/metadataGeneration/parameterGenerator.ts
@@ -1,6 +1,6 @@
 import { MetadataGenerator, Parameter, Type } from './metadataGenerator';
 import { ResolveType } from './resolveType';
-import { getDecoratorName, getDecoratorTextValue } from './../utils/decoratorUtils';
+import { getDecoratorName, getDecoratorTextValue, getDecoratorOptionValue } from './../utils/decoratorUtils';
 import * as ts from 'typescript';
 
 export class ParameterGenerator {
@@ -39,14 +39,14 @@ export class ParameterGenerator {
 
   private getRequestParameter(parameter: ts.ParameterDeclaration): Parameter {
     const parameterName = (parameter.name as ts.Identifier).text;
-    return {
+    return this.getDetailParameter({
       description: this.getParameterDescription(parameter),
       in: 'request',
       name: parameterName,
       required: !parameter.questionToken,
       type: { typeName: 'object' },
       parameterName
-    };
+    });
   }
 
   private getBodyPropParameter(parameter: ts.ParameterDeclaration): Parameter {
@@ -57,14 +57,14 @@ export class ParameterGenerator {
       throw new Error(`Body can't support '${this.getCurrentLocation()}' method.`);
     }
 
-    return {
+    return this.getDetailParameter({
       description: this.getParameterDescription(parameter),
       in: 'body-prop',
       name: getDecoratorTextValue(this.parameter, ident => ident.text === 'BodyProp') || parameterName,
       required: !parameter.questionToken,
       type: type,
       parameterName
-    };
+    });
   }
 
   private getBodyParameter(parameter: ts.ParameterDeclaration): Parameter {
@@ -75,14 +75,14 @@ export class ParameterGenerator {
       throw new Error(`Body can't support ${this.method} method`);
     }
 
-    return {
+    return this.getDetailParameter({
       description: this.getParameterDescription(parameter),
       in: 'body',
       name: parameterName,
       required: !parameter.questionToken,
       type,
       parameterName
-    };
+    });
   }
 
   private getHeaderParameter(parameter: ts.ParameterDeclaration): Parameter {
@@ -93,14 +93,14 @@ export class ParameterGenerator {
       throw new InvalidParameterException(`Parameter '${parameterName}' can't be passed as a header parameter in '${this.getCurrentLocation()}'.`);
     }
 
-    return {
+    return this.getDetailParameter({
       description: this.getParameterDescription(parameter),
       in: 'header',
       name: getDecoratorTextValue(this.parameter, ident => ident.text === 'Header') || parameterName,
       required: !parameter.questionToken,
       type,
-      parameterName
-    };
+      parameterName,
+    });
   }
 
   private getQueryParameter(parameter: ts.ParameterDeclaration): Parameter {
@@ -111,14 +111,14 @@ export class ParameterGenerator {
       throw new InvalidParameterException(`Parameter '${parameterName}' can't be passed as a query parameter in '${this.getCurrentLocation()}'.`);
     }
 
-    return {
+    return this.getDetailParameter({
       description: this.getParameterDescription(parameter),
       in: 'query',
       name: getDecoratorTextValue(this.parameter, ident => ident.text === 'Query') || parameterName,
       required: !parameter.questionToken,
       type,
       parameterName
-    };
+    });
   }
 
   private getPathParameter(parameter: ts.ParameterDeclaration): Parameter {
@@ -133,13 +133,34 @@ export class ParameterGenerator {
       throw new Error(`Parameter '${parameterName}' can't macth in path: '${this.path}'`);
     }
 
-    return {
+    return this.getDetailParameter({
       description: this.getParameterDescription(parameter),
       in: 'path',
       name: pathName,
       required: true,
       type,
       parameterName
+    });
+  }
+
+  private getDetailParameter(parameter: Parameter): Parameter {
+    const options = getDecoratorOptionValue(this.parameter, identifier => {
+      return ['IsString', 'IsInt', 'IsLong', 'IsDouble', 'IsFloat', 'IsDate', 'IsDateTime', 'IsArray'].some(m => m === identifier.text);
+    });
+
+    return <Parameter> {
+      ...parameter,
+      maxLength: options && options.maxLength ? options.maxLength : undefined,
+      minLength: options && options.minLength ? options.minLength : undefined,
+      pattern: options && options.pattern ? options.pattern : undefined,
+      // tslint:disable-next-line:object-literal-sort-keys
+      maximum: options && options.max ? options.max : undefined,
+      minimum: options && options.min ? options.min : undefined,
+      maxDate: options && options.maxDate ? options.maxDate : undefined,
+      minDate: options && options.minDate ? options.minDate : undefined,
+      maxItems: options && options.maxItems ? options.maxItems : undefined,
+      minItems: options && options.minItems ? options.minItems : undefined,
+      uniqueItens: options && options.uniqueItens ? options.uniqueItens : undefined,
     };
   }
 
diff --git a/src/metadataGeneration/resolveType.ts b/src/metadataGeneration/resolveType.ts
index 11dce8c1..9cab65bd 100644
--- a/src/metadataGeneration/resolveType.ts
+++ b/src/metadataGeneration/resolveType.ts
@@ -1,6 +1,6 @@
 import * as ts from 'typescript';
 import { MetadataGenerator, Type, EnumerateType, ReferenceType, ArrayType, Property } from './metadataGenerator';
-import { getDecoratorName } from './../utils/decoratorUtils';
+import { getDecoratorName, getDecoratorOptionValue } from './../utils/decoratorUtils';
 import * as _ from 'lodash';
 
 const syntaxKindMap: { [kind: number]: string } = {};
@@ -419,11 +419,24 @@ function getModelTypeProperties(node: UsableDeclaration, genericTypes?: ts.TypeN
 
       if (!declaration.type) { throw new Error('No valid type found for property declaration.'); }
 
+      const options = getDecoratorOptionValue(declaration, identify => {
+        return ['IsString', 'IsInt', 'IsLong', 'IsDouble', 'IsFloat', 'IsDate', 'IsDateTime', 'IsArray'].some(m => m === identify.text);
+      });
+
       return {
         description: getNodeDescription(declaration),
         name: identifier.text,
         required: !declaration.questionToken,
-        type: ResolveType(declaration.type)
+        type: ResolveType(declaration.type),
+        // tslint:disable-next-line:object-literal-sort-keys
+        maxLength: options && options.maxLength ? options.maxLength : undefined,
+        minLength: options && options.minLength ? options.minLength : undefined,
+        pattern: options && options.pattern ? options.pattern : undefined,
+        maximum: options && options.max ? options.max : undefined,
+        minimum: options && options.min ? options.min : undefined,
+        maxItems: options && options.maxItems ? options.maxItems : undefined,
+        minItems: options && options.minItems ? options.minItems : undefined,
+        uniqueItens: options && options.uniqueItens ? options.uniqueItens : undefined,
       };
     });
 }
diff --git a/src/swagger/specGenerator.ts b/src/swagger/specGenerator.ts
index 175a9307..8692c657 100644
--- a/src/swagger/specGenerator.ts
+++ b/src/swagger/specGenerator.ts
@@ -59,10 +59,11 @@ export class SpecGenerator {
     const definitions: { [definitionsName: string]: Swagger.Schema } = {};
     Object.keys(this.metadata.ReferenceTypes).map(typeName => {
       const referenceType = this.metadata.ReferenceTypes[typeName];
+      const required = referenceType.properties.filter(p => p.required).map(p => p.name);
       definitions[referenceType.typeName] = {
         description: referenceType.description,
         properties: this.buildProperties(referenceType.properties),
-        required: referenceType.properties.filter(p => p.required).map(p => p.name),
+        required: required && required.length > 0 ? required : undefined,
         type: 'object'
       };
       if (referenceType.additionalProperties) {
@@ -162,6 +163,15 @@ export class SpecGenerator {
 
     if (parameterType.format) { swaggerParameter.format = parameterType.format; }
 
+    swaggerParameter.minimum = parameter.minimum;
+    swaggerParameter.maximum = parameter.maximum;
+    swaggerParameter.minLength = parameter.minLength;
+    swaggerParameter.maxLength = parameter.maxLength;
+    swaggerParameter.pattern = parameter.pattern;
+    swaggerParameter.minItems = parameter.minItems;
+    swaggerParameter.maxItems = parameter.maxItems;
+    swaggerParameter.uniqueItems = parameter.uniqueItems;
+
     return swaggerParameter;
   }
 
@@ -172,6 +182,14 @@ export class SpecGenerator {
       const swaggerType = this.getSwaggerType(property.type);
       if (!swaggerType.$ref) {
         swaggerType.description = property.description;
+        swaggerType.minimum = property.minimum;
+        swaggerType.maximum = property.maximum;
+        swaggerType.minLength = property.minLength;
+        swaggerType.maxLength = property.maxLength;
+        swaggerType.pattern = property.pattern;
+        swaggerType.minItems = property.minItems;
+        swaggerType.maxItems = property.maxItems;
+        swaggerType.uniqueItems = property.uniqueItems;
       }
       swaggerProperties[property.name] = swaggerType;
     });
diff --git a/src/swagger/swagger.ts b/src/swagger/swagger.ts
index f8810699..d3399494 100644
--- a/src/swagger/swagger.ts
+++ b/src/swagger/swagger.ts
@@ -41,6 +41,16 @@ export namespace Swagger {
     in: string;
     required?: boolean;
     description?: string;
+    maximum?: number;
+    exclusiveMaximum?: number;
+    minimum?: number;
+    exclusiveMinimum?: number;
+    maxLength?: number;
+    minLength?: number;
+    pattern?: string;
+    maxItems?: number;
+    minItems?: number;
+    uniqueItems?: boolean;
   }
 
   export interface BodyParameter extends BaseParameter {
diff --git a/src/utils/decoratorUtils.ts b/src/utils/decoratorUtils.ts
index 252d04c0..dad953f9 100644
--- a/src/utils/decoratorUtils.ts
+++ b/src/utils/decoratorUtils.ts
@@ -27,6 +27,16 @@ export function getDecoratorTextValue(node: ts.Node, isMatching: (identifier: ts
   return (expArguments[0] as ts.StringLiteral).text;
 }
 
+export function getDecoratorOptionValue(node: ts.Node, isMatching: (identifier: ts.Identifier) => boolean) {
+  const decorators = getDecorators(node, isMatching);
+  if (!decorators || !decorators.length) { return; }
+
+  const expression = decorators[0].parent as ts.CallExpression;
+  const expArguments = expression.arguments;
+  if (!expArguments || !expArguments.length) { return; }
+  return getInitializerValue(expArguments[0] as any);
+}
+
 export function isDecorator(node: ts.Node, isMatching: (identifier: ts.Identifier) => boolean) {
   const decorators = getDecorators(node, isMatching);
   if (!decorators || !decorators.length) {
@@ -34,3 +44,29 @@ export function isDecorator(node: ts.Node, isMatching: (identifier: ts.Identifie
   }
   return true;
 }
+
+export function getInitializerValue(initializer: any) {
+  switch (initializer.kind as ts.SyntaxKind) {
+    case ts.SyntaxKind.ArrayLiteralExpression:
+      return initializer.elements.map((e: any) => getInitializerValue(e));
+    case ts.SyntaxKind.StringLiteral:
+      return initializer.text;
+    case ts.SyntaxKind.TrueKeyword:
+      return true;
+    case ts.SyntaxKind.FalseKeyword:
+      return false;
+    case ts.SyntaxKind.NumberKeyword:
+    case ts.SyntaxKind.FirstLiteralToken:
+      return parseInt(initializer.text, 10);
+    case ts.SyntaxKind.ObjectLiteralExpression:
+      const nestedObject: any = {};
+
+      initializer.properties.forEach((p: any) => {
+        nestedObject[p.name.text] = getInitializerValue(p.initializer);
+      });
+
+      return nestedObject;
+    default:
+      return undefined;
+  }
+}


Test patch:
diff --git a/tests/fixtures/controllers/getController.ts b/tests/fixtures/controllers/getController.ts
index 4beeb29e..365b0377 100644
--- a/tests/fixtures/controllers/getController.ts
+++ b/tests/fixtures/controllers/getController.ts
@@ -6,6 +6,7 @@ import { ModelService } from '../services/modelService';
 import { Route } from '../../../src/decorators/route';
 import { GenericModel, TestModel, TestSubModel, TestClassModel } from '../testModel';
 import { Tags } from '../../../src/decorators/tags';
+import { IsString, IsDouble } from '../../../src/decorators/validations';
 
 @Route('GetTest')
 export class GetTestController extends Controller {
@@ -60,11 +61,11 @@ export class GetTestController extends Controller {
   */
   @Get('{numberPathParam}/{booleanPathParam}/{stringPathParam}')
   public async getModelByParams(
-    numberPathParam: number,
-    stringPathParam: string,
+    @IsDouble({min: 2, max: 10}) numberPathParam: number,
+    @IsString({minLength: 1, maxLength: 5}) stringPathParam: string,
     booleanPathParam: boolean,
     @Query() booleanParam: boolean,
-    @Query() stringParam: string,
+    @IsString({minLength: 3, maxLength: 7}) @Query() stringParam: string,
     @Query() numberParam: number,
     @Query() optionalStringParam?: string): Promise<TestModel> {
     const model = new ModelService().getModel();
diff --git a/tests/fixtures/testModel.ts b/tests/fixtures/testModel.ts
index d7c1a7a0..5d10553c 100644
--- a/tests/fixtures/testModel.ts
+++ b/tests/fixtures/testModel.ts
@@ -1,4 +1,4 @@
-import { IsInt, IsFloat } from './../../src/decorators/validations';
+import { IsInt, IsFloat, IsString } from './../../src/decorators/validations';
 /**
  * This is a description of a model
  */
@@ -87,7 +87,7 @@ export interface UserResponseModel {
 export class ParameterTestModel {
   public firstname: string;
   public lastname: string;
-  @IsInt() public age: number;
+  @IsInt({min: 1, max: 10}) public age: number;
   @IsFloat() public weight: number;
   public human: boolean;
   public gender: Gender;
@@ -117,7 +117,9 @@ export class TestClassModel extends TestClassBaseModel {
   /**
   * This is a description of a public string property
   */
+  @IsString({minLength: 3, maxLength: 20, pattern: '[a-zA-Z]'})
   public publicStringProperty: string;
+  @IsString({minLength: 0, maxLength: 10})
   public optionalPublicStringProperty?: string;
   /* tslint:disable-next-line */
   stringProperty: string;
diff --git a/tests/unit/swagger/definitionsGeneration/definitions.spec.ts b/tests/unit/swagger/definitionsGeneration/definitions.spec.ts
index 21f54492..9dc7d811 100644
--- a/tests/unit/swagger/definitionsGeneration/definitions.spec.ts
+++ b/tests/unit/swagger/definitionsGeneration/definitions.spec.ts
@@ -167,6 +167,36 @@ describe('Definition generation', () => {
       expect(property).to.exist;
       expect(property.description).to.equal('This is a description for publicConstructorVar');
     });
+
+    it('should generate a property minLength', () => {
+      const propertyName = 'publicStringProperty';
+
+      const property = properties[propertyName];
+      if (!property) { throw new Error(`There was no '${propertyName}' property.`); }
+
+      expect(property).to.exist;
+      expect(property.minLength).to.equal(3);
+    });
+
+    it('should generate a property maxLength', () => {
+      const propertyName = 'publicStringProperty';
+
+      const property = properties[propertyName];
+      if (!property) { throw new Error(`There was no '${propertyName}' property.`); }
+
+      expect(property).to.exist;
+      expect(property.maxLength).to.equal(20);
+    });
+
+    it('should generate a property pattern', () => {
+      const propertyName = 'publicStringProperty';
+
+      const property = properties[propertyName];
+      if (!property) { throw new Error(`There was no '${propertyName}' property.`); }
+
+      expect(property).to.exist;
+      expect(property.pattern).to.equal('[a-zA-Z]');
+    });
   });
 
   describe('Generic-based generation', () => {
diff --git a/tests/unit/swagger/pathGeneration/getRoutes.spec.ts b/tests/unit/swagger/pathGeneration/getRoutes.spec.ts
index ec0cd841..cb5a50c4 100644
--- a/tests/unit/swagger/pathGeneration/getRoutes.spec.ts
+++ b/tests/unit/swagger/pathGeneration/getRoutes.spec.ts
@@ -3,7 +3,7 @@ import { getDefaultOptions } from '../../../fixtures/defaultOptions';
 import { MetadataGenerator } from '../../../../src/metadataGeneration/metadataGenerator';
 import { SpecGenerator } from '../../../../src/swagger/specGenerator';
 import { VerifyPath } from '../../utilities/verifyPath';
-import { VerifyPathableParameter } from '../../utilities/verifyParameter';
+import { VerifyPathableParameter, VerifyPathableNumberParameter, VerifyPathableStringParameter } from '../../utilities/verifyParameter';
 import * as chai from 'chai';
 
 const expect = chai.expect;
@@ -87,6 +87,24 @@ describe('GET route generation', () => {
     VerifyPathableParameter(parameters, 'stringParam', 'string', 'query');
   });
 
+  it('should generate a parameter for path parameters with decorator', () => {
+    const actionRoute = `${baseRoute}/{numberPathParam}/{booleanPathParam}/{stringPathParam}`;
+    const parameters = getValidatedParameters(actionRoute);
+
+    VerifyPathableParameter(parameters, 'booleanPathParam', 'boolean', 'path');
+    VerifyPathableNumberParameter(parameters, 'numberPathParam', 'number', 'path', 'double', 2, 10);
+    VerifyPathableStringParameter(parameters, 'stringPathParam', 'string', 'path', 1, 5);
+  });
+
+  it('should generate a parameter for query parameters with decorator', () => {
+    const actionRoute = `${baseRoute}/{numberPathParam}/{booleanPathParam}/{stringPathParam}`;
+    const parameters = getValidatedParameters(actionRoute);
+
+    VerifyPathableParameter(parameters, 'booleanParam', 'boolean', 'query');
+    VerifyPathableParameter(parameters, 'numberParam', 'number', 'query', 'double');
+    VerifyPathableStringParameter(parameters, 'stringParam', 'string', 'query', 3, 7);
+  });
+
   it('should set a valid response type for collection responses', () => {
     const actionRoute = `${baseRoute}/Multi`;
     verifyPath(actionRoute, true);
diff --git a/tests/unit/utilities/verifyParameter.ts b/tests/unit/utilities/verifyParameter.ts
index 08b0e2b3..52ab7b48 100644
--- a/tests/unit/utilities/verifyParameter.ts
+++ b/tests/unit/utilities/verifyParameter.ts
@@ -11,6 +11,34 @@ export function VerifyPathableParameter(params: Swagger.PathParameter[], paramVa
   }
 }
 
+export function VerifyPathableStringParameter(params: Swagger.PathParameter[], paramValue: string, paramType: string, paramIn: string, min?: number, max?: number, pattern?: string) {
+  const parameter = verifyParameter(params, paramValue, paramIn);
+  expect(parameter.type).to.equal(paramType);
+  if (min) {
+    expect(parameter.minLength).to.equal(min);
+  }
+  if (max) {
+    expect(parameter.maxLength).to.equal(max);
+  }
+  if (pattern) {
+    expect(parameter.pattern).to.equal(pattern);
+  }
+}
+
+export function VerifyPathableNumberParameter(params: Swagger.PathParameter[], paramValue: string, paramType: string, paramIn: string, formatType?: string, min?: number, max?: number) {
+  const parameter = verifyParameter(params, paramValue, paramIn);
+  expect(parameter.type).to.equal(paramType);
+  if (formatType) {
+    expect(parameter.format).to.equal(formatType);
+  }
+  if (min) {
+    expect(parameter.minimum).to.equal(min);
+  }
+  if (max) {
+    expect(parameter.maximum).to.equal(max);
+  }
+}
+
 export function VerifyBodyParameter(params: Swagger.PathParameter[], paramValue: string, paramType: string, paramIn: string) {
   const parameter = verifyParameter(params, paramValue, paramIn) as any;
   expect(parameter.schema.$ref).to.equal(paramType);