@kubb/plugin-fetch 5.0.0-beta.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -0
- package/dist/index.cjs +1260 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +211 -0
- package/dist/index.js +1231 -0
- package/dist/index.js.map +1 -0
- package/dist/rolldown-runtime-C0LytTxp.js +8 -0
- package/package.json +82 -0
- package/src/generators/clientGenerator.tsx +90 -0
- package/src/index.ts +3 -0
- package/src/plugin.ts +95 -0
- package/src/templates.ts +8 -0
- package/src/types.ts +19 -0
- package/templates/fetch.ts +578 -0
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP status codes treated as a success. A resolved call only ever carries a body from one of
|
|
3
|
+
* these; everything else is an error (thrown by default, or surfaced on `error`).
|
|
4
|
+
*/
|
|
5
|
+
export type SuccessStatusCode = '200' | '201' | '202' | '203' | '204' | '205' | '206' | '207' | '208' | '226'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The success members of a per-status responses record (`{ '200': ...; '404': ... }`).
|
|
9
|
+
*/
|
|
10
|
+
export type SuccessOf<TResponses> = TResponses[Extract<keyof TResponses, SuccessStatusCode>]
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The error members of a per-status responses record — every documented status that is not a 2xx.
|
|
14
|
+
*/
|
|
15
|
+
export type ErrorOf<TResponses> = TResponses[Exclude<keyof TResponses, SuccessStatusCode>]
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Converts a response record's string status key to its numeric literal (`'200'` becomes `200`).
|
|
19
|
+
* Non-numeric keys such as the OpenAPI `default` response stay `number`.
|
|
20
|
+
*/
|
|
21
|
+
export type ToStatusNumber<TStatus> = TStatus extends `${infer TNumber extends number}` ? TNumber : number
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One result variant for a single documented status. `status` is the numeric literal at the top
|
|
25
|
+
* level, so a `switch (result.status)` narrows `data` (a 2xx status) or `error` (everything else) to
|
|
26
|
+
* that status's payload.
|
|
27
|
+
*/
|
|
28
|
+
export type ResultByStatus<TResponses, TStatus extends keyof TResponses, TRequest, TResponse> = TStatus extends SuccessStatusCode
|
|
29
|
+
? { status: ToStatusNumber<TStatus>; data: TResponses[TStatus]; error: undefined; request: TRequest; response: TResponse }
|
|
30
|
+
: { status: ToStatusNumber<TStatus>; data: undefined; error: TResponses[TStatus]; request: TRequest; response: TResponse }
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The union of every documented status' result variant.
|
|
34
|
+
*/
|
|
35
|
+
export type ResultUnion<TResponses, TRequest, TResponse> = {
|
|
36
|
+
[TStatus in keyof TResponses]: ResultByStatus<TResponses, TStatus, TRequest, TResponse>
|
|
37
|
+
}[keyof TResponses]
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The union of just the success (2xx) status variants. Selected by status code, not by `error`, so an
|
|
41
|
+
* untyped (`any`) error payload can never widen a success result's `data`.
|
|
42
|
+
*/
|
|
43
|
+
export type SuccessResultUnion<TResponses, TRequest, TResponse> = {
|
|
44
|
+
[TStatus in Extract<keyof TResponses, SuccessStatusCode>]: ResultByStatus<TResponses, TStatus, TRequest, TResponse>
|
|
45
|
+
}[Extract<keyof TResponses, SuccessStatusCode>]
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The shape every generated function returns, discriminated by the top-level `status`. With
|
|
49
|
+
* `throwOnError` (the default) a resolved call always means success, so the result is the union of the
|
|
50
|
+
* 2xx variants and `error` is `undefined`; without it every documented status is a variant, so a
|
|
51
|
+
* `switch (result.status)` (or narrowing on `error`) narrows `data` and `error` to that status's
|
|
52
|
+
* payload. Operations with no typed responses fall back to a `status`/`request`/`response`-only result.
|
|
53
|
+
*/
|
|
54
|
+
export type RequestResult<TResponses, ThrowOnError extends boolean = true, TRequest = Request, TResponse = Response> = ThrowOnError extends true
|
|
55
|
+
? [SuccessResultUnion<TResponses, TRequest, TResponse>] extends [never]
|
|
56
|
+
? { status: number; data: SuccessOf<TResponses>; error: undefined; request: TRequest; response: TResponse }
|
|
57
|
+
: SuccessResultUnion<TResponses, TRequest, TResponse>
|
|
58
|
+
: [ResultUnion<TResponses, TRequest, TResponse>] extends [never]
|
|
59
|
+
? { status: number; data: undefined; error: undefined; request: TRequest; response: TResponse }
|
|
60
|
+
: ResultUnion<TResponses, TRequest, TResponse>
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The data-shaped keys of the grouped options object. `Options` subtracts these from the runtime
|
|
64
|
+
* `RequestConfig` and adds them back, typed per operation, from the generated `<Name>Request` type.
|
|
65
|
+
*/
|
|
66
|
+
export type DataShape = { body?: unknown; headers?: unknown; path?: unknown; query?: unknown }
|
|
67
|
+
|
|
68
|
+
export type HeaderValue = string | number | boolean | null | undefined | object
|
|
69
|
+
export type HeadersInit = Array<[string, HeaderValue]> | Record<string, HeaderValue>
|
|
70
|
+
export type RequestCredentials = 'omit' | 'same-origin' | 'include'
|
|
71
|
+
export type ResponseType = 'arraybuffer' | 'blob' | 'document' | 'json' | 'text' | 'stream'
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Serializes the query object into a search string. Array and object members follow the configured
|
|
75
|
+
* style (`form` with `explode` by default; `deepObject` for nested objects).
|
|
76
|
+
*/
|
|
77
|
+
export type QuerySerializer = (params: Record<string, unknown>) => string
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Serializes the request body. JSON by default; `FormData`, `URLSearchParams`, `Blob`,
|
|
81
|
+
* `ArrayBuffer`, and string bodies pass through untouched.
|
|
82
|
+
*/
|
|
83
|
+
export type BodySerializer = (body: unknown, contentType?: string) => BodyInit | undefined
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Parses a value before it is sent or after it is received, returning the parsed (and optionally
|
|
87
|
+
* transformed) value. Wires zod parsing through the per-call `parser.request` / `parser.response` hooks.
|
|
88
|
+
*/
|
|
89
|
+
export type Parser<T = unknown> = (value: T) => T | Promise<T>
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A resolved security scheme carried on each generated call's `security` array. The runtime passes it
|
|
93
|
+
* to the configured `auth` resolver and places the returned token accordingly.
|
|
94
|
+
*/
|
|
95
|
+
export type Auth = {
|
|
96
|
+
type: 'http' | 'apiKey' | 'oauth2' | 'openIdConnect'
|
|
97
|
+
scheme?: 'bearer' | 'basic'
|
|
98
|
+
name?: string
|
|
99
|
+
in?: 'header' | 'query' | 'cookie'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The token a consumer returns for a scheme, or `undefined` to skip it. Bearer and basic schemes are
|
|
104
|
+
* prefixed by the runtime (basic is base64-encoded), so return the raw token or `user:password`.
|
|
105
|
+
*/
|
|
106
|
+
export type AuthToken = string | undefined
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolves the token for a security scheme: either a static token used for every scheme, or a
|
|
110
|
+
* callback called once per scheme on a guarded operation until one returns a token.
|
|
111
|
+
*/
|
|
112
|
+
export type AuthResolver = AuthToken | ((auth: Auth) => AuthToken | Promise<AuthToken>)
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The request a generated function hands to the runtime. `body` / `headers` / `path` / `query` come
|
|
116
|
+
* from the grouped options; everything else is plain request configuration.
|
|
117
|
+
*/
|
|
118
|
+
export type RequestConfig<TBody = unknown, TRequest = Request, TResponse = Response> = {
|
|
119
|
+
baseURL?: string
|
|
120
|
+
url?: string
|
|
121
|
+
method?: 'GET' | 'PUT' | 'PATCH' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD'
|
|
122
|
+
path?: Record<string, unknown>
|
|
123
|
+
query?: unknown
|
|
124
|
+
params?: unknown
|
|
125
|
+
body?: TBody
|
|
126
|
+
headers?: HeadersInit
|
|
127
|
+
signal?: AbortSignal
|
|
128
|
+
credentials?: RequestCredentials
|
|
129
|
+
contentType?: string
|
|
130
|
+
responseType?: ResponseType
|
|
131
|
+
throwOnError?: boolean
|
|
132
|
+
client?: ClientInstance<TRequest, TResponse>
|
|
133
|
+
transport?: Transport<TRequest, TResponse>
|
|
134
|
+
querySerializer?: QuerySerializer
|
|
135
|
+
bodySerializer?: BodySerializer
|
|
136
|
+
parser?: { request?: Parser; response?: Parser }
|
|
137
|
+
security?: Array<Auth>
|
|
138
|
+
auth?: AuthResolver
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The grouped options object passed to every generated function: the request config minus the
|
|
143
|
+
* data-shaped keys and the literal `url`, plus the per-operation `<Name>Request`.
|
|
144
|
+
*/
|
|
145
|
+
export type Options<TData extends DataShape, ThrowOnError extends boolean = true, TRequest = Request, TResponse = Response> = Omit<
|
|
146
|
+
RequestConfig<unknown, TRequest, TResponse>,
|
|
147
|
+
keyof DataShape | 'url'
|
|
148
|
+
> &
|
|
149
|
+
TData & {
|
|
150
|
+
client?: ClientInstance<TRequest, TResponse>
|
|
151
|
+
throwOnError?: ThrowOnError
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Client-level configuration shared by every call an instance makes. Per-call `RequestConfig`
|
|
156
|
+
* overrides these.
|
|
157
|
+
*/
|
|
158
|
+
export type ClientConfig<TRequest = Request, TResponse = Response> = {
|
|
159
|
+
baseURL?: string
|
|
160
|
+
headers?: HeadersInit
|
|
161
|
+
credentials?: RequestCredentials
|
|
162
|
+
throwOnError?: boolean
|
|
163
|
+
transport?: Transport<TRequest, TResponse>
|
|
164
|
+
querySerializer?: QuerySerializer
|
|
165
|
+
bodySerializer?: BodySerializer
|
|
166
|
+
auth?: AuthResolver
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The normalized request the transport receives. The shared core does all serialization, auth, and
|
|
171
|
+
* header work; the transport only performs the send.
|
|
172
|
+
*/
|
|
173
|
+
export type ResolvedRequest = {
|
|
174
|
+
url: string
|
|
175
|
+
method: string
|
|
176
|
+
headers: Record<string, string>
|
|
177
|
+
body?: BodyInit
|
|
178
|
+
signal?: AbortSignal
|
|
179
|
+
credentials?: RequestCredentials
|
|
180
|
+
responseType?: ResponseType
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* What a transport returns: the parsed body plus the native request/response objects, kept reachable
|
|
185
|
+
* so status, headers, and the raw body never have to grow the result type.
|
|
186
|
+
*/
|
|
187
|
+
export type TransportResult<TData = unknown, TRequest = Request, TResponse = Response> = {
|
|
188
|
+
data: TData
|
|
189
|
+
status: number
|
|
190
|
+
statusText: string
|
|
191
|
+
headers: Headers
|
|
192
|
+
request: TRequest
|
|
193
|
+
response: TResponse
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The per-plugin send. plugin-fetch wraps `globalThis.fetch`, plugin-axios an axios instance, and
|
|
198
|
+
* plugin-ky a ky instance. Supplied to `createClientCore` as `defaultTransport`.
|
|
199
|
+
*/
|
|
200
|
+
export type Transport<TRequest = Request, TResponse = Response> = (request: ResolvedRequest) => Promise<TransportResult<unknown, TRequest, TResponse>>
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The result a resolved call produces before it is cast to `RequestResult` by the generated wrapper.
|
|
204
|
+
*/
|
|
205
|
+
export type CallResult<TRequest = Request, TResponse = Response> = {
|
|
206
|
+
status: number
|
|
207
|
+
data: unknown
|
|
208
|
+
error: unknown
|
|
209
|
+
request: TRequest
|
|
210
|
+
response: TResponse
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A registered interceptor with its ejection id.
|
|
215
|
+
*/
|
|
216
|
+
export type InterceptorFn<T> = (value: T) => T | Promise<T>
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* A single interceptor channel — request, response, or error — with a transport-agnostic
|
|
220
|
+
* `use` / `eject` / `update` API.
|
|
221
|
+
*/
|
|
222
|
+
export type InterceptorStack<T> = {
|
|
223
|
+
use: (fn: InterceptorFn<T>) => number
|
|
224
|
+
eject: (id: number) => void
|
|
225
|
+
update: (id: number, fn: InterceptorFn<T>) => void
|
|
226
|
+
run: (value: T) => Promise<T>
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The three interceptor channels every client instance exposes.
|
|
231
|
+
*/
|
|
232
|
+
export type Interceptors<TRequest = Request, TResponse = Response> = {
|
|
233
|
+
request: InterceptorStack<ResolvedRequest>
|
|
234
|
+
response: InterceptorStack<TransportResult<unknown, TRequest, TResponse>>
|
|
235
|
+
error: InterceptorStack<ResponseError<unknown, TRequest, TResponse>>
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* A client instance: the callable send plus configuration, interceptors, and an isolated
|
|
240
|
+
* `createClient` factory bound to the same transport.
|
|
241
|
+
*/
|
|
242
|
+
export type ClientInstance<TRequest = Request, TResponse = Response> = {
|
|
243
|
+
<TBody = unknown>(config: RequestConfig<TBody, TRequest, TResponse>): Promise<CallResult<TRequest, TResponse>>
|
|
244
|
+
getConfig: () => ClientConfig<TRequest, TResponse>
|
|
245
|
+
setConfig: (config: ClientConfig<TRequest, TResponse>) => ClientConfig<TRequest, TResponse>
|
|
246
|
+
interceptors: Interceptors<TRequest, TResponse>
|
|
247
|
+
createClient: (config?: ClientConfig<TRequest, TResponse>) => ClientInstance<TRequest, TResponse>
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Thrown for responses outside the 2xx range, so a resolved call always means success. The parsed
|
|
252
|
+
* error body and the native request/response stay reachable on the error.
|
|
253
|
+
*/
|
|
254
|
+
export class ResponseError<TError = unknown, TRequest = Request, TResponse = Response> extends Error {
|
|
255
|
+
data: TError
|
|
256
|
+
status: number
|
|
257
|
+
statusText: string
|
|
258
|
+
request: TRequest
|
|
259
|
+
response: TResponse
|
|
260
|
+
|
|
261
|
+
constructor(config: { data: TError; status: number; statusText: string; request: TRequest; response: TResponse }) {
|
|
262
|
+
super(`Request failed with status ${config.status}${config.statusText ? ` ${config.statusText}` : ''}`)
|
|
263
|
+
this.name = 'ResponseError'
|
|
264
|
+
this.data = config.data
|
|
265
|
+
this.status = config.status
|
|
266
|
+
this.statusText = config.statusText
|
|
267
|
+
this.request = config.request
|
|
268
|
+
this.response = config.response
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export type ResponseErrorConfig<TError = unknown> = ResponseError<TError>
|
|
273
|
+
|
|
274
|
+
function isFormBody(body: unknown): body is BodyInit {
|
|
275
|
+
return (
|
|
276
|
+
body instanceof FormData ||
|
|
277
|
+
body instanceof URLSearchParams ||
|
|
278
|
+
body instanceof Blob ||
|
|
279
|
+
body instanceof ArrayBuffer ||
|
|
280
|
+
ArrayBuffer.isView(body) ||
|
|
281
|
+
typeof body === 'string'
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Default body serializer: passes binary/form bodies through and JSON-serializes everything else.
|
|
287
|
+
* For `application/x-www-form-urlencoded` plain objects become `URLSearchParams`.
|
|
288
|
+
*/
|
|
289
|
+
export const defaultBodySerializer: BodySerializer = (body, contentType) => {
|
|
290
|
+
if (body === undefined || body === null) return undefined
|
|
291
|
+
if (isFormBody(body)) return body as BodyInit
|
|
292
|
+
if (contentType?.includes('application/x-www-form-urlencoded')) {
|
|
293
|
+
return new URLSearchParams(body as Record<string, string>)
|
|
294
|
+
}
|
|
295
|
+
return JSON.stringify(body)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function appendQueryValue(search: URLSearchParams, key: string, value: unknown): void {
|
|
299
|
+
if (value === undefined || value === null) return
|
|
300
|
+
if (Array.isArray(value)) {
|
|
301
|
+
for (const item of value) appendQueryValue(search, key, item)
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
if (typeof value === 'object') {
|
|
305
|
+
for (const [prop, propValue] of Object.entries(value as Record<string, unknown>)) {
|
|
306
|
+
appendQueryValue(search, `${key}[${prop}]`, propValue)
|
|
307
|
+
}
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
search.append(key, String(value))
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Default query serializer: arrays explode into repeated keys and nested objects use the
|
|
315
|
+
* `deepObject` style (`key[prop]=value`).
|
|
316
|
+
*/
|
|
317
|
+
export const defaultQuerySerializer: QuerySerializer = (params) => {
|
|
318
|
+
const search = new URLSearchParams()
|
|
319
|
+
for (const [key, value] of Object.entries(params)) {
|
|
320
|
+
appendQueryValue(search, key, value)
|
|
321
|
+
}
|
|
322
|
+
return search.toString()
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function serializeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
|
326
|
+
if (!headers) return {}
|
|
327
|
+
const entries = Array.isArray(headers) ? headers : Object.entries(headers)
|
|
328
|
+
const result: Record<string, string> = {}
|
|
329
|
+
for (const [key, value] of entries) {
|
|
330
|
+
if (value === undefined || value === null) continue
|
|
331
|
+
result[key] = typeof value === 'string' ? value : typeof value === 'object' ? JSON.stringify(value) : String(value)
|
|
332
|
+
}
|
|
333
|
+
return result
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function mergeHeaders(...sources: Array<HeadersInit | undefined>): Record<string, string> {
|
|
337
|
+
return Object.assign({}, ...sources.map(serializeHeaders))
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Creates a transport-agnostic interceptor channel. Interceptors run in registration order; `eject`
|
|
342
|
+
* removes one by id and `update` swaps its function in place without reordering.
|
|
343
|
+
*/
|
|
344
|
+
export function createInterceptorStack<T>(): InterceptorStack<T> {
|
|
345
|
+
let entries: Array<{ id: number; fn: InterceptorFn<T> }> = []
|
|
346
|
+
let counter = 0
|
|
347
|
+
return {
|
|
348
|
+
use(fn) {
|
|
349
|
+
const id = ++counter
|
|
350
|
+
entries.push({ id, fn })
|
|
351
|
+
return id
|
|
352
|
+
},
|
|
353
|
+
eject(id) {
|
|
354
|
+
entries = entries.filter((entry) => entry.id !== id)
|
|
355
|
+
},
|
|
356
|
+
update(id, fn) {
|
|
357
|
+
const entry = entries.find((item) => item.id === id)
|
|
358
|
+
if (entry) entry.fn = fn
|
|
359
|
+
},
|
|
360
|
+
async run(value) {
|
|
361
|
+
let result = value
|
|
362
|
+
for (const entry of entries) {
|
|
363
|
+
result = await entry.fn(result)
|
|
364
|
+
}
|
|
365
|
+
return result
|
|
366
|
+
},
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Walks the per-operation security in order and places the first resolved token on the request,
|
|
372
|
+
* mutating `headers` / `query` in place. Bearer (and oauth2 / openIdConnect) tokens become a `Bearer`
|
|
373
|
+
* Authorization header, basic credentials are base64-encoded, and an apiKey is placed under its
|
|
374
|
+
* `name` in the header, query, or cookie.
|
|
375
|
+
*/
|
|
376
|
+
export async function resolveAuth(params: {
|
|
377
|
+
security: Array<Auth> | undefined
|
|
378
|
+
auth: AuthResolver | undefined
|
|
379
|
+
headers: Record<string, string>
|
|
380
|
+
query: Record<string, unknown>
|
|
381
|
+
}): Promise<void> {
|
|
382
|
+
const { security, auth, headers, query } = params
|
|
383
|
+
if (!security?.length || auth === undefined) return
|
|
384
|
+
|
|
385
|
+
for (const scheme of security) {
|
|
386
|
+
const token = typeof auth === 'function' ? await auth(scheme) : auth
|
|
387
|
+
if (token === undefined) continue
|
|
388
|
+
|
|
389
|
+
if (scheme.type === 'apiKey') {
|
|
390
|
+
const name = scheme.name ?? 'Authorization'
|
|
391
|
+
if (scheme.in === 'query') query[name] = token
|
|
392
|
+
else if (scheme.in === 'cookie') headers.Cookie = [headers.Cookie, `${name}=${token}`].filter(Boolean).join('; ')
|
|
393
|
+
else headers[name] = token
|
|
394
|
+
} else {
|
|
395
|
+
headers.Authorization = scheme.scheme === 'basic' ? `Basic ${btoa(token)}` : `Bearer ${token}`
|
|
396
|
+
}
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function runParser<T>(parser: Parser | undefined, value: T): Promise<T> {
|
|
402
|
+
if (!parser) return value
|
|
403
|
+
return (await parser(value)) as T
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Builds the shared client core bound to a transport. Each plugin calls this with its
|
|
408
|
+
* `defaultTransport` and exports the resulting instance as `client`, plus a `createClient` factory.
|
|
409
|
+
*/
|
|
410
|
+
export function createClientCore<TRequest = Request, TResponse = Response>(
|
|
411
|
+
options: { defaultTransport: Transport<TRequest, TResponse> } & ClientConfig<TRequest, TResponse>,
|
|
412
|
+
): ClientInstance<TRequest, TResponse> {
|
|
413
|
+
const { defaultTransport, ...initialConfig } = options
|
|
414
|
+
let config: ClientConfig<TRequest, TResponse> = { ...initialConfig }
|
|
415
|
+
|
|
416
|
+
const interceptors: Interceptors<TRequest, TResponse> = {
|
|
417
|
+
request: createInterceptorStack<ResolvedRequest>(),
|
|
418
|
+
response: createInterceptorStack<TransportResult<unknown, TRequest, TResponse>>(),
|
|
419
|
+
error: createInterceptorStack<ResponseError<unknown, TRequest, TResponse>>(),
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const client = (async <TBody = unknown>(requestConfig: RequestConfig<TBody, TRequest, TResponse>): Promise<CallResult<TRequest, TResponse>> => {
|
|
423
|
+
const transport = requestConfig.transport ?? config.transport ?? defaultTransport
|
|
424
|
+
const querySerializer = requestConfig.querySerializer ?? config.querySerializer ?? defaultQuerySerializer
|
|
425
|
+
const bodySerializer = requestConfig.bodySerializer ?? config.bodySerializer ?? defaultBodySerializer
|
|
426
|
+
|
|
427
|
+
const headers = mergeHeaders(config.headers, requestConfig.headers)
|
|
428
|
+
if (requestConfig.contentType && requestConfig.contentType !== 'multipart/form-data') {
|
|
429
|
+
headers['Content-Type'] = requestConfig.contentType
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const query: Record<string, unknown> = { ...((requestConfig.query ?? requestConfig.params) as Record<string, unknown> | undefined) }
|
|
433
|
+
|
|
434
|
+
await resolveAuth({
|
|
435
|
+
security: requestConfig.security,
|
|
436
|
+
auth: requestConfig.auth ?? config.auth,
|
|
437
|
+
headers,
|
|
438
|
+
query,
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
const rawBody = requestConfig.body
|
|
442
|
+
const validatedBody = await runParser(requestConfig.parser?.request, rawBody)
|
|
443
|
+
const search = querySerializer(query)
|
|
444
|
+
const pathParams = requestConfig.path ?? {}
|
|
445
|
+
const interpolatedUrl = [config.baseURL, requestConfig.baseURL, requestConfig.url]
|
|
446
|
+
.filter(Boolean)
|
|
447
|
+
.join('')
|
|
448
|
+
.replace(/\{([^{}]+)\}/g, (_, key: string) => encodeURIComponent(String(pathParams[key] ?? '')))
|
|
449
|
+
const url = interpolatedUrl + (search ? `?${search}` : '')
|
|
450
|
+
|
|
451
|
+
let resolvedRequest: ResolvedRequest = {
|
|
452
|
+
url,
|
|
453
|
+
method: (requestConfig.method ?? 'GET').toUpperCase(),
|
|
454
|
+
headers,
|
|
455
|
+
body: bodySerializer(validatedBody, headers['Content-Type'] ?? headers['content-type']),
|
|
456
|
+
signal: requestConfig.signal,
|
|
457
|
+
credentials: requestConfig.credentials,
|
|
458
|
+
responseType: requestConfig.responseType,
|
|
459
|
+
}
|
|
460
|
+
resolvedRequest = await interceptors.request.run(resolvedRequest)
|
|
461
|
+
|
|
462
|
+
let result = await transport(resolvedRequest)
|
|
463
|
+
result = await interceptors.response.run(result)
|
|
464
|
+
|
|
465
|
+
const isSuccess = result.status >= 200 && result.status < 300
|
|
466
|
+
const throwOnError = requestConfig.throwOnError ?? config.throwOnError ?? true
|
|
467
|
+
|
|
468
|
+
if (!isSuccess && throwOnError) {
|
|
469
|
+
const error = new ResponseError({
|
|
470
|
+
data: result.data,
|
|
471
|
+
status: result.status,
|
|
472
|
+
statusText: result.statusText,
|
|
473
|
+
request: result.request,
|
|
474
|
+
response: result.response,
|
|
475
|
+
})
|
|
476
|
+
await interceptors.error.run(error)
|
|
477
|
+
throw error
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const data = isSuccess ? await runParser(requestConfig.parser?.response, result.data) : undefined
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
status: result.status,
|
|
484
|
+
data,
|
|
485
|
+
error: isSuccess ? undefined : result.data,
|
|
486
|
+
request: result.request,
|
|
487
|
+
response: result.response,
|
|
488
|
+
}
|
|
489
|
+
}) as ClientInstance<TRequest, TResponse>
|
|
490
|
+
|
|
491
|
+
client.getConfig = () => config
|
|
492
|
+
client.setConfig = (next) => {
|
|
493
|
+
config = { ...config, ...next, headers: { ...serializeHeaders(config.headers), ...serializeHeaders(next.headers) } }
|
|
494
|
+
return config
|
|
495
|
+
}
|
|
496
|
+
client.interceptors = interceptors
|
|
497
|
+
client.createClient = (next) => createClientCore({ defaultTransport, ...config, ...next })
|
|
498
|
+
|
|
499
|
+
return client
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Picks a `responseType` from a `Content-Type` header, or `undefined` when it is not recognized.
|
|
504
|
+
*/
|
|
505
|
+
function detectResponseType(contentType: string | null): ResponseType | undefined {
|
|
506
|
+
if (!contentType) return undefined
|
|
507
|
+
if (contentType.includes('application/json') || contentType.includes('text/json')) return 'json'
|
|
508
|
+
if (contentType.includes('text/')) return 'text'
|
|
509
|
+
if (contentType.includes('image/') || contentType.includes('application/octet-stream')) return 'blob'
|
|
510
|
+
return undefined
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Parses a `fetch` response body. Empty responses (204/205/304 or no body) resolve to `undefined`.
|
|
515
|
+
* An explicit `responseType`, or one detected from the `Content-Type` header, forces the matching
|
|
516
|
+
* `Response` method; otherwise the body is read as text and `JSON.parse`d, falling back to raw text.
|
|
517
|
+
*/
|
|
518
|
+
async function parseResponse(response: Response, responseType?: ResponseType): Promise<unknown> {
|
|
519
|
+
if (response.status === 204 || response.status === 205 || response.status === 304 || !response.body) {
|
|
520
|
+
return undefined
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
switch (responseType ?? detectResponseType(response.headers.get('Content-Type'))) {
|
|
524
|
+
case 'text':
|
|
525
|
+
case 'document':
|
|
526
|
+
return response.text()
|
|
527
|
+
case 'blob':
|
|
528
|
+
return response.blob()
|
|
529
|
+
case 'arraybuffer':
|
|
530
|
+
return response.arrayBuffer()
|
|
531
|
+
case 'stream':
|
|
532
|
+
return response.body ?? undefined
|
|
533
|
+
case 'json': {
|
|
534
|
+
// An empty body with a JSON content-type would make response.json() throw; treat it as no data.
|
|
535
|
+
const body = await response.text()
|
|
536
|
+
return body ? JSON.parse(body) : undefined
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const text = await response.text()
|
|
541
|
+
if (!text) return undefined
|
|
542
|
+
try {
|
|
543
|
+
return JSON.parse(text)
|
|
544
|
+
} catch {
|
|
545
|
+
return text
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* The default transport: builds a native `Request` from the resolved request, sends it through
|
|
551
|
+
* `globalThis.fetch`, and returns the parsed body alongside the native request/response objects.
|
|
552
|
+
*/
|
|
553
|
+
const defaultTransport: Transport = async (request: ResolvedRequest): Promise<TransportResult> => {
|
|
554
|
+
const init: RequestInit = {
|
|
555
|
+
method: request.method,
|
|
556
|
+
headers: request.headers,
|
|
557
|
+
body: request.body,
|
|
558
|
+
signal: request.signal,
|
|
559
|
+
}
|
|
560
|
+
if (request.credentials) init.credentials = request.credentials
|
|
561
|
+
|
|
562
|
+
const nativeRequest = new Request(request.url, init)
|
|
563
|
+
const response = await globalThis.fetch(nativeRequest)
|
|
564
|
+
const data = await parseResponse(response, request.responseType)
|
|
565
|
+
|
|
566
|
+
return {
|
|
567
|
+
data,
|
|
568
|
+
status: response.status,
|
|
569
|
+
statusText: response.statusText,
|
|
570
|
+
headers: response.headers,
|
|
571
|
+
request: nativeRequest,
|
|
572
|
+
response,
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export const client = createClientCore({ defaultTransport })
|
|
577
|
+
|
|
578
|
+
export const createClient = (config?: Parameters<typeof client.createClient>[0]) => client.createClient(config)
|