diff --git a/.changeset/add-ky-http-client.md b/.changeset/add-ky-http-client.md new file mode 100644 index 000000000..856b40220 --- /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 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/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..5c84b83f6 100644 --- a/index.ts +++ b/index.ts @@ -342,10 +342,19 @@ 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) { + 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; + })(), 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..e8c7bb711 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,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..b5c285ed1 --- /dev/null +++ b/templates/base/http-clients/ky-http-client.ejs @@ -0,0 +1,267 @@ +<% +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: 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, + }); + } + + 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 (!config.disableThrowOnError) { %> + if (!response.ok) throw data; +<% } %> +<% if (config.unwrapResponseData) { %> + return data.data; +<% } else { %> + return data; +<% } %> + }).finally(() => { + if (cancelToken) { + this.abortControllers.delete(cancelToken); + } + }); + }; +} 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..791f845b2 --- /dev/null +++ b/tests/spec/jsKy/__snapshots__/basic.test.ts.snap @@ -0,0 +1,287 @@ +// 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: 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) => { + 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 (!response.ok) throw data; + return data; + }) + .finally(() => { + if (cancelToken) { + this.abortControllers.delete(cancelToken); + } + }); + }; +} +/** + * @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..25f5606f1 --- /dev/null +++ b/tests/spec/ky/__snapshots__/basic.test.ts.snap @@ -0,0 +1,392 @@ +// 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: 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, + }); + } + + 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 (!response.ok) throw data; + return data; + }) + .finally(() => { + if (cancelToken) { + this.abortControllers.delete(cancelToken); + } + }); + }; +} + +/** + * @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..5e0e16f5e --- /dev/null +++ b/tests/spec/kySingleHttpClient/__snapshots__/basic.test.ts.snap @@ -0,0 +1,396 @@ +// 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: 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, + }); + } + + 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 (!response.ok) throw data; + return data; + }) + .finally(() => { + if (cancelToken) { + this.abortControllers.delete(cancelToken); + } + }); + }; +} + +/** + * @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/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 7d52b6add..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"; + httpClientType: HttpClientType; /** generate readonly properties */ addReadonly: boolean; /** customise primitive type mappings */