diff --git a/.changeset/chilled-tools-complain.md b/.changeset/chilled-tools-complain.md
new file mode 100644
index 000000000..869140301
--- /dev/null
+++ b/.changeset/chilled-tools-complain.md
@@ -0,0 +1,5 @@
+---
+"@hey-api/openapi-ts": patch
+---
+
+**clients**: add support for Ky client
diff --git a/examples/openapi-ts-ky/.gitignore b/examples/openapi-ts-ky/.gitignore
new file mode 100644
index 000000000..a547bf36d
--- /dev/null
+++ b/examples/openapi-ts-ky/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/examples/openapi-ts-ky/CHANGELOG.md b/examples/openapi-ts-ky/CHANGELOG.md
new file mode 100644
index 000000000..8de47d766
--- /dev/null
+++ b/examples/openapi-ts-ky/CHANGELOG.md
@@ -0,0 +1,7 @@
+# @example/openapi-ts-ky
+
+## 0.0.1
+
+### Patch Changes
+
+- Initial release of ky client example
diff --git a/examples/openapi-ts-ky/index.html b/examples/openapi-ts-ky/index.html
new file mode 100644
index 000000000..d301a3a91
--- /dev/null
+++ b/examples/openapi-ts-ky/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Hey API + Fetch API Demo
+
+
+
+
+
+
diff --git a/examples/openapi-ts-ky/openapi-ts.config.ts b/examples/openapi-ts-ky/openapi-ts.config.ts
new file mode 100644
index 000000000..6e3b33f8b
--- /dev/null
+++ b/examples/openapi-ts-ky/openapi-ts.config.ts
@@ -0,0 +1,20 @@
+import { defineConfig } from '@hey-api/openapi-ts';
+
+export default defineConfig({
+ input:
+ 'https://raw.githubusercontent.com/swagger-api/swagger-petstore/master/src/main/resources/openapi.yaml',
+ output: {
+ format: 'prettier',
+ lint: 'eslint',
+ path: './src/client',
+ },
+ plugins: [
+ '@hey-api/client-ky',
+ '@hey-api/schemas',
+ '@hey-api/sdk',
+ {
+ enums: 'javascript',
+ name: '@hey-api/typescript',
+ },
+ ],
+});
diff --git a/examples/openapi-ts-ky/package.json b/examples/openapi-ts-ky/package.json
new file mode 100644
index 000000000..76fe42f59
--- /dev/null
+++ b/examples/openapi-ts-ky/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@example/openapi-ts-ky",
+ "private": true,
+ "version": "0.0.1",
+ "type": "module",
+ "scripts": {
+ "build": "tsc && vite build",
+ "dev": "vite",
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+ "openapi-ts": "openapi-ts",
+ "preview": "vite preview",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@radix-ui/react-form": "0.1.1",
+ "@radix-ui/react-icons": "1.3.2",
+ "@radix-ui/themes": "3.1.6",
+ "ky": "1.14.0",
+ "react": "19.0.0",
+ "react-dom": "19.0.0"
+ },
+ "devDependencies": {
+ "@config/vite-base": "workspace:*",
+ "@hey-api/openapi-ts": "workspace:*",
+ "@types/react": "19.0.1",
+ "@types/react-dom": "19.0.1",
+ "@typescript-eslint/eslint-plugin": "8.29.1",
+ "@typescript-eslint/parser": "8.29.1",
+ "@vitejs/plugin-react": "4.4.0-beta.1",
+ "autoprefixer": "10.4.19",
+ "eslint": "9.17.0",
+ "eslint-plugin-react-hooks": "5.2.0",
+ "eslint-plugin-react-refresh": "0.4.7",
+ "postcss": "8.4.41",
+ "prettier": "3.4.2",
+ "tailwindcss": "3.4.9",
+ "typescript": "5.8.3",
+ "vite": "7.1.2"
+ }
+}
diff --git a/examples/openapi-ts-ky/postcss.config.js b/examples/openapi-ts-ky/postcss.config.js
new file mode 100644
index 000000000..9eef821c4
--- /dev/null
+++ b/examples/openapi-ts-ky/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ autoprefixer: {},
+ tailwindcss: {},
+ },
+};
diff --git a/examples/openapi-ts-ky/src/App.css b/examples/openapi-ts-ky/src/App.css
new file mode 100644
index 000000000..b5c61c956
--- /dev/null
+++ b/examples/openapi-ts-ky/src/App.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/examples/openapi-ts-ky/src/App.tsx b/examples/openapi-ts-ky/src/App.tsx
new file mode 100644
index 000000000..915ebb592
--- /dev/null
+++ b/examples/openapi-ts-ky/src/App.tsx
@@ -0,0 +1,243 @@
+import './App.css';
+
+import * as Form from '@radix-ui/react-form';
+import { DownloadIcon, PlusIcon, ReloadIcon } from '@radix-ui/react-icons';
+import {
+ Avatar,
+ Box,
+ Button,
+ Card,
+ Container,
+ Flex,
+ Heading,
+ Section,
+ Text,
+ TextField,
+} from '@radix-ui/themes';
+import { useState } from 'react';
+
+import { createClient } from './client/client';
+import { PetSchema } from './client/schemas.gen';
+import { addPet, getPetById, updatePet } from './client/sdk.gen';
+import type { Pet } from './client/types.gen';
+
+const localClient = createClient({
+ // set default base url for requests made by this client
+ baseUrl: 'https://petstore3.swagger.io/api/v3',
+ /**
+ * Set default headers only for requests made by this client. This is to
+ * demonstrate local clients and their configuration taking precedence over
+ * internal service client.
+ */
+ headers: {
+ Authorization: 'Bearer ',
+ },
+});
+
+localClient.interceptors.request.use((request, options) => {
+ // Middleware is great for adding authorization tokens to requests made to
+ // protected paths. Headers are set randomly here to allow surfacing the
+ // default headers, too.
+ if (
+ options.url === '/pet/{petId}' &&
+ options.method === 'GET' &&
+ Math.random() < 0.5
+ ) {
+ request.headers.set('Authorization', 'Bearer ');
+ }
+ return request;
+});
+
+localClient.interceptors.error.use((error) => {
+ console.log(error);
+ return error;
+});
+
+function App() {
+ const [pet, setPet] = useState();
+ const [isRequiredNameError, setIsRequiredNameError] = useState(false);
+
+ const onAddPet = async (formData: FormData) => {
+ // simple form field validation to demonstrate using schemas
+ if (PetSchema.required.includes('name') && !formData.get('name')) {
+ setIsRequiredNameError(true);
+ return;
+ }
+
+ const { data, error } = await addPet({
+ body: {
+ category: {
+ id: 0,
+ name: formData.get('category') as string,
+ },
+ id: 0,
+ name: formData.get('name') as string,
+ photoUrls: ['string'],
+ status: 'available',
+ tags: [
+ {
+ id: 0,
+ name: 'string',
+ },
+ ],
+ },
+ });
+ if (error) {
+ console.log(error);
+ return;
+ }
+ setPet(data!);
+ setIsRequiredNameError(false);
+ };
+
+ const onGetPetById = async () => {
+ const { data, error } = await getPetById({
+ client: localClient,
+ path: {
+ // random id 1-10
+ petId: Math.floor(Math.random() * (10 - 1 + 1) + 1),
+ },
+ });
+ if (error) {
+ console.log(error);
+ return;
+ }
+ setPet(data!);
+ };
+
+ const onUpdatePet = async () => {
+ const { data, error } = await updatePet({
+ body: {
+ category: {
+ id: 0,
+ name: 'Cats',
+ },
+ id: 2,
+ name: 'Updated Kitty',
+ photoUrls: ['string'],
+ status: 'available',
+ tags: [
+ {
+ id: 0,
+ name: 'string',
+ },
+ ],
+ },
+ // setting headers per request
+ headers: {
+ Authorization: 'Bearer ',
+ },
+ });
+ if (error) {
+ console.log(error);
+ return;
+ }
+ setPet(data!);
+ };
+
+ return (
+
+
+
+
+
+
+
+ @hey-api/openapi-ts 🤝 Fetch API
+
+
+
+
+
+
+
+
+
+ Name: {pet?.name ?? 'N/A'}
+
+
+ Category: {pet?.category?.name ?? 'N/A'}
+
+
+
+
+
+
+
+
+
+ {
+ event.preventDefault();
+ onAddPet(new FormData(event.currentTarget));
+ }}
+ >
+
+
+
+ Name
+
+ {isRequiredNameError && (
+
+ Please enter a name
+
+ )}
+
+
+
+
+
+
+
+
+ Category
+
+
+ Please enter a category
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default App;
diff --git a/examples/openapi-ts-ky/src/client/client.gen.ts b/examples/openapi-ts-ky/src/client/client.gen.ts
new file mode 100644
index 000000000..069f4daba
--- /dev/null
+++ b/examples/openapi-ts-ky/src/client/client.gen.ts
@@ -0,0 +1,27 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+import {
+ type ClientOptions,
+ type Config,
+ createClient,
+ createConfig,
+} from './client';
+import type { ClientOptions as ClientOptions2 } from './types.gen';
+
+/**
+ * The `createClientConfig()` function will be called on client initialization
+ * and the returned object will become the client's initial configuration.
+ *
+ * You may want to initialize your client this way instead of calling
+ * `setConfig()`. This is useful for example if you're using Next.js
+ * to ensure your client always has the correct values.
+ */
+export type CreateClientConfig = (
+ override?: Config,
+) => Config & T>;
+
+export const client = createClient(
+ createConfig({
+ baseUrl: 'https://petstore3.swagger.io/api/v3',
+ }),
+);
diff --git a/examples/openapi-ts-ky/src/client/client/client.gen.ts b/examples/openapi-ts-ky/src/client/client/client.gen.ts
new file mode 100644
index 000000000..ea60987ff
--- /dev/null
+++ b/examples/openapi-ts-ky/src/client/client/client.gen.ts
@@ -0,0 +1,341 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+import type { HTTPError, Options as KyOptions } from 'ky';
+import ky from 'ky';
+
+import { createSseClient } from '../core/serverSentEvents.gen';
+import type { HttpMethod } from '../core/types.gen';
+import { getValidRequestBody } from '../core/utils.gen';
+import type {
+ Client,
+ Config,
+ RequestOptions,
+ ResolvedRequestOptions,
+ RetryOptions,
+} from './types.gen';
+import type { Middleware } from './utils.gen';
+import {
+ buildUrl,
+ createConfig,
+ createInterceptors,
+ getParseAs,
+ mergeConfigs,
+ mergeHeaders,
+ setAuthParams,
+} from './utils.gen';
+
+export const createClient = (config: Config = {}): Client => {
+ let _config = mergeConfigs(createConfig(), config);
+
+ const getConfig = (): Config => ({ ..._config });
+
+ const setConfig = (config: Config): Config => {
+ _config = mergeConfigs(_config, config);
+ return getConfig();
+ };
+
+ const interceptors = createInterceptors<
+ Request,
+ Response,
+ unknown,
+ ResolvedRequestOptions
+ >();
+
+ const beforeRequest = async (options: RequestOptions) => {
+ const opts = {
+ ..._config,
+ ...options,
+ headers: mergeHeaders(_config.headers, options.headers),
+ ky: options.ky ?? _config.ky ?? ky,
+ serializedBody: undefined,
+ };
+
+ if (opts.security) {
+ await setAuthParams({
+ ...opts,
+ security: opts.security,
+ });
+ }
+
+ if (opts.requestValidator) {
+ await opts.requestValidator(opts);
+ }
+
+ if (opts.body !== undefined && opts.bodySerializer) {
+ opts.serializedBody = opts.bodySerializer(opts.body);
+ }
+
+ if (opts.body === undefined || opts.serializedBody === '') {
+ opts.headers.delete('Content-Type');
+ }
+
+ const url = buildUrl(opts);
+
+ return { opts, url };
+ };
+
+ const parseErrorResponse = async (
+ response: Response,
+ request: Request,
+ opts: ResolvedRequestOptions,
+ interceptorsMiddleware: Middleware<
+ Request,
+ Response,
+ unknown,
+ ResolvedRequestOptions
+ >,
+ ) => {
+ const result = {
+ request,
+ response,
+ };
+
+ const textError = await response.text();
+ let jsonError: unknown;
+
+ try {
+ jsonError = JSON.parse(textError);
+ } catch {
+ jsonError = undefined;
+ }
+
+ const error = jsonError ?? textError;
+ let finalError = error;
+
+ for (const fn of interceptorsMiddleware.error.fns) {
+ if (fn) {
+ finalError = (await fn(error, response, request, opts)) as string;
+ }
+ }
+
+ finalError = finalError || ({} as string);
+
+ if (opts.throwOnError) {
+ throw finalError;
+ }
+
+ return opts.responseStyle === 'data'
+ ? undefined
+ : {
+ error: finalError,
+ ...result,
+ };
+ };
+
+ const request: Client['request'] = async (options) => {
+ // @ts-expect-error
+ const { opts, url } = await beforeRequest(options);
+
+ const kyInstance = opts.ky!;
+
+ const validBody = getValidRequestBody(opts);
+
+ const kyOptions: KyOptions = {
+ body: validBody as BodyInit,
+ cache: opts.cache,
+ credentials: opts.credentials,
+ headers: opts.headers,
+ integrity: opts.integrity,
+ keepalive: opts.keepalive,
+ method: opts.method as KyOptions['method'],
+ mode: opts.mode,
+ redirect: 'follow',
+ referrer: opts.referrer,
+ referrerPolicy: opts.referrerPolicy,
+ signal: opts.signal,
+ throwHttpErrors: opts.throwOnError ?? false,
+ timeout: opts.timeout,
+ ...(opts.kyOptions || {}),
+ };
+
+ if (opts.retry && typeof opts.retry === 'object') {
+ const retryOpts = opts.retry as RetryOptions;
+ kyOptions.retry = {
+ limit: retryOpts.limit ?? 2,
+ methods: retryOpts.methods as Array<
+ | 'get'
+ | 'post'
+ | 'put'
+ | 'patch'
+ | 'head'
+ | 'delete'
+ | 'options'
+ | 'trace'
+ >,
+ statusCodes: retryOpts.statusCodes,
+ };
+ }
+
+ let request = new Request(url, {
+ body: kyOptions.body as BodyInit,
+ headers: kyOptions.headers as HeadersInit,
+ method: kyOptions.method,
+ });
+
+ for (const fn of interceptors.request.fns) {
+ if (fn) {
+ request = await fn(request, opts);
+ }
+ }
+
+ let response: Response;
+
+ try {
+ response = await kyInstance(request, kyOptions);
+ } catch (error) {
+ if (error && typeof error === 'object' && 'response' in error) {
+ const httpError = error as HTTPError;
+ response = httpError.response;
+
+ for (const fn of interceptors.response.fns) {
+ if (fn) {
+ response = await fn(response, request, opts);
+ }
+ }
+
+ return parseErrorResponse(response, request, opts, interceptors);
+ }
+
+ throw error;
+ }
+
+ for (const fn of interceptors.response.fns) {
+ if (fn) {
+ response = await fn(response, request, opts);
+ }
+ }
+
+ const result = {
+ request,
+ response,
+ };
+
+ if (response.ok) {
+ const parseAs =
+ (opts.parseAs === 'auto'
+ ? getParseAs(response.headers.get('Content-Type'))
+ : opts.parseAs) ?? 'json';
+
+ if (
+ response.status === 204 ||
+ response.headers.get('Content-Length') === '0'
+ ) {
+ let emptyData: any;
+ switch (parseAs) {
+ case 'arrayBuffer':
+ case 'blob':
+ case 'text':
+ emptyData = await response[parseAs]();
+ break;
+ case 'formData':
+ emptyData = new FormData();
+ break;
+ case 'stream':
+ emptyData = response.body;
+ break;
+ case 'json':
+ default:
+ emptyData = {};
+ break;
+ }
+ return opts.responseStyle === 'data'
+ ? emptyData
+ : {
+ data: emptyData,
+ ...result,
+ };
+ }
+
+ let data: any;
+ switch (parseAs) {
+ case 'arrayBuffer':
+ case 'blob':
+ case 'formData':
+ case 'json':
+ case 'text':
+ data = await response[parseAs]();
+ break;
+ case 'stream':
+ return opts.responseStyle === 'data'
+ ? response.body
+ : {
+ data: response.body,
+ ...result,
+ };
+ }
+
+ if (parseAs === 'json') {
+ if (opts.responseValidator) {
+ await opts.responseValidator(data);
+ }
+
+ if (opts.responseTransformer) {
+ data = await opts.responseTransformer(data);
+ }
+ }
+
+ return opts.responseStyle === 'data'
+ ? data
+ : {
+ data,
+ ...result,
+ };
+ }
+
+ return parseErrorResponse(response, request, opts, interceptors);
+ };
+
+ const makeMethodFn =
+ (method: Uppercase) => (options: RequestOptions) =>
+ request({ ...options, method });
+
+ const makeSseFn =
+ (method: Uppercase) => async (options: RequestOptions) => {
+ const { opts, url } = await beforeRequest(options);
+ return createSseClient({
+ ...opts,
+ body: opts.body as BodyInit | null | undefined,
+ fetch: globalThis.fetch,
+ headers: opts.headers as unknown as Record,
+ method,
+ onRequest: async (url, init) => {
+ let request = new Request(url, init);
+ for (const fn of interceptors.request.fns) {
+ if (fn) {
+ request = await fn(request, opts);
+ }
+ }
+ return request;
+ },
+ url,
+ });
+ };
+
+ return {
+ buildUrl,
+ connect: makeMethodFn('CONNECT'),
+ delete: makeMethodFn('DELETE'),
+ get: makeMethodFn('GET'),
+ getConfig,
+ head: makeMethodFn('HEAD'),
+ interceptors,
+ options: makeMethodFn('OPTIONS'),
+ patch: makeMethodFn('PATCH'),
+ post: makeMethodFn('POST'),
+ put: makeMethodFn('PUT'),
+ request,
+ setConfig,
+ sse: {
+ connect: makeSseFn('CONNECT'),
+ delete: makeSseFn('DELETE'),
+ get: makeSseFn('GET'),
+ head: makeSseFn('HEAD'),
+ options: makeSseFn('OPTIONS'),
+ patch: makeSseFn('PATCH'),
+ post: makeSseFn('POST'),
+ put: makeSseFn('PUT'),
+ trace: makeSseFn('TRACE'),
+ },
+ trace: makeMethodFn('TRACE'),
+ } as Client;
+};
diff --git a/examples/openapi-ts-ky/src/client/client/index.ts b/examples/openapi-ts-ky/src/client/client/index.ts
new file mode 100644
index 000000000..c2814a610
--- /dev/null
+++ b/examples/openapi-ts-ky/src/client/client/index.ts
@@ -0,0 +1,26 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+export type { Auth } from '../core/auth.gen';
+export type { QuerySerializerOptions } from '../core/bodySerializer.gen';
+export {
+ formDataBodySerializer,
+ jsonBodySerializer,
+ urlSearchParamsBodySerializer,
+} from '../core/bodySerializer.gen';
+export { buildClientParams } from '../core/params.gen';
+export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen';
+export { createClient } from './client.gen';
+export type {
+ Client,
+ ClientOptions,
+ Config,
+ CreateClientConfig,
+ Options,
+ RequestOptions,
+ RequestResult,
+ ResolvedRequestOptions,
+ ResponseStyle,
+ RetryOptions,
+ TDataShape,
+} from './types.gen';
+export { createConfig, mergeHeaders } from './utils.gen';
diff --git a/examples/openapi-ts-ky/src/client/client/types.gen.ts b/examples/openapi-ts-ky/src/client/client/types.gen.ts
new file mode 100644
index 000000000..62d18352c
--- /dev/null
+++ b/examples/openapi-ts-ky/src/client/client/types.gen.ts
@@ -0,0 +1,273 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+import type { Auth } from '../core/auth.gen';
+import type {
+ ServerSentEventsOptions,
+ ServerSentEventsResult,
+} from '../core/serverSentEvents.gen';
+import type {
+ Client as CoreClient,
+ Config as CoreConfig,
+} from '../core/types.gen';
+import type { Middleware } from './utils.gen';
+
+export type ResponseStyle = 'data' | 'fields';
+
+export interface RetryOptions {
+ /**
+ * Maximum number of retry attempts
+ *
+ * @default 2
+ */
+ limit?: number;
+ /**
+ * HTTP methods to retry
+ *
+ * @default ['get', 'put', 'head', 'delete', 'options', 'trace']
+ */
+ methods?: Array<
+ 'get' | 'post' | 'put' | 'delete' | 'patch' | 'head' | 'options' | 'trace'
+ >;
+ /**
+ * HTTP status codes to retry
+ *
+ * @default [408, 413, 429, 500, 502, 503, 504]
+ */
+ statusCodes?: number[];
+}
+
+export interface Config
+ extends Omit<
+ import('ky').Options,
+ 'body' | 'headers' | 'method' | 'prefixUrl' | 'retry' | 'throwHttpErrors'
+ >,
+ CoreConfig {
+ /**
+ * Base URL for all requests made by this client.
+ */
+ baseUrl?: T['baseUrl'];
+ /**
+ * Ky instance to use. You can use this option to provide a custom
+ * ky instance.
+ */
+ ky?: typeof import('ky').default;
+ /**
+ * Additional ky-specific options that will be passed directly to ky.
+ * This allows you to use any ky option not explicitly exposed in the config.
+ */
+ kyOptions?: Omit;
+ /**
+ * Return the response data parsed in a specified format. By default, `auto`
+ * will infer the appropriate method from the `Content-Type` response header.
+ * You can override this behavior with any of the {@link Body} methods.
+ * Select `stream` if you don't want to parse response data at all.
+ *
+ * @default 'auto'
+ */
+ parseAs?:
+ | 'arrayBuffer'
+ | 'auto'
+ | 'blob'
+ | 'formData'
+ | 'json'
+ | 'stream'
+ | 'text';
+ /**
+ * Should we return only data or multiple fields (data, error, response, etc.)?
+ *
+ * @default 'fields'
+ */
+ responseStyle?: ResponseStyle;
+ /**
+ * Retry configuration
+ */
+ retry?: RetryOptions;
+ /**
+ * Throw an error instead of returning it in the response?
+ *
+ * @default false
+ */
+ throwOnError?: T['throwOnError'];
+ /**
+ * Request timeout in milliseconds
+ *
+ * @default 10000
+ */
+ timeout?: number;
+}
+
+export interface RequestOptions<
+ TData = unknown,
+ TResponseStyle extends ResponseStyle = 'fields',
+ ThrowOnError extends boolean = boolean,
+ Url extends string = string,
+> extends Config<{
+ responseStyle: TResponseStyle;
+ throwOnError: ThrowOnError;
+ }>,
+ Pick<
+ ServerSentEventsOptions,
+ | 'onSseError'
+ | 'onSseEvent'
+ | 'sseDefaultRetryDelay'
+ | 'sseMaxRetryAttempts'
+ | 'sseMaxRetryDelay'
+ > {
+ /**
+ * Any body that you want to add to your request.
+ *
+ * {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
+ */
+ body?: unknown;
+ path?: Record;
+ query?: Record;
+ /**
+ * Security mechanism(s) to use for the request.
+ */
+ security?: ReadonlyArray;
+ url: Url;
+}
+
+export interface ResolvedRequestOptions<
+ TResponseStyle extends ResponseStyle = 'fields',
+ ThrowOnError extends boolean = boolean,
+ Url extends string = string,
+> extends RequestOptions {
+ serializedBody?: string;
+}
+
+export type RequestResult<
+ TData = unknown,
+ TError = unknown,
+ ThrowOnError extends boolean = boolean,
+ TResponseStyle extends ResponseStyle = 'fields',
+> = ThrowOnError extends true
+ ? Promise<
+ TResponseStyle extends 'data'
+ ? TData extends Record
+ ? TData[keyof TData]
+ : TData
+ : {
+ data: TData extends Record
+ ? TData[keyof TData]
+ : TData;
+ request: Request;
+ response: Response;
+ }
+ >
+ : Promise<
+ TResponseStyle extends 'data'
+ ?
+ | (TData extends Record
+ ? TData[keyof TData]
+ : TData)
+ | undefined
+ : (
+ | {
+ data: TData extends Record
+ ? TData[keyof TData]
+ : TData;
+ error: undefined;
+ }
+ | {
+ data: undefined;
+ error: TError extends Record
+ ? TError[keyof TError]
+ : TError;
+ }
+ ) & {
+ request: Request;
+ response: Response;
+ }
+ >;
+
+export interface ClientOptions {
+ baseUrl?: string;
+ responseStyle?: ResponseStyle;
+ throwOnError?: boolean;
+}
+
+type MethodFn = <
+ TData = unknown,
+ TError = unknown,
+ ThrowOnError extends boolean = false,
+ TResponseStyle extends ResponseStyle = 'fields',
+>(
+ options: Omit, 'method'>,
+) => RequestResult;
+
+type SseFn = <
+ TData = unknown,
+ TError = unknown,
+ ThrowOnError extends boolean = false,
+ TResponseStyle extends ResponseStyle = 'fields',
+>(
+ options: Omit, 'method'>,
+) => Promise>;
+
+type RequestFn = <
+ TData = unknown,
+ TError = unknown,
+ ThrowOnError extends boolean = false,
+ TResponseStyle extends ResponseStyle = 'fields',
+>(
+ options: Omit, 'method'> &
+ Pick<
+ Required>,
+ 'method'
+ >,
+) => RequestResult;
+
+type BuildUrlFn = <
+ TData extends {
+ body?: unknown;
+ path?: Record;
+ query?: Record;
+ url: string;
+ },
+>(
+ options: TData & Options,
+) => string;
+
+export type Client = CoreClient<
+ RequestFn,
+ Config,
+ MethodFn,
+ BuildUrlFn,
+ SseFn
+> & {
+ interceptors: Middleware;
+};
+
+/**
+ * The `createClientConfig()` function will be called on client initialization
+ * and the returned object will become the client's initial configuration.
+ *
+ * You may want to initialize your client this way instead of calling
+ * `setConfig()`. This is useful for example if you're using Next.js
+ * to ensure your client always has the correct values.
+ */
+export type CreateClientConfig = (
+ override?: Config,
+) => Config & T>;
+
+export interface TDataShape {
+ body?: unknown;
+ headers?: unknown;
+ path?: unknown;
+ query?: unknown;
+ url: string;
+}
+
+type OmitKeys = Pick>;
+
+export type Options<
+ TData extends TDataShape = TDataShape,
+ ThrowOnError extends boolean = boolean,
+ TResponse = unknown,
+ TResponseStyle extends ResponseStyle = 'fields',
+> = OmitKeys<
+ RequestOptions,
+ 'body' | 'path' | 'query' | 'url'
+> &
+ ([TData] extends [never] ? unknown : Omit);
diff --git a/examples/openapi-ts-ky/src/client/client/utils.gen.ts b/examples/openapi-ts-ky/src/client/client/utils.gen.ts
new file mode 100644
index 000000000..3582a06db
--- /dev/null
+++ b/examples/openapi-ts-ky/src/client/client/utils.gen.ts
@@ -0,0 +1,335 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+import { getAuthToken } from '../core/auth.gen';
+import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
+import { jsonBodySerializer } from '../core/bodySerializer.gen';
+import {
+ serializeArrayParam,
+ serializeObjectParam,
+ serializePrimitiveParam,
+} from '../core/pathSerializer.gen';
+import { getUrl } from '../core/utils.gen';
+import type {
+ Client,
+ ClientOptions,
+ Config,
+ RequestOptions,
+} from './types.gen';
+
+export const createQuerySerializer = ({
+ parameters = {},
+ ...args
+}: QuerySerializerOptions = {}) => {
+ const querySerializer = (queryParams: T) => {
+ const search: string[] = [];
+ if (queryParams && typeof queryParams === 'object') {
+ for (const name in queryParams) {
+ const value = queryParams[name];
+
+ if (value === undefined || value === null) {
+ continue;
+ }
+
+ const options = parameters[name] || args;
+
+ if (Array.isArray(value)) {
+ const serializedArray = serializeArrayParam({
+ allowReserved: options.allowReserved,
+ explode: true,
+ name,
+ style: 'form',
+ value,
+ ...options.array,
+ });
+ if (serializedArray) search.push(serializedArray);
+ } else if (typeof value === 'object') {
+ const serializedObject = serializeObjectParam({
+ allowReserved: options.allowReserved,
+ explode: true,
+ name,
+ style: 'deepObject',
+ value: value as Record,
+ ...options.object,
+ });
+ if (serializedObject) search.push(serializedObject);
+ } else {
+ const serializedPrimitive = serializePrimitiveParam({
+ allowReserved: options.allowReserved,
+ name,
+ value: value as string,
+ });
+ if (serializedPrimitive) search.push(serializedPrimitive);
+ }
+ }
+ }
+ return search.join('&');
+ };
+ return querySerializer;
+};
+
+/**
+ * Infers parseAs value from provided Content-Type header.
+ */
+export const getParseAs = (
+ contentType: string | null,
+): Exclude => {
+ if (!contentType) {
+ return 'stream';
+ }
+
+ const cleanContent = contentType.split(';')[0]?.trim();
+
+ if (!cleanContent) {
+ return;
+ }
+
+ if (
+ cleanContent.startsWith('application/json') ||
+ cleanContent.endsWith('+json')
+ ) {
+ return 'json';
+ }
+
+ if (cleanContent === 'multipart/form-data') {
+ return 'formData';
+ }
+
+ if (
+ ['application/', 'audio/', 'image/', 'video/'].some((type) =>
+ cleanContent.startsWith(type),
+ )
+ ) {
+ return 'blob';
+ }
+
+ if (cleanContent.startsWith('text/')) {
+ return 'text';
+ }
+
+ return;
+};
+
+const checkForExistence = (
+ options: Pick & {
+ headers: Headers;
+ },
+ name?: string,
+): boolean => {
+ if (!name) {
+ return false;
+ }
+ if (
+ options.headers.has(name) ||
+ options.query?.[name] ||
+ options.headers.get('Cookie')?.includes(`${name}=`)
+ ) {
+ return true;
+ }
+ return false;
+};
+
+export const setAuthParams = async ({
+ security,
+ ...options
+}: Pick, 'security'> &
+ Pick & {
+ headers: Headers;
+ }) => {
+ for (const auth of security) {
+ if (checkForExistence(options, auth.name)) {
+ continue;
+ }
+
+ const token = await getAuthToken(auth, options.auth);
+
+ if (!token) {
+ continue;
+ }
+
+ const name = auth.name ?? 'Authorization';
+
+ switch (auth.in) {
+ case 'query':
+ if (!options.query) {
+ options.query = {};
+ }
+ options.query[name] = token;
+ break;
+ case 'cookie':
+ options.headers.append('Cookie', `${name}=${token}`);
+ break;
+ case 'header':
+ default:
+ options.headers.set(name, token);
+ break;
+ }
+ }
+};
+
+export const buildUrl: Client['buildUrl'] = (options) =>
+ getUrl({
+ baseUrl: options.baseUrl as string,
+ path: options.path,
+ query: options.query,
+ querySerializer:
+ typeof options.querySerializer === 'function'
+ ? options.querySerializer
+ : createQuerySerializer(options.querySerializer),
+ url: options.url,
+ });
+
+export const mergeConfigs = (a: Config, b: Config): Config => {
+ const config = { ...a, ...b };
+ if (config.baseUrl?.endsWith('/')) {
+ config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
+ }
+ config.headers = mergeHeaders(a.headers, b.headers);
+ return config;
+};
+
+const headersEntries = (headers: Headers): Array<[string, string]> => {
+ const entries: Array<[string, string]> = [];
+ headers.forEach((value, key) => {
+ entries.push([key, value]);
+ });
+ return entries;
+};
+
+export const mergeHeaders = (
+ ...headers: Array['headers'] | undefined>
+): Headers => {
+ const mergedHeaders = new Headers();
+ for (const header of headers) {
+ if (!header) {
+ continue;
+ }
+
+ const iterator =
+ header instanceof Headers
+ ? headersEntries(header)
+ : Object.entries(header);
+
+ for (const [key, value] of iterator) {
+ if (value === null) {
+ mergedHeaders.delete(key);
+ } else if (Array.isArray(value)) {
+ for (const v of value) {
+ mergedHeaders.append(key, v as string);
+ }
+ } else if (value !== undefined) {
+ mergedHeaders.set(
+ key,
+ typeof value === 'object' ? JSON.stringify(value) : (value as string),
+ );
+ }
+ }
+ }
+ return mergedHeaders;
+};
+
+type ErrInterceptor = (
+ error: Err,
+ response: Res,
+ request: Req,
+ options: Options,
+) => Err | Promise;
+
+type ReqInterceptor = (
+ request: Req,
+ options: Options,
+) => Req | Promise;
+
+type ResInterceptor = (
+ response: Res,
+ request: Req,
+ options: Options,
+) => Res | Promise;
+
+class Interceptors {
+ fns: Array = [];
+
+ clear(): void {
+ this.fns = [];
+ }
+
+ eject(id: number | Interceptor): void {
+ const index = this.getInterceptorIndex(id);
+ if (this.fns[index]) {
+ this.fns[index] = null;
+ }
+ }
+
+ exists(id: number | Interceptor): boolean {
+ const index = this.getInterceptorIndex(id);
+ return Boolean(this.fns[index]);
+ }
+
+ getInterceptorIndex(id: number | Interceptor): number {
+ if (typeof id === 'number') {
+ return this.fns[id] ? id : -1;
+ }
+ return this.fns.indexOf(id);
+ }
+
+ update(
+ id: number | Interceptor,
+ fn: Interceptor,
+ ): number | Interceptor | false {
+ const index = this.getInterceptorIndex(id);
+ if (this.fns[index]) {
+ this.fns[index] = fn;
+ return id;
+ }
+ return false;
+ }
+
+ use(fn: Interceptor): number {
+ this.fns.push(fn);
+ return this.fns.length - 1;
+ }
+}
+
+export interface Middleware {
+ error: Interceptors>;
+ request: Interceptors>;
+ response: Interceptors>;
+}
+
+export const createInterceptors = (): Middleware<
+ Req,
+ Res,
+ Err,
+ Options
+> => ({
+ error: new Interceptors>(),
+ request: new Interceptors>(),
+ response: new Interceptors>(),
+});
+
+const defaultQuerySerializer = createQuerySerializer({
+ allowReserved: false,
+ array: {
+ explode: true,
+ style: 'form',
+ },
+ object: {
+ explode: true,
+ style: 'deepObject',
+ },
+});
+
+const defaultHeaders = {
+ 'Content-Type': 'application/json',
+};
+
+export const createConfig = (
+ override: Config & T> = {},
+): Config & T> => ({
+ ...jsonBodySerializer,
+ headers: defaultHeaders,
+ parseAs: 'auto',
+ querySerializer: defaultQuerySerializer,
+ throwOnError: false,
+ timeout: 10000,
+ ...override,
+});
diff --git a/examples/openapi-ts-ky/src/client/core/auth.gen.ts b/examples/openapi-ts-ky/src/client/core/auth.gen.ts
new file mode 100644
index 000000000..f8a73266f
--- /dev/null
+++ b/examples/openapi-ts-ky/src/client/core/auth.gen.ts
@@ -0,0 +1,42 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+export type AuthToken = string | undefined;
+
+export interface Auth {
+ /**
+ * Which part of the request do we use to send the auth?
+ *
+ * @default 'header'
+ */
+ in?: 'header' | 'query' | 'cookie';
+ /**
+ * Header or query parameter name.
+ *
+ * @default 'Authorization'
+ */
+ name?: string;
+ scheme?: 'basic' | 'bearer';
+ type: 'apiKey' | 'http';
+}
+
+export const getAuthToken = async (
+ auth: Auth,
+ callback: ((auth: Auth) => Promise | AuthToken) | AuthToken,
+): Promise => {
+ const token =
+ typeof callback === 'function' ? await callback(auth) : callback;
+
+ if (!token) {
+ return;
+ }
+
+ if (auth.scheme === 'bearer') {
+ return `Bearer ${token}`;
+ }
+
+ if (auth.scheme === 'basic') {
+ return `Basic ${btoa(token)}`;
+ }
+
+ return token;
+};
diff --git a/examples/openapi-ts-ky/src/client/core/bodySerializer.gen.ts b/examples/openapi-ts-ky/src/client/core/bodySerializer.gen.ts
new file mode 100644
index 000000000..552b50f7c
--- /dev/null
+++ b/examples/openapi-ts-ky/src/client/core/bodySerializer.gen.ts
@@ -0,0 +1,100 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+import type {
+ ArrayStyle,
+ ObjectStyle,
+ SerializerOptions,
+} from './pathSerializer.gen';
+
+export type QuerySerializer = (query: Record) => string;
+
+export type BodySerializer = (body: any) => any;
+
+type QuerySerializerOptionsObject = {
+ allowReserved?: boolean;
+ array?: Partial>;
+ object?: Partial>;
+};
+
+export type QuerySerializerOptions = QuerySerializerOptionsObject & {
+ /**
+ * Per-parameter serialization overrides. When provided, these settings
+ * override the global array/object settings for specific parameter names.
+ */
+ parameters?: Record;
+};
+
+const serializeFormDataPair = (
+ data: FormData,
+ key: string,
+ value: unknown,
+): void => {
+ if (typeof value === 'string' || value instanceof Blob) {
+ data.append(key, value);
+ } else if (value instanceof Date) {
+ data.append(key, value.toISOString());
+ } else {
+ data.append(key, JSON.stringify(value));
+ }
+};
+
+const serializeUrlSearchParamsPair = (
+ data: URLSearchParams,
+ key: string,
+ value: unknown,
+): void => {
+ if (typeof value === 'string') {
+ data.append(key, value);
+ } else {
+ data.append(key, JSON.stringify(value));
+ }
+};
+
+export const formDataBodySerializer = {
+ bodySerializer: | Array>>(
+ body: T,
+ ): FormData => {
+ const data = new FormData();
+
+ Object.entries(body).forEach(([key, value]) => {
+ if (value === undefined || value === null) {
+ return;
+ }
+ if (Array.isArray(value)) {
+ value.forEach((v) => serializeFormDataPair(data, key, v));
+ } else {
+ serializeFormDataPair(data, key, value);
+ }
+ });
+
+ return data;
+ },
+};
+
+export const jsonBodySerializer = {
+ bodySerializer: (body: T): string =>
+ JSON.stringify(body, (_key, value) =>
+ typeof value === 'bigint' ? value.toString() : value,
+ ),
+};
+
+export const urlSearchParamsBodySerializer = {
+ bodySerializer: | Array>>(
+ body: T,
+ ): string => {
+ const data = new URLSearchParams();
+
+ Object.entries(body).forEach(([key, value]) => {
+ if (value === undefined || value === null) {
+ return;
+ }
+ if (Array.isArray(value)) {
+ value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
+ } else {
+ serializeUrlSearchParamsPair(data, key, value);
+ }
+ });
+
+ return data.toString();
+ },
+};
diff --git a/examples/openapi-ts-ky/src/client/core/params.gen.ts b/examples/openapi-ts-ky/src/client/core/params.gen.ts
new file mode 100644
index 000000000..602715c46
--- /dev/null
+++ b/examples/openapi-ts-ky/src/client/core/params.gen.ts
@@ -0,0 +1,176 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+type Slot = 'body' | 'headers' | 'path' | 'query';
+
+export type Field =
+ | {
+ in: Exclude;
+ /**
+ * Field name. This is the name we want the user to see and use.
+ */
+ key: string;
+ /**
+ * Field mapped name. This is the name we want to use in the request.
+ * If omitted, we use the same value as `key`.
+ */
+ map?: string;
+ }
+ | {
+ in: Extract;
+ /**
+ * Key isn't required for bodies.
+ */
+ key?: string;
+ map?: string;
+ }
+ | {
+ /**
+ * Field name. This is the name we want the user to see and use.
+ */
+ key: string;
+ /**
+ * Field mapped name. This is the name we want to use in the request.
+ * If `in` is omitted, `map` aliases `key` to the transport layer.
+ */
+ map: Slot;
+ };
+
+export interface Fields {
+ allowExtra?: Partial>;
+ args?: ReadonlyArray;
+}
+
+export type FieldsConfig = ReadonlyArray;
+
+const extraPrefixesMap: Record = {
+ $body_: 'body',
+ $headers_: 'headers',
+ $path_: 'path',
+ $query_: 'query',
+};
+const extraPrefixes = Object.entries(extraPrefixesMap);
+
+type KeyMap = Map<
+ string,
+ | {
+ in: Slot;
+ map?: string;
+ }
+ | {
+ in?: never;
+ map: Slot;
+ }
+>;
+
+const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
+ if (!map) {
+ map = new Map();
+ }
+
+ for (const config of fields) {
+ if ('in' in config) {
+ if (config.key) {
+ map.set(config.key, {
+ in: config.in,
+ map: config.map,
+ });
+ }
+ } else if ('key' in config) {
+ map.set(config.key, {
+ map: config.map,
+ });
+ } else if (config.args) {
+ buildKeyMap(config.args, map);
+ }
+ }
+
+ return map;
+};
+
+interface Params {
+ body: unknown;
+ headers: Record;
+ path: Record;
+ query: Record;
+}
+
+const stripEmptySlots = (params: Params) => {
+ for (const [slot, value] of Object.entries(params)) {
+ if (value && typeof value === 'object' && !Object.keys(value).length) {
+ delete params[slot as Slot];
+ }
+ }
+};
+
+export const buildClientParams = (
+ args: ReadonlyArray,
+ fields: FieldsConfig,
+) => {
+ const params: Params = {
+ body: {},
+ headers: {},
+ path: {},
+ query: {},
+ };
+
+ const map = buildKeyMap(fields);
+
+ let config: FieldsConfig[number] | undefined;
+
+ for (const [index, arg] of args.entries()) {
+ if (fields[index]) {
+ config = fields[index];
+ }
+
+ if (!config) {
+ continue;
+ }
+
+ if ('in' in config) {
+ if (config.key) {
+ const field = map.get(config.key)!;
+ const name = field.map || config.key;
+ if (field.in) {
+ (params[field.in] as Record)[name] = arg;
+ }
+ } else {
+ params.body = arg;
+ }
+ } else {
+ for (const [key, value] of Object.entries(arg ?? {})) {
+ const field = map.get(key);
+
+ if (field) {
+ if (field.in) {
+ const name = field.map || key;
+ (params[field.in] as Record)[name] = value;
+ } else {
+ params[field.map] = value;
+ }
+ } else {
+ const extra = extraPrefixes.find(([prefix]) =>
+ key.startsWith(prefix),
+ );
+
+ if (extra) {
+ const [prefix, slot] = extra;
+ (params[slot] as Record)[
+ key.slice(prefix.length)
+ ] = value;
+ } else if ('allowExtra' in config && config.allowExtra) {
+ for (const [slot, allowed] of Object.entries(config.allowExtra)) {
+ if (allowed) {
+ (params[slot as Slot] as Record)[key] = value;
+ break;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ stripEmptySlots(params);
+
+ return params;
+};
diff --git a/examples/openapi-ts-ky/src/client/core/pathSerializer.gen.ts b/examples/openapi-ts-ky/src/client/core/pathSerializer.gen.ts
new file mode 100644
index 000000000..8d9993104
--- /dev/null
+++ b/examples/openapi-ts-ky/src/client/core/pathSerializer.gen.ts
@@ -0,0 +1,181 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+interface SerializeOptions
+ extends SerializePrimitiveOptions,
+ SerializerOptions {}
+
+interface SerializePrimitiveOptions {
+ allowReserved?: boolean;
+ name: string;
+}
+
+export interface SerializerOptions {
+ /**
+ * @default true
+ */
+ explode: boolean;
+ style: T;
+}
+
+export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
+export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
+type MatrixStyle = 'label' | 'matrix' | 'simple';
+export type ObjectStyle = 'form' | 'deepObject';
+type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
+
+interface SerializePrimitiveParam extends SerializePrimitiveOptions {
+ value: string;
+}
+
+export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
+ switch (style) {
+ case 'label':
+ return '.';
+ case 'matrix':
+ return ';';
+ case 'simple':
+ return ',';
+ default:
+ return '&';
+ }
+};
+
+export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
+ switch (style) {
+ case 'form':
+ return ',';
+ case 'pipeDelimited':
+ return '|';
+ case 'spaceDelimited':
+ return '%20';
+ default:
+ return ',';
+ }
+};
+
+export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
+ switch (style) {
+ case 'label':
+ return '.';
+ case 'matrix':
+ return ';';
+ case 'simple':
+ return ',';
+ default:
+ return '&';
+ }
+};
+
+export const serializeArrayParam = ({
+ allowReserved,
+ explode,
+ name,
+ style,
+ value,
+}: SerializeOptions & {
+ value: unknown[];
+}) => {
+ if (!explode) {
+ const joinedValues = (
+ allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
+ ).join(separatorArrayNoExplode(style));
+ switch (style) {
+ case 'label':
+ return `.${joinedValues}`;
+ case 'matrix':
+ return `;${name}=${joinedValues}`;
+ case 'simple':
+ return joinedValues;
+ default:
+ return `${name}=${joinedValues}`;
+ }
+ }
+
+ const separator = separatorArrayExplode(style);
+ const joinedValues = value
+ .map((v) => {
+ if (style === 'label' || style === 'simple') {
+ return allowReserved ? v : encodeURIComponent(v as string);
+ }
+
+ return serializePrimitiveParam({
+ allowReserved,
+ name,
+ value: v as string,
+ });
+ })
+ .join(separator);
+ return style === 'label' || style === 'matrix'
+ ? separator + joinedValues
+ : joinedValues;
+};
+
+export const serializePrimitiveParam = ({
+ allowReserved,
+ name,
+ value,
+}: SerializePrimitiveParam) => {
+ if (value === undefined || value === null) {
+ return '';
+ }
+
+ if (typeof value === 'object') {
+ throw new Error(
+ 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.',
+ );
+ }
+
+ return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
+};
+
+export const serializeObjectParam = ({
+ allowReserved,
+ explode,
+ name,
+ style,
+ value,
+ valueOnly,
+}: SerializeOptions & {
+ value: Record | Date;
+ valueOnly?: boolean;
+}) => {
+ if (value instanceof Date) {
+ return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
+ }
+
+ if (style !== 'deepObject' && !explode) {
+ let values: string[] = [];
+ Object.entries(value).forEach(([key, v]) => {
+ values = [
+ ...values,
+ key,
+ allowReserved ? (v as string) : encodeURIComponent(v as string),
+ ];
+ });
+ const joinedValues = values.join(',');
+ switch (style) {
+ case 'form':
+ return `${name}=${joinedValues}`;
+ case 'label':
+ return `.${joinedValues}`;
+ case 'matrix':
+ return `;${name}=${joinedValues}`;
+ default:
+ return joinedValues;
+ }
+ }
+
+ const separator = separatorObjectExplode(style);
+ const joinedValues = Object.entries(value)
+ .map(([key, v]) =>
+ serializePrimitiveParam({
+ allowReserved,
+ name: style === 'deepObject' ? `${name}[${key}]` : key,
+ value: v as string,
+ }),
+ )
+ .join(separator);
+ return style === 'label' || style === 'matrix'
+ ? separator + joinedValues
+ : joinedValues;
+};
diff --git a/examples/openapi-ts-ky/src/client/core/queryKeySerializer.gen.ts b/examples/openapi-ts-ky/src/client/core/queryKeySerializer.gen.ts
new file mode 100644
index 000000000..d3bb68396
--- /dev/null
+++ b/examples/openapi-ts-ky/src/client/core/queryKeySerializer.gen.ts
@@ -0,0 +1,136 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+/**
+ * JSON-friendly union that mirrors what Pinia Colada can hash.
+ */
+export type JsonValue =
+ | null
+ | string
+ | number
+ | boolean
+ | JsonValue[]
+ | { [key: string]: JsonValue };
+
+/**
+ * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
+ */
+export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
+ if (
+ value === undefined ||
+ typeof value === 'function' ||
+ typeof value === 'symbol'
+ ) {
+ return undefined;
+ }
+ if (typeof value === 'bigint') {
+ return value.toString();
+ }
+ if (value instanceof Date) {
+ return value.toISOString();
+ }
+ return value;
+};
+
+/**
+ * Safely stringifies a value and parses it back into a JsonValue.
+ */
+export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
+ try {
+ const json = JSON.stringify(input, queryKeyJsonReplacer);
+ if (json === undefined) {
+ return undefined;
+ }
+ return JSON.parse(json) as JsonValue;
+ } catch {
+ return undefined;
+ }
+};
+
+/**
+ * Detects plain objects (including objects with a null prototype).
+ */
+const isPlainObject = (value: unknown): value is Record => {
+ if (value === null || typeof value !== 'object') {
+ return false;
+ }
+ const prototype = Object.getPrototypeOf(value as object);
+ return prototype === Object.prototype || prototype === null;
+};
+
+/**
+ * Turns URLSearchParams into a sorted JSON object for deterministic keys.
+ */
+const serializeSearchParams = (params: URLSearchParams): JsonValue => {
+ const entries = Array.from(params.entries()).sort(([a], [b]) =>
+ a.localeCompare(b),
+ );
+ const result: Record = {};
+
+ for (const [key, value] of entries) {
+ const existing = result[key];
+ if (existing === undefined) {
+ result[key] = value;
+ continue;
+ }
+
+ if (Array.isArray(existing)) {
+ (existing as string[]).push(value);
+ } else {
+ result[key] = [existing, value];
+ }
+ }
+
+ return result;
+};
+
+/**
+ * Normalizes any accepted value into a JSON-friendly shape for query keys.
+ */
+export const serializeQueryKeyValue = (
+ value: unknown,
+): JsonValue | undefined => {
+ if (value === null) {
+ return null;
+ }
+
+ if (
+ typeof value === 'string' ||
+ typeof value === 'number' ||
+ typeof value === 'boolean'
+ ) {
+ return value;
+ }
+
+ if (
+ value === undefined ||
+ typeof value === 'function' ||
+ typeof value === 'symbol'
+ ) {
+ return undefined;
+ }
+
+ if (typeof value === 'bigint') {
+ return value.toString();
+ }
+
+ if (value instanceof Date) {
+ return value.toISOString();
+ }
+
+ if (Array.isArray(value)) {
+ return stringifyToJsonValue(value);
+ }
+
+ if (
+ typeof URLSearchParams !== 'undefined' &&
+ value instanceof URLSearchParams
+ ) {
+ return serializeSearchParams(value);
+ }
+
+ if (isPlainObject(value)) {
+ return stringifyToJsonValue(value);
+ }
+
+ return undefined;
+};
diff --git a/examples/openapi-ts-ky/src/client/core/serverSentEvents.gen.ts b/examples/openapi-ts-ky/src/client/core/serverSentEvents.gen.ts
new file mode 100644
index 000000000..f8fd78e28
--- /dev/null
+++ b/examples/openapi-ts-ky/src/client/core/serverSentEvents.gen.ts
@@ -0,0 +1,264 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+import type { Config } from './types.gen';
+
+export type ServerSentEventsOptions = Omit<
+ RequestInit,
+ 'method'
+> &
+ Pick & {
+ /**
+ * Fetch API implementation. You can use this option to provide a custom
+ * fetch instance.
+ *
+ * @default globalThis.fetch
+ */
+ fetch?: typeof fetch;
+ /**
+ * Implementing clients can call request interceptors inside this hook.
+ */
+ onRequest?: (url: string, init: RequestInit) => Promise;
+ /**
+ * Callback invoked when a network or parsing error occurs during streaming.
+ *
+ * This option applies only if the endpoint returns a stream of events.
+ *
+ * @param error The error that occurred.
+ */
+ onSseError?: (error: unknown) => void;
+ /**
+ * Callback invoked when an event is streamed from the server.
+ *
+ * This option applies only if the endpoint returns a stream of events.
+ *
+ * @param event Event streamed from the server.
+ * @returns Nothing (void).
+ */
+ onSseEvent?: (event: StreamEvent) => void;
+ serializedBody?: RequestInit['body'];
+ /**
+ * Default retry delay in milliseconds.
+ *
+ * This option applies only if the endpoint returns a stream of events.
+ *
+ * @default 3000
+ */
+ sseDefaultRetryDelay?: number;
+ /**
+ * Maximum number of retry attempts before giving up.
+ */
+ sseMaxRetryAttempts?: number;
+ /**
+ * Maximum retry delay in milliseconds.
+ *
+ * Applies only when exponential backoff is used.
+ *
+ * This option applies only if the endpoint returns a stream of events.
+ *
+ * @default 30000
+ */
+ sseMaxRetryDelay?: number;
+ /**
+ * Optional sleep function for retry backoff.
+ *
+ * Defaults to using `setTimeout`.
+ */
+ sseSleepFn?: (ms: number) => Promise;
+ url: string;
+ };
+
+export interface StreamEvent {
+ data: TData;
+ event?: string;
+ id?: string;
+ retry?: number;
+}
+
+export type ServerSentEventsResult<
+ TData = unknown,
+ TReturn = void,
+ TNext = unknown,
+> = {
+ stream: AsyncGenerator<
+ TData extends Record ? TData[keyof TData] : TData,
+ TReturn,
+ TNext
+ >;
+};
+
+export const createSseClient = ({
+ onRequest,
+ onSseError,
+ onSseEvent,
+ responseTransformer,
+ responseValidator,
+ sseDefaultRetryDelay,
+ sseMaxRetryAttempts,
+ sseMaxRetryDelay,
+ sseSleepFn,
+ url,
+ ...options
+}: ServerSentEventsOptions): ServerSentEventsResult => {
+ let lastEventId: string | undefined;
+
+ const sleep =
+ sseSleepFn ??
+ ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
+
+ const createStream = async function* () {
+ let retryDelay: number = sseDefaultRetryDelay ?? 3000;
+ let attempt = 0;
+ const signal = options.signal ?? new AbortController().signal;
+
+ while (true) {
+ if (signal.aborted) break;
+
+ attempt++;
+
+ const headers =
+ options.headers instanceof Headers
+ ? options.headers
+ : new Headers(options.headers as Record | undefined);
+
+ if (lastEventId !== undefined) {
+ headers.set('Last-Event-ID', lastEventId);
+ }
+
+ try {
+ const requestInit: RequestInit = {
+ redirect: 'follow',
+ ...options,
+ body: options.serializedBody,
+ headers,
+ signal,
+ };
+ let request = new Request(url, requestInit);
+ if (onRequest) {
+ request = await onRequest(url, requestInit);
+ }
+ // fetch must be assigned here, otherwise it would throw the error:
+ // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
+ const _fetch = options.fetch ?? globalThis.fetch;
+ const response = await _fetch(request);
+
+ if (!response.ok)
+ throw new Error(
+ `SSE failed: ${response.status} ${response.statusText}`,
+ );
+
+ if (!response.body) throw new Error('No body in SSE response');
+
+ const reader = response.body
+ .pipeThrough(new TextDecoderStream())
+ .getReader();
+
+ let buffer = '';
+
+ const abortHandler = () => {
+ try {
+ reader.cancel();
+ } catch {
+ // noop
+ }
+ };
+
+ signal.addEventListener('abort', abortHandler);
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += value;
+
+ const chunks = buffer.split('\n\n');
+ buffer = chunks.pop() ?? '';
+
+ for (const chunk of chunks) {
+ const lines = chunk.split('\n');
+ const dataLines: Array = [];
+ let eventName: string | undefined;
+
+ for (const line of lines) {
+ if (line.startsWith('data:')) {
+ dataLines.push(line.replace(/^data:\s*/, ''));
+ } else if (line.startsWith('event:')) {
+ eventName = line.replace(/^event:\s*/, '');
+ } else if (line.startsWith('id:')) {
+ lastEventId = line.replace(/^id:\s*/, '');
+ } else if (line.startsWith('retry:')) {
+ const parsed = Number.parseInt(
+ line.replace(/^retry:\s*/, ''),
+ 10,
+ );
+ if (!Number.isNaN(parsed)) {
+ retryDelay = parsed;
+ }
+ }
+ }
+
+ let data: unknown;
+ let parsedJson = false;
+
+ if (dataLines.length) {
+ const rawData = dataLines.join('\n');
+ try {
+ data = JSON.parse(rawData);
+ parsedJson = true;
+ } catch {
+ data = rawData;
+ }
+ }
+
+ if (parsedJson) {
+ if (responseValidator) {
+ await responseValidator(data);
+ }
+
+ if (responseTransformer) {
+ data = await responseTransformer(data);
+ }
+ }
+
+ onSseEvent?.({
+ data,
+ event: eventName,
+ id: lastEventId,
+ retry: retryDelay,
+ });
+
+ if (dataLines.length) {
+ yield data as any;
+ }
+ }
+ }
+ } finally {
+ signal.removeEventListener('abort', abortHandler);
+ reader.releaseLock();
+ }
+
+ break; // exit loop on normal completion
+ } catch (error) {
+ // connection failed or aborted; retry after delay
+ onSseError?.(error);
+
+ if (
+ sseMaxRetryAttempts !== undefined &&
+ attempt >= sseMaxRetryAttempts
+ ) {
+ break; // stop after firing error
+ }
+
+ // exponential backoff: double retry each attempt, cap at 30s
+ const backoff = Math.min(
+ retryDelay * 2 ** (attempt - 1),
+ sseMaxRetryDelay ?? 30000,
+ );
+ await sleep(backoff);
+ }
+ }
+ };
+
+ const stream = createStream();
+
+ return { stream };
+};
diff --git a/examples/openapi-ts-ky/src/client/core/types.gen.ts b/examples/openapi-ts-ky/src/client/core/types.gen.ts
new file mode 100644
index 000000000..643c070c9
--- /dev/null
+++ b/examples/openapi-ts-ky/src/client/core/types.gen.ts
@@ -0,0 +1,118 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+import type { Auth, AuthToken } from './auth.gen';
+import type {
+ BodySerializer,
+ QuerySerializer,
+ QuerySerializerOptions,
+} from './bodySerializer.gen';
+
+export type HttpMethod =
+ | 'connect'
+ | 'delete'
+ | 'get'
+ | 'head'
+ | 'options'
+ | 'patch'
+ | 'post'
+ | 'put'
+ | 'trace';
+
+export type Client<
+ RequestFn = never,
+ Config = unknown,
+ MethodFn = never,
+ BuildUrlFn = never,
+ SseFn = never,
+> = {
+ /**
+ * Returns the final request URL.
+ */
+ buildUrl: BuildUrlFn;
+ getConfig: () => Config;
+ request: RequestFn;
+ setConfig: (config: Config) => Config;
+} & {
+ [K in HttpMethod]: MethodFn;
+} & ([SseFn] extends [never]
+ ? { sse?: never }
+ : { sse: { [K in HttpMethod]: SseFn } });
+
+export interface Config {
+ /**
+ * Auth token or a function returning auth token. The resolved value will be
+ * added to the request payload as defined by its `security` array.
+ */
+ auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken;
+ /**
+ * A function for serializing request body parameter. By default,
+ * {@link JSON.stringify()} will be used.
+ */
+ bodySerializer?: BodySerializer | null;
+ /**
+ * An object containing any HTTP headers that you want to pre-populate your
+ * `Headers` object with.
+ *
+ * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
+ */
+ headers?:
+ | RequestInit['headers']
+ | Record<
+ string,
+ | string
+ | number
+ | boolean
+ | (string | number | boolean)[]
+ | null
+ | undefined
+ | unknown
+ >;
+ /**
+ * The request method.
+ *
+ * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
+ */
+ method?: Uppercase;
+ /**
+ * A function for serializing request query parameters. By default, arrays
+ * will be exploded in form style, objects will be exploded in deepObject
+ * style, and reserved characters are percent-encoded.
+ *
+ * This method will have no effect if the native `paramsSerializer()` Axios
+ * API function is used.
+ *
+ * {@link https://swagger.io/docs/specification/serialization/#query View examples}
+ */
+ querySerializer?: QuerySerializer | QuerySerializerOptions;
+ /**
+ * A function validating request data. This is useful if you want to ensure
+ * the request conforms to the desired shape, so it can be safely sent to
+ * the server.
+ */
+ requestValidator?: (data: unknown) => Promise;
+ /**
+ * A function transforming response data before it's returned. This is useful
+ * for post-processing data, e.g. converting ISO strings into Date objects.
+ */
+ responseTransformer?: (data: unknown) => Promise;
+ /**
+ * A function validating response data. This is useful if you want to ensure
+ * the response conforms to the desired shape, so it can be safely passed to
+ * the transformers and returned to the user.
+ */
+ responseValidator?: (data: unknown) => Promise;
+}
+
+type IsExactlyNeverOrNeverUndefined = [T] extends [never]
+ ? true
+ : [T] extends [never | undefined]
+ ? [undefined] extends [T]
+ ? false
+ : true
+ : false;
+
+export type OmitNever> = {
+ [K in keyof T as IsExactlyNeverOrNeverUndefined extends true
+ ? never
+ : K]: T[K];
+};
diff --git a/examples/openapi-ts-ky/src/client/core/utils.gen.ts b/examples/openapi-ts-ky/src/client/core/utils.gen.ts
new file mode 100644
index 000000000..0b5389d08
--- /dev/null
+++ b/examples/openapi-ts-ky/src/client/core/utils.gen.ts
@@ -0,0 +1,143 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+import type { BodySerializer, QuerySerializer } from './bodySerializer.gen';
+import {
+ type ArraySeparatorStyle,
+ serializeArrayParam,
+ serializeObjectParam,
+ serializePrimitiveParam,
+} from './pathSerializer.gen';
+
+export interface PathSerializer {
+ path: Record;
+ url: string;
+}
+
+export const PATH_PARAM_RE = /\{[^{}]+\}/g;
+
+export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
+ let url = _url;
+ const matches = _url.match(PATH_PARAM_RE);
+ if (matches) {
+ for (const match of matches) {
+ let explode = false;
+ let name = match.substring(1, match.length - 1);
+ let style: ArraySeparatorStyle = 'simple';
+
+ if (name.endsWith('*')) {
+ explode = true;
+ name = name.substring(0, name.length - 1);
+ }
+
+ if (name.startsWith('.')) {
+ name = name.substring(1);
+ style = 'label';
+ } else if (name.startsWith(';')) {
+ name = name.substring(1);
+ style = 'matrix';
+ }
+
+ const value = path[name];
+
+ if (value === undefined || value === null) {
+ continue;
+ }
+
+ if (Array.isArray(value)) {
+ url = url.replace(
+ match,
+ serializeArrayParam({ explode, name, style, value }),
+ );
+ continue;
+ }
+
+ if (typeof value === 'object') {
+ url = url.replace(
+ match,
+ serializeObjectParam({
+ explode,
+ name,
+ style,
+ value: value as Record,
+ valueOnly: true,
+ }),
+ );
+ continue;
+ }
+
+ if (style === 'matrix') {
+ url = url.replace(
+ match,
+ `;${serializePrimitiveParam({
+ name,
+ value: value as string,
+ })}`,
+ );
+ continue;
+ }
+
+ const replaceValue = encodeURIComponent(
+ style === 'label' ? `.${value as string}` : (value as string),
+ );
+ url = url.replace(match, replaceValue);
+ }
+ }
+ return url;
+};
+
+export const getUrl = ({
+ baseUrl,
+ path,
+ query,
+ querySerializer,
+ url: _url,
+}: {
+ baseUrl?: string;
+ path?: Record;
+ query?: Record;
+ querySerializer: QuerySerializer;
+ url: string;
+}) => {
+ const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
+ let url = (baseUrl ?? '') + pathUrl;
+ if (path) {
+ url = defaultPathSerializer({ path, url });
+ }
+ let search = query ? querySerializer(query) : '';
+ if (search.startsWith('?')) {
+ search = search.substring(1);
+ }
+ if (search) {
+ url += `?${search}`;
+ }
+ return url;
+};
+
+export function getValidRequestBody(options: {
+ body?: unknown;
+ bodySerializer?: BodySerializer | null;
+ serializedBody?: unknown;
+}) {
+ const hasBody = options.body !== undefined;
+ const isSerializedBody = hasBody && options.bodySerializer;
+
+ if (isSerializedBody) {
+ if ('serializedBody' in options) {
+ const hasSerializedBody =
+ options.serializedBody !== undefined && options.serializedBody !== '';
+
+ return hasSerializedBody ? options.serializedBody : null;
+ }
+
+ // not all clients implement a serializedBody property (i.e. client-axios)
+ return options.body !== '' ? options.body : null;
+ }
+
+ // plain/text body
+ if (hasBody) {
+ return options.body;
+ }
+
+ // no body was provided
+ return undefined;
+}
diff --git a/examples/openapi-ts-ky/src/client/index.ts b/examples/openapi-ts-ky/src/client/index.ts
new file mode 100644
index 000000000..57ed02bf5
--- /dev/null
+++ b/examples/openapi-ts-ky/src/client/index.ts
@@ -0,0 +1,4 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+export * from './sdk.gen';
+export type * from './types.gen';
diff --git a/examples/openapi-ts-ky/src/client/schemas.gen.ts b/examples/openapi-ts-ky/src/client/schemas.gen.ts
new file mode 100644
index 000000000..646632e83
--- /dev/null
+++ b/examples/openapi-ts-ky/src/client/schemas.gen.ts
@@ -0,0 +1,188 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+export const OrderSchema = {
+ properties: {
+ complete: {
+ type: 'boolean',
+ },
+ id: {
+ example: 10,
+ format: 'int64',
+ type: 'integer',
+ },
+ petId: {
+ example: 198772,
+ format: 'int64',
+ type: 'integer',
+ },
+ quantity: {
+ example: 7,
+ format: 'int32',
+ type: 'integer',
+ },
+ shipDate: {
+ format: 'date-time',
+ type: 'string',
+ },
+ status: {
+ description: 'Order Status',
+ enum: ['placed', 'approved', 'delivered'],
+ example: 'approved',
+ type: 'string',
+ },
+ },
+ type: 'object',
+ 'x-swagger-router-model': 'io.swagger.petstore.model.Order',
+ xml: {
+ name: 'order',
+ },
+} as const;
+
+export const CategorySchema = {
+ properties: {
+ id: {
+ example: 1,
+ format: 'int64',
+ type: 'integer',
+ },
+ name: {
+ example: 'Dogs',
+ type: 'string',
+ },
+ },
+ type: 'object',
+ 'x-swagger-router-model': 'io.swagger.petstore.model.Category',
+ xml: {
+ name: 'category',
+ },
+} as const;
+
+export const UserSchema = {
+ properties: {
+ email: {
+ example: 'john@email.com',
+ type: 'string',
+ },
+ firstName: {
+ example: 'John',
+ type: 'string',
+ },
+ id: {
+ example: 10,
+ format: 'int64',
+ type: 'integer',
+ },
+ lastName: {
+ example: 'James',
+ type: 'string',
+ },
+ password: {
+ example: '12345',
+ type: 'string',
+ },
+ phone: {
+ example: '12345',
+ type: 'string',
+ },
+ userStatus: {
+ description: 'User Status',
+ example: 1,
+ format: 'int32',
+ type: 'integer',
+ },
+ username: {
+ example: 'theUser',
+ type: 'string',
+ },
+ },
+ type: 'object',
+ 'x-swagger-router-model': 'io.swagger.petstore.model.User',
+ xml: {
+ name: 'user',
+ },
+} as const;
+
+export const TagSchema = {
+ properties: {
+ id: {
+ format: 'int64',
+ type: 'integer',
+ },
+ name: {
+ type: 'string',
+ },
+ },
+ type: 'object',
+ 'x-swagger-router-model': 'io.swagger.petstore.model.Tag',
+ xml: {
+ name: 'tag',
+ },
+} as const;
+
+export const PetSchema = {
+ properties: {
+ category: {
+ $ref: '#/components/schemas/Category',
+ },
+ id: {
+ example: 10,
+ format: 'int64',
+ type: 'integer',
+ },
+ name: {
+ example: 'doggie',
+ type: 'string',
+ },
+ photoUrls: {
+ items: {
+ type: 'string',
+ xml: {
+ name: 'photoUrl',
+ },
+ },
+ type: 'array',
+ xml: {
+ wrapped: true,
+ },
+ },
+ status: {
+ description: 'pet status in the store',
+ enum: ['available', 'pending', 'sold'],
+ type: 'string',
+ },
+ tags: {
+ items: {
+ $ref: '#/components/schemas/Tag',
+ },
+ type: 'array',
+ xml: {
+ wrapped: true,
+ },
+ },
+ },
+ required: ['name', 'photoUrls'],
+ type: 'object',
+ 'x-swagger-router-model': 'io.swagger.petstore.model.Pet',
+ xml: {
+ name: 'pet',
+ },
+} as const;
+
+export const ApiResponseSchema = {
+ properties: {
+ code: {
+ format: 'int32',
+ type: 'integer',
+ },
+ message: {
+ type: 'string',
+ },
+ type: {
+ type: 'string',
+ },
+ },
+ type: 'object',
+ xml: {
+ name: '##default',
+ },
+} as const;
diff --git a/examples/openapi-ts-ky/src/client/sdk.gen.ts b/examples/openapi-ts-ky/src/client/sdk.gen.ts
new file mode 100644
index 000000000..f424fe675
--- /dev/null
+++ b/examples/openapi-ts-ky/src/client/sdk.gen.ts
@@ -0,0 +1,486 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+import type { Client, Options as Options2, TDataShape } from './client';
+import { client } from './client.gen';
+import type {
+ AddPetData,
+ AddPetErrors,
+ AddPetResponses,
+ CreateUserData,
+ CreateUserErrors,
+ CreateUserResponses,
+ CreateUsersWithListInputData,
+ CreateUsersWithListInputErrors,
+ CreateUsersWithListInputResponses,
+ DeleteOrderData,
+ DeleteOrderErrors,
+ DeleteOrderResponses,
+ DeletePetData,
+ DeletePetErrors,
+ DeletePetResponses,
+ DeleteUserData,
+ DeleteUserErrors,
+ DeleteUserResponses,
+ FindPetsByStatusData,
+ FindPetsByStatusErrors,
+ FindPetsByStatusResponses,
+ FindPetsByTagsData,
+ FindPetsByTagsErrors,
+ FindPetsByTagsResponses,
+ GetInventoryData,
+ GetInventoryErrors,
+ GetInventoryResponses,
+ GetOrderByIdData,
+ GetOrderByIdErrors,
+ GetOrderByIdResponses,
+ GetPetByIdData,
+ GetPetByIdErrors,
+ GetPetByIdResponses,
+ GetUserByNameData,
+ GetUserByNameErrors,
+ GetUserByNameResponses,
+ LoginUserData,
+ LoginUserErrors,
+ LoginUserResponses,
+ LogoutUserData,
+ LogoutUserErrors,
+ LogoutUserResponses,
+ PlaceOrderData,
+ PlaceOrderErrors,
+ PlaceOrderResponses,
+ UpdatePetData,
+ UpdatePetErrors,
+ UpdatePetResponses,
+ UpdatePetWithFormData,
+ UpdatePetWithFormErrors,
+ UpdatePetWithFormResponses,
+ UpdateUserData,
+ UpdateUserErrors,
+ UpdateUserResponses,
+ UploadFileData,
+ UploadFileErrors,
+ UploadFileResponses,
+} from './types.gen';
+
+export type Options<
+ TData extends TDataShape = TDataShape,
+ ThrowOnError extends boolean = boolean,
+> = Options2 & {
+ /**
+ * You can provide a client instance returned by `createClient()` instead of
+ * individual options. This might be also useful if you want to implement a
+ * custom client.
+ */
+ client?: Client;
+ /**
+ * You can pass arbitrary values through the `meta` object. This can be
+ * used to access values that aren't defined as part of the SDK function.
+ */
+ meta?: Record;
+};
+
+/**
+ * Add a new pet to the store.
+ *
+ * Add a new pet to the store.
+ */
+export const addPet = (
+ options: Options,
+) =>
+ (options.client ?? client).post({
+ security: [
+ {
+ scheme: 'bearer',
+ type: 'http',
+ },
+ ],
+ url: '/pet',
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers,
+ },
+ });
+
+/**
+ * Update an existing pet.
+ *
+ * Update an existing pet by Id.
+ */
+export const updatePet = (
+ options: Options,
+) =>
+ (options.client ?? client).put<
+ UpdatePetResponses,
+ UpdatePetErrors,
+ ThrowOnError
+ >({
+ security: [
+ {
+ scheme: 'bearer',
+ type: 'http',
+ },
+ ],
+ url: '/pet',
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers,
+ },
+ });
+
+/**
+ * Finds Pets by status.
+ *
+ * Multiple status values can be provided with comma separated strings.
+ */
+export const findPetsByStatus = (
+ options: Options,
+) =>
+ (options.client ?? client).get<
+ FindPetsByStatusResponses,
+ FindPetsByStatusErrors,
+ ThrowOnError
+ >({
+ security: [
+ {
+ scheme: 'bearer',
+ type: 'http',
+ },
+ ],
+ url: '/pet/findByStatus',
+ ...options,
+ });
+
+/**
+ * Finds Pets by tags.
+ *
+ * Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.
+ */
+export const findPetsByTags = (
+ options: Options,
+) =>
+ (options.client ?? client).get<
+ FindPetsByTagsResponses,
+ FindPetsByTagsErrors,
+ ThrowOnError
+ >({
+ security: [
+ {
+ scheme: 'bearer',
+ type: 'http',
+ },
+ ],
+ url: '/pet/findByTags',
+ ...options,
+ });
+
+/**
+ * Deletes a pet.
+ *
+ * Delete a pet.
+ */
+export const deletePet = (
+ options: Options,
+) =>
+ (options.client ?? client).delete<
+ DeletePetResponses,
+ DeletePetErrors,
+ ThrowOnError
+ >({
+ security: [
+ {
+ scheme: 'bearer',
+ type: 'http',
+ },
+ ],
+ url: '/pet/{petId}',
+ ...options,
+ });
+
+/**
+ * Find pet by ID.
+ *
+ * Returns a single pet.
+ */
+export const getPetById = (
+ options: Options,
+) =>
+ (options.client ?? client).get<
+ GetPetByIdResponses,
+ GetPetByIdErrors,
+ ThrowOnError
+ >({
+ security: [
+ {
+ name: 'api_key',
+ type: 'apiKey',
+ },
+ {
+ scheme: 'bearer',
+ type: 'http',
+ },
+ ],
+ url: '/pet/{petId}',
+ ...options,
+ });
+
+/**
+ * Updates a pet in the store with form data.
+ *
+ * Updates a pet resource based on the form data.
+ */
+export const updatePetWithForm = (
+ options: Options,
+) =>
+ (options.client ?? client).post<
+ UpdatePetWithFormResponses,
+ UpdatePetWithFormErrors,
+ ThrowOnError
+ >({
+ security: [
+ {
+ scheme: 'bearer',
+ type: 'http',
+ },
+ ],
+ url: '/pet/{petId}',
+ ...options,
+ });
+
+/**
+ * Uploads an image.
+ *
+ * Upload image of the pet.
+ */
+export const uploadFile = (
+ options: Options,
+) =>
+ (options.client ?? client).post<
+ UploadFileResponses,
+ UploadFileErrors,
+ ThrowOnError
+ >({
+ bodySerializer: null,
+ security: [
+ {
+ scheme: 'bearer',
+ type: 'http',
+ },
+ ],
+ url: '/pet/{petId}/uploadImage',
+ ...options,
+ headers: {
+ 'Content-Type': 'application/octet-stream',
+ ...options.headers,
+ },
+ });
+
+/**
+ * Returns pet inventories by status.
+ *
+ * Returns a map of status codes to quantities.
+ */
+export const getInventory = (
+ options?: Options,
+) =>
+ (options?.client ?? client).get<
+ GetInventoryResponses,
+ GetInventoryErrors,
+ ThrowOnError
+ >({
+ security: [
+ {
+ name: 'api_key',
+ type: 'apiKey',
+ },
+ ],
+ url: '/store/inventory',
+ ...options,
+ });
+
+/**
+ * Place an order for a pet.
+ *
+ * Place a new order in the store.
+ */
+export const placeOrder = (
+ options?: Options,
+) =>
+ (options?.client ?? client).post<
+ PlaceOrderResponses,
+ PlaceOrderErrors,
+ ThrowOnError
+ >({
+ url: '/store/order',
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options?.headers,
+ },
+ });
+
+/**
+ * Delete purchase order by identifier.
+ *
+ * For valid response try integer IDs with value < 1000. Anything above 1000 or non-integers will generate API errors.
+ */
+export const deleteOrder = (
+ options: Options,
+) =>
+ (options.client ?? client).delete<
+ DeleteOrderResponses,
+ DeleteOrderErrors,
+ ThrowOnError
+ >({
+ url: '/store/order/{orderId}',
+ ...options,
+ });
+
+/**
+ * Find purchase order by ID.
+ *
+ * For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions.
+ */
+export const getOrderById = (
+ options: Options,
+) =>
+ (options.client ?? client).get<
+ GetOrderByIdResponses,
+ GetOrderByIdErrors,
+ ThrowOnError
+ >({
+ url: '/store/order/{orderId}',
+ ...options,
+ });
+
+/**
+ * Create user.
+ *
+ * This can only be done by the logged in user.
+ */
+export const createUser = (
+ options?: Options,
+) =>
+ (options?.client ?? client).post<
+ CreateUserResponses,
+ CreateUserErrors,
+ ThrowOnError
+ >({
+ url: '/user',
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options?.headers,
+ },
+ });
+
+/**
+ * Creates list of users with given input array.
+ *
+ * Creates list of users with given input array.
+ */
+export const createUsersWithListInput = (
+ options?: Options,
+) =>
+ (options?.client ?? client).post<
+ CreateUsersWithListInputResponses,
+ CreateUsersWithListInputErrors,
+ ThrowOnError
+ >({
+ url: '/user/createWithList',
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options?.headers,
+ },
+ });
+
+/**
+ * Logs user into the system.
+ *
+ * Log into the system.
+ */
+export const loginUser = (
+ options?: Options,
+) =>
+ (options?.client ?? client).get<
+ LoginUserResponses,
+ LoginUserErrors,
+ ThrowOnError
+ >({
+ url: '/user/login',
+ ...options,
+ });
+
+/**
+ * Logs out current logged in user session.
+ *
+ * Log user out of the system.
+ */
+export const logoutUser = (
+ options?: Options,
+) =>
+ (options?.client ?? client).get<
+ LogoutUserResponses,
+ LogoutUserErrors,
+ ThrowOnError
+ >({
+ url: '/user/logout',
+ ...options,
+ });
+
+/**
+ * Delete user resource.
+ *
+ * This can only be done by the logged in user.
+ */
+export const deleteUser = (
+ options: Options,
+) =>
+ (options.client ?? client).delete<
+ DeleteUserResponses,
+ DeleteUserErrors,
+ ThrowOnError
+ >({
+ url: '/user/{username}',
+ ...options,
+ });
+
+/**
+ * Get user by user name.
+ *
+ * Get user detail based on username.
+ */
+export const getUserByName = (
+ options: Options,
+) =>
+ (options.client ?? client).get<
+ GetUserByNameResponses,
+ GetUserByNameErrors,
+ ThrowOnError
+ >({
+ url: '/user/{username}',
+ ...options,
+ });
+
+/**
+ * Update user resource.
+ *
+ * This can only be done by the logged in user.
+ */
+export const updateUser = (
+ options: Options,
+) =>
+ (options.client ?? client).put<
+ UpdateUserResponses,
+ UpdateUserErrors,
+ ThrowOnError
+ >({
+ url: '/user/{username}',
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers,
+ },
+ });
diff --git a/examples/openapi-ts-ky/src/client/types.gen.ts b/examples/openapi-ts-ky/src/client/types.gen.ts
new file mode 100644
index 000000000..a2e6be0fa
--- /dev/null
+++ b/examples/openapi-ts-ky/src/client/types.gen.ts
@@ -0,0 +1,699 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+export type ClientOptions = {
+ baseUrl: 'https://petstore3.swagger.io/api/v3' | (string & {});
+};
+
+export type Order = {
+ complete?: boolean;
+ id?: number;
+ petId?: number;
+ quantity?: number;
+ shipDate?: string;
+ /**
+ * Order Status
+ */
+ status?: 'placed' | 'approved' | 'delivered';
+};
+
+export type Category = {
+ id?: number;
+ name?: string;
+};
+
+export type User = {
+ email?: string;
+ firstName?: string;
+ id?: number;
+ lastName?: string;
+ password?: string;
+ phone?: string;
+ /**
+ * User Status
+ */
+ userStatus?: number;
+ username?: string;
+};
+
+export type Tag = {
+ id?: number;
+ name?: string;
+};
+
+export type Pet = {
+ category?: Category;
+ id?: number;
+ name: string;
+ photoUrls: Array;
+ /**
+ * pet status in the store
+ */
+ status?: 'available' | 'pending' | 'sold';
+ tags?: Array;
+};
+
+export type ApiResponse = {
+ code?: number;
+ message?: string;
+ type?: string;
+};
+
+export type Pet2 = Pet;
+
+/**
+ * List of user object
+ */
+export type UserArray = Array;
+
+export type AddPetData = {
+ /**
+ * Create a new pet in the store
+ */
+ body: Pet;
+ path?: never;
+ query?: never;
+ url: '/pet';
+};
+
+export type AddPetErrors = {
+ /**
+ * Invalid input
+ */
+ 400: unknown;
+ /**
+ * Validation exception
+ */
+ 422: unknown;
+ /**
+ * Unexpected error
+ */
+ default: unknown;
+};
+
+export type AddPetResponses = {
+ /**
+ * Successful operation
+ */
+ 200: Pet;
+};
+
+export type AddPetResponse = AddPetResponses[keyof AddPetResponses];
+
+export type UpdatePetData = {
+ /**
+ * Update an existent pet in the store
+ */
+ body: Pet;
+ path?: never;
+ query?: never;
+ url: '/pet';
+};
+
+export type UpdatePetErrors = {
+ /**
+ * Invalid ID supplied
+ */
+ 400: unknown;
+ /**
+ * Pet not found
+ */
+ 404: unknown;
+ /**
+ * Validation exception
+ */
+ 422: unknown;
+ /**
+ * Unexpected error
+ */
+ default: unknown;
+};
+
+export type UpdatePetResponses = {
+ /**
+ * Successful operation
+ */
+ 200: Pet;
+};
+
+export type UpdatePetResponse = UpdatePetResponses[keyof UpdatePetResponses];
+
+export type FindPetsByStatusData = {
+ body?: never;
+ path?: never;
+ query: {
+ /**
+ * Status values that need to be considered for filter
+ */
+ status: 'available' | 'pending' | 'sold';
+ };
+ url: '/pet/findByStatus';
+};
+
+export type FindPetsByStatusErrors = {
+ /**
+ * Invalid status value
+ */
+ 400: unknown;
+ /**
+ * Unexpected error
+ */
+ default: unknown;
+};
+
+export type FindPetsByStatusResponses = {
+ /**
+ * successful operation
+ */
+ 200: Array;
+};
+
+export type FindPetsByStatusResponse =
+ FindPetsByStatusResponses[keyof FindPetsByStatusResponses];
+
+export type FindPetsByTagsData = {
+ body?: never;
+ path?: never;
+ query: {
+ /**
+ * Tags to filter by
+ */
+ tags: Array;
+ };
+ url: '/pet/findByTags';
+};
+
+export type FindPetsByTagsErrors = {
+ /**
+ * Invalid tag value
+ */
+ 400: unknown;
+ /**
+ * Unexpected error
+ */
+ default: unknown;
+};
+
+export type FindPetsByTagsResponses = {
+ /**
+ * successful operation
+ */
+ 200: Array;
+};
+
+export type FindPetsByTagsResponse =
+ FindPetsByTagsResponses[keyof FindPetsByTagsResponses];
+
+export type DeletePetData = {
+ body?: never;
+ headers?: {
+ api_key?: string;
+ };
+ path: {
+ /**
+ * Pet id to delete
+ */
+ petId: number;
+ };
+ query?: never;
+ url: '/pet/{petId}';
+};
+
+export type DeletePetErrors = {
+ /**
+ * Invalid pet value
+ */
+ 400: unknown;
+ /**
+ * Unexpected error
+ */
+ default: unknown;
+};
+
+export type DeletePetResponses = {
+ /**
+ * Pet deleted
+ */
+ 200: unknown;
+};
+
+export type GetPetByIdData = {
+ body?: never;
+ path: {
+ /**
+ * ID of pet to return
+ */
+ petId: number;
+ };
+ query?: never;
+ url: '/pet/{petId}';
+};
+
+export type GetPetByIdErrors = {
+ /**
+ * Invalid ID supplied
+ */
+ 400: unknown;
+ /**
+ * Pet not found
+ */
+ 404: unknown;
+ /**
+ * Unexpected error
+ */
+ default: unknown;
+};
+
+export type GetPetByIdResponses = {
+ /**
+ * successful operation
+ */
+ 200: Pet;
+};
+
+export type GetPetByIdResponse = GetPetByIdResponses[keyof GetPetByIdResponses];
+
+export type UpdatePetWithFormData = {
+ body?: never;
+ path: {
+ /**
+ * ID of pet that needs to be updated
+ */
+ petId: number;
+ };
+ query?: {
+ /**
+ * Name of pet that needs to be updated
+ */
+ name?: string;
+ /**
+ * Status of pet that needs to be updated
+ */
+ status?: string;
+ };
+ url: '/pet/{petId}';
+};
+
+export type UpdatePetWithFormErrors = {
+ /**
+ * Invalid input
+ */
+ 400: unknown;
+ /**
+ * Unexpected error
+ */
+ default: unknown;
+};
+
+export type UpdatePetWithFormResponses = {
+ /**
+ * successful operation
+ */
+ 200: Pet;
+};
+
+export type UpdatePetWithFormResponse =
+ UpdatePetWithFormResponses[keyof UpdatePetWithFormResponses];
+
+export type UploadFileData = {
+ body?: Blob | File;
+ path: {
+ /**
+ * ID of pet to update
+ */
+ petId: number;
+ };
+ query?: {
+ /**
+ * Additional Metadata
+ */
+ additionalMetadata?: string;
+ };
+ url: '/pet/{petId}/uploadImage';
+};
+
+export type UploadFileErrors = {
+ /**
+ * No file uploaded
+ */
+ 400: unknown;
+ /**
+ * Pet not found
+ */
+ 404: unknown;
+ /**
+ * Unexpected error
+ */
+ default: unknown;
+};
+
+export type UploadFileResponses = {
+ /**
+ * successful operation
+ */
+ 200: ApiResponse;
+};
+
+export type UploadFileResponse = UploadFileResponses[keyof UploadFileResponses];
+
+export type GetInventoryData = {
+ body?: never;
+ path?: never;
+ query?: never;
+ url: '/store/inventory';
+};
+
+export type GetInventoryErrors = {
+ /**
+ * Unexpected error
+ */
+ default: unknown;
+};
+
+export type GetInventoryResponses = {
+ /**
+ * successful operation
+ */
+ 200: {
+ [key: string]: number;
+ };
+};
+
+export type GetInventoryResponse =
+ GetInventoryResponses[keyof GetInventoryResponses];
+
+export type PlaceOrderData = {
+ body?: Order;
+ path?: never;
+ query?: never;
+ url: '/store/order';
+};
+
+export type PlaceOrderErrors = {
+ /**
+ * Invalid input
+ */
+ 400: unknown;
+ /**
+ * Validation exception
+ */
+ 422: unknown;
+ /**
+ * Unexpected error
+ */
+ default: unknown;
+};
+
+export type PlaceOrderResponses = {
+ /**
+ * successful operation
+ */
+ 200: Order;
+};
+
+export type PlaceOrderResponse = PlaceOrderResponses[keyof PlaceOrderResponses];
+
+export type DeleteOrderData = {
+ body?: never;
+ path: {
+ /**
+ * ID of the order that needs to be deleted
+ */
+ orderId: number;
+ };
+ query?: never;
+ url: '/store/order/{orderId}';
+};
+
+export type DeleteOrderErrors = {
+ /**
+ * Invalid ID supplied
+ */
+ 400: unknown;
+ /**
+ * Order not found
+ */
+ 404: unknown;
+ /**
+ * Unexpected error
+ */
+ default: unknown;
+};
+
+export type DeleteOrderResponses = {
+ /**
+ * order deleted
+ */
+ 200: unknown;
+};
+
+export type GetOrderByIdData = {
+ body?: never;
+ path: {
+ /**
+ * ID of order that needs to be fetched
+ */
+ orderId: number;
+ };
+ query?: never;
+ url: '/store/order/{orderId}';
+};
+
+export type GetOrderByIdErrors = {
+ /**
+ * Invalid ID supplied
+ */
+ 400: unknown;
+ /**
+ * Order not found
+ */
+ 404: unknown;
+ /**
+ * Unexpected error
+ */
+ default: unknown;
+};
+
+export type GetOrderByIdResponses = {
+ /**
+ * successful operation
+ */
+ 200: Order;
+};
+
+export type GetOrderByIdResponse =
+ GetOrderByIdResponses[keyof GetOrderByIdResponses];
+
+export type CreateUserData = {
+ /**
+ * Created user object
+ */
+ body?: User;
+ path?: never;
+ query?: never;
+ url: '/user';
+};
+
+export type CreateUserErrors = {
+ /**
+ * Unexpected error
+ */
+ default: unknown;
+};
+
+export type CreateUserResponses = {
+ /**
+ * successful operation
+ */
+ 200: User;
+};
+
+export type CreateUserResponse = CreateUserResponses[keyof CreateUserResponses];
+
+export type CreateUsersWithListInputData = {
+ body?: Array;
+ path?: never;
+ query?: never;
+ url: '/user/createWithList';
+};
+
+export type CreateUsersWithListInputErrors = {
+ /**
+ * Unexpected error
+ */
+ default: unknown;
+};
+
+export type CreateUsersWithListInputResponses = {
+ /**
+ * Successful operation
+ */
+ 200: User;
+};
+
+export type CreateUsersWithListInputResponse =
+ CreateUsersWithListInputResponses[keyof CreateUsersWithListInputResponses];
+
+export type LoginUserData = {
+ body?: never;
+ path?: never;
+ query?: {
+ /**
+ * The password for login in clear text
+ */
+ password?: string;
+ /**
+ * The user name for login
+ */
+ username?: string;
+ };
+ url: '/user/login';
+};
+
+export type LoginUserErrors = {
+ /**
+ * Invalid username/password supplied
+ */
+ 400: unknown;
+ /**
+ * Unexpected error
+ */
+ default: unknown;
+};
+
+export type LoginUserResponses = {
+ /**
+ * successful operation
+ */
+ 200: string;
+};
+
+export type LoginUserResponse = LoginUserResponses[keyof LoginUserResponses];
+
+export type LogoutUserData = {
+ body?: never;
+ path?: never;
+ query?: never;
+ url: '/user/logout';
+};
+
+export type LogoutUserErrors = {
+ /**
+ * Unexpected error
+ */
+ default: unknown;
+};
+
+export type LogoutUserResponses = {
+ /**
+ * successful operation
+ */
+ 200: unknown;
+};
+
+export type DeleteUserData = {
+ body?: never;
+ path: {
+ /**
+ * The name that needs to be deleted
+ */
+ username: string;
+ };
+ query?: never;
+ url: '/user/{username}';
+};
+
+export type DeleteUserErrors = {
+ /**
+ * Invalid username supplied
+ */
+ 400: unknown;
+ /**
+ * User not found
+ */
+ 404: unknown;
+ /**
+ * Unexpected error
+ */
+ default: unknown;
+};
+
+export type DeleteUserResponses = {
+ /**
+ * User deleted
+ */
+ 200: unknown;
+};
+
+export type GetUserByNameData = {
+ body?: never;
+ path: {
+ /**
+ * The name that needs to be fetched. Use user1 for testing
+ */
+ username: string;
+ };
+ query?: never;
+ url: '/user/{username}';
+};
+
+export type GetUserByNameErrors = {
+ /**
+ * Invalid username supplied
+ */
+ 400: unknown;
+ /**
+ * User not found
+ */
+ 404: unknown;
+ /**
+ * Unexpected error
+ */
+ default: unknown;
+};
+
+export type GetUserByNameResponses = {
+ /**
+ * successful operation
+ */
+ 200: User;
+};
+
+export type GetUserByNameResponse =
+ GetUserByNameResponses[keyof GetUserByNameResponses];
+
+export type UpdateUserData = {
+ /**
+ * Update an existent user in the store
+ */
+ body?: User;
+ path: {
+ /**
+ * name that need to be deleted
+ */
+ username: string;
+ };
+ query?: never;
+ url: '/user/{username}';
+};
+
+export type UpdateUserErrors = {
+ /**
+ * bad request
+ */
+ 400: unknown;
+ /**
+ * user not found
+ */
+ 404: unknown;
+ /**
+ * Unexpected error
+ */
+ default: unknown;
+};
+
+export type UpdateUserResponses = {
+ /**
+ * successful operation
+ */
+ 200: unknown;
+};
diff --git a/examples/openapi-ts-ky/src/main.tsx b/examples/openapi-ts-ky/src/main.tsx
new file mode 100644
index 000000000..201f55e26
--- /dev/null
+++ b/examples/openapi-ts-ky/src/main.tsx
@@ -0,0 +1,26 @@
+import '@radix-ui/themes/styles.css';
+
+import { Theme } from '@radix-ui/themes';
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+
+import App from './App.tsx';
+import { client } from './client/client.gen';
+
+// configure internal service client
+client.setConfig({
+ // set default base url for requests
+ baseUrl: 'https://petstore3.swagger.io/api/v3',
+ // set default headers for requests
+ headers: {
+ Authorization: 'Bearer ',
+ },
+});
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+
+ ,
+);
diff --git a/examples/openapi-ts-ky/src/vite-env.d.ts b/examples/openapi-ts-ky/src/vite-env.d.ts
new file mode 100644
index 000000000..11f02fe2a
--- /dev/null
+++ b/examples/openapi-ts-ky/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/openapi-ts-ky/tailwind.config.js b/examples/openapi-ts-ky/tailwind.config.js
new file mode 100644
index 000000000..0284c5614
--- /dev/null
+++ b/examples/openapi-ts-ky/tailwind.config.js
@@ -0,0 +1,8 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: ['./index.html', './src/**/*.{html,js,ts,jsx,tsx}'],
+ plugins: [],
+ theme: {
+ extend: {},
+ },
+};
diff --git a/examples/openapi-ts-ky/tsconfig.json b/examples/openapi-ts-ky/tsconfig.json
new file mode 100644
index 000000000..04664de39
--- /dev/null
+++ b/examples/openapi-ts-ky/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": false,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/examples/openapi-ts-ky/tsconfig.node.json b/examples/openapi-ts-ky/tsconfig.node.json
new file mode 100644
index 000000000..97ede7ee6
--- /dev/null
+++ b/examples/openapi-ts-ky/tsconfig.node.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/examples/openapi-ts-ky/vite.config.ts b/examples/openapi-ts-ky/vite.config.ts
new file mode 100644
index 000000000..8cbf22f1b
--- /dev/null
+++ b/examples/openapi-ts-ky/vite.config.ts
@@ -0,0 +1,7 @@
+import { createViteConfig } from '@config/vite-base';
+import react from '@vitejs/plugin-react';
+
+// https://vitejs.dev/config/
+export default createViteConfig({
+ plugins: [react()],
+});
diff --git a/packages/openapi-ts/package.json b/packages/openapi-ts/package.json
index aa84bf3b2..9e322c0b9 100644
--- a/packages/openapi-ts/package.json
+++ b/packages/openapi-ts/package.json
@@ -116,6 +116,7 @@
"axios": "1.8.2",
"cross-spawn": "7.0.6",
"eslint": "9.17.0",
+ "ky": "1.14.0",
"nuxt": "3.14.1592",
"ofetch": "1.4.1",
"prettier": "3.4.2",
diff --git a/packages/openapi-ts/src/plugins/@hey-api/client-ky/__tests__/client.test.ts b/packages/openapi-ts/src/plugins/@hey-api/client-ky/__tests__/client.test.ts
new file mode 100644
index 000000000..2da2979b3
--- /dev/null
+++ b/packages/openapi-ts/src/plugins/@hey-api/client-ky/__tests__/client.test.ts
@@ -0,0 +1,649 @@
+import type { KyInstance } from 'ky';
+import { HTTPError } from 'ky';
+import { describe, expect, it, vi } from 'vitest';
+
+import type { ResolvedRequestOptions } from '../bundle';
+import { createClient } from '../bundle/client';
+
+describe('buildUrl', () => {
+ const client = createClient();
+
+ const scenarios: {
+ options: Parameters[0];
+ url: string;
+ }[] = [
+ {
+ options: {
+ url: '',
+ },
+ url: '/',
+ },
+ {
+ options: {
+ url: '/foo',
+ },
+ url: '/foo',
+ },
+ {
+ options: {
+ path: {
+ fooId: 1,
+ },
+ url: '/foo/{fooId}',
+ },
+ url: '/foo/1',
+ },
+ {
+ options: {
+ path: {
+ fooId: 1,
+ },
+ query: {
+ bar: 'baz',
+ },
+ url: '/foo/{fooId}',
+ },
+ url: '/foo/1?bar=baz',
+ },
+ {
+ options: {
+ query: {
+ bar: [],
+ foo: [],
+ },
+ url: '/',
+ },
+ url: '/',
+ },
+ {
+ options: {
+ query: {
+ bar: [],
+ foo: ['abc', 'def'],
+ },
+ url: '/',
+ },
+ url: '/?foo=abc&foo=def',
+ },
+ ];
+
+ it.each(scenarios)('returns $url', ({ options, url }) => {
+ expect(client.buildUrl(options)).toBe(url);
+ });
+});
+
+describe('zero-length body handling', () => {
+ const client = createClient({ baseUrl: 'https://example.com' });
+
+ it('returns empty Blob for zero-length application/octet-stream response', async () => {
+ const mockResponse = new Response(null, {
+ headers: {
+ 'Content-Length': '0',
+ 'Content-Type': 'application/octet-stream',
+ },
+ status: 200,
+ });
+
+ const mockKy = vi.fn().mockResolvedValue(mockResponse);
+
+ const result = await client.request({
+ ky: mockKy as Partial as KyInstance,
+ method: 'GET',
+ url: '/test',
+ });
+
+ expect(result.data).toBeInstanceOf(Blob);
+ expect((result.data as Blob).size).toBe(0);
+ });
+
+ it('returns empty ArrayBuffer for zero-length response with arrayBuffer parseAs', async () => {
+ const mockResponse = new Response(null, {
+ headers: {
+ 'Content-Length': '0',
+ },
+ status: 200,
+ });
+
+ const mockKy = vi.fn().mockResolvedValue(mockResponse);
+
+ const result = await client.request({
+ ky: mockKy as Partial as KyInstance,
+ method: 'GET',
+ parseAs: 'arrayBuffer',
+ url: '/test',
+ });
+
+ expect(result.data).toBeInstanceOf(ArrayBuffer);
+ expect((result.data as ArrayBuffer).byteLength).toBe(0);
+ });
+
+ it('returns empty string for zero-length text response', async () => {
+ const mockResponse = new Response(null, {
+ headers: {
+ 'Content-Length': '0',
+ 'Content-Type': 'text/plain',
+ },
+ status: 200,
+ });
+
+ const mockKy = vi.fn().mockResolvedValue(mockResponse);
+
+ const result = await client.request({
+ ky: mockKy as Partial as KyInstance,
+ method: 'GET',
+ url: '/test',
+ });
+
+ expect(result.data).toBe('');
+ });
+
+ it('returns empty object for zero-length JSON response', async () => {
+ const mockResponse = new Response(null, {
+ headers: {
+ 'Content-Length': '0',
+ 'Content-Type': 'application/json',
+ },
+ status: 200,
+ });
+
+ const mockKy = vi.fn().mockResolvedValue(mockResponse);
+
+ const result = await client.request({
+ ky: mockKy as Partial as KyInstance,
+ method: 'GET',
+ url: '/test',
+ });
+
+ expect(result.data).toEqual({});
+ });
+
+ it('returns empty FormData for zero-length multipart/form-data response', async () => {
+ const mockResponse = new Response(null, {
+ headers: {
+ 'Content-Length': '0',
+ 'Content-Type': 'multipart/form-data',
+ },
+ status: 200,
+ });
+
+ const mockKy = vi.fn().mockResolvedValue(mockResponse);
+
+ const result = await client.request({
+ ky: mockKy as Partial as KyInstance,
+ method: 'GET',
+ url: '/test',
+ });
+
+ expect(result.data).toBeInstanceOf(FormData);
+ expect([...(result.data as FormData).entries()]).toHaveLength(0);
+ });
+
+ it('returns stream body for zero-length stream response', async () => {
+ const mockBody = new ReadableStream();
+ const mockResponse = new Response(mockBody, {
+ headers: {
+ 'Content-Length': '0',
+ },
+ status: 200,
+ });
+
+ const mockKy = vi.fn().mockResolvedValue(mockResponse);
+
+ const result = await client.request({
+ ky: mockKy as Partial as KyInstance,
+ method: 'GET',
+ parseAs: 'stream',
+ url: '/test',
+ });
+
+ expect(result.data).toBe(mockBody);
+ });
+
+ it('handles non-zero content correctly for comparison', async () => {
+ const blobContent = new Blob(['test data']);
+ const mockResponse = new Response(blobContent, {
+ headers: {
+ 'Content-Type': 'application/octet-stream',
+ },
+ status: 200,
+ });
+
+ const mockKy = vi.fn().mockResolvedValue(mockResponse);
+
+ const result = await client.request({
+ ky: mockKy as Partial as KyInstance,
+ method: 'GET',
+ url: '/test',
+ });
+
+ expect(result.data).toBeInstanceOf(Blob);
+ expect((result.data as Blob).size).toBeGreaterThan(0);
+ });
+});
+
+describe('unserialized request body handling', () => {
+ const client = createClient({ baseUrl: 'https://example.com' });
+
+ const scenarios = [
+ { body: 0, textValue: '0' },
+ { body: false, textValue: 'false' },
+ { body: 'test string', textValue: 'test string' },
+ { body: '', textValue: '' },
+ ];
+
+ it.each(scenarios)(
+ 'handles plain text body with $body value',
+ async ({ body, textValue }) => {
+ const mockResponse = new Response(JSON.stringify({ success: true }), {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ status: 200,
+ });
+
+ const mockKy = vi.fn().mockResolvedValueOnce(mockResponse);
+
+ const result = await client.post({
+ body,
+ bodySerializer: null,
+ headers: {
+ 'Content-Type': 'text/plain',
+ },
+ ky: mockKy as Partial as KyInstance,
+ url: '/test',
+ });
+
+ expect(mockKy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ body: expect.any(ReadableStream),
+ }),
+ expect.any(Object),
+ );
+
+ await expect(result.request.text()).resolves.toEqual(textValue);
+ expect(result.request.headers.get('Content-Type')).toEqual('text/plain');
+ },
+ );
+});
+
+describe('serialized request body handling', () => {
+ const client = createClient({ baseUrl: 'https://example.com' });
+
+ const scenarios = [
+ {
+ body: '',
+ expectBodyValue: false,
+ expectContentHeader: false,
+ serializedBody: '',
+ textValue: '',
+ },
+ {
+ body: 0,
+ expectBodyValue: true,
+ expectContentHeader: true,
+ serializedBody: 0,
+ textValue: '0',
+ },
+ {
+ body: false,
+ expectBodyValue: true,
+ expectContentHeader: true,
+ serializedBody: false,
+ textValue: 'false',
+ },
+ {
+ body: {},
+ expectBodyValue: true,
+ expectContentHeader: true,
+ serializedBody: '{"key":"value"}',
+ textValue: '{"key":"value"}',
+ },
+ ];
+
+ it.each(scenarios)(
+ 'handles $serializedBody serializedBody value',
+ async ({
+ body,
+ expectBodyValue,
+ expectContentHeader,
+ serializedBody,
+ textValue,
+ }) => {
+ const mockResponse = new Response(JSON.stringify({ success: true }), {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ status: 200,
+ });
+
+ const mockKy = vi.fn().mockResolvedValueOnce(mockResponse);
+
+ const result = await client.post({
+ body,
+ bodySerializer: () => serializedBody,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ ky: mockKy as Partial as KyInstance,
+ url: '/test',
+ });
+
+ expect(mockKy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ body: expectBodyValue ? expect.any(ReadableStream) : null,
+ }),
+ expect.any(Object),
+ );
+
+ await expect(result.request.text()).resolves.toEqual(textValue);
+ expect(result.request.headers.get('Content-Type')).toEqual(
+ expectContentHeader ? 'application/json' : null,
+ );
+ },
+ );
+});
+
+describe('request interceptor', () => {
+ const client = createClient({ baseUrl: 'https://example.com' });
+
+ const scenarios = [
+ {
+ body: 'test string',
+ bodySerializer: null,
+ contentType: 'text/plain',
+ expectedSerializedValue: undefined,
+ expectedValue: async (request: Request) => await request.text(),
+ },
+ {
+ body: { key: 'value' },
+ bodySerializer: (body: object) => JSON.stringify(body),
+ contentType: 'application/json',
+ expectedSerializedValue: '{"key":"value"}',
+ expectedValue: async (request: Request) => await request.json(),
+ },
+ ];
+
+ it.each(scenarios)(
+ 'exposes $contentType serialized and raw body values',
+ async ({ body, bodySerializer, contentType, expectedSerializedValue }) => {
+ const mockResponse = new Response(JSON.stringify({ success: true }), {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ status: 200,
+ });
+
+ const mockKy = vi.fn().mockResolvedValueOnce(mockResponse);
+
+ const mockRequestInterceptor = vi
+ .fn()
+ .mockImplementation(
+ (request: Request, options: ResolvedRequestOptions) => {
+ expect(options.serializedBody).toBe(expectedSerializedValue);
+ expect(options.body).toBe(body);
+
+ return request;
+ },
+ );
+
+ const interceptorId = client.interceptors.request.use(
+ mockRequestInterceptor,
+ );
+
+ await client.post({
+ body,
+ bodySerializer,
+ headers: {
+ 'Content-Type': contentType,
+ },
+ ky: mockKy as Partial as KyInstance,
+ url: '/test',
+ });
+
+ expect(mockRequestInterceptor).toHaveBeenCalledOnce();
+
+ client.interceptors.request.eject(interceptorId);
+ },
+ );
+});
+
+describe('response interceptor', () => {
+ const client = createClient({ baseUrl: 'https://example.com' });
+
+ it('allows response transformation', async () => {
+ const mockResponse = new Response(JSON.stringify({ success: true }), {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ status: 200,
+ });
+
+ const mockKy = vi.fn().mockResolvedValue(mockResponse);
+
+ const mockResponseInterceptor = vi
+ .fn()
+ .mockImplementation((response: Response) => {
+ expect(response).toBe(mockResponse);
+ return response;
+ });
+
+ const interceptorId = client.interceptors.response.use(
+ mockResponseInterceptor,
+ );
+
+ await client.get({
+ ky: mockKy as Partial as KyInstance,
+ url: '/test',
+ });
+
+ expect(mockResponseInterceptor).toHaveBeenCalledOnce();
+
+ client.interceptors.response.eject(interceptorId);
+ });
+});
+
+describe('error handling', () => {
+ const client = createClient({ baseUrl: 'https://example.com' });
+
+ it('handles HTTP errors with throwOnError: false', async () => {
+ const errorResponse = new Response(
+ JSON.stringify({ message: 'Not found' }),
+ {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ status: 404,
+ },
+ );
+
+ const mockKy = vi.fn().mockRejectedValue(
+ new HTTPError(errorResponse, new Request('https://example.com/test'), {
+ method: 'GET',
+ } as any),
+ );
+
+ const result = await client.get({
+ ky: mockKy as Partial as KyInstance,
+ throwOnError: false,
+ url: '/test',
+ });
+
+ expect(result.error).toEqual({ message: 'Not found' });
+ expect(result.response.status).toBe(404);
+ });
+
+ it('throws HTTP errors with throwOnError: true', async () => {
+ const errorResponse = new Response(
+ JSON.stringify({ message: 'Not found' }),
+ {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ status: 404,
+ },
+ );
+
+ const mockKy = vi.fn().mockRejectedValue(
+ new HTTPError(errorResponse, new Request('https://example.com/test'), {
+ method: 'GET',
+ } as any),
+ );
+
+ await expect(
+ client.get({
+ ky: mockKy as Partial as KyInstance,
+ throwOnError: true,
+ url: '/test',
+ }),
+ ).rejects.toEqual({ message: 'Not found' });
+ });
+
+ it('handles text error responses', async () => {
+ const errorResponse = new Response('Internal Server Error', {
+ status: 500,
+ });
+
+ const mockKy = vi.fn().mockRejectedValue(
+ new HTTPError(errorResponse, new Request('https://example.com/test'), {
+ method: 'GET',
+ } as any),
+ );
+
+ const result = await client.get({
+ ky: mockKy as Partial as KyInstance,
+ throwOnError: false,
+ url: '/test',
+ });
+
+ expect(result.error).toBe('Internal Server Error');
+ expect(result.response.status).toBe(500);
+ });
+});
+
+describe('error interceptor', () => {
+ const client = createClient({ baseUrl: 'https://example.com' });
+
+ it('allows error transformation', async () => {
+ const errorResponse = new Response(
+ JSON.stringify({ message: 'Not found' }),
+ {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ status: 404,
+ },
+ );
+
+ const mockKy = vi.fn().mockRejectedValue(
+ new HTTPError(errorResponse, new Request('https://example.com/test'), {
+ method: 'GET',
+ } as any),
+ );
+
+ const mockErrorInterceptor = vi
+ .fn()
+ .mockImplementation((error: any) => ({ transformed: true, ...error }));
+
+ const interceptorId = client.interceptors.error.use(mockErrorInterceptor);
+
+ const result = await client.get({
+ ky: mockKy as Partial as KyInstance,
+ throwOnError: false,
+ url: '/test',
+ });
+
+ expect(mockErrorInterceptor).toHaveBeenCalledOnce();
+ expect(result.error).toEqual({ message: 'Not found', transformed: true });
+
+ client.interceptors.error.eject(interceptorId);
+ });
+});
+
+describe('retry configuration', () => {
+ const client = createClient({ baseUrl: 'https://example.com' });
+
+ it('passes retry configuration to ky', async () => {
+ const mockResponse = new Response(JSON.stringify({ success: true }), {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ status: 200,
+ });
+
+ const mockKy = vi.fn().mockResolvedValue(mockResponse);
+
+ await client.get({
+ ky: mockKy as Partial as KyInstance,
+ retry: {
+ limit: 3,
+ methods: ['get', 'post'],
+ statusCodes: [408, 429, 500],
+ },
+ url: '/test',
+ });
+
+ expect(mockKy).toHaveBeenCalledWith(
+ expect.any(Request),
+ expect.objectContaining({
+ retry: {
+ limit: 3,
+ methods: ['get', 'post'],
+ statusCodes: [408, 429, 500],
+ },
+ }),
+ );
+ });
+});
+
+describe('responseStyle configuration', () => {
+ const client = createClient({
+ baseUrl: 'https://example.com',
+ responseStyle: 'data',
+ });
+
+ it('returns only data when responseStyle is "data"', async () => {
+ const mockResponse = new Response(JSON.stringify({ result: 'success' }), {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ status: 200,
+ });
+
+ const mockKy = vi.fn().mockResolvedValue(mockResponse);
+
+ const result = await client.get({
+ ky: mockKy as Partial as KyInstance,
+ url: '/test',
+ });
+
+ expect(result).toEqual({ result: 'success' });
+ expect(result).not.toHaveProperty('response');
+ expect(result).not.toHaveProperty('request');
+ });
+
+ it('returns undefined for errors when responseStyle is "data"', async () => {
+ const errorResponse = new Response(
+ JSON.stringify({ message: 'Not found' }),
+ {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ status: 404,
+ },
+ );
+
+ const mockKy = vi.fn().mockRejectedValue(
+ new HTTPError(errorResponse, new Request('https://example.com/test'), {
+ method: 'GET',
+ } as any),
+ );
+
+ const result = await client.get({
+ ky: mockKy as Partial as KyInstance,
+ throwOnError: false,
+ url: '/test',
+ });
+
+ expect(result).toBeUndefined();
+ });
+});
diff --git a/packages/openapi-ts/src/plugins/@hey-api/client-ky/__tests__/utils.test.ts b/packages/openapi-ts/src/plugins/@hey-api/client-ky/__tests__/utils.test.ts
new file mode 100644
index 000000000..4be082d47
--- /dev/null
+++ b/packages/openapi-ts/src/plugins/@hey-api/client-ky/__tests__/utils.test.ts
@@ -0,0 +1,269 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import type { Auth } from '../../client-core/bundle/auth';
+import type { Client } from '../bundle/types';
+import { buildUrl, getParseAs, setAuthParams } from '../bundle/utils';
+
+describe('buildUrl', () => {
+ const scenarios: Array<{
+ options: Parameters[0];
+ url: string;
+ }> = [
+ {
+ options: {
+ path: {
+ id: new Date('2025-01-01T00:00:00.000Z'),
+ },
+ url: '/foo/{id}',
+ },
+ url: '/foo/2025-01-01T00:00:00.000Z',
+ },
+ ];
+
+ it.each(scenarios)('builds $url', async ({ options, url }) => {
+ expect(buildUrl(options)).toEqual(url);
+ });
+});
+
+describe('getParseAs', () => {
+ const scenarios: Array<{
+ content: Parameters[0];
+ parseAs: ReturnType;
+ }> = [
+ {
+ content: null,
+ parseAs: 'stream',
+ },
+ {
+ content: 'application/json',
+ parseAs: 'json',
+ },
+ {
+ content: 'application/ld+json',
+ parseAs: 'json',
+ },
+ {
+ content: 'application/ld+json;charset=utf-8',
+ parseAs: 'json',
+ },
+ {
+ content: 'application/ld+json; charset=utf-8',
+ parseAs: 'json',
+ },
+ {
+ content: 'multipart/form-data',
+ parseAs: 'formData',
+ },
+ {
+ content: 'application/*',
+ parseAs: 'blob',
+ },
+ {
+ content: 'audio/*',
+ parseAs: 'blob',
+ },
+ {
+ content: 'image/*',
+ parseAs: 'blob',
+ },
+ {
+ content: 'video/*',
+ parseAs: 'blob',
+ },
+ {
+ content: 'text/*',
+ parseAs: 'text',
+ },
+ {
+ content: 'unsupported',
+ parseAs: undefined,
+ },
+ ];
+
+ it.each(scenarios)(
+ 'detects $content as $parseAs',
+ async ({ content, parseAs }) => {
+ expect(getParseAs(content)).toEqual(parseAs);
+ },
+ );
+});
+
+describe('setAuthParams', () => {
+ it('sets bearer token in headers', async () => {
+ const auth = vi.fn().mockReturnValue('foo');
+ const headers = new Headers();
+ const query: Record = {};
+ await setAuthParams({
+ auth,
+ headers,
+ query,
+ security: [
+ {
+ name: 'baz',
+ scheme: 'bearer',
+ type: 'http',
+ },
+ ],
+ });
+ expect(auth).toHaveBeenCalled();
+ expect(headers.get('baz')).toBe('Bearer foo');
+ expect(Object.keys(query).length).toBe(0);
+ });
+
+ it('sets access token in query', async () => {
+ const auth = vi.fn().mockReturnValue('foo');
+ const headers = new Headers();
+ const query: Record = {};
+ await setAuthParams({
+ auth,
+ headers,
+ query,
+ security: [
+ {
+ in: 'query',
+ name: 'baz',
+ scheme: 'bearer',
+ type: 'http',
+ },
+ ],
+ });
+ expect(auth).toHaveBeenCalled();
+ expect(headers.get('baz')).toBeNull();
+ expect(query.baz).toBe('Bearer foo');
+ });
+
+ it('sets Authorization header when `in` and `name` are undefined', async () => {
+ const auth = vi.fn().mockReturnValue('foo');
+ const headers = new Headers();
+ const query: Record = {};
+ await setAuthParams({
+ auth,
+ headers,
+ query,
+ security: [
+ {
+ type: 'http',
+ },
+ ],
+ });
+ expect(auth).toHaveBeenCalled();
+ expect(headers.get('Authorization')).toBe('foo');
+ expect(query).toEqual({});
+ });
+
+ it('sets first scheme only', async () => {
+ const auth = vi.fn().mockReturnValue('foo');
+ const headers = new Headers();
+ const query: Record = {};
+ await setAuthParams({
+ auth,
+ headers,
+ query,
+ security: [
+ {
+ name: 'baz',
+ scheme: 'bearer',
+ type: 'http',
+ },
+ {
+ in: 'query',
+ name: 'baz',
+ scheme: 'bearer',
+ type: 'http',
+ },
+ ],
+ });
+ expect(auth).toHaveBeenCalled();
+ expect(headers.get('baz')).toBe('Bearer foo');
+ expect(Object.keys(query).length).toBe(0);
+ });
+
+ it('sets first scheme with token', async () => {
+ const auth = vi.fn().mockImplementation((auth: Auth) => {
+ if (auth.type === 'apiKey') {
+ return;
+ }
+ return 'foo';
+ });
+ const headers = new Headers();
+ const query: Record = {};
+ await setAuthParams({
+ auth,
+ headers,
+ query,
+ security: [
+ {
+ name: 'baz',
+ type: 'apiKey',
+ },
+ {
+ in: 'query',
+ name: 'baz',
+ scheme: 'bearer',
+ type: 'http',
+ },
+ ],
+ });
+ expect(auth).toHaveBeenCalled();
+ expect(headers.get('baz')).toBeNull();
+ expect(query.baz).toBe('Bearer foo');
+ });
+
+ it('sets an API key in a cookie', async () => {
+ const auth = vi.fn().mockReturnValue('foo');
+ const headers = new Headers();
+ const query: Record = {};
+ await setAuthParams({
+ auth,
+ headers,
+ query,
+ security: [
+ {
+ in: 'cookie',
+ name: 'baz',
+ type: 'apiKey',
+ },
+ ],
+ });
+ expect(auth).toHaveBeenCalled();
+ expect(headers.get('Cookie')).toBe('baz=foo');
+ expect(query).toEqual({});
+ });
+
+ it('sets only one specific header', async () => {
+ const auth = vi.fn(({ name }: Auth) => {
+ if (name === 'baz') {
+ return 'foo';
+ }
+ return 'buz';
+ });
+ const headers = new Headers();
+ const query: Record = {};
+ await setAuthParams({
+ auth,
+ headers,
+ query,
+ security: [
+ {
+ name: 'baz',
+ scheme: 'bearer',
+ type: 'http',
+ },
+ {
+ name: 'fiz',
+ type: 'http',
+ },
+ {
+ in: 'query',
+ name: 'baz',
+ scheme: 'bearer',
+ type: 'http',
+ },
+ ],
+ });
+ expect(auth).toHaveBeenCalled();
+ expect(headers.get('baz')).toBe('Bearer foo');
+ expect(headers.get('fiz')).toBe('buz');
+ expect(Object.keys(query).length).toBe(0);
+ });
+});
diff --git a/packages/openapi-ts/src/plugins/@hey-api/client-ky/bundle/client.ts b/packages/openapi-ts/src/plugins/@hey-api/client-ky/bundle/client.ts
new file mode 100644
index 000000000..3b19dccd8
--- /dev/null
+++ b/packages/openapi-ts/src/plugins/@hey-api/client-ky/bundle/client.ts
@@ -0,0 +1,339 @@
+import type { HTTPError, Options as KyOptions } from 'ky';
+import ky from 'ky';
+
+import { createSseClient } from '../../client-core/bundle/serverSentEvents';
+import type { HttpMethod } from '../../client-core/bundle/types';
+import { getValidRequestBody } from '../../client-core/bundle/utils';
+import type {
+ Client,
+ Config,
+ RequestOptions,
+ ResolvedRequestOptions,
+ RetryOptions,
+} from './types';
+import type { Middleware } from './utils';
+import {
+ buildUrl,
+ createConfig,
+ createInterceptors,
+ getParseAs,
+ mergeConfigs,
+ mergeHeaders,
+ setAuthParams,
+} from './utils';
+
+export const createClient = (config: Config = {}): Client => {
+ let _config = mergeConfigs(createConfig(), config);
+
+ const getConfig = (): Config => ({ ..._config });
+
+ const setConfig = (config: Config): Config => {
+ _config = mergeConfigs(_config, config);
+ return getConfig();
+ };
+
+ const interceptors = createInterceptors<
+ Request,
+ Response,
+ unknown,
+ ResolvedRequestOptions
+ >();
+
+ const beforeRequest = async (options: RequestOptions) => {
+ const opts = {
+ ..._config,
+ ...options,
+ headers: mergeHeaders(_config.headers, options.headers),
+ ky: options.ky ?? _config.ky ?? ky,
+ serializedBody: undefined,
+ };
+
+ if (opts.security) {
+ await setAuthParams({
+ ...opts,
+ security: opts.security,
+ });
+ }
+
+ if (opts.requestValidator) {
+ await opts.requestValidator(opts);
+ }
+
+ if (opts.body !== undefined && opts.bodySerializer) {
+ opts.serializedBody = opts.bodySerializer(opts.body);
+ }
+
+ if (opts.body === undefined || opts.serializedBody === '') {
+ opts.headers.delete('Content-Type');
+ }
+
+ const url = buildUrl(opts);
+
+ return { opts, url };
+ };
+
+ const parseErrorResponse = async (
+ response: Response,
+ request: Request,
+ opts: ResolvedRequestOptions,
+ interceptorsMiddleware: Middleware<
+ Request,
+ Response,
+ unknown,
+ ResolvedRequestOptions
+ >,
+ ) => {
+ const result = {
+ request,
+ response,
+ };
+
+ const textError = await response.text();
+ let jsonError: unknown;
+
+ try {
+ jsonError = JSON.parse(textError);
+ } catch {
+ jsonError = undefined;
+ }
+
+ const error = jsonError ?? textError;
+ let finalError = error;
+
+ for (const fn of interceptorsMiddleware.error.fns) {
+ if (fn) {
+ finalError = (await fn(error, response, request, opts)) as string;
+ }
+ }
+
+ finalError = finalError || ({} as string);
+
+ if (opts.throwOnError) {
+ throw finalError;
+ }
+
+ return opts.responseStyle === 'data'
+ ? undefined
+ : {
+ error: finalError,
+ ...result,
+ };
+ };
+
+ const request: Client['request'] = async (options) => {
+ // @ts-expect-error
+ const { opts, url } = await beforeRequest(options);
+
+ const kyInstance = opts.ky!;
+
+ const validBody = getValidRequestBody(opts);
+
+ const kyOptions: KyOptions = {
+ body: validBody as BodyInit,
+ cache: opts.cache,
+ credentials: opts.credentials,
+ headers: opts.headers,
+ integrity: opts.integrity,
+ keepalive: opts.keepalive,
+ method: opts.method as KyOptions['method'],
+ mode: opts.mode,
+ redirect: 'follow',
+ referrer: opts.referrer,
+ referrerPolicy: opts.referrerPolicy,
+ signal: opts.signal,
+ throwHttpErrors: opts.throwOnError ?? false,
+ timeout: opts.timeout,
+ ...(opts.kyOptions || {}),
+ };
+
+ if (opts.retry && typeof opts.retry === 'object') {
+ const retryOpts = opts.retry as RetryOptions;
+ kyOptions.retry = {
+ limit: retryOpts.limit ?? 2,
+ methods: retryOpts.methods as Array<
+ | 'get'
+ | 'post'
+ | 'put'
+ | 'patch'
+ | 'head'
+ | 'delete'
+ | 'options'
+ | 'trace'
+ >,
+ statusCodes: retryOpts.statusCodes,
+ };
+ }
+
+ let request = new Request(url, {
+ body: kyOptions.body as BodyInit,
+ headers: kyOptions.headers as HeadersInit,
+ method: kyOptions.method,
+ });
+
+ for (const fn of interceptors.request.fns) {
+ if (fn) {
+ request = await fn(request, opts);
+ }
+ }
+
+ let response: Response;
+
+ try {
+ response = await kyInstance(request, kyOptions);
+ } catch (error) {
+ if (error && typeof error === 'object' && 'response' in error) {
+ const httpError = error as HTTPError;
+ response = httpError.response;
+
+ for (const fn of interceptors.response.fns) {
+ if (fn) {
+ response = await fn(response, request, opts);
+ }
+ }
+
+ return parseErrorResponse(response, request, opts, interceptors);
+ }
+
+ throw error;
+ }
+
+ for (const fn of interceptors.response.fns) {
+ if (fn) {
+ response = await fn(response, request, opts);
+ }
+ }
+
+ const result = {
+ request,
+ response,
+ };
+
+ if (response.ok) {
+ const parseAs =
+ (opts.parseAs === 'auto'
+ ? getParseAs(response.headers.get('Content-Type'))
+ : opts.parseAs) ?? 'json';
+
+ if (
+ response.status === 204 ||
+ response.headers.get('Content-Length') === '0'
+ ) {
+ let emptyData: any;
+ switch (parseAs) {
+ case 'arrayBuffer':
+ case 'blob':
+ case 'text':
+ emptyData = await response[parseAs]();
+ break;
+ case 'formData':
+ emptyData = new FormData();
+ break;
+ case 'stream':
+ emptyData = response.body;
+ break;
+ case 'json':
+ default:
+ emptyData = {};
+ break;
+ }
+ return opts.responseStyle === 'data'
+ ? emptyData
+ : {
+ data: emptyData,
+ ...result,
+ };
+ }
+
+ let data: any;
+ switch (parseAs) {
+ case 'arrayBuffer':
+ case 'blob':
+ case 'formData':
+ case 'json':
+ case 'text':
+ data = await response[parseAs]();
+ break;
+ case 'stream':
+ return opts.responseStyle === 'data'
+ ? response.body
+ : {
+ data: response.body,
+ ...result,
+ };
+ }
+
+ if (parseAs === 'json') {
+ if (opts.responseValidator) {
+ await opts.responseValidator(data);
+ }
+
+ if (opts.responseTransformer) {
+ data = await opts.responseTransformer(data);
+ }
+ }
+
+ return opts.responseStyle === 'data'
+ ? data
+ : {
+ data,
+ ...result,
+ };
+ }
+
+ return parseErrorResponse(response, request, opts, interceptors);
+ };
+
+ const makeMethodFn =
+ (method: Uppercase) => (options: RequestOptions) =>
+ request({ ...options, method });
+
+ const makeSseFn =
+ (method: Uppercase) => async (options: RequestOptions) => {
+ const { opts, url } = await beforeRequest(options);
+ return createSseClient({
+ ...opts,
+ body: opts.body as BodyInit | null | undefined,
+ fetch: globalThis.fetch,
+ headers: opts.headers as unknown as Record,
+ method,
+ onRequest: async (url, init) => {
+ let request = new Request(url, init);
+ for (const fn of interceptors.request.fns) {
+ if (fn) {
+ request = await fn(request, opts);
+ }
+ }
+ return request;
+ },
+ url,
+ });
+ };
+
+ return {
+ buildUrl,
+ connect: makeMethodFn('CONNECT'),
+ delete: makeMethodFn('DELETE'),
+ get: makeMethodFn('GET'),
+ getConfig,
+ head: makeMethodFn('HEAD'),
+ interceptors,
+ options: makeMethodFn('OPTIONS'),
+ patch: makeMethodFn('PATCH'),
+ post: makeMethodFn('POST'),
+ put: makeMethodFn('PUT'),
+ request,
+ setConfig,
+ sse: {
+ connect: makeSseFn('CONNECT'),
+ delete: makeSseFn('DELETE'),
+ get: makeSseFn('GET'),
+ head: makeSseFn('HEAD'),
+ options: makeSseFn('OPTIONS'),
+ patch: makeSseFn('PATCH'),
+ post: makeSseFn('POST'),
+ put: makeSseFn('PUT'),
+ trace: makeSseFn('TRACE'),
+ },
+ trace: makeMethodFn('TRACE'),
+ } as Client;
+};
diff --git a/packages/openapi-ts/src/plugins/@hey-api/client-ky/bundle/index.ts b/packages/openapi-ts/src/plugins/@hey-api/client-ky/bundle/index.ts
new file mode 100644
index 000000000..00f096436
--- /dev/null
+++ b/packages/openapi-ts/src/plugins/@hey-api/client-ky/bundle/index.ts
@@ -0,0 +1,24 @@
+export type { Auth } from '../../client-core/bundle/auth';
+export type { QuerySerializerOptions } from '../../client-core/bundle/bodySerializer';
+export {
+ formDataBodySerializer,
+ jsonBodySerializer,
+ urlSearchParamsBodySerializer,
+} from '../../client-core/bundle/bodySerializer';
+export { buildClientParams } from '../../client-core/bundle/params';
+export { serializeQueryKeyValue } from '../../client-core/bundle/queryKeySerializer';
+export { createClient } from './client';
+export type {
+ Client,
+ ClientOptions,
+ Config,
+ CreateClientConfig,
+ Options,
+ RequestOptions,
+ RequestResult,
+ ResolvedRequestOptions,
+ ResponseStyle,
+ RetryOptions,
+ TDataShape,
+} from './types';
+export { createConfig, mergeHeaders } from './utils';
diff --git a/packages/openapi-ts/src/plugins/@hey-api/client-ky/bundle/types.ts b/packages/openapi-ts/src/plugins/@hey-api/client-ky/bundle/types.ts
new file mode 100644
index 000000000..baba033c9
--- /dev/null
+++ b/packages/openapi-ts/src/plugins/@hey-api/client-ky/bundle/types.ts
@@ -0,0 +1,271 @@
+import type { Auth } from '../../client-core/bundle/auth';
+import type {
+ ServerSentEventsOptions,
+ ServerSentEventsResult,
+} from '../../client-core/bundle/serverSentEvents';
+import type {
+ Client as CoreClient,
+ Config as CoreConfig,
+} from '../../client-core/bundle/types';
+import type { Middleware } from './utils';
+
+export type ResponseStyle = 'data' | 'fields';
+
+export interface RetryOptions {
+ /**
+ * Maximum number of retry attempts
+ *
+ * @default 2
+ */
+ limit?: number;
+ /**
+ * HTTP methods to retry
+ *
+ * @default ['get', 'put', 'head', 'delete', 'options', 'trace']
+ */
+ methods?: Array<
+ 'get' | 'post' | 'put' | 'delete' | 'patch' | 'head' | 'options' | 'trace'
+ >;
+ /**
+ * HTTP status codes to retry
+ *
+ * @default [408, 413, 429, 500, 502, 503, 504]
+ */
+ statusCodes?: number[];
+}
+
+export interface Config
+ extends Omit<
+ import('ky').Options,
+ 'body' | 'headers' | 'method' | 'prefixUrl' | 'retry' | 'throwHttpErrors'
+ >,
+ CoreConfig {
+ /**
+ * Base URL for all requests made by this client.
+ */
+ baseUrl?: T['baseUrl'];
+ /**
+ * Ky instance to use. You can use this option to provide a custom
+ * ky instance.
+ */
+ ky?: typeof import('ky').default;
+ /**
+ * Additional ky-specific options that will be passed directly to ky.
+ * This allows you to use any ky option not explicitly exposed in the config.
+ */
+ kyOptions?: Omit;
+ /**
+ * Return the response data parsed in a specified format. By default, `auto`
+ * will infer the appropriate method from the `Content-Type` response header.
+ * You can override this behavior with any of the {@link Body} methods.
+ * Select `stream` if you don't want to parse response data at all.
+ *
+ * @default 'auto'
+ */
+ parseAs?:
+ | 'arrayBuffer'
+ | 'auto'
+ | 'blob'
+ | 'formData'
+ | 'json'
+ | 'stream'
+ | 'text';
+ /**
+ * Should we return only data or multiple fields (data, error, response, etc.)?
+ *
+ * @default 'fields'
+ */
+ responseStyle?: ResponseStyle;
+ /**
+ * Retry configuration
+ */
+ retry?: RetryOptions;
+ /**
+ * Throw an error instead of returning it in the response?
+ *
+ * @default false
+ */
+ throwOnError?: T['throwOnError'];
+ /**
+ * Request timeout in milliseconds
+ *
+ * @default 10000
+ */
+ timeout?: number;
+}
+
+export interface RequestOptions<
+ TData = unknown,
+ TResponseStyle extends ResponseStyle = 'fields',
+ ThrowOnError extends boolean = boolean,
+ Url extends string = string,
+> extends Config<{
+ responseStyle: TResponseStyle;
+ throwOnError: ThrowOnError;
+ }>,
+ Pick<
+ ServerSentEventsOptions,
+ | 'onSseError'
+ | 'onSseEvent'
+ | 'sseDefaultRetryDelay'
+ | 'sseMaxRetryAttempts'
+ | 'sseMaxRetryDelay'
+ > {
+ /**
+ * Any body that you want to add to your request.
+ *
+ * {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
+ */
+ body?: unknown;
+ path?: Record;
+ query?: Record;
+ /**
+ * Security mechanism(s) to use for the request.
+ */
+ security?: ReadonlyArray;
+ url: Url;
+}
+
+export interface ResolvedRequestOptions<
+ TResponseStyle extends ResponseStyle = 'fields',
+ ThrowOnError extends boolean = boolean,
+ Url extends string = string,
+> extends RequestOptions {
+ serializedBody?: string;
+}
+
+export type RequestResult<
+ TData = unknown,
+ TError = unknown,
+ ThrowOnError extends boolean = boolean,
+ TResponseStyle extends ResponseStyle = 'fields',
+> = ThrowOnError extends true
+ ? Promise<
+ TResponseStyle extends 'data'
+ ? TData extends Record
+ ? TData[keyof TData]
+ : TData
+ : {
+ data: TData extends Record
+ ? TData[keyof TData]
+ : TData;
+ request: Request;
+ response: Response;
+ }
+ >
+ : Promise<
+ TResponseStyle extends 'data'
+ ?
+ | (TData extends Record
+ ? TData[keyof TData]
+ : TData)
+ | undefined
+ : (
+ | {
+ data: TData extends Record
+ ? TData[keyof TData]
+ : TData;
+ error: undefined;
+ }
+ | {
+ data: undefined;
+ error: TError extends Record
+ ? TError[keyof TError]
+ : TError;
+ }
+ ) & {
+ request: Request;
+ response: Response;
+ }
+ >;
+
+export interface ClientOptions {
+ baseUrl?: string;
+ responseStyle?: ResponseStyle;
+ throwOnError?: boolean;
+}
+
+type MethodFn = <
+ TData = unknown,
+ TError = unknown,
+ ThrowOnError extends boolean = false,
+ TResponseStyle extends ResponseStyle = 'fields',
+>(
+ options: Omit, 'method'>,
+) => RequestResult;
+
+type SseFn = <
+ TData = unknown,
+ TError = unknown,
+ ThrowOnError extends boolean = false,
+ TResponseStyle extends ResponseStyle = 'fields',
+>(
+ options: Omit, 'method'>,
+) => Promise>;
+
+type RequestFn = <
+ TData = unknown,
+ TError = unknown,
+ ThrowOnError extends boolean = false,
+ TResponseStyle extends ResponseStyle = 'fields',
+>(
+ options: Omit, 'method'> &
+ Pick<
+ Required>,
+ 'method'
+ >,
+) => RequestResult;
+
+type BuildUrlFn = <
+ TData extends {
+ body?: unknown;
+ path?: Record;
+ query?: Record;
+ url: string;
+ },
+>(
+ options: TData & Options,
+) => string;
+
+export type Client = CoreClient<
+ RequestFn,
+ Config,
+ MethodFn,
+ BuildUrlFn,
+ SseFn
+> & {
+ interceptors: Middleware;
+};
+
+/**
+ * The `createClientConfig()` function will be called on client initialization
+ * and the returned object will become the client's initial configuration.
+ *
+ * You may want to initialize your client this way instead of calling
+ * `setConfig()`. This is useful for example if you're using Next.js
+ * to ensure your client always has the correct values.
+ */
+export type CreateClientConfig = (
+ override?: Config,
+) => Config & T>;
+
+export interface TDataShape {
+ body?: unknown;
+ headers?: unknown;
+ path?: unknown;
+ query?: unknown;
+ url: string;
+}
+
+type OmitKeys = Pick>;
+
+export type Options<
+ TData extends TDataShape = TDataShape,
+ ThrowOnError extends boolean = boolean,
+ TResponse = unknown,
+ TResponseStyle extends ResponseStyle = 'fields',
+> = OmitKeys<
+ RequestOptions,
+ 'body' | 'path' | 'query' | 'url'
+> &
+ ([TData] extends [never] ? unknown : Omit);
diff --git a/packages/openapi-ts/src/plugins/@hey-api/client-ky/bundle/utils.ts b/packages/openapi-ts/src/plugins/@hey-api/client-ky/bundle/utils.ts
new file mode 100644
index 000000000..efeb558fe
--- /dev/null
+++ b/packages/openapi-ts/src/plugins/@hey-api/client-ky/bundle/utils.ts
@@ -0,0 +1,328 @@
+import { getAuthToken } from '../../client-core/bundle/auth';
+import type { QuerySerializerOptions } from '../../client-core/bundle/bodySerializer';
+import { jsonBodySerializer } from '../../client-core/bundle/bodySerializer';
+import {
+ serializeArrayParam,
+ serializeObjectParam,
+ serializePrimitiveParam,
+} from '../../client-core/bundle/pathSerializer';
+import { getUrl } from '../../client-core/bundle/utils';
+import type { Client, ClientOptions, Config, RequestOptions } from './types';
+
+export const createQuerySerializer = ({
+ parameters = {},
+ ...args
+}: QuerySerializerOptions = {}) => {
+ const querySerializer = (queryParams: T) => {
+ const search: string[] = [];
+ if (queryParams && typeof queryParams === 'object') {
+ for (const name in queryParams) {
+ const value = queryParams[name];
+
+ if (value === undefined || value === null) {
+ continue;
+ }
+
+ const options = parameters[name] || args;
+
+ if (Array.isArray(value)) {
+ const serializedArray = serializeArrayParam({
+ allowReserved: options.allowReserved,
+ explode: true,
+ name,
+ style: 'form',
+ value,
+ ...options.array,
+ });
+ if (serializedArray) search.push(serializedArray);
+ } else if (typeof value === 'object') {
+ const serializedObject = serializeObjectParam({
+ allowReserved: options.allowReserved,
+ explode: true,
+ name,
+ style: 'deepObject',
+ value: value as Record,
+ ...options.object,
+ });
+ if (serializedObject) search.push(serializedObject);
+ } else {
+ const serializedPrimitive = serializePrimitiveParam({
+ allowReserved: options.allowReserved,
+ name,
+ value: value as string,
+ });
+ if (serializedPrimitive) search.push(serializedPrimitive);
+ }
+ }
+ }
+ return search.join('&');
+ };
+ return querySerializer;
+};
+
+/**
+ * Infers parseAs value from provided Content-Type header.
+ */
+export const getParseAs = (
+ contentType: string | null,
+): Exclude => {
+ if (!contentType) {
+ return 'stream';
+ }
+
+ const cleanContent = contentType.split(';')[0]?.trim();
+
+ if (!cleanContent) {
+ return;
+ }
+
+ if (
+ cleanContent.startsWith('application/json') ||
+ cleanContent.endsWith('+json')
+ ) {
+ return 'json';
+ }
+
+ if (cleanContent === 'multipart/form-data') {
+ return 'formData';
+ }
+
+ if (
+ ['application/', 'audio/', 'image/', 'video/'].some((type) =>
+ cleanContent.startsWith(type),
+ )
+ ) {
+ return 'blob';
+ }
+
+ if (cleanContent.startsWith('text/')) {
+ return 'text';
+ }
+
+ return;
+};
+
+const checkForExistence = (
+ options: Pick & {
+ headers: Headers;
+ },
+ name?: string,
+): boolean => {
+ if (!name) {
+ return false;
+ }
+ if (
+ options.headers.has(name) ||
+ options.query?.[name] ||
+ options.headers.get('Cookie')?.includes(`${name}=`)
+ ) {
+ return true;
+ }
+ return false;
+};
+
+export const setAuthParams = async ({
+ security,
+ ...options
+}: Pick, 'security'> &
+ Pick & {
+ headers: Headers;
+ }) => {
+ for (const auth of security) {
+ if (checkForExistence(options, auth.name)) {
+ continue;
+ }
+
+ const token = await getAuthToken(auth, options.auth);
+
+ if (!token) {
+ continue;
+ }
+
+ const name = auth.name ?? 'Authorization';
+
+ switch (auth.in) {
+ case 'query':
+ if (!options.query) {
+ options.query = {};
+ }
+ options.query[name] = token;
+ break;
+ case 'cookie':
+ options.headers.append('Cookie', `${name}=${token}`);
+ break;
+ case 'header':
+ default:
+ options.headers.set(name, token);
+ break;
+ }
+ }
+};
+
+export const buildUrl: Client['buildUrl'] = (options) =>
+ getUrl({
+ baseUrl: options.baseUrl as string,
+ path: options.path,
+ query: options.query,
+ querySerializer:
+ typeof options.querySerializer === 'function'
+ ? options.querySerializer
+ : createQuerySerializer(options.querySerializer),
+ url: options.url,
+ });
+
+export const mergeConfigs = (a: Config, b: Config): Config => {
+ const config = { ...a, ...b };
+ if (config.baseUrl?.endsWith('/')) {
+ config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
+ }
+ config.headers = mergeHeaders(a.headers, b.headers);
+ return config;
+};
+
+const headersEntries = (headers: Headers): Array<[string, string]> => {
+ const entries: Array<[string, string]> = [];
+ headers.forEach((value, key) => {
+ entries.push([key, value]);
+ });
+ return entries;
+};
+
+export const mergeHeaders = (
+ ...headers: Array['headers'] | undefined>
+): Headers => {
+ const mergedHeaders = new Headers();
+ for (const header of headers) {
+ if (!header) {
+ continue;
+ }
+
+ const iterator =
+ header instanceof Headers
+ ? headersEntries(header)
+ : Object.entries(header);
+
+ for (const [key, value] of iterator) {
+ if (value === null) {
+ mergedHeaders.delete(key);
+ } else if (Array.isArray(value)) {
+ for (const v of value) {
+ mergedHeaders.append(key, v as string);
+ }
+ } else if (value !== undefined) {
+ mergedHeaders.set(
+ key,
+ typeof value === 'object' ? JSON.stringify(value) : (value as string),
+ );
+ }
+ }
+ }
+ return mergedHeaders;
+};
+
+type ErrInterceptor = (
+ error: Err,
+ response: Res,
+ request: Req,
+ options: Options,
+) => Err | Promise;
+
+type ReqInterceptor = (
+ request: Req,
+ options: Options,
+) => Req | Promise;
+
+type ResInterceptor = (
+ response: Res,
+ request: Req,
+ options: Options,
+) => Res | Promise;
+
+class Interceptors {
+ fns: Array = [];
+
+ clear(): void {
+ this.fns = [];
+ }
+
+ eject(id: number | Interceptor): void {
+ const index = this.getInterceptorIndex(id);
+ if (this.fns[index]) {
+ this.fns[index] = null;
+ }
+ }
+
+ exists(id: number | Interceptor): boolean {
+ const index = this.getInterceptorIndex(id);
+ return Boolean(this.fns[index]);
+ }
+
+ getInterceptorIndex(id: number | Interceptor): number {
+ if (typeof id === 'number') {
+ return this.fns[id] ? id : -1;
+ }
+ return this.fns.indexOf(id);
+ }
+
+ update(
+ id: number | Interceptor,
+ fn: Interceptor,
+ ): number | Interceptor | false {
+ const index = this.getInterceptorIndex(id);
+ if (this.fns[index]) {
+ this.fns[index] = fn;
+ return id;
+ }
+ return false;
+ }
+
+ use(fn: Interceptor): number {
+ this.fns.push(fn);
+ return this.fns.length - 1;
+ }
+}
+
+export interface Middleware {
+ error: Interceptors>;
+ request: Interceptors>;
+ response: Interceptors>;
+}
+
+export const createInterceptors =