From d5de37b0dea73fa723ebfaa8f17815395f08d3b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=B2=D0=B0=D0=BB=D1=8C=D0=BA=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=90=D0=BD=D0=B4=D1=80?= =?UTF-8?q?=D0=B5=D0=B5=D0=B2=D0=B8=D1=87?= Date: Thu, 21 May 2026 19:14:13 +0500 Subject: [PATCH 1/2] Fix ContentType not respecting enumStyle: "union" in http-client When enumStyle is set to "union", user-defined enums are generated as plain type aliases, but ContentType in the http-client was still generated as a regular enum. Call sites in the generated API also used ContentType.Json etc., which are invalid without a runtime object. Adds a union branch to both http-client templates that generates ContentType as a type alias. Introduces a shared partial (content-type-accessors.ejs) that returns the correct accessor strings for both http-client templates (CT.Json) and procedure-call templates (requestContentKind["JSON"]) based on the active enumStyle. Updates modular/api.ejs to use an inline type import for ContentType when in union mode, avoiding isolatedModules errors. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- templates/base/content-type-accessors.ejs | 18 + .../base/http-clients/axios-http-client.ejs | 7 +- .../base/http-clients/fetch-http-client.ejs | 17 +- templates/default/procedure-call.ejs | 8 +- templates/modular/api.ejs | 4 + templates/modular/procedure-call.ejs | 8 +- .../__snapshots__/basic.test.ts.snap | 337 ++++++++++++++++++ tests/spec/enumStyle/basic.test.ts | 18 + .../__snapshots__/basic.test.ts.snap | 28 +- 9 files changed, 408 insertions(+), 37 deletions(-) create mode 100644 templates/base/content-type-accessors.ejs diff --git a/templates/base/content-type-accessors.ejs b/templates/base/content-type-accessors.ejs new file mode 100644 index 000000000..25afb9161 --- /dev/null +++ b/templates/base/content-type-accessors.ejs @@ -0,0 +1,18 @@ +<% +const { config } = it; +const isUnion = config.enumStyle === "union"; + +// camelCase keys (Json, FormData, …) are used by http-client templates as CT.Json, CT.FormData, … +// They mirror the ContentType property names so the access pattern is identical regardless of enumStyle. +const v = { + Json: isUnion ? '"application/json"' : "ContentType.Json", + JsonApi: isUnion ? '"application/vnd.api+json"' : "ContentType.JsonApi", + FormData: isUnion ? '"multipart/form-data"' : "ContentType.FormData", + UrlEncoded: isUnion ? '"application/x-www-form-urlencoded"' : "ContentType.UrlEncoded", + Text: isUnion ? '"text/plain"' : "ContentType.Text", +}; + +// UPPER_SNAKE aliases (JSON, FORM_DATA, …) are used by procedure-call templates as +// requestContentKind["JSON"], matching the CONTENT_KIND constants produced by schema-routes.ts. +return { ...v, JSON: v.Json, JSON_API: v.JsonApi, FORM_DATA: v.FormData, URL_ENCODED: v.UrlEncoded, TEXT: v.Text }; +%> diff --git a/templates/base/http-clients/axios-http-client.ejs b/templates/base/http-clients/axios-http-client.ejs index 2b9441840..d3d725b23 100644 --- a/templates/base/http-clients/axios-http-client.ejs +++ b/templates/base/http-clients/axios-http-client.ejs @@ -1,5 +1,6 @@ <% const { apiConfig, generateResponses, config } = it; +const CT = includeFile("@base/content-type-accessors", { config }); %> import type { AxiosInstance, AxiosRequestConfig, HeadersDefaults, ResponseType, AxiosResponse } from "axios"; @@ -39,6 +40,8 @@ export const ContentType = { Text: "text/plain", } as const; export type ContentType = (typeof ContentType)[keyof typeof ContentType]; +<% } else if (config.enumStyle === "union") { %> +export type ContentType = "application/json" | "application/vnd.api+json" | "multipart/form-data" | "application/x-www-form-urlencoded" | "text/plain"; <% } else { %> export enum ContentType { Json = "application/json", @@ -127,11 +130,11 @@ export class HttpClient { const requestParams = this.mergeRequestParams(params, secureParams); const responseFormat = (format || this.format) || undefined; - if (type === ContentType.FormData && body && body !== null && typeof body === "object") { + if (type === <%~ CT.FormData %> && body && body !== null && typeof body === "object") { body = this.createFormData(body as Record); } - if (type === ContentType.Text && body && body !== null && typeof body !== "string") { + if (type === <%~ CT.Text %> && body && body !== null && typeof body !== "string") { body = JSON.stringify(body); } diff --git a/templates/base/http-clients/fetch-http-client.ejs b/templates/base/http-clients/fetch-http-client.ejs index 96e548539..d0be2e6a9 100644 --- a/templates/base/http-clients/fetch-http-client.ejs +++ b/templates/base/http-clients/fetch-http-client.ejs @@ -1,5 +1,6 @@ <% const { apiConfig, generateResponses, config } = it; +const CT = includeFile("@base/content-type-accessors", { config }); %> export type QueryParamsType = Record; @@ -50,6 +51,8 @@ export const ContentType = { Text: "text/plain", } as const; export type ContentType = (typeof ContentType)[keyof typeof ContentType]; +<% } else if (config.enumStyle === "union") { %> +export type ContentType = "application/json" | "application/vnd.api+json" | "multipart/form-data" | "application/x-www-form-urlencoded" | "text/plain"; <% } else { %> export enum ContentType { Json = "application/json", @@ -114,10 +117,10 @@ export class HttpClient { } 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) => { + [<%~ CT.Json %>]: (input:any) => input !== null && (typeof input === "object" || typeof input === "string") ? JSON.stringify(input) : input, + [<%~ CT.JsonApi %>]: (input:any) => input !== null && (typeof input === "object" || typeof input === "string") ? JSON.stringify(input) : input, + [<%~ CT.Text %>]: (input:any) => input !== null && typeof input !== "string" ? JSON.stringify(input) : input, + [<%~ CT.FormData %>]: (input: any) => { if (input instanceof FormData) { return input; } @@ -135,7 +138,7 @@ export class HttpClient { return formData; }, new FormData()); }, - [ContentType.UrlEncoded]: (input: any) => this.toQueryString(input), + [<%~ CT.UrlEncoded %>]: (input: any) => this.toQueryString(input), } protected mergeRequestParams(params1: RequestParams, params2?: RequestParams): RequestParams { @@ -192,7 +195,7 @@ export class HttpClient { 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 payloadFormatter = this.contentFormatters[type || <%~ CT.Json %>]; const responseFormat = format || requestParams.format; return this.customFetch( @@ -201,7 +204,7 @@ export class HttpClient { ...requestParams, headers: { ...(requestParams.headers || {}), - ...(type && type !== ContentType.FormData ? { "Content-Type": type } : {}), + ...(type && type !== <%~ CT.FormData %> ? { "Content-Type": type } : {}), }, signal: (cancelToken ? this.createAbortSignal(cancelToken) : requestParams.signal) || null, body: typeof body === "undefined" || body === null ? null : payloadFormatter(body), diff --git a/templates/default/procedure-call.ejs b/templates/default/procedure-call.ejs index de3e1427f..5c116e01d 100644 --- a/templates/default/procedure-call.ejs +++ b/templates/default/procedure-call.ejs @@ -53,13 +53,7 @@ const wrapperArgs = _ .join(', ') // RequestParams["type"] -const requestContentKind = { - "JSON": "ContentType.Json", - "JSON_API": "ContentType.JsonApi", - "URL_ENCODED": "ContentType.UrlEncoded", - "FORM_DATA": "ContentType.FormData", - "TEXT": "ContentType.Text", -} +const requestContentKind = includeFile("@base/content-type-accessors", { config }); // RequestParams["format"] const responseContentKind = { "JSON": '"json"', diff --git a/templates/modular/api.ejs b/templates/modular/api.ejs index 25bfa182d..ab668ab89 100644 --- a/templates/modular/api.ejs +++ b/templates/modular/api.ejs @@ -8,7 +8,11 @@ const dataContracts = _.map(modelTypes, "name"); <% if (config.httpClientType === config.constants.HTTP_CLIENT.AXIOS) { %> import type { AxiosRequestConfig, AxiosResponse } from "axios"; <% } %> +<% if (config.enumStyle === "union") { %> +import { HttpClient, RequestParams, type ContentType, HttpResponse } from "./<%~ config.fileNames.httpClient %>"; +<% } else { %> import { HttpClient, RequestParams, ContentType, HttpResponse } from "./<%~ config.fileNames.httpClient %>"; +<% } %> <% if (dataContracts.length) { %> import { <%~ dataContracts.join(", ") %> } from "./<%~ config.fileNames.dataContracts %>" <% } %> diff --git a/templates/modular/procedure-call.ejs b/templates/modular/procedure-call.ejs index 53843d35a..83b3f179a 100644 --- a/templates/modular/procedure-call.ejs +++ b/templates/modular/procedure-call.ejs @@ -53,13 +53,7 @@ const wrapperArgs = _ .join(', ') // RequestParams["type"] -const requestContentKind = { - "JSON": "ContentType.Json", - "JSON_API": "ContentType.JsonApi", - "URL_ENCODED": "ContentType.UrlEncoded", - "FORM_DATA": "ContentType.FormData", - "TEXT": "ContentType.Text", -} +const requestContentKind = includeFile("@base/content-type-accessors", { config }); // RequestParams["format"] const responseContentKind = { "JSON": '"json"', diff --git a/tests/spec/enumStyle/__snapshots__/basic.test.ts.snap b/tests/spec/enumStyle/__snapshots__/basic.test.ts.snap index 037926cb7..88e42c7d2 100644 --- a/tests/spec/enumStyle/__snapshots__/basic.test.ts.snap +++ b/tests/spec/enumStyle/__snapshots__/basic.test.ts.snap @@ -54,3 +54,340 @@ export type SchBustlingTypeface38TYPESUFFIX = (typeof SchBustlingTypeface38TYPESUFFIX)[keyof typeof SchBustlingTypeface38TYPESUFFIX]; " `; + +exports[`basic > union-enums-http-client 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 type PetKind = 0 | 1 | 2; + +export type PetStatus = "available" | "pending" | "sold"; + +/** Fixture field. */ +export type SchBustlingTypeface38 = + | "ev0" + | "ev1" + | "ev2" + | "ev3" + | "ev4" + | "ev5" + | "ev6"; + +export interface Pet { + id?: number; + name: string; + status: PetStatus; + kind?: PetKind; +} + +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 type ContentType = + | "application/json" + | "application/vnd.api+json" + | "multipart/form-data" + | "application/x-www-form-urlencoded" + | "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> = { + ["application/json"]: (input: any) => + input !== null && (typeof input === "object" || typeof input === "string") + ? JSON.stringify(input) + : input, + ["application/vnd.api+json"]: (input: any) => + input !== null && (typeof input === "object" || typeof input === "string") + ? JSON.stringify(input) + : input, + ["text/plain"]: (input: any) => + input !== null && typeof input !== "string" + ? JSON.stringify(input) + : input, + ["multipart/form-data"]: (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()); + }, + ["application/x-www-form-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 || "application/json"]; + const responseFormat = format || requestParams.format; + + return this.customFetch( + \`\${baseUrl || this.baseUrl || ""}\${path}\${queryString ? \`?\${queryString}\` : ""}\`, + { + ...requestParams, + headers: { + ...(requestParams.headers || {}), + ...(type && type !== "multipart/form-data" + ? { "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 Test + * @version 1.0.0 + */ +export class Api< + SecurityDataType extends unknown, +> extends HttpClient { + pets = { + /** + * No description + * + * @name ListPets + * @request GET:/pets + */ + listPets: ( + query?: { + status?: PetStatus; + }, + params: RequestParams = {}, + ) => + this.request({ + path: \`/pets\`, + method: "GET", + query: query, + format: "json", + ...params, + }), + + /** + * No description + * + * @name CreatePet + * @request POST:/pets + */ + createPet: (data: Pet, params: RequestParams = {}) => + this.request({ + path: \`/pets\`, + method: "POST", + body: data, + type: "application/json", + format: "json", + ...params, + }), + }; +} +" +`; diff --git a/tests/spec/enumStyle/basic.test.ts b/tests/spec/enumStyle/basic.test.ts index 4ed8cd793..260fd5f93 100644 --- a/tests/spec/enumStyle/basic.test.ts +++ b/tests/spec/enumStyle/basic.test.ts @@ -45,4 +45,22 @@ describe("basic", async () => { expect(content).toMatchSnapshot(); }); + + test("union-enums-http-client", async () => { + await generateApi({ + fileName: "schema-union", + input: path.resolve(import.meta.dirname, "schema.json"), + output: tmpdir, + silent: true, + enumStyle: "union", + cleanOutput: false, + modular: false, + }); + + const content = await fs.readFile(path.join(tmpdir, "schema-union.ts"), { + encoding: "utf8", + }); + + expect(content).toMatchSnapshot(); + }); }); diff --git a/tests/spec/unionEnums/__snapshots__/basic.test.ts.snap b/tests/spec/unionEnums/__snapshots__/basic.test.ts.snap index 1c1388696..ae09e1b1b 100644 --- a/tests/spec/unionEnums/__snapshots__/basic.test.ts.snap +++ b/tests/spec/unionEnums/__snapshots__/basic.test.ts.snap @@ -69,13 +69,12 @@ export interface HttpResponse 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 type ContentType = + | "application/json" + | "application/vnd.api+json" + | "multipart/form-data" + | "application/x-www-form-urlencoded" + | "text/plain"; export class HttpClient { public baseUrl: string = "http://localhost:8080/api/v1"; @@ -134,19 +133,19 @@ export class HttpClient { } private contentFormatters: Record any> = { - [ContentType.Json]: (input: any) => + ["application/json"]: (input: any) => input !== null && (typeof input === "object" || typeof input === "string") ? JSON.stringify(input) : input, - [ContentType.JsonApi]: (input: any) => + ["application/vnd.api+json"]: (input: any) => input !== null && (typeof input === "object" || typeof input === "string") ? JSON.stringify(input) : input, - [ContentType.Text]: (input: any) => + ["text/plain"]: (input: any) => input !== null && typeof input !== "string" ? JSON.stringify(input) : input, - [ContentType.FormData]: (input: any) => { + ["multipart/form-data"]: (input: any) => { if (input instanceof FormData) { return input; } @@ -164,7 +163,8 @@ export class HttpClient { return formData; }, new FormData()); }, - [ContentType.UrlEncoded]: (input: any) => this.toQueryString(input), + ["application/x-www-form-urlencoded"]: (input: any) => + this.toQueryString(input), }; protected mergeRequestParams( @@ -226,7 +226,7 @@ export class HttpClient { {}; const requestParams = this.mergeRequestParams(params, secureParams); const queryString = query && this.toQueryString(query); - const payloadFormatter = this.contentFormatters[type || ContentType.Json]; + const payloadFormatter = this.contentFormatters[type || "application/json"]; const responseFormat = format || requestParams.format; return this.customFetch( @@ -235,7 +235,7 @@ export class HttpClient { ...requestParams, headers: { ...(requestParams.headers || {}), - ...(type && type !== ContentType.FormData + ...(type && type !== "multipart/form-data" ? { "Content-Type": type } : {}), }, From 90cf9cc9d522d043a602b651a1fa48a365427124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=B2=D0=B0=D0=BB=D1=8C=D0=BA=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=90=D0=BD=D0=B4=D1=80?= =?UTF-8?q?=D0=B5=D0=B5=D0=B2=D0=B8=D1=87?= Date: Thu, 21 May 2026 19:22:42 +0500 Subject: [PATCH 2/2] Add changeset for union ContentType fix Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .changeset/fix-union-content-type.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-union-content-type.md diff --git a/.changeset/fix-union-content-type.md b/.changeset/fix-union-content-type.md new file mode 100644 index 000000000..6230e866b --- /dev/null +++ b/.changeset/fix-union-content-type.md @@ -0,0 +1,5 @@ +--- +"swagger-typescript-api": patch +--- + +Fix `ContentType` in http-client not respecting `enumStyle: "union"`. It now generates a plain type alias instead of an enum, and all call sites emit string literals instead of `ContentType.Json` etc.