Skip to content

Commit 621264a

Browse files
committed
feat: add exclusiveMinimum and exclusiveMaximum
Add @exclusiveMinimum and @exclusiveMaximum JSDoc annotations for strict inequality bounds (> / <), complementing @minimum/@maximum (>= / <=). Spec output follows each OpenAPI version's JSON Schema draft: - Swagger 2.0 / OAS 3.0: boolean form (minimum: X, exclusiveMinimum: true) - OAS 3.1: numeric form (exclusiveMinimum: X) When both @Minimum and @exclusiveMinimum are present, v2/v3.0 errors; v3.1 emits both. Closes #1842
1 parent f0f9aa7 commit 621264a

18 files changed

Lines changed: 482 additions & 49 deletions

packages/cli/src/swagger/specGenerator2.ts

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,32 @@ export class SpecGenerator2 extends SpecGenerator {
1818
super(metadata, config);
1919
}
2020

21+
// In Swagger 2.0, exclusiveMinimum/exclusiveMaximum are boolean modifiers on minimum/maximum.
22+
private transformValidatorsForSchema(validators: Tsoa.Validators): Record<string, unknown> {
23+
if (validators.exclusiveMinimum !== undefined && validators.minimum !== undefined) {
24+
throw new Error('Cannot use both @minimum and @exclusiveMinimum in Swagger 2.0. Use one or the other, or target OpenAPI 3.1.');
25+
}
26+
if (validators.exclusiveMaximum !== undefined && validators.maximum !== undefined) {
27+
throw new Error('Cannot use both @maximum and @exclusiveMaximum in Swagger 2.0. Use one or the other, or target OpenAPI 3.1.');
28+
}
29+
30+
const result: Record<string, unknown> = {};
31+
Object.keys(validators)
32+
.filter(shouldIncludeValidatorInSchema)
33+
.forEach(key => {
34+
if (key === 'exclusiveMinimum') {
35+
result['minimum'] = validators.exclusiveMinimum!.value;
36+
result['exclusiveMinimum'] = true;
37+
} else if (key === 'exclusiveMaximum') {
38+
result['maximum'] = validators.exclusiveMaximum!.value;
39+
result['exclusiveMaximum'] = true;
40+
} else {
41+
result[key] = validators[key]!.value;
42+
}
43+
});
44+
return result;
45+
}
46+
2147
public GetSpec() {
2248
let spec: Swagger.Spec2 = {
2349
basePath: normalisePath(this.config.basePath as string, '/', undefined, false),
@@ -129,14 +155,7 @@ export class SpecGenerator2 extends SpecGenerator {
129155
} else if (referenceType.dataType === 'refAlias') {
130156
const swaggerType = this.getSwaggerType(referenceType.type);
131157
const format = referenceType.format as Swagger.DataFormat;
132-
const validators = Object.keys(referenceType.validators)
133-
.filter(shouldIncludeValidatorInSchema)
134-
.reduce((acc, key) => {
135-
return {
136-
...acc,
137-
[key]: referenceType.validators[key]!.value,
138-
};
139-
}, {});
158+
const validators = this.transformValidatorsForSchema(referenceType.validators);
140159

141160
definitions[referenceType.refName] = {
142161
...(swaggerType as Swagger.Schema2),
@@ -365,12 +384,7 @@ export class SpecGenerator2 extends SpecGenerator {
365384
return parameter;
366385
}
367386

368-
const validatorObjs: Partial<Record<Tsoa.SchemaValidatorKey, unknown>> = {};
369-
Object.keys(source.validators)
370-
.filter(shouldIncludeValidatorInSchema)
371-
.forEach(key => {
372-
validatorObjs[key] = source.validators[key]!.value;
373-
});
387+
const validatorObjs = this.transformValidatorsForSchema(source.validators);
374388

375389
if (source.in === 'body' && source.type.dataType === 'array') {
376390
parameter.schema = {
@@ -414,11 +428,8 @@ export class SpecGenerator2 extends SpecGenerator {
414428
if (!swaggerType.$ref) {
415429
swaggerType.default = property.default;
416430

417-
Object.keys(property.validators)
418-
.filter(shouldIncludeValidatorInSchema)
419-
.forEach(key => {
420-
swaggerType = { ...swaggerType, [key]: property.validators[key]!.value };
421-
});
431+
const propertyValidators = this.transformValidatorsForSchema(property.validators);
432+
swaggerType = { ...swaggerType, ...propertyValidators };
422433
}
423434
if (property.deprecated) {
424435
swaggerType['x-deprecated'] = true;

packages/cli/src/swagger/specGenerator3.ts

Lines changed: 32 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,33 @@ export class SpecGenerator3 extends SpecGenerator {
2727
super(metadata, config);
2828
}
2929

30+
// In OAS 3.0, exclusiveMinimum/exclusiveMaximum are boolean modifiers on minimum/maximum.
31+
// OAS 3.1 overrides this to pass through numeric values directly.
32+
protected transformValidatorsForSchema(validators: Tsoa.Validators): Record<string, unknown> {
33+
if (validators.exclusiveMinimum !== undefined && validators.minimum !== undefined) {
34+
throw new Error('Cannot use both @minimum and @exclusiveMinimum in OpenAPI 3.0. Use one or the other, or target OpenAPI 3.1.');
35+
}
36+
if (validators.exclusiveMaximum !== undefined && validators.maximum !== undefined) {
37+
throw new Error('Cannot use both @maximum and @exclusiveMaximum in OpenAPI 3.0. Use one or the other, or target OpenAPI 3.1.');
38+
}
39+
40+
const result: Record<string, unknown> = {};
41+
Object.keys(validators)
42+
.filter(shouldIncludeValidatorInSchema)
43+
.forEach(key => {
44+
if (key === 'exclusiveMinimum') {
45+
result['minimum'] = validators.exclusiveMinimum!.value;
46+
result['exclusiveMinimum'] = true;
47+
} else if (key === 'exclusiveMaximum') {
48+
result['maximum'] = validators.exclusiveMaximum!.value;
49+
result['exclusiveMaximum'] = true;
50+
} else {
51+
result[key] = validators[key]!.value;
52+
}
53+
});
54+
return result;
55+
}
56+
3057
public GetSpec(): Swagger.Spec3 {
3158
let spec: Swagger.Spec30 = {
3259
openapi: '3.0.0',
@@ -214,14 +241,7 @@ export class SpecGenerator3 extends SpecGenerator {
214241
} else if (referenceType.dataType === 'refAlias') {
215242
const swaggerType = this.getSwaggerType(referenceType.type);
216243
const format = referenceType.format as Swagger.DataFormat;
217-
const validators = Object.keys(referenceType.validators)
218-
.filter(shouldIncludeValidatorInSchema)
219-
.reduce((acc, key) => {
220-
return {
221-
...acc,
222-
[key]: referenceType.validators[key]!.value,
223-
};
224-
}, {});
244+
const validators = this.transformValidatorsForSchema(referenceType.validators);
225245

226246
schema[referenceType.refName] = {
227247
...(swaggerType as Swagger.Schema3),
@@ -448,14 +468,7 @@ export class SpecGenerator3 extends SpecGenerator {
448468
}
449469

450470
protected buildMediaType(controllerName: string, method: Tsoa.Method, parameter: Tsoa.Parameter): Swagger.MediaType {
451-
const validators = Object.keys(parameter.validators)
452-
.filter(shouldIncludeValidatorInSchema)
453-
.reduce((acc, key) => {
454-
return {
455-
...acc,
456-
[key]: parameter.validators[key]!.value,
457-
};
458-
}, {});
471+
const validators = this.transformValidatorsForSchema(parameter.validators);
459472

460473
const mediaType: Swagger.MediaType = {
461474
schema: {
@@ -516,12 +529,7 @@ export class SpecGenerator3 extends SpecGenerator {
516529
return Object.assign(parameter, this.buildExamples(source));
517530
}
518531

519-
const validatorObjs: { [key in Tsoa.SchemaValidatorKey]?: unknown } = {};
520-
Object.keys(source.validators)
521-
.filter(shouldIncludeValidatorInSchema)
522-
.forEach(key => {
523-
validatorObjs[key] = source.validators[key]!.value;
524-
});
532+
const validatorObjs = this.transformValidatorsForSchema(source.validators);
525533

526534
if (source.type.dataType === 'any') {
527535
parameter.schema.type = 'string';
@@ -578,11 +586,8 @@ export class SpecGenerator3 extends SpecGenerator {
578586
if (!swaggerType.$ref) {
579587
swaggerType.default = property.default;
580588

581-
Object.keys(property.validators)
582-
.filter(shouldIncludeValidatorInSchema)
583-
.forEach(key => {
584-
swaggerType = { ...swaggerType, [key]: property.validators[key]!.value };
585-
});
589+
const propertyValidators = this.transformValidatorsForSchema(property.validators);
590+
swaggerType = { ...swaggerType, ...propertyValidators };
586591
}
587592
if (property.deprecated) {
588593
swaggerType.deprecated = true;

packages/cli/src/swagger/specGenerator31.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { merge as deepMerge } from 'ts-deepmerge';
44

55
import { ExtendedSpecConfig } from '../cli';
66
import { UnspecifiedObject } from '../utils/unspecifiedObject';
7+
import { shouldIncludeValidatorInSchema } from '../utils/validatorUtils';
78
import { SpecGenerator3 } from './specGenerator3';
89

910
/**
@@ -50,6 +51,17 @@ export class SpecGenerator31 extends SpecGenerator3 {
5051
return spec;
5152
}
5253

54+
// In OAS 3.1, exclusiveMinimum/exclusiveMaximum are standalone numbers.
55+
protected override transformValidatorsForSchema(validators: Tsoa.Validators): Record<string, unknown> {
56+
const result: Record<string, unknown> = {};
57+
Object.keys(validators)
58+
.filter(shouldIncludeValidatorInSchema)
59+
.forEach(key => {
60+
result[key] = validators[key]!.value;
61+
});
62+
return result;
63+
}
64+
5365
/**
5466
* Override to add tuple type support (OpenAPI 3.1 feature via prefixItems)
5567
*/

packages/cli/src/utils/validatorUtils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ export function getParameterValidators(parameter: ts.ParameterDeclaration, param
5353
break;
5454
case 'minimum':
5555
case 'maximum':
56+
case 'exclusiveMinimum':
57+
case 'exclusiveMaximum':
5658
case 'minItems':
5759
case 'maxItems':
5860
case 'minLength':
@@ -152,6 +154,8 @@ export function getPropertyValidators(property: ts.Node): Tsoa.Validators | unde
152154
break;
153155
case 'minimum':
154156
case 'maximum':
157+
case 'exclusiveMinimum':
158+
case 'exclusiveMaximum':
155159
case 'minItems':
156160
case 'maxItems':
157161
case 'minLength':
@@ -228,6 +232,8 @@ function getParameterTagSupport() {
228232
'pattern',
229233
'minimum',
230234
'maximum',
235+
'exclusiveMinimum',
236+
'exclusiveMaximum',
231237
'minDate',
232238
'maxDate',
233239
'title',

packages/runtime/src/routeGeneration/templateHelpers.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,24 @@ export class ValidationService {
223223
return;
224224
}
225225
}
226+
if (validators.exclusiveMinimum && validators.exclusiveMinimum.value !== undefined) {
227+
if (validators.exclusiveMinimum.value >= numberValue) {
228+
fieldErrors[parent + name] = {
229+
message: validators.exclusiveMinimum.errorMsg || `exclusiveMin ${validators.exclusiveMinimum.value}`,
230+
value,
231+
};
232+
return;
233+
}
234+
}
235+
if (validators.exclusiveMaximum && validators.exclusiveMaximum.value !== undefined) {
236+
if (validators.exclusiveMaximum.value <= numberValue) {
237+
fieldErrors[parent + name] = {
238+
message: validators.exclusiveMaximum.errorMsg || `exclusiveMax ${validators.exclusiveMaximum.value}`,
239+
value,
240+
};
241+
return;
242+
}
243+
}
226244
return numberValue;
227245
}
228246

@@ -266,6 +284,24 @@ export class ValidationService {
266284
return;
267285
}
268286
}
287+
if (validators.exclusiveMinimum && validators.exclusiveMinimum.value !== undefined) {
288+
if (validators.exclusiveMinimum.value >= numberValue) {
289+
fieldErrors[parent + name] = {
290+
message: validators.exclusiveMinimum.errorMsg || `exclusiveMin ${validators.exclusiveMinimum.value}`,
291+
value,
292+
};
293+
return;
294+
}
295+
}
296+
if (validators.exclusiveMaximum && validators.exclusiveMaximum.value !== undefined) {
297+
if (validators.exclusiveMaximum.value <= numberValue) {
298+
fieldErrors[parent + name] = {
299+
message: validators.exclusiveMaximum.errorMsg || `exclusiveMax ${validators.exclusiveMaximum.value}`,
300+
value,
301+
};
302+
return;
303+
}
304+
}
269305
return numberValue;
270306
}
271307

@@ -940,13 +976,17 @@ export interface IntegerValidator {
940976
isLong?: { errorMsg?: string };
941977
minimum?: { value: number; errorMsg?: string };
942978
maximum?: { value: number; errorMsg?: string };
979+
exclusiveMinimum?: { value: number; errorMsg?: string };
980+
exclusiveMaximum?: { value: number; errorMsg?: string };
943981
}
944982

945983
export interface FloatValidator {
946984
isFloat?: { errorMsg?: string };
947985
isDouble?: { errorMsg?: string };
948986
minimum?: { value: number; errorMsg?: string };
949987
maximum?: { value: number; errorMsg?: string };
988+
exclusiveMinimum?: { value: number; errorMsg?: string };
989+
exclusiveMaximum?: { value: number; errorMsg?: string };
950990
}
951991

952992
export interface DateValidator {

packages/runtime/src/swagger/swagger.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -339,9 +339,7 @@ export namespace Swagger {
339339
default?: string | boolean | number | unknown;
340340
multipleOf?: number;
341341
maximum?: number;
342-
exclusiveMaximum?: number;
343342
minimum?: number;
344-
exclusiveMinimum?: number;
345343
maxLength?: number;
346344
minLength?: number;
347345
pattern?: string;
@@ -368,8 +366,10 @@ export namespace Swagger {
368366
items?: BaseSchema;
369367
}
370368

371-
export interface Schema31 extends Omit<Schema3, 'items' | 'properties' | 'additionalProperties' | 'discriminator' | 'anyOf' | 'allOf'> {
369+
export interface Schema31 extends Omit<Schema3, 'items' | 'properties' | 'additionalProperties' | 'discriminator' | 'anyOf' | 'allOf' | 'exclusiveMinimum' | 'exclusiveMaximum'> {
372370
examples?: unknown[];
371+
exclusiveMinimum?: number;
372+
exclusiveMaximum?: number;
373373

374374
properties?: { [key: string]: Schema31 };
375375
additionalProperties?: boolean | Schema31;
@@ -397,13 +397,17 @@ export namespace Swagger {
397397
allOf?: BaseSchema[];
398398
deprecated?: boolean;
399399
properties?: { [propertyName: string]: Schema3 };
400+
exclusiveMinimum?: boolean;
401+
exclusiveMaximum?: boolean;
400402
}
401403

402404
export interface Schema2 extends BaseSchema {
403405
type?: DataType;
404406
properties?: { [propertyName: string]: Schema2 };
405407
['x-nullable']?: boolean;
406408
['x-deprecated']?: boolean;
409+
exclusiveMinimum?: boolean;
410+
exclusiveMaximum?: boolean;
407411
}
408412

409413
export interface Header {

tests/fixtures/testModel.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -880,6 +880,14 @@ export class ValidateModel {
880880
* @minimum 5
881881
*/
882882
public numberMin5!: number;
883+
/**
884+
* @exclusiveMinimum 5
885+
*/
886+
public numberExclusiveMin5!: number;
887+
/**
888+
* @exclusiveMaximum 10
889+
*/
890+
public numberExclusiveMax10!: number;
883891
/**
884892
* @maxLength 10
885893
*/
@@ -990,6 +998,14 @@ export class ValidateModel {
990998
* @minimum 5
991999
*/
9921000
numberMin5: number;
1001+
/**
1002+
* @exclusiveMinimum 5
1003+
*/
1004+
numberExclusiveMin5: number;
1005+
/**
1006+
* @exclusiveMaximum 10
1007+
*/
1008+
numberExclusiveMax10: number;
9931009
/**
9941010
* @maxLength 10
9951011
*/

tests/fixtures/testModel31.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -872,6 +872,14 @@ export class ValidateModel {
872872
* @minimum 5
873873
*/
874874
public numberMin5!: number;
875+
/**
876+
* @exclusiveMinimum 5
877+
*/
878+
public numberExclusiveMin5!: number;
879+
/**
880+
* @exclusiveMaximum 10
881+
*/
882+
public numberExclusiveMax10!: number;
875883
/**
876884
* @maxLength 10
877885
*/
@@ -982,6 +990,14 @@ export class ValidateModel {
982990
* @minimum 5
983991
*/
984992
numberMin5: number;
993+
/**
994+
* @exclusiveMinimum 5
995+
*/
996+
numberExclusiveMin5: number;
997+
/**
998+
* @exclusiveMaximum 10
999+
*/
1000+
numberExclusiveMax10: number;
9851001
/**
9861002
* @maxLength 10
9871003
*/

0 commit comments

Comments
 (0)