From 4a3990578536e439a2e8fbd89273fe00d0f78bdc Mon Sep 17 00:00:00 2001 From: Alexander Hofstede Date: Tue, 2 Dec 2025 18:06:11 +0100 Subject: [PATCH] Improve implicit form data detection. Add tests. --- src/schema-routes/schema-routes.ts | 12 +- .../__snapshots__/basic.test.ts.snap | 463 ++++++++++++++++++ tests/spec/implicitFormData/basic.test.ts | 53 ++ tests/spec/implicitFormData/schema.json | 246 ++++++++++ 4 files changed, 771 insertions(+), 3 deletions(-) create mode 100644 tests/spec/implicitFormData/__snapshots__/basic.test.ts.snap create mode 100644 tests/spec/implicitFormData/basic.test.ts create mode 100644 tests/spec/implicitFormData/schema.json diff --git a/src/schema-routes/schema-routes.ts b/src/schema-routes/schema-routes.ts index 0d64813f..8cd8b899 100644 --- a/src/schema-routes/schema-routes.ts +++ b/src/schema-routes/schema-routes.ts @@ -590,9 +590,15 @@ export class SchemaRoutes { // It needed for cases when swagger schema is not declared request body type as form data // but request body data type contains form data types like File if ( - this.FORM_DATA_TYPES.some((dataType) => - content.includes(`: ${dataType}`), - ) + this.FORM_DATA_TYPES.some((dataType) => { + // Match e.g. ": File" followed by word boundary, array brackets, or union/nullable syntax + // This prevents false positives like ": FileType" or ": SomeFile" + // Escape special regex characters (getSchemaType can theoretically return complex types like "File | null" or "UtilRequiredKeys") + const escapedType = dataType.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`:\\s*${escapedType}(?:\\[\\]|\\s*\\||\\b|$)`).test( + content, + ); + }) ) { contentKind = CONTENT_KIND.FORM_DATA; } diff --git a/tests/spec/implicitFormData/__snapshots__/basic.test.ts.snap b/tests/spec/implicitFormData/__snapshots__/basic.test.ts.snap new file mode 100644 index 00000000..dd92ea1b --- /dev/null +++ b/tests/spec/implicitFormData/__snapshots__/basic.test.ts.snap @@ -0,0 +1,463 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`basic > implicit FormData detection workaround 1`] = ` +"/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +export interface FileMetadata { + /** Name of the file (just metadata, not the actual file) */ + fileName?: string; + /** Size of the file in bytes */ + fileSize?: number; + /** File description */ + description?: string; +} + +export interface UploadFilePayload { + /** + * File to upload + * @format file + */ + file?: File; + /** File description */ + description?: string; +} + +export interface UploadBinaryPayload { + /** + * Binary data to upload + * @format binary + */ + data?: File; + /** Additional metadata */ + metadata?: string; +} + +export interface CreateDocumentPayload { + /** + * Document type (custom format, not a file upload) + * @format FileType + */ + type?: string; + /** Document name */ + name?: string; + /** Document content */ + content?: string; +} + +export interface CreateWithCustomFileFormatPayload { + /** + * Document type enum (custom format mapped to FileType type, but not actually a file upload) + * @format file-type-enum + */ + type?: FileType; + /** Document name */ + name?: string; +} + +export interface CreateWithFileFormatPayload { + /** + * File using custom format that maps to File type + * @format custom-file + */ + file?: File; + /** Document name */ + name?: string; +} + +export type QueryParamsType = Record; +export type ResponseFormat = keyof Omit; + +export interface FullRequestParams extends Omit { + /** set parameter to \`true\` for call \`securityWorker\` for this request */ + secure?: boolean; + /** request path */ + path: string; + /** content type of request body */ + type?: ContentType; + /** query params */ + query?: QueryParamsType; + /** format of response (i.e. response.json() -> format: "json") */ + format?: ResponseFormat; + /** request body */ + body?: unknown; + /** base url */ + baseUrl?: string; + /** request cancellation token */ + cancelToken?: CancelToken; +} + +export type RequestParams = Omit< + FullRequestParams, + "body" | "method" | "query" | "path" +>; + +export interface ApiConfig { + baseUrl?: string; + baseApiParams?: Omit; + securityWorker?: ( + securityData: SecurityDataType | null, + ) => Promise | RequestParams | void; + customFetch?: typeof fetch; +} + +export interface HttpResponse + extends Response { + data: D; + error: E; +} + +type CancelToken = Symbol | string | number; + +export enum ContentType { + Json = "application/json", + JsonApi = "application/vnd.api+json", + FormData = "multipart/form-data", + UrlEncoded = "application/x-www-form-urlencoded", + Text = "text/plain", +} + +export class HttpClient { + public baseUrl: string = ""; + private securityData: SecurityDataType | null = null; + private securityWorker?: ApiConfig["securityWorker"]; + private abortControllers = new Map(); + private customFetch = (...fetchParams: Parameters) => + fetch(...fetchParams); + + private baseApiParams: RequestParams = { + credentials: "same-origin", + headers: {}, + redirect: "follow", + referrerPolicy: "no-referrer", + }; + + constructor(apiConfig: ApiConfig = {}) { + Object.assign(this, apiConfig); + } + + public setSecurityData = (data: SecurityDataType | null) => { + this.securityData = data; + }; + + protected encodeQueryParam(key: string, value: any) { + const encodedKey = encodeURIComponent(key); + return \`\${encodedKey}=\${encodeURIComponent(typeof value === "number" ? value : \`\${value}\`)}\`; + } + + protected addQueryParam(query: QueryParamsType, key: string) { + return this.encodeQueryParam(key, query[key]); + } + + protected addArrayQueryParam(query: QueryParamsType, key: string) { + const value = query[key]; + return value.map((v: any) => this.encodeQueryParam(key, v)).join("&"); + } + + protected toQueryString(rawQuery?: QueryParamsType): string { + const query = rawQuery || {}; + const keys = Object.keys(query).filter( + (key) => "undefined" !== typeof query[key], + ); + return keys + .map((key) => + Array.isArray(query[key]) + ? this.addArrayQueryParam(query, key) + : this.addQueryParam(query, key), + ) + .join("&"); + } + + protected addQueryParams(rawQuery?: QueryParamsType): string { + const queryString = this.toQueryString(rawQuery); + return queryString ? \`?\${queryString}\` : ""; + } + + private contentFormatters: Record any> = { + [ContentType.Json]: (input: any) => + input !== null && (typeof input === "object" || typeof input === "string") + ? JSON.stringify(input) + : input, + [ContentType.JsonApi]: (input: any) => + input !== null && (typeof input === "object" || typeof input === "string") + ? JSON.stringify(input) + : input, + [ContentType.Text]: (input: any) => + input !== null && typeof input !== "string" + ? JSON.stringify(input) + : input, + [ContentType.FormData]: (input: any) => { + if (input instanceof FormData) { + return input; + } + + return Object.keys(input || {}).reduce((formData, key) => { + const property = input[key]; + formData.append( + key, + property instanceof Blob + ? property + : typeof property === "object" && property !== null + ? JSON.stringify(property) + : \`\${property}\`, + ); + return formData; + }, new FormData()); + }, + [ContentType.UrlEncoded]: (input: any) => this.toQueryString(input), + }; + + protected mergeRequestParams( + params1: RequestParams, + params2?: RequestParams, + ): RequestParams { + return { + ...this.baseApiParams, + ...params1, + ...(params2 || {}), + headers: { + ...(this.baseApiParams.headers || {}), + ...(params1.headers || {}), + ...((params2 && params2.headers) || {}), + }, + }; + } + + protected createAbortSignal = ( + cancelToken: CancelToken, + ): AbortSignal | undefined => { + if (this.abortControllers.has(cancelToken)) { + const abortController = this.abortControllers.get(cancelToken); + if (abortController) { + return abortController.signal; + } + return void 0; + } + + const abortController = new AbortController(); + this.abortControllers.set(cancelToken, abortController); + return abortController.signal; + }; + + public abortRequest = (cancelToken: CancelToken) => { + const abortController = this.abortControllers.get(cancelToken); + + if (abortController) { + abortController.abort(); + this.abortControllers.delete(cancelToken); + } + }; + + public request = async ({ + body, + secure, + path, + type, + query, + format, + baseUrl, + cancelToken, + ...params + }: FullRequestParams): Promise> => { + const secureParams = + ((typeof secure === "boolean" ? secure : this.baseApiParams.secure) && + this.securityWorker && + (await this.securityWorker(this.securityData))) || + {}; + const requestParams = this.mergeRequestParams(params, secureParams); + const queryString = query && this.toQueryString(query); + const payloadFormatter = this.contentFormatters[type || ContentType.Json]; + const responseFormat = format || requestParams.format; + + return this.customFetch( + \`\${baseUrl || this.baseUrl || ""}\${path}\${queryString ? \`?\${queryString}\` : ""}\`, + { + ...requestParams, + headers: { + ...(requestParams.headers || {}), + ...(type && type !== ContentType.FormData + ? { "Content-Type": type } + : {}), + }, + signal: + (cancelToken + ? this.createAbortSignal(cancelToken) + : requestParams.signal) || null, + body: + typeof body === "undefined" || body === null + ? null + : payloadFormatter(body), + }, + ).then(async (response) => { + const r = response as HttpResponse; + r.data = null as unknown as T; + r.error = null as unknown as E; + + const responseToParse = responseFormat ? response.clone() : response; + const data = !responseFormat + ? r + : await responseToParse[responseFormat]() + .then((data) => { + if (r.ok) { + r.data = data; + } else { + r.error = data; + } + return r; + }) + .catch((e) => { + r.error = e; + return r; + }); + + if (cancelToken) { + this.abortControllers.delete(cancelToken); + } + + if (!response.ok) throw data; + return data; + }); + }; +} + +/** + * @title Implicit FormData Test + * @version 1.0.0 + * + * Test case for workaround that detects FormData when request body contains file/binary types but doesn't explicitly declare multipart/form-data + */ +export class Api< + SecurityDataType extends unknown, +> extends HttpClient { + upload = { + /** + * @description This endpoint has a request body with file type but uses application/json content type (not multipart/form-data). The workaround should detect this and set contentKind to FORM_DATA. + * + * @name UploadFile + * @summary Upload a file + * @request POST:/upload + */ + uploadFile: (data: UploadFilePayload, params: RequestParams = {}) => + this.request< + { + success?: boolean; + }, + any + >({ + path: \`/upload\`, + method: "POST", + body: data, + type: ContentType.FormData, + format: "json", + ...params, + }), + }; + uploadBinary = { + /** + * @description This endpoint has a request body with binary format but uses application/json content type. The workaround should detect this. + * + * @name UploadBinary + * @summary Upload binary data + * @request POST:/upload-binary + */ + uploadBinary: (data: UploadBinaryPayload, params: RequestParams = {}) => + this.request({ + path: \`/upload-binary\`, + method: "POST", + body: data, + type: ContentType.FormData, + ...params, + }), + }; + createDocument = { + /** + * @description This endpoint has a request body with a property 'type' that uses a custom format 'FileType' (not file or binary). The content type is application/json. This should remain JSON, not FormData. + * + * @name CreateDocument + * @summary Create a document + * @request POST:/create-document + */ + createDocument: (data: CreateDocumentPayload, params: RequestParams = {}) => + this.request< + { + id?: string; + }, + any + >({ + path: \`/create-document\`, + method: "POST", + body: data, + type: ContentType.Json, + format: "json", + ...params, + }), + }; + updateFileMetadata = { + /** + * @description This endpoint has a request body that references a component schema 'FileMetadata' (which contains 'File' in the name but is NOT a file upload). The content type is application/json. The workaround should NOT apply, but it currently does because the type name contains 'File'. + * + * @name UpdateFileMetadata + * @summary Update file metadata + * @request POST:/update-file-metadata + */ + updateFileMetadata: (data: FileMetadata, params: RequestParams = {}) => + this.request({ + path: \`/update-file-metadata\`, + method: "POST", + body: data, + type: ContentType.Json, + ...params, + }), + }; + createWithCustomFileFormat = { + /** + * @description This endpoint has a request body with a property using custom format 'file-type-enum' that maps to 'FileType' type via primitiveTypeConstructs. The content type is application/json. The workaround should NOT apply - this should remain JSON, not FormData. + * + * @name CreateWithCustomFileFormat + * @summary Create with custom file format + * @request POST:/create-with-custom-file-format + */ + createWithCustomFileFormat: ( + data: CreateWithCustomFileFormatPayload, + params: RequestParams = {}, + ) => + this.request({ + path: \`/create-with-custom-file-format\`, + method: "POST", + body: data, + type: ContentType.Json, + ...params, + }), + }; + createWithFileFormat = { + /** + * @description This endpoint has a request body with a property using custom format 'custom-file' that maps to 'File' type via primitiveTypeConstructs. Even though it's a custom format, since it maps to File, the workaround SHOULD apply and it should be FormData. + * + * @name CreateWithFileFormat + * @summary Create with file format + * @request POST:/create-with-file-format + */ + createWithFileFormat: ( + data: CreateWithFileFormatPayload, + params: RequestParams = {}, + ) => + this.request({ + path: \`/create-with-file-format\`, + method: "POST", + body: data, + type: ContentType.FormData, + ...params, + }), + }; +} +" +`; diff --git a/tests/spec/implicitFormData/basic.test.ts b/tests/spec/implicitFormData/basic.test.ts new file mode 100644 index 00000000..27b24c10 --- /dev/null +++ b/tests/spec/implicitFormData/basic.test.ts @@ -0,0 +1,53 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { generateApi } from "../../../src/index.js"; + +describe("basic", async () => { + let tmpdir = ""; + + beforeAll(async () => { + tmpdir = await fs.mkdtemp(path.join(os.tmpdir(), "swagger-typescript-api")); + }); + + afterAll(async () => { + await fs.rm(tmpdir, { recursive: true }); + }); + + test("implicit FormData detection workaround", async () => { + await generateApi({ + fileName: "schema", + input: path.resolve(import.meta.dirname, "schema.json"), + output: tmpdir, + silent: true, + generateClient: true, + extractRequestBody: true, + // map custom formats to test the workaround + primitiveTypeConstructs: (struct) => ({ + ...struct, + string: { + "file-type-enum": "FileType", // Maps to FileType (should NOT trigger workaround) + "custom-file": "File", // Maps to File (SHOULD trigger workaround) + $default: "string", + }, + }), + }); + + const content = await fs.readFile(path.join(tmpdir, "schema.ts"), { + encoding: "utf8", + }); + + // This test verifies: + // 1. Endpoints with file/binary types but without explicit multipart/form-data + // should correctly be detected as FormData (workaround applies) + // 2. Endpoints with custom formats like "FileType" or component schemas + // with "File" in the name should NOT trigger the workaround + // (should remain JSON, not FormData) + // 3. Endpoints with custom formats mapped to 'FileType' should NOT trigger + // the workaround (fixed: uses whole-word matching to avoid false positives) + // 4. Endpoints with custom formats mapped to 'File' SHOULD trigger + // the workaround (correctly detects actual File types) + expect(content).toMatchSnapshot(); + }); +}); diff --git a/tests/spec/implicitFormData/schema.json b/tests/spec/implicitFormData/schema.json new file mode 100644 index 00000000..dca82b46 --- /dev/null +++ b/tests/spec/implicitFormData/schema.json @@ -0,0 +1,246 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Implicit FormData Test", + "version": "1.0.0", + "description": "Test case for workaround that detects FormData when request body contains file/binary types but doesn't explicitly declare multipart/form-data" + }, + "paths": { + "/upload": { + "post": { + "operationId": "uploadFile", + "summary": "Upload a file", + "description": "This endpoint has a request body with file type but uses application/json content type (not multipart/form-data). The workaround should detect this and set contentKind to FORM_DATA.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "file", + "description": "File to upload" + }, + "description": { + "type": "string", + "description": "File description" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + } + } + } + } + } + } + } + }, + "/upload-binary": { + "post": { + "operationId": "uploadBinary", + "summary": "Upload binary data", + "description": "This endpoint has a request body with binary format but uses application/json content type. The workaround should detect this.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "string", + "format": "binary", + "description": "Binary data to upload" + }, + "metadata": { + "type": "string", + "description": "Additional metadata" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/create-document": { + "post": { + "operationId": "createDocument", + "summary": "Create a document", + "description": "This endpoint has a request body with a property 'type' that uses a custom format 'FileType' (not file or binary). The content type is application/json. This should remain JSON, not FormData.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "format": "FileType", + "description": "Document type (custom format, not a file upload)" + }, + "name": { + "type": "string", + "description": "Document name" + }, + "content": { + "type": "string", + "description": "Document content" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/update-file-metadata": { + "post": { + "operationId": "updateFileMetadata", + "summary": "Update file metadata", + "description": "This endpoint has a request body that references a component schema 'FileMetadata' (which contains 'File' in the name but is NOT a file upload). The content type is application/json. The workaround should NOT apply, but it currently does because the type name contains 'File'.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileMetadata" + } + } + } + }, + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/create-with-custom-file-format": { + "post": { + "operationId": "createWithCustomFileFormat", + "summary": "Create with custom file format", + "description": "This endpoint has a request body with a property using custom format 'file-type-enum' that maps to 'FileType' type via primitiveTypeConstructs. The content type is application/json. The workaround should NOT apply - this should remain JSON, not FormData.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "format": "file-type-enum", + "description": "Document type enum (custom format mapped to FileType type, but not actually a file upload)" + }, + "name": { + "type": "string", + "description": "Document name" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/create-with-file-format": { + "post": { + "operationId": "createWithFileFormat", + "summary": "Create with file format", + "description": "This endpoint has a request body with a property using custom format 'custom-file' that maps to 'File' type via primitiveTypeConstructs. Even though it's a custom format, since it maps to File, the workaround SHOULD apply and it should be FormData.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "custom-file", + "description": "File using custom format that maps to File type" + }, + "name": { + "type": "string", + "description": "Document name" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success" + } + } + } + } + }, + "components": { + "schemas": { + "FileMetadata": { + "type": "object", + "properties": { + "fileName": { + "type": "string", + "description": "Name of the file (just metadata, not the actual file)" + }, + "fileSize": { + "type": "number", + "description": "Size of the file in bytes" + }, + "description": { + "type": "string", + "description": "File description" + } + } + } + } + } +}