From 31266c21a7cdb9009cfa855fc9e55f139ab56cc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Torres?= Date: Sat, 27 Jun 2026 00:00:27 +0200 Subject: [PATCH 1/3] Add ky HTTP client support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces ky as a third supported HTTP client alongside Fetch and Axios. The generated client is a drop-in for Fetch: same HttpResponse wrapper, query serialization, cancelToken, secure/securityWorker, format, unwrapResponseData, and disableThrowOnError — backed by ky.create({ throwHttpErrors: false }). Also fixes a CLI bug where any truthy --http-client value was coerced to axios instead of being honored as the literal string. Co-Authored-By: Claude Sonnet 4.6 --- .changeset/add-ky-http-client.md | 11 + README.md | 16 +- bun.lock | 3 + index.ts | 11 +- package.json | 2 +- src/constants.ts | 1 + .../base/http-clients/ky-http-client.ejs | 263 ++++++++++++ templates/default/procedure-call.ejs | 2 +- templates/modular/procedure-call.ejs | 2 +- .../jsKy/__snapshots__/basic.test.ts.snap | 280 +++++++++++++ tests/spec/jsKy/basic.test.ts | 35 ++ tests/spec/jsKy/schema.json | 177 ++++++++ .../spec/ky/__snapshots__/basic.test.ts.snap | 386 +++++++++++++++++ tests/spec/ky/basic.test.ts | 34 ++ tests/spec/ky/schema.json | 177 ++++++++ .../__snapshots__/basic.test.ts.snap | 390 ++++++++++++++++++ tests/spec/kySingleHttpClient/basic.test.ts | 35 ++ tests/spec/kySingleHttpClient/schema.json | 177 ++++++++ types/index.ts | 2 +- 19 files changed, 1995 insertions(+), 9 deletions(-) create mode 100644 .changeset/add-ky-http-client.md create mode 100644 templates/base/http-clients/ky-http-client.ejs create mode 100644 tests/spec/jsKy/__snapshots__/basic.test.ts.snap create mode 100644 tests/spec/jsKy/basic.test.ts create mode 100644 tests/spec/jsKy/schema.json create mode 100644 tests/spec/ky/__snapshots__/basic.test.ts.snap create mode 100644 tests/spec/ky/basic.test.ts create mode 100644 tests/spec/ky/schema.json create mode 100644 tests/spec/kySingleHttpClient/__snapshots__/basic.test.ts.snap create mode 100644 tests/spec/kySingleHttpClient/basic.test.ts create mode 100644 tests/spec/kySingleHttpClient/schema.json diff --git a/.changeset/add-ky-http-client.md b/.changeset/add-ky-http-client.md new file mode 100644 index 000000000..75fd68545 --- /dev/null +++ b/.changeset/add-ky-http-client.md @@ -0,0 +1,11 @@ +--- +"swagger-typescript-api": minor +--- + +Add ky HTTP client support + +The `--http-client ky` CLI flag and `httpClientType: "ky"` library option now generate an API client backed by [ky](https://github.com/sindresorhus/ky), a tiny fetch-based HTTP client with retries and a cleaner API. + +The generated ky client is a drop-in match for the Fetch client: it uses the same `HttpResponse` response wrapper, `ResponseFormat`, query serialization, `secure`/`securityWorker`, `cancelToken`, `unwrapResponseData`, and `disableThrowOnError` semantics. Users only need to install `ky` as a dependency in their project. + +Also fixes a CLI bug where any truthy `--http-client` value was silently coerced to `axios` instead of being honored. diff --git a/README.md b/README.md index 81c8be997..5c05a6a68 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Swagger TypeScript API - Support for OpenAPI 3.0, 2.0, JSON and YAML -- Generate the API Client for Fetch or Axios from an OpenAPI Specification +- Generate the API Client for Fetch, Axios, or ky from an OpenAPI Specification Any questions you can ask here: @@ -19,6 +19,12 @@ You can use this package in two ways: npx swagger-typescript-api generate --path ./swagger.json ``` +To generate a `ky`-based client (install `ky` in your project first): + +```bash +npx swagger-typescript-api generate --path ./swagger.json --http-client ky +``` + Or install locally in your project: ```bash @@ -38,8 +44,16 @@ import * as process from "node:process"; import { generateApi } from "swagger-typescript-api"; await generateApi({ input: path.resolve(process.cwd(), "./swagger.json") }); + +// Use ky as the HTTP client (install ky in your project first) +await generateApi({ + input: path.resolve(process.cwd(), "./swagger.json"), + httpClientType: "ky", +}); ``` +The `httpClientType` option accepts `"fetch"` (default), `"axios"`, or `"ky"`. When using `"axios"` or `"ky"`, add the corresponding package as a dependency of the generated client's project. + For more detailed configuration options, please consult the documentation. ## Mass media diff --git a/bun.lock b/bun.lock index 792bbfdd2..cb9907297 100644 --- a/bun.lock +++ b/bun.lock @@ -32,6 +32,7 @@ "@types/node": "26.0.0", "@types/swagger2openapi": "7.0.4", "axios": "1.18.0", + "ky": "^2.0.2", "tsdown": "0.22.3", "typedoc": "0.28.19", "unrun": "^0.3.1", @@ -566,6 +567,8 @@ "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + "ky": ["ky@2.0.2", "", {}, "sha512-/GmXpo9F9W+f8n4Ivr2iH+7h7wL7jLbLKWkMlpflcCRb6kGjBfTlASEXaZ9qUgNTn4VgS0P2pwxxzQ4EM6Ulgg=="], + "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], diff --git a/index.ts b/index.ts index e4544a1db..ebb3097cd 100644 --- a/index.ts +++ b/index.ts @@ -342,10 +342,13 @@ const generateCommand = defineCommand({ | "const" | "const-enum" | undefined, - httpClientType: - args["http-client"] || args.axios - ? HTTP_CLIENT.AXIOS - : HTTP_CLIENT.FETCH, + httpClientType: (() => { + const raw = args["http-client"] as string | undefined; + const validValues = Object.values(HTTP_CLIENT) as string[]; + if (raw && validValues.includes(raw)) return raw as HttpClientType; + if (args.axios) return HTTP_CLIENT.AXIOS; + return HTTP_CLIENT.FETCH; + })(), input: path.resolve(process.cwd(), args.path as string), modular: args.modular, moduleNameFirstTag: args["module-name-first-tag"], diff --git a/package.json b/package.json index 65c179654..e57f8677a 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ "module": "./dist/index.mjs", "types": "./dist/index.d.cts", "bin": { - "sta": "./dist/cli.mjs", "swagger-typescript-api": "./dist/cli.mjs" }, "files": [ @@ -71,6 +70,7 @@ "@types/node": "26.0.0", "@types/swagger2openapi": "7.0.4", "axios": "1.18.0", + "ky": "^2.0.2", "tsdown": "0.22.3", "typedoc": "0.28.19", "unrun": "^0.3.1", diff --git a/src/constants.ts b/src/constants.ts index a64a9da61..79d2c9bf8 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -19,6 +19,7 @@ export const FILE_PREFIX = `/* eslint-disable */ export const HTTP_CLIENT = { FETCH: "fetch", AXIOS: "axios", + KY: "ky", } as const; export const PROJECT_VERSION = packageJson.version; diff --git a/templates/base/http-clients/ky-http-client.ejs b/templates/base/http-clients/ky-http-client.ejs new file mode 100644 index 000000000..a4c72bf60 --- /dev/null +++ b/templates/base/http-clients/ky-http-client.ejs @@ -0,0 +1,263 @@ +<% +const { apiConfig, generateResponses, config } = it; +const CT = includeFile("@base/content-type-accessors", { config }); +%> +import ky from "ky"; +import type { KyInstance } from "ky"; + +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 + + +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; + +<% if (config.enumStyle === "const") { %> +export const ContentType = { + Json: "application/json", + JsonApi: "application/vnd.api+json", + FormData: "multipart/form-data", + UrlEncoded: "application/x-www-form-urlencoded", + 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 if (config.enumStyle === "const-enum") { %> +export const enum ContentType { + Json = "application/json", + JsonApi = "application/vnd.api+json", + FormData = "multipart/form-data", + UrlEncoded = "application/x-www-form-urlencoded", + Text = "text/plain", +} +<% } else { %> +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 = "<%~ apiConfig.baseUrl %>"; + private securityData: SecurityDataType | null = null; + private securityWorker?: ApiConfig["securityWorker"]; + private abortControllers = new Map(); + private customFetch?: typeof fetch; + private instance: KyInstance; + + private baseApiParams: RequestParams = { + credentials: 'same-origin', + headers: {}, + redirect: 'follow', + referrerPolicy: 'no-referrer', + } + + constructor(apiConfig: ApiConfig = {}) { + Object.assign(this, apiConfig); + // ky wraps fetch; use the caller-supplied fetch if provided, otherwise the global. + // throwHttpErrors is always false so we handle errors ourselves (matching fetch behavior). + this.instance = ky.create({ + fetch: this.customFetch ?? fetch, + throwHttpErrors: false, + }); + } + + 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> = { + [<%~ 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; + } + + 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()); + }, + [<%~ CT.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 +<% if (config.unwrapResponseData) { %> + }: FullRequestParams): Promise => { +<% } else { %> + }: 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 || <%~ CT.Json %>]; + const responseFormat = format || requestParams.format; + + return this.instance( + `${baseUrl || this.baseUrl || ""}${path}${queryString ? `?${queryString}` : ""}`, + { + ...requestParams, + headers: { + ...(requestParams.headers || {}), + ...(type && type !== <%~ CT.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 (!config.disableThrowOnError) { %> + if (!response.ok) throw data; +<% } %> +<% if (config.unwrapResponseData) { %> + return data.data; +<% } else { %> + return data; +<% } %> + }); + }; +} diff --git a/templates/default/procedure-call.ejs b/templates/default/procedure-call.ejs index 5c116e01d..60cc85c1a 100644 --- a/templates/default/procedure-call.ejs +++ b/templates/default/procedure-call.ejs @@ -58,7 +58,7 @@ const requestContentKind = includeFile("@base/content-type-accessors", { config const responseContentKind = { "JSON": '"json"', "IMAGE": '"blob"', - "FORM_DATA": isFetchTemplate ? '"formData"' : '"document"' + "FORM_DATA": config.httpClientType !== HTTP_CLIENT.AXIOS ? '"formData"' : '"document"' } const bodyTmpl = _.get(payload, "name") || null; diff --git a/templates/modular/procedure-call.ejs b/templates/modular/procedure-call.ejs index 83b3f179a..d4320c731 100644 --- a/templates/modular/procedure-call.ejs +++ b/templates/modular/procedure-call.ejs @@ -58,7 +58,7 @@ const requestContentKind = includeFile("@base/content-type-accessors", { config const responseContentKind = { "JSON": '"json"', "IMAGE": '"blob"', - "FORM_DATA": isFetchTemplate ? '"formData"' : '"document"' + "FORM_DATA": config.httpClientType !== HTTP_CLIENT.AXIOS ? '"formData"' : '"document"' } const bodyTmpl = _.get(payload, "name") || null; diff --git a/tests/spec/jsKy/__snapshots__/basic.test.ts.snap b/tests/spec/jsKy/__snapshots__/basic.test.ts.snap new file mode 100644 index 000000000..1d79ab351 --- /dev/null +++ b/tests/spec/jsKy/__snapshots__/basic.test.ts.snap @@ -0,0 +1,280 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`basic > --http-client ky --js 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 ## + * --------------------------------------------------------------- + */ + +import ky from "ky"; +export var ContentType; +(function (ContentType) { + ContentType["Json"] = "application/json"; + ContentType["JsonApi"] = "application/vnd.api+json"; + ContentType["FormData"] = "multipart/form-data"; + ContentType["UrlEncoded"] = "application/x-www-form-urlencoded"; + ContentType["Text"] = "text/plain"; +})(ContentType || (ContentType = {})); +export class HttpClient { + baseUrl = ""; + securityData = null; + securityWorker; + abortControllers = new Map(); + customFetch; + instance; + baseApiParams = { + credentials: "same-origin", + headers: {}, + redirect: "follow", + referrerPolicy: "no-referrer", + }; + constructor(apiConfig = {}) { + Object.assign(this, apiConfig); + // ky wraps fetch; use the caller-supplied fetch if provided, otherwise the global. + // throwHttpErrors is always false so we handle errors ourselves (matching fetch behavior). + this.instance = ky.create({ + fetch: this.customFetch ?? fetch, + throwHttpErrors: false, + }); + } + setSecurityData = (data) => { + this.securityData = data; + }; + encodeQueryParam(key, value) { + const encodedKey = encodeURIComponent(key); + return \`\${encodedKey}=\${encodeURIComponent(typeof value === "number" ? value : \`\${value}\`)}\`; + } + addQueryParam(query, key) { + return this.encodeQueryParam(key, query[key]); + } + addArrayQueryParam(query, key) { + const value = query[key]; + return value.map((v) => this.encodeQueryParam(key, v)).join("&"); + } + toQueryString(rawQuery) { + 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("&"); + } + addQueryParams(rawQuery) { + const queryString = this.toQueryString(rawQuery); + return queryString ? \`?\${queryString}\` : ""; + } + contentFormatters = { + [ContentType.Json]: (input) => + input !== null && (typeof input === "object" || typeof input === "string") + ? JSON.stringify(input) + : input, + [ContentType.JsonApi]: (input) => + input !== null && (typeof input === "object" || typeof input === "string") + ? JSON.stringify(input) + : input, + [ContentType.Text]: (input) => + input !== null && typeof input !== "string" + ? JSON.stringify(input) + : input, + [ContentType.FormData]: (input) => { + 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) => this.toQueryString(input), + }; + mergeRequestParams(params1, params2) { + return { + ...this.baseApiParams, + ...params1, + ...(params2 || {}), + headers: { + ...(this.baseApiParams.headers || {}), + ...(params1.headers || {}), + ...((params2 && params2.headers) || {}), + }, + }; + } + createAbortSignal = (cancelToken) => { + 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; + }; + abortRequest = (cancelToken) => { + const abortController = this.abortControllers.get(cancelToken); + if (abortController) { + abortController.abort(); + this.abortControllers.delete(cancelToken); + } + }; + request = async ({ + body, + secure, + path, + type, + query, + format, + baseUrl, + cancelToken, + ...params + }) => { + 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.instance( + \`\${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; + r.data = null; + r.error = null; + 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 API + * @version 1.0.0 + */ +export class Api extends HttpClient { + users = { + /** + * No description + * + * @name ListUsers + * @summary List users + * @request GET:/users + */ + listUsers: (query, params = {}) => + this.request({ + path: \`/users\`, + method: "GET", + query: query, + format: "json", + ...params, + }), + /** + * No description + * + * @name CreateUser + * @summary Create a user + * @request POST:/users + */ + createUser: (data, params = {}) => + this.request({ + path: \`/users\`, + method: "POST", + body: data, + type: ContentType.Json, + format: "json", + ...params, + }), + /** + * No description + * + * @name GetUser + * @summary Get a user by ID + * @request GET:/users/{id} + * @secure + */ + getUser: (id, params = {}) => + this.request({ + path: \`/users/\${id}\`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + /** + * No description + * + * @name UploadAvatar + * @summary Upload a user avatar + * @request PUT:/users/{id}/avatar + */ + uploadAvatar: (id, data, params = {}) => + this.request({ + path: \`/users/\${id}/avatar\`, + method: "PUT", + body: data, + type: ContentType.FormData, + format: "json", + ...params, + }), + }; +} +" +`; diff --git a/tests/spec/jsKy/basic.test.ts b/tests/spec/jsKy/basic.test.ts new file mode 100644 index 000000000..48834d80b --- /dev/null +++ b/tests/spec/jsKy/basic.test.ts @@ -0,0 +1,35 @@ +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("--http-client ky --js", async () => { + await generateApi({ + fileName: "schema", + input: path.resolve(import.meta.dirname, "schema.json"), + output: tmpdir, + silent: true, + generateClient: true, + httpClientType: "ky", + toJS: true, + }); + + const content = await fs.readFile(path.join(tmpdir, "schema.js"), { + encoding: "utf8", + }); + + expect(content).toMatchSnapshot(); + }); +}); diff --git a/tests/spec/jsKy/schema.json b/tests/spec/jsKy/schema.json new file mode 100644 index 000000000..8bfdd6754 --- /dev/null +++ b/tests/spec/jsKy/schema.json @@ -0,0 +1,177 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Test API", + "version": "1.0.0" + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer" + } + }, + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "email": { "type": "string" } + } + }, + "CreateUserPayload": { + "type": "object", + "required": ["name", "email"], + "properties": { + "name": { "type": "string" }, + "email": { "type": "string" } + } + }, + "Error": { + "type": "object", + "properties": { + "code": { "type": "integer" }, + "message": { "type": "string" } + } + } + } + }, + "security": [], + "paths": { + "/users": { + "get": { + "operationId": "listUsers", + "summary": "List users", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { "type": "integer" } + }, + { + "name": "limit", + "in": "query", + "schema": { "type": "integer" } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/User" } + } + } + } + } + } + }, + "post": { + "operationId": "createUser", + "summary": "Create a user", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateUserPayload" } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/User" } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + } + } + } + }, + "/users/{id}": { + "get": { + "operationId": "getUser", + "summary": "Get a user by ID", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "integer" } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/User" } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + } + } + } + }, + "/users/{id}/avatar": { + "put": { + "operationId": "uploadAvatar", + "summary": "Upload a user avatar", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "integer" } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/User" } + } + } + } + } + } + } + } +} diff --git a/tests/spec/ky/__snapshots__/basic.test.ts.snap b/tests/spec/ky/__snapshots__/basic.test.ts.snap new file mode 100644 index 000000000..eb8089350 --- /dev/null +++ b/tests/spec/ky/__snapshots__/basic.test.ts.snap @@ -0,0 +1,386 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`basic > --http-client ky 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 User { + id?: number; + name?: string; + email?: string; +} + +export interface CreateUserPayload { + name: string; + email: string; +} + +export interface Error { + code?: number; + message?: string; +} + +import type { KyInstance } from "ky"; +import ky from "ky"; + +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?: typeof fetch; + private instance: KyInstance; + + private baseApiParams: RequestParams = { + credentials: "same-origin", + headers: {}, + redirect: "follow", + referrerPolicy: "no-referrer", + }; + + constructor(apiConfig: ApiConfig = {}) { + Object.assign(this, apiConfig); + // ky wraps fetch; use the caller-supplied fetch if provided, otherwise the global. + // throwHttpErrors is always false so we handle errors ourselves (matching fetch behavior). + this.instance = ky.create({ + fetch: this.customFetch ?? fetch, + throwHttpErrors: false, + }); + } + + 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.instance( + \`\${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 Test API + * @version 1.0.0 + */ +export class Api< + SecurityDataType extends unknown, +> extends HttpClient { + users = { + /** + * No description + * + * @name ListUsers + * @summary List users + * @request GET:/users + */ + listUsers: ( + query?: { + page?: number; + limit?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: \`/users\`, + method: "GET", + query: query, + format: "json", + ...params, + }), + + /** + * No description + * + * @name CreateUser + * @summary Create a user + * @request POST:/users + */ + createUser: (data: CreateUserPayload, params: RequestParams = {}) => + this.request({ + path: \`/users\`, + method: "POST", + body: data, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * No description + * + * @name GetUser + * @summary Get a user by ID + * @request GET:/users/{id} + * @secure + */ + getUser: (id: number, params: RequestParams = {}) => + this.request({ + path: \`/users/\${id}\`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * No description + * + * @name UploadAvatar + * @summary Upload a user avatar + * @request PUT:/users/{id}/avatar + */ + uploadAvatar: ( + id: number, + data: { + /** @format binary */ + file?: File; + }, + params: RequestParams = {}, + ) => + this.request({ + path: \`/users/\${id}/avatar\`, + method: "PUT", + body: data, + type: ContentType.FormData, + format: "json", + ...params, + }), + }; +} +" +`; diff --git a/tests/spec/ky/basic.test.ts b/tests/spec/ky/basic.test.ts new file mode 100644 index 000000000..4760f7d2d --- /dev/null +++ b/tests/spec/ky/basic.test.ts @@ -0,0 +1,34 @@ +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("--http-client ky", async () => { + await generateApi({ + fileName: "schema", + input: path.resolve(import.meta.dirname, "schema.json"), + output: tmpdir, + silent: true, + generateClient: true, + httpClientType: "ky", + }); + + const content = await fs.readFile(path.join(tmpdir, "schema.ts"), { + encoding: "utf8", + }); + + expect(content).toMatchSnapshot(); + }); +}); diff --git a/tests/spec/ky/schema.json b/tests/spec/ky/schema.json new file mode 100644 index 000000000..8bfdd6754 --- /dev/null +++ b/tests/spec/ky/schema.json @@ -0,0 +1,177 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Test API", + "version": "1.0.0" + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer" + } + }, + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "email": { "type": "string" } + } + }, + "CreateUserPayload": { + "type": "object", + "required": ["name", "email"], + "properties": { + "name": { "type": "string" }, + "email": { "type": "string" } + } + }, + "Error": { + "type": "object", + "properties": { + "code": { "type": "integer" }, + "message": { "type": "string" } + } + } + } + }, + "security": [], + "paths": { + "/users": { + "get": { + "operationId": "listUsers", + "summary": "List users", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { "type": "integer" } + }, + { + "name": "limit", + "in": "query", + "schema": { "type": "integer" } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/User" } + } + } + } + } + } + }, + "post": { + "operationId": "createUser", + "summary": "Create a user", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateUserPayload" } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/User" } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + } + } + } + }, + "/users/{id}": { + "get": { + "operationId": "getUser", + "summary": "Get a user by ID", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "integer" } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/User" } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + } + } + } + }, + "/users/{id}/avatar": { + "put": { + "operationId": "uploadAvatar", + "summary": "Upload a user avatar", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "integer" } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/User" } + } + } + } + } + } + } + } +} diff --git a/tests/spec/kySingleHttpClient/__snapshots__/basic.test.ts.snap b/tests/spec/kySingleHttpClient/__snapshots__/basic.test.ts.snap new file mode 100644 index 000000000..847a9ded3 --- /dev/null +++ b/tests/spec/kySingleHttpClient/__snapshots__/basic.test.ts.snap @@ -0,0 +1,390 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`basic > --http-client ky --single-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 interface User { + id?: number; + name?: string; + email?: string; +} + +export interface CreateUserPayload { + name: string; + email: string; +} + +export interface Error { + code?: number; + message?: string; +} + +import type { KyInstance } from "ky"; +import ky from "ky"; + +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?: typeof fetch; + private instance: KyInstance; + + private baseApiParams: RequestParams = { + credentials: "same-origin", + headers: {}, + redirect: "follow", + referrerPolicy: "no-referrer", + }; + + constructor(apiConfig: ApiConfig = {}) { + Object.assign(this, apiConfig); + // ky wraps fetch; use the caller-supplied fetch if provided, otherwise the global. + // throwHttpErrors is always false so we handle errors ourselves (matching fetch behavior). + this.instance = ky.create({ + fetch: this.customFetch ?? fetch, + throwHttpErrors: false, + }); + } + + 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.instance( + \`\${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 Test API + * @version 1.0.0 + */ +export class Api { + http: HttpClient; + + constructor(http: HttpClient) { + this.http = http; + } + + users = { + /** + * No description + * + * @name ListUsers + * @summary List users + * @request GET:/users + */ + listUsers: ( + query?: { + page?: number; + limit?: number; + }, + params: RequestParams = {}, + ) => + this.http.request({ + path: \`/users\`, + method: "GET", + query: query, + format: "json", + ...params, + }), + + /** + * No description + * + * @name CreateUser + * @summary Create a user + * @request POST:/users + */ + createUser: (data: CreateUserPayload, params: RequestParams = {}) => + this.http.request({ + path: \`/users\`, + method: "POST", + body: data, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * No description + * + * @name GetUser + * @summary Get a user by ID + * @request GET:/users/{id} + * @secure + */ + getUser: (id: number, params: RequestParams = {}) => + this.http.request({ + path: \`/users/\${id}\`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * No description + * + * @name UploadAvatar + * @summary Upload a user avatar + * @request PUT:/users/{id}/avatar + */ + uploadAvatar: ( + id: number, + data: { + /** @format binary */ + file?: File; + }, + params: RequestParams = {}, + ) => + this.http.request({ + path: \`/users/\${id}/avatar\`, + method: "PUT", + body: data, + type: ContentType.FormData, + format: "json", + ...params, + }), + }; +} +" +`; diff --git a/tests/spec/kySingleHttpClient/basic.test.ts b/tests/spec/kySingleHttpClient/basic.test.ts new file mode 100644 index 000000000..69dde74b1 --- /dev/null +++ b/tests/spec/kySingleHttpClient/basic.test.ts @@ -0,0 +1,35 @@ +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("--http-client ky --single-http-client", async () => { + await generateApi({ + fileName: "schema", + input: path.resolve(import.meta.dirname, "schema.json"), + output: tmpdir, + silent: true, + generateClient: true, + httpClientType: "ky", + singleHttpClient: true, + }); + + const content = await fs.readFile(path.join(tmpdir, "schema.ts"), { + encoding: "utf8", + }); + + expect(content).toMatchSnapshot(); + }); +}); diff --git a/tests/spec/kySingleHttpClient/schema.json b/tests/spec/kySingleHttpClient/schema.json new file mode 100644 index 000000000..8bfdd6754 --- /dev/null +++ b/tests/spec/kySingleHttpClient/schema.json @@ -0,0 +1,177 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Test API", + "version": "1.0.0" + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer" + } + }, + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "email": { "type": "string" } + } + }, + "CreateUserPayload": { + "type": "object", + "required": ["name", "email"], + "properties": { + "name": { "type": "string" }, + "email": { "type": "string" } + } + }, + "Error": { + "type": "object", + "properties": { + "code": { "type": "integer" }, + "message": { "type": "string" } + } + } + } + }, + "security": [], + "paths": { + "/users": { + "get": { + "operationId": "listUsers", + "summary": "List users", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { "type": "integer" } + }, + { + "name": "limit", + "in": "query", + "schema": { "type": "integer" } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/User" } + } + } + } + } + } + }, + "post": { + "operationId": "createUser", + "summary": "Create a user", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateUserPayload" } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/User" } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + } + } + } + }, + "/users/{id}": { + "get": { + "operationId": "getUser", + "summary": "Get a user by ID", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "integer" } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/User" } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + } + } + } + }, + "/users/{id}/avatar": { + "put": { + "operationId": "uploadAvatar", + "summary": "Upload a user avatar", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "integer" } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/User" } + } + } + } + } + } + } + } +} diff --git a/types/index.ts b/types/index.ts index 7d52b6add..0e7f1da3d 100644 --- a/types/index.ts +++ b/types/index.ts @@ -589,7 +589,7 @@ export interface GenerateApiConfiguration { /** extract request body type to data contract */ extractRequestBody: boolean; /** generated http client type */ - httpClientType: "axios" | "fetch"; + httpClientType: "axios" | "fetch" | "ky"; /** generate readonly properties */ addReadonly: boolean; /** customise primitive type mappings */ From 56b19df9bd23a823d6b7b35b55e42794ed4c0d4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Torres?= Date: Sat, 27 Jun 2026 13:10:40 +0200 Subject: [PATCH 2/3] Address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add timeout: false and retry: 0 to ky.create() — fetch has no built-in timeout or retry; disabling ky's defaults preserves parity - Move AbortController cleanup to .finally() so it runs on rejection too - Restore sta bin alias and fix tsdown.config.ts to emit both bin entries explicitly (bun add had dropped sta from the auto-generated bin field) - Use HttpClientType alias in types/index.ts instead of a hard-coded union - Update changeset to drop "drop-in match" claim and note retry/timeout defaults Co-Authored-By: Claude Sonnet 4.6 --- .changeset/add-ky-http-client.md | 2 +- package.json | 1 + .../base/http-clients/ky-http-client.ejs | 14 ++-- .../jsKy/__snapshots__/basic.test.ts.snap | 61 +++++++++-------- .../spec/ky/__snapshots__/basic.test.ts.snap | 66 ++++++++++--------- .../__snapshots__/basic.test.ts.snap | 66 ++++++++++--------- tsdown.config.ts | 7 +- types/index.ts | 2 +- 8 files changed, 124 insertions(+), 95 deletions(-) diff --git a/.changeset/add-ky-http-client.md b/.changeset/add-ky-http-client.md index 75fd68545..856b40220 100644 --- a/.changeset/add-ky-http-client.md +++ b/.changeset/add-ky-http-client.md @@ -6,6 +6,6 @@ Add ky HTTP client support The `--http-client ky` CLI flag and `httpClientType: "ky"` library option now generate an API client backed by [ky](https://github.com/sindresorhus/ky), a tiny fetch-based HTTP client with retries and a cleaner API. -The generated ky client is a drop-in match for the Fetch client: it uses the same `HttpResponse` response wrapper, `ResponseFormat`, query serialization, `secure`/`securityWorker`, `cancelToken`, `unwrapResponseData`, and `disableThrowOnError` semantics. Users only need to install `ky` as a dependency in their project. +The generated ky client reuses the same `HttpResponse` response wrapper, `ResponseFormat`, query serialization, `secure`/`securityWorker`, `cancelToken`, `unwrapResponseData`, and `disableThrowOnError` semantics. ky's default timeout (10 s) and retry behavior are both disabled so the generated client behaves like the Fetch client out of the box; users can re-enable them via `ApiConfig.baseApiParams`. Users only need to install `ky` as a dependency in their project. Also fixes a CLI bug where any truthy `--http-client` value was silently coerced to `axios` instead of being honored. diff --git a/package.json b/package.json index e57f8677a..e8c7bb711 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "module": "./dist/index.mjs", "types": "./dist/index.d.cts", "bin": { + "sta": "./dist/cli.mjs", "swagger-typescript-api": "./dist/cli.mjs" }, "files": [ diff --git a/templates/base/http-clients/ky-http-client.ejs b/templates/base/http-clients/ky-http-client.ejs index a4c72bf60..b5c285ed1 100644 --- a/templates/base/http-clients/ky-http-client.ejs +++ b/templates/base/http-clients/ky-http-client.ejs @@ -91,10 +91,14 @@ export class HttpClient { constructor(apiConfig: ApiConfig = {}) { Object.assign(this, apiConfig); // ky wraps fetch; use the caller-supplied fetch if provided, otherwise the global. - // throwHttpErrors is always false so we handle errors ourselves (matching fetch behavior). + // throwHttpErrors: false — we handle non-2xx ourselves to match fetch behavior. + // timeout: false — fetch has no built-in timeout; disable ky's default 10 s limit. + // retry: 0 — fetch does not retry; disable ky's default retry behavior. this.instance = ky.create({ fetch: this.customFetch ?? fetch, throwHttpErrors: false, + timeout: false, + retry: 0, }); } @@ -246,10 +250,6 @@ export class HttpClient { return r; }); - if (cancelToken) { - this.abortControllers.delete(cancelToken); - } - <% if (!config.disableThrowOnError) { %> if (!response.ok) throw data; <% } %> @@ -258,6 +258,10 @@ export class HttpClient { <% } else { %> return data; <% } %> + }).finally(() => { + if (cancelToken) { + this.abortControllers.delete(cancelToken); + } }); }; } diff --git a/tests/spec/jsKy/__snapshots__/basic.test.ts.snap b/tests/spec/jsKy/__snapshots__/basic.test.ts.snap index 1d79ab351..791f845b2 100644 --- a/tests/spec/jsKy/__snapshots__/basic.test.ts.snap +++ b/tests/spec/jsKy/__snapshots__/basic.test.ts.snap @@ -38,10 +38,14 @@ export class HttpClient { constructor(apiConfig = {}) { Object.assign(this, apiConfig); // ky wraps fetch; use the caller-supplied fetch if provided, otherwise the global. - // throwHttpErrors is always false so we handle errors ourselves (matching fetch behavior). + // throwHttpErrors: false — we handle non-2xx ourselves to match fetch behavior. + // timeout: false — fetch has no built-in timeout; disable ky's default 10 s limit. + // retry: 0 — fetch does not retry; disable ky's default retry behavior. this.instance = ky.create({ fetch: this.customFetch ?? fetch, throwHttpErrors: false, + timeout: false, + retry: 0, }); } setSecurityData = (data) => { @@ -177,32 +181,35 @@ export class HttpClient { ? null : payloadFormatter(body), }, - ).then(async (response) => { - const r = response; - r.data = null; - r.error = null; - 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; - }); + ) + .then(async (response) => { + const r = response; + r.data = null; + r.error = null; + 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 (!response.ok) throw data; + return data; + }) + .finally(() => { + if (cancelToken) { + this.abortControllers.delete(cancelToken); + } + }); }; } /** diff --git a/tests/spec/ky/__snapshots__/basic.test.ts.snap b/tests/spec/ky/__snapshots__/basic.test.ts.snap index eb8089350..25f5606f1 100644 --- a/tests/spec/ky/__snapshots__/basic.test.ts.snap +++ b/tests/spec/ky/__snapshots__/basic.test.ts.snap @@ -102,10 +102,14 @@ export class HttpClient { constructor(apiConfig: ApiConfig = {}) { Object.assign(this, apiConfig); // ky wraps fetch; use the caller-supplied fetch if provided, otherwise the global. - // throwHttpErrors is always false so we handle errors ourselves (matching fetch behavior). + // throwHttpErrors: false — we handle non-2xx ourselves to match fetch behavior. + // timeout: false — fetch has no built-in timeout; disable ky's default 10 s limit. + // retry: 0 — fetch does not retry; disable ky's default retry behavior. this.instance = ky.create({ fetch: this.customFetch ?? fetch, throwHttpErrors: false, + timeout: false, + retry: 0, }); } @@ -261,35 +265,37 @@ export class HttpClient { ? 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; - }); + ) + .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 (!response.ok) throw data; + return data; + }) + .finally(() => { + if (cancelToken) { + this.abortControllers.delete(cancelToken); + } + }); }; } diff --git a/tests/spec/kySingleHttpClient/__snapshots__/basic.test.ts.snap b/tests/spec/kySingleHttpClient/__snapshots__/basic.test.ts.snap index 847a9ded3..5e0e16f5e 100644 --- a/tests/spec/kySingleHttpClient/__snapshots__/basic.test.ts.snap +++ b/tests/spec/kySingleHttpClient/__snapshots__/basic.test.ts.snap @@ -102,10 +102,14 @@ export class HttpClient { constructor(apiConfig: ApiConfig = {}) { Object.assign(this, apiConfig); // ky wraps fetch; use the caller-supplied fetch if provided, otherwise the global. - // throwHttpErrors is always false so we handle errors ourselves (matching fetch behavior). + // throwHttpErrors: false — we handle non-2xx ourselves to match fetch behavior. + // timeout: false — fetch has no built-in timeout; disable ky's default 10 s limit. + // retry: 0 — fetch does not retry; disable ky's default retry behavior. this.instance = ky.create({ fetch: this.customFetch ?? fetch, throwHttpErrors: false, + timeout: false, + retry: 0, }); } @@ -261,35 +265,37 @@ export class HttpClient { ? 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; - }); + ) + .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 (!response.ok) throw data; + return data; + }) + .finally(() => { + if (cancelToken) { + this.abortControllers.delete(cancelToken); + } + }); }; } diff --git a/tsdown.config.ts b/tsdown.config.ts index 044d2e94c..57bdd7465 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -8,5 +8,10 @@ export default defineConfig({ dts: true, format: ["esm", "cjs"], sourcemap: true, - exports: true, + exports: { + bin: { + sta: "./index.ts", + "swagger-typescript-api": "./index.ts", + }, + }, }); diff --git a/types/index.ts b/types/index.ts index 0e7f1da3d..2b3b25a6f 100644 --- a/types/index.ts +++ b/types/index.ts @@ -589,7 +589,7 @@ export interface GenerateApiConfiguration { /** extract request body type to data contract */ extractRequestBody: boolean; /** generated http client type */ - httpClientType: "axios" | "fetch" | "ky"; + httpClientType: HttpClientType; /** generate readonly properties */ addReadonly: boolean; /** customise primitive type mappings */ From 17be8d5d9b0b36c126e2a9be9e2f884ed6402488 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Torres?= Date: Sat, 27 Jun 2026 20:15:39 +0200 Subject: [PATCH 3/3] Error on unrecognised --http-client value Previously any invalid value silently fell back to fetch. Now the CLI throws with a clear message listing the valid options. Co-Authored-By: Claude Sonnet 4.6 --- index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/index.ts b/index.ts index ebb3097cd..5c84b83f6 100644 --- a/index.ts +++ b/index.ts @@ -345,7 +345,13 @@ const generateCommand = defineCommand({ httpClientType: (() => { const raw = args["http-client"] as string | undefined; const validValues = Object.values(HTTP_CLIENT) as string[]; - if (raw && validValues.includes(raw)) return raw as HttpClientType; + if (raw) { + if (!validValues.includes(raw)) + throw new Error( + `Invalid --http-client value "${raw}". Valid values: ${validValues.join(", ")}`, + ); + return raw as HttpClientType; + } if (args.axios) return HTTP_CLIENT.AXIOS; return HTTP_CLIENT.FETCH; })(),