Skip to content

Commit 2d4e2e2

Browse files
committed
feat(OpenApi 3.1.0): Tuple Support
1 parent 790d717 commit 2d4e2e2

File tree

14 files changed

+6418
-41
lines changed

14 files changed

+6418
-41
lines changed

packages/cli/src/cli.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { generateRoutes } from './module/generate-routes';
88
import { generateSpec } from './module/generate-spec';
99
import { fsExists, fsReadFile } from './utils/fs';
1010
import { AbstractRouteGenerator } from './routeGeneration/routeGenerator';
11-
import { extname,isAbsolute } from 'node:path';
11+
import { extname, isAbsolute } from 'node:path';
1212
import type { CompilerOptions } from 'typescript';
1313

1414
const workingDir: string = process.cwd();
@@ -54,7 +54,7 @@ const isJsExtension = (extension: string): boolean => extension === '.js' || ext
5454
const getConfig = async (configPath = 'tsoa.json'): Promise<Config> => {
5555
let config: Config;
5656
const ext = extname(configPath);
57-
const configFullPath = isAbsolute(configPath) ? configPath : `${workingDir}/${configPath}`
57+
const configFullPath = isAbsolute(configPath) ? configPath : `${workingDir}/${configPath}`;
5858
try {
5959
if (isYamlExtension(ext)) {
6060
const configRaw = await fsReadFile(configFullPath);
@@ -114,8 +114,9 @@ export const validateSpecConfig = async (config: Config): Promise<ExtendedSpecCo
114114
config.spec.version = config.spec.version || (await versionDefault());
115115

116116
config.spec.specVersion = config.spec.specVersion || 2;
117-
if (config.spec.specVersion !== 2 && config.spec.specVersion !== 3) {
118-
throw new Error('Unsupported Spec version.');
117+
const supportedVersions = [2, 3, 3.1];
118+
if (!supportedVersions.includes(config.spec.specVersion)) {
119+
throw new Error(`Unsupported Spec version: ${config.spec.specVersion}.`);
119120
}
120121

121122
if (config.spec.spec && !['immediate', 'recursive', 'deepmerge', undefined].includes(config.spec.specMerging)) {

packages/cli/src/metadataGeneration/typeResolver.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ export class TypeResolver {
6262
return arrayMetaType;
6363
}
6464

65+
if (ts.isRestTypeNode(this.typeNode)) {
66+
return new TypeResolver(this.typeNode.type, this.current, this.parentNode, this.context).resolve();
67+
}
68+
6569
if (ts.isUnionTypeNode(this.typeNode)) {
6670
const types = this.typeNode.types.map(type => {
6771
return new TypeResolver(type, this.current, this.parentNode, this.context).resolve();
@@ -87,6 +91,33 @@ export class TypeResolver {
8791
return intersectionMetaType;
8892
}
8993

94+
if (ts.isTupleTypeNode(this.typeNode)) {
95+
const elementTypes: Tsoa.Type[] = [];
96+
let restType: Tsoa.Type | undefined;
97+
98+
for (const element of this.typeNode.elements) {
99+
if (ts.isRestTypeNode(element)) {
100+
const resolvedRest = new TypeResolver(element.type, this.current, element, this.context).resolve();
101+
102+
if (resolvedRest.dataType === 'array') {
103+
restType = resolvedRest.elementType;
104+
} else {
105+
restType = resolvedRest;
106+
}
107+
} else {
108+
const typeNode = ts.isNamedTupleMember(element) ? element.type : element;
109+
const type = new TypeResolver(typeNode, this.current, element, this.context).resolve();
110+
elementTypes.push(type);
111+
}
112+
}
113+
114+
return {
115+
dataType: 'tuple',
116+
types: elementTypes,
117+
...(restType ? { restType } : {}),
118+
};
119+
}
120+
90121
if (this.typeNode.kind === ts.SyntaxKind.AnyKeyword || this.typeNode.kind === ts.SyntaxKind.UnknownKeyword) {
91122
const literallyAny: Tsoa.AnyType = {
92123
dataType: 'any',

packages/cli/src/module/generate-spec.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { MetadataGenerator } from '../metadataGeneration/metadataGenerator';
55
import { Tsoa, Swagger, Config } from '@tsoa/runtime';
66
import { SpecGenerator2 } from '../swagger/specGenerator2';
77
import { SpecGenerator3 } from '../swagger/specGenerator3';
8+
import { SpecGenerator31 } from '../swagger/specGenerator31';
89
import { fsMkDir, fsWriteFile } from '../utils/fs';
910

1011
export const getSwaggerOutputPath = (swaggerConfig: ExtendedSpecConfig) => {
@@ -29,8 +30,14 @@ export const generateSpec = async (
2930
}
3031

3132
let spec: Swagger.Spec;
32-
if (swaggerConfig.specVersion && swaggerConfig.specVersion === 3) {
33-
spec = new SpecGenerator3(metadata, swaggerConfig).GetSpec();
33+
if (swaggerConfig.specVersion) {
34+
if (swaggerConfig.specVersion === 3) {
35+
spec = new SpecGenerator3(metadata, swaggerConfig).GetSpec();
36+
} else if (swaggerConfig.specVersion === 3.1) {
37+
spec = new SpecGenerator31(metadata, swaggerConfig).GetSpec();
38+
} else {
39+
spec = new SpecGenerator2(metadata, swaggerConfig).GetSpec();
40+
}
3441
} else {
3542
spec = new SpecGenerator2(metadata, swaggerConfig).GetSpec();
3643
}

packages/cli/src/swagger/specGenerator.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ export abstract class SpecGenerator {
9494
return this.getSwaggerTypeForIntersectionType(type, title);
9595
} else if (type.dataType === 'nestedObjectLiteral') {
9696
return this.getSwaggerTypeForObjectLiteral(type, title);
97+
} else if (type.dataType === 'tuple') {
98+
throw new Error('Tuple types are only supported in OpenAPI 3.1+');
9799
} else {
98100
return assertNever(type);
99101
}

packages/cli/src/swagger/specGenerator31.ts

Lines changed: 15 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -500,14 +500,9 @@ export class SpecGenerator31 extends SpecGenerator {
500500
if (parameterType.format) {
501501
schema.format = this.throwIfNotDataFormat(parameterType.format);
502502
}
503-
if (parameterType.type) {
504-
const isValid = Array.isArray(parameterType.type) ? parameterType.type.every(el => this.isDataType(el)) : this.isDataType(parameterType.type);
505-
506-
if (!isValid) {
507-
throw new Error(`Unhandled discriminated union member: ${JSON.stringify(parameterType.type)}`);
508-
}
509503

510-
schema.type = parameterType.type;
504+
if (parameterType.type) {
505+
schema.type = this.throwIfNotDataType(parameterType.type);
511506
}
512507

513508
// Handle ref case
@@ -715,24 +710,6 @@ export class SpecGenerator31 extends SpecGenerator {
715710
}
716711
}
717712

718-
public throwIfNotDataType(strToTest: string): Swagger.DataType {
719-
const guiltyUntilInnocent = strToTest as Swagger.DataType;
720-
if (
721-
guiltyUntilInnocent === 'array' ||
722-
guiltyUntilInnocent === 'boolean' ||
723-
guiltyUntilInnocent === 'integer' ||
724-
guiltyUntilInnocent === 'file' ||
725-
guiltyUntilInnocent === 'number' ||
726-
guiltyUntilInnocent === 'object' ||
727-
guiltyUntilInnocent === 'string' ||
728-
guiltyUntilInnocent === 'undefined'
729-
) {
730-
return guiltyUntilInnocent;
731-
} else {
732-
return assertNever(guiltyUntilInnocent);
733-
}
734-
}
735-
736713
protected buildExamples(source: Pick<Tsoa.Parameter, 'example' | 'exampleLabels'>): {
737714
example?: unknown;
738715
examples?: { [name: string]: Swagger.Example3 };
@@ -763,11 +740,20 @@ export class SpecGenerator31 extends SpecGenerator {
763740
return { examples };
764741
}
765742

766-
private isDataType(input: unknown): input is Swagger.DataType {
767-
if (typeof input !== 'string') {
768-
return false;
743+
protected getSwaggerType(type: Tsoa.Type, title?: string): Swagger.BaseSchema {
744+
if (type.dataType === 'tuple') {
745+
const tupleType = type as Tsoa.TupleType;
746+
747+
const schema: Swagger.Schema31 = {
748+
type: 'array',
749+
prefixItems: tupleType.types.map(t => this.getSwaggerType(t) as Swagger.Schema31),
750+
...(tupleType.restType && { items: this.getSwaggerType(tupleType.restType) as Swagger.Schema31 }),
751+
};
752+
753+
// then cast it to satisfy the base method's return signature
754+
return schema as unknown as Swagger.BaseSchema;
769755
}
770756

771-
return input === 'array' || input === 'boolean' || input === 'integer' || input === 'file' || input === 'number' || input === 'object' || input === 'string' || input === 'undefined';
757+
return super.getSwaggerType(type, title);
772758
}
773759
}

packages/cli/src/utils/internalTypeGuards.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ export function isRefType(metaType: Tsoa.Type): metaType is Tsoa.ReferenceType {
4848
return true;
4949
case 'string':
5050
return false;
51+
case 'tuple':
52+
return false;
5153
case 'union':
5254
return false;
5355
case 'void':

packages/runtime/src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export interface SpecConfig {
103103
* Possible values:
104104
* - 2: generates OpenAPI version 2.
105105
* - 3: generates OpenAPI version 3.
106+
* - 3.1: generates OpenAPI version 3.1.
106107
*/
107108
specVersion?: Swagger.SupportedSpecMajorVersion;
108109

packages/runtime/src/metadataGeneration/tsoa.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,11 +125,12 @@ export namespace Tsoa {
125125
| 'nestedObjectLiteral'
126126
| 'union'
127127
| 'intersection'
128-
| 'undefined';
128+
| 'undefined'
129+
| 'tuple';
129130

130131
export type RefTypeLiteral = 'refObject' | 'refEnum' | 'refAlias';
131132

132-
export type PrimitiveTypeLiteral = Exclude<TypeStringLiteral, RefTypeLiteral | 'enum' | 'array' | 'void' | 'undefined' | 'nestedObjectLiteral' | 'union' | 'intersection'>;
133+
export type PrimitiveTypeLiteral = Exclude<TypeStringLiteral, RefTypeLiteral | 'enum' | 'array' | 'void' | 'undefined' | 'nestedObjectLiteral' | 'union' | 'intersection' | 'tuple'>;
133134

134135
export interface TypeBase {
135136
dataType: TypeStringLiteral;
@@ -157,7 +158,8 @@ export namespace Tsoa {
157158
| RefAliasType
158159
| NestedObjectLiteralType
159160
| UnionType
160-
| IntersectionType;
161+
| IntersectionType
162+
| TupleType;
161163

162164
export interface StringType extends TypeBase {
163165
dataType: 'string';
@@ -282,6 +284,12 @@ export namespace Tsoa {
282284
types: Type[];
283285
}
284286

287+
export interface TupleType extends TypeBase {
288+
dataType: 'tuple';
289+
types: Type[];
290+
restType?: Type;
291+
}
292+
285293
export interface ReferenceTypeMap {
286294
[refName: string]: Tsoa.ReferenceType;
287295
}

packages/runtime/src/swagger/swagger.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ export namespace Swagger {
339339
}
340340

341341
export interface Schema31 extends Omit<BaseSchema, 'type' | 'items' | 'properties' | 'additionalProperties' | 'discriminator'> {
342-
type?: DataType | DataType[];
342+
type?: DataType; // could support an array, but we already do anyOf for that
343343
nullable?: boolean;
344344
deprecated?: boolean;
345345
example?: unknown;
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { Body, Deprecated, File, FormField, Patch, Post, Query, Route, UploadedFile, UploadedFiles } from '@tsoa/runtime';
2+
import { ModelService } from '../services/modelService';
3+
import { GenericRequest, TestClassModel, TestModel, TupleTestModel } from '../testModel31';
4+
5+
@Route('PostTest')
6+
export class PostTestController {
7+
private statusCode?: number = undefined;
8+
9+
public setStatus(statusCode: number) {
10+
this.statusCode = statusCode;
11+
}
12+
13+
public getStatus() {
14+
return this.statusCode;
15+
}
16+
17+
public getHeaders() {
18+
return [];
19+
}
20+
21+
@Post()
22+
public async postModel(@Body() model: TestModel): Promise<TestModel> {
23+
return model;
24+
}
25+
26+
@Post('Object')
27+
public async postObject(@Body() body: { obj: { [key: string]: string } }): Promise<{ [key: string]: string }> {
28+
return body.obj;
29+
}
30+
31+
@Post('MultiType')
32+
public async postMultiType(@Body() input: number | string): Promise<number | string> {
33+
return input;
34+
}
35+
36+
@Patch()
37+
public async updateModel(@Body() model: TestModel): Promise<TestModel> {
38+
return new ModelService().getModel();
39+
}
40+
41+
@Post('WithDifferentReturnCode')
42+
public async postWithDifferentReturnCode(@Body() model: TestModel): Promise<TestModel> {
43+
this.setStatus(201);
44+
return model;
45+
}
46+
47+
@Post('WithClassModel')
48+
public async postClassModel(@Body() model: TestClassModel): Promise<TestClassModel> {
49+
const augmentedModel = new TestClassModel('test', 'test2', 'test3', 'test4', 'test5');
50+
augmentedModel.id = 700;
51+
52+
return augmentedModel;
53+
}
54+
55+
@Post('File')
56+
public async postWithFile(@UploadedFile('someFile') aFile: File): Promise<File> {
57+
return aFile;
58+
}
59+
60+
@Post('FileOptional')
61+
public async postWithOptionalFile(@UploadedFile('optionalFile') optionalFile?: File): Promise<string> {
62+
return optionalFile?.originalname ?? 'no file';
63+
}
64+
65+
@Post('FileWithoutName')
66+
public async postWithFileWithoutName(@UploadedFile() aFile: File): Promise<File> {
67+
return aFile;
68+
}
69+
70+
@Post('ManyFilesAndFormFields')
71+
public async postWithFiles(@UploadedFiles('someFiles') files: File[], @FormField('a') a: string, @FormField('c') c: string): Promise<File[]> {
72+
return files;
73+
}
74+
75+
@Post('ManyFilesInDifferentFields')
76+
public async postWithDifferentFields(@UploadedFile('file_a') fileA: File, @UploadedFile('file_b') fileB: File): Promise<File[]> {
77+
return [fileA, fileB];
78+
}
79+
80+
@Post('ManyFilesInDifferentArrayFields')
81+
public async postWithDifferentArrayFields(@UploadedFiles('files_a') filesA: File[], @UploadedFile('file_b') fileB: File, @UploadedFiles('files_c') filesC: File[]): Promise<File[][]> {
82+
return [filesA, [fileB], filesC];
83+
}
84+
85+
@Post('MixedFormDataWithFilesContainsOptionalFile')
86+
public async mixedFormDataWithFile(
87+
@FormField('username') username: string,
88+
@UploadedFile('avatar') avatar: File,
89+
@UploadedFile('optionalAvatar') optionalAvatar?: File,
90+
): Promise<{ username: string; avatar: File; optionalAvatar?: File }> {
91+
return { username, avatar, optionalAvatar };
92+
}
93+
94+
/**
95+
*
96+
* @param aFile File description of multipart
97+
* @param a FormField description of multipart
98+
* @param c
99+
*/
100+
@Post('DescriptionOfFileAndFormFields')
101+
public async postWithFileAndParams(@UploadedFile('file') aFile: File, @FormField('a') a: string, @FormField('c') c: string): Promise<File> {
102+
return aFile;
103+
}
104+
105+
@Post('DeprecatedFormField')
106+
public async postWithDeprecatedParam(@FormField('a') a: string, @FormField('dontUse') @Deprecated() dontUse?: string): Promise<TestModel> {
107+
return new ModelService().getModel();
108+
}
109+
110+
@Post('Location')
111+
public async postModelAtLocation(): Promise<TestModel> {
112+
return new ModelService().getModel();
113+
}
114+
115+
@Post('Multi')
116+
public async postWithMultiReturn(): Promise<TestModel[]> {
117+
const model = new ModelService().getModel();
118+
119+
return [model, model];
120+
}
121+
122+
@Post('WithId/{id}')
123+
public async postWithId(id: number): Promise<TestModel> {
124+
return new ModelService().getModel();
125+
}
126+
127+
@Post('WithBodyAndQueryParams')
128+
public async postWithBodyAndQueryParams(@Body() model: TestModel, @Query() query: string): Promise<TestModel> {
129+
return new ModelService().getModel();
130+
}
131+
132+
@Post('GenericBody')
133+
public async getGenericRequest(@Body() genericReq: GenericRequest<TestModel>): Promise<TestModel> {
134+
return genericReq.value;
135+
}
136+
137+
@Post('TupleTest')
138+
public async postTuple(@Body() model: TupleTestModel): Promise<TupleTestModel> {
139+
return model;
140+
}
141+
}

0 commit comments

Comments
 (0)