@kubb/plugin-fetch 5.0.0-beta.100
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/LICENSE +21 -0
- package/README.md +70 -0
- package/dist/index.cjs +1381 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +207 -0
- package/dist/index.js +1352 -0
- package/dist/index.js.map +1 -0
- package/dist/rolldown-runtime-C0LytTxp.js +8 -0
- package/package.json +76 -0
- package/src/generators/clientGenerator.tsx +9 -0
- package/src/index.ts +3 -0
- package/src/plugin.ts +108 -0
- package/src/templates.ts +13 -0
- package/src/types.ts +19 -0
- package/templates/fetch.ts +818 -0
- package/templates/serializers.ts +428 -0
- package/templates/standardSchema.ts +54 -0
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
import { applyHeaderStyles, defaultBodySerializer, defaultPathSerializer, defaultQuerySerializer, isDefaultJsonBody, serializeCookies } from './serializers'
|
|
2
|
+
import type { HeadersInit, PathParamStyle, PathSerializer, Serializers, Styles } from './serializers'
|
|
3
|
+
import { type StandardSchemaValidator, validateStandardSchema } from './standardSchema.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* HTTP status codes treated as a success, everything else is an error.
|
|
7
|
+
*/
|
|
8
|
+
export type SuccessStatusCode = '200' | '201' | '202' | '203' | '204' | '205' | '206' | '207' | '208' | '226'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The success members of a per-status responses record.
|
|
12
|
+
*/
|
|
13
|
+
export type SuccessOf<TResponses> = TResponses[Extract<keyof TResponses, SuccessStatusCode>]
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The error members of a per-status responses record, every documented status that is not a 2xx.
|
|
17
|
+
*/
|
|
18
|
+
export type ErrorOf<TResponses> = TResponses[Exclude<keyof TResponses, SuccessStatusCode>]
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Converts a response record's string status key to its numeric literal, leaving non-numeric keys like `default` as `number`.
|
|
22
|
+
*/
|
|
23
|
+
export type ToStatusNumber<TStatus> = TStatus extends `${infer TNumber extends number}` ? TNumber : number
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The plain body of a per-status response, unwrapping the `{ contentType; data }` union so an error result keeps the bare body union on `error`.
|
|
27
|
+
*/
|
|
28
|
+
export type DataOf<T> = T extends { contentType: string; data: infer TData } ? TData : T
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The success variant for a single status, flattened so the negotiated `contentType` sits next to `data` and `switch (result.contentType)` narrows it.
|
|
32
|
+
*/
|
|
33
|
+
export type SuccessVariant<TStatus, TEntry, TRequest, TResponse> = TEntry extends { contentType: string; data: unknown }
|
|
34
|
+
? TEntry extends { contentType: infer TContentType; data: infer TData }
|
|
35
|
+
? { status: ToStatusNumber<TStatus>; data: TData; error: undefined; contentType: TContentType; request: TRequest; response: TResponse }
|
|
36
|
+
: never
|
|
37
|
+
: { status: ToStatusNumber<TStatus>; data: TEntry; error: undefined; contentType: string | undefined; request: TRequest; response: TResponse }
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One result variant for a single documented status, keyed by the numeric `status` so a `switch (result.status)` narrows `data` or `error`.
|
|
41
|
+
*/
|
|
42
|
+
export type ResultByStatus<TResponses, TStatus extends keyof TResponses, TRequest, TResponse> = TStatus extends SuccessStatusCode
|
|
43
|
+
? SuccessVariant<TStatus, TResponses[TStatus], TRequest, TResponse>
|
|
44
|
+
: {
|
|
45
|
+
status: ToStatusNumber<TStatus>
|
|
46
|
+
data: undefined
|
|
47
|
+
error: DataOf<TResponses[TStatus]>
|
|
48
|
+
contentType: string | undefined
|
|
49
|
+
request: TRequest
|
|
50
|
+
response: TResponse
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The union of every documented status' result variant.
|
|
55
|
+
*/
|
|
56
|
+
export type ResultUnion<TResponses, TRequest, TResponse> = {
|
|
57
|
+
[TStatus in keyof TResponses]: ResultByStatus<TResponses, TStatus, TRequest, TResponse>
|
|
58
|
+
}[keyof TResponses]
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The union of just the success (2xx) status variants, selected by status code so an untyped error payload can never widen `data`.
|
|
62
|
+
*/
|
|
63
|
+
export type SuccessResultUnion<TResponses, TRequest, TResponse> = {
|
|
64
|
+
[TStatus in Extract<keyof TResponses, SuccessStatusCode>]: ResultByStatus<TResponses, TStatus, TRequest, TResponse>
|
|
65
|
+
}[Extract<keyof TResponses, SuccessStatusCode>]
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The shape every generated function returns, discriminated by the top-level `status`, narrowing to the 2xx variants under `throwOnError` and to every documented status without it.
|
|
69
|
+
*/
|
|
70
|
+
export type RequestResult<TResponses, ThrowOnError extends boolean = true, TRequest = Request, TResponse = Response> = ThrowOnError extends true
|
|
71
|
+
? [SuccessResultUnion<TResponses, TRequest, TResponse>] extends [never]
|
|
72
|
+
? {
|
|
73
|
+
status: number
|
|
74
|
+
data: SuccessOf<TResponses>
|
|
75
|
+
error: undefined
|
|
76
|
+
contentType: string | undefined
|
|
77
|
+
request: TRequest
|
|
78
|
+
response: TResponse
|
|
79
|
+
}
|
|
80
|
+
: SuccessResultUnion<TResponses, TRequest, TResponse>
|
|
81
|
+
: [ResultUnion<TResponses, TRequest, TResponse>] extends [never]
|
|
82
|
+
? { status: number; data: undefined; error: undefined; contentType: string | undefined; request: TRequest; response: TResponse }
|
|
83
|
+
: ResultUnion<TResponses, TRequest, TResponse>
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The data-shaped keys of the grouped options object, which `Options` re-adds typed per operation.
|
|
87
|
+
*/
|
|
88
|
+
export type DataShape = { body?: unknown; cookies?: unknown; headers?: unknown; path?: unknown; query?: unknown }
|
|
89
|
+
|
|
90
|
+
export type RequestCredentials = 'omit' | 'same-origin' | 'include'
|
|
91
|
+
export type ResponseType = 'arraybuffer' | 'blob' | 'document' | 'json' | 'text' | 'stream'
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Turns a raw response body into a parsed value, registered per media type as a codec's `deserialize` to handle formats the runtime does not decode itself.
|
|
95
|
+
*/
|
|
96
|
+
export type Deserializer<T = unknown> = (raw: unknown, contentType: string) => T | Promise<T>
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Serializes a request body for a single media type, registered per content type as a codec's `serialize` to encode formats the default serializer does not handle.
|
|
100
|
+
*/
|
|
101
|
+
export type ContentBodySerializer = (body: unknown, contentType?: string) => BodyInit | undefined
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A per-content-type codec registered on `codecs`, keyed by content type. `serialize` encodes the
|
|
105
|
+
* request body for that media type and `deserialize` decodes the response body. Either half is
|
|
106
|
+
* optional, so a codec can handle one direction.
|
|
107
|
+
*/
|
|
108
|
+
export type Codec = {
|
|
109
|
+
serialize?: ContentBodySerializer
|
|
110
|
+
deserialize?: Deserializer
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The per-call content type selection, where a bare string sets the request content type and the object form also sets the response format sent as `Accept`.
|
|
115
|
+
*/
|
|
116
|
+
export type ContentType = string | { request?: string; response?: string }
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A Standard Schema validator (zod, valibot, arktype) that parses a value before it is sent or after
|
|
120
|
+
* it is received. `runValidator` runs it through `validateStandardSchema`. Wired through the per-call
|
|
121
|
+
* `validator.request` / `validator.response` / `validator.error` hooks (`error` runs on the error body when a
|
|
122
|
+
* non-2xx call does not throw).
|
|
123
|
+
*/
|
|
124
|
+
export type Validator<T = unknown> = StandardSchemaValidator<T>
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A resolved security scheme carried on each generated call's `security` array and passed to the `auth` resolver.
|
|
128
|
+
*/
|
|
129
|
+
export type Auth = {
|
|
130
|
+
type: 'http' | 'apiKey' | 'oauth2' | 'openIdConnect'
|
|
131
|
+
scheme?: 'bearer' | 'basic'
|
|
132
|
+
name?: string
|
|
133
|
+
in?: 'header' | 'query' | 'cookie'
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The raw token a consumer returns for a scheme (or `user:password` for basic), or `undefined` to skip it.
|
|
138
|
+
*/
|
|
139
|
+
export type AuthToken = string | undefined
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Resolves the token for a security scheme, either a static token or a callback called per scheme until one returns a token.
|
|
143
|
+
*/
|
|
144
|
+
export type AuthResolver = AuthToken | ((auth: Auth) => AuthToken | Promise<AuthToken>)
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Extra `fetch` init the transport spreads onto every `Request`, an escape hatch for fields the runtime does not set itself such as `cache`, `redirect`, and Next.js's `next`.
|
|
148
|
+
*/
|
|
149
|
+
export type FetchOptions = RequestInit & { next?: Record<string, unknown> }
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The request a generated function hands to the runtime, with `body` / `headers` / `path` / `query` from the grouped options.
|
|
153
|
+
*/
|
|
154
|
+
export type RequestConfig<TBody = unknown, TRequest = Request, TResponse = Response> = {
|
|
155
|
+
baseURL?: string
|
|
156
|
+
url?: string
|
|
157
|
+
method?: 'GET' | 'PUT' | 'PATCH' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD'
|
|
158
|
+
path?: Record<string, unknown>
|
|
159
|
+
query?: unknown
|
|
160
|
+
params?: unknown
|
|
161
|
+
cookies?: Record<string, unknown>
|
|
162
|
+
body?: TBody
|
|
163
|
+
headers?: HeadersInit
|
|
164
|
+
styles?: Styles
|
|
165
|
+
signal?: AbortSignal
|
|
166
|
+
credentials?: RequestCredentials
|
|
167
|
+
options?: FetchOptions
|
|
168
|
+
contentType?: ContentType
|
|
169
|
+
responseType?: ResponseType
|
|
170
|
+
throwOnError?: boolean
|
|
171
|
+
client?: ClientInstance<TRequest, TResponse>
|
|
172
|
+
transport?: Transport<TRequest, TResponse>
|
|
173
|
+
serializer?: Serializers
|
|
174
|
+
codecs?: Record<string, Codec>
|
|
175
|
+
validator?: { request?: Validator; response?: Validator; error?: Validator }
|
|
176
|
+
security?: Array<Auth>
|
|
177
|
+
auth?: AuthResolver
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The grouped options object passed to every generated function: the request config minus the
|
|
182
|
+
* data-shaped keys and the literal `url`, plus the per-operation `<Name>Request`.
|
|
183
|
+
*/
|
|
184
|
+
export type Options<TData extends DataShape, ThrowOnError extends boolean = true, TRequest = Request, TResponse = Response> = Omit<
|
|
185
|
+
RequestConfig<unknown, TRequest, TResponse>,
|
|
186
|
+
keyof DataShape | 'url'
|
|
187
|
+
> &
|
|
188
|
+
TData & {
|
|
189
|
+
client?: ClientInstance<TRequest, TResponse>
|
|
190
|
+
throwOnError?: ThrowOnError
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Client-level configuration shared by every call an instance makes, overridden by the per-call `RequestConfig`.
|
|
195
|
+
*/
|
|
196
|
+
export type ClientConfig<TRequest = Request, TResponse = Response> = {
|
|
197
|
+
baseURL?: string
|
|
198
|
+
headers?: HeadersInit
|
|
199
|
+
credentials?: RequestCredentials
|
|
200
|
+
options?: FetchOptions
|
|
201
|
+
throwOnError?: boolean
|
|
202
|
+
transport?: Transport<TRequest, TResponse>
|
|
203
|
+
serializer?: Serializers
|
|
204
|
+
codecs?: Record<string, Codec>
|
|
205
|
+
auth?: AuthResolver
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The normalized request the transport receives, with all serialization, auth, and header work already done.
|
|
210
|
+
*/
|
|
211
|
+
export type ResolvedRequest = {
|
|
212
|
+
url: string
|
|
213
|
+
method: string
|
|
214
|
+
headers: Record<string, string>
|
|
215
|
+
body?: BodyInit
|
|
216
|
+
signal?: AbortSignal
|
|
217
|
+
credentials?: RequestCredentials
|
|
218
|
+
options?: FetchOptions
|
|
219
|
+
responseType?: ResponseType
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* What a transport returns: the parsed body plus the native request and response objects.
|
|
224
|
+
*/
|
|
225
|
+
export type TransportResult<TData = unknown, TRequest = Request, TResponse = Response> = {
|
|
226
|
+
data: TData
|
|
227
|
+
status: number
|
|
228
|
+
statusText: string
|
|
229
|
+
headers: Headers
|
|
230
|
+
contentType?: string
|
|
231
|
+
request: TRequest
|
|
232
|
+
response: TResponse
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The per-plugin send, supplied to `createClientCore` as `defaultTransport`.
|
|
237
|
+
*/
|
|
238
|
+
export type Transport<TRequest = Request, TResponse = Response> = (request: ResolvedRequest) => Promise<TransportResult<unknown, TRequest, TResponse>>
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The result a resolved call produces before it is cast to `RequestResult` by the generated wrapper.
|
|
242
|
+
*/
|
|
243
|
+
export type CallResult<TRequest = Request, TResponse = Response> = {
|
|
244
|
+
status: number
|
|
245
|
+
data: unknown
|
|
246
|
+
error: unknown
|
|
247
|
+
contentType: string | undefined
|
|
248
|
+
request: TRequest
|
|
249
|
+
response: TResponse
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export type InterceptorFn<T> = (value: T) => T | Promise<T>
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* A single interceptor channel with a transport-agnostic `use` / `eject` / `update` API.
|
|
256
|
+
*/
|
|
257
|
+
export type InterceptorStack<T> = {
|
|
258
|
+
use: (fn: InterceptorFn<T>) => number
|
|
259
|
+
eject: (id: number) => void
|
|
260
|
+
update: (id: number, fn: InterceptorFn<T>) => void
|
|
261
|
+
run: (value: T) => Promise<T>
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The three interceptor channels every client instance exposes.
|
|
266
|
+
*/
|
|
267
|
+
export type Interceptors<TRequest = Request, TResponse = Response> = {
|
|
268
|
+
request: InterceptorStack<ResolvedRequest>
|
|
269
|
+
response: InterceptorStack<TransportResult<unknown, TRequest, TResponse>>
|
|
270
|
+
error: InterceptorStack<ResponseError<unknown, TRequest, TResponse>>
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* A client instance: the callable send plus configuration, interceptors, and an isolated
|
|
275
|
+
* `createClient` factory bound to the same transport.
|
|
276
|
+
*/
|
|
277
|
+
export type ClientInstance<TRequest = Request, TResponse = Response> = {
|
|
278
|
+
<TBody = unknown>(config: RequestConfig<TBody, TRequest, TResponse>): Promise<CallResult<TRequest, TResponse>>
|
|
279
|
+
getConfig: () => ClientConfig<TRequest, TResponse>
|
|
280
|
+
setConfig: (config: ClientConfig<TRequest, TResponse>) => ClientConfig<TRequest, TResponse>
|
|
281
|
+
getUrl: <TBody = unknown>(config: RequestConfig<TBody, TRequest, TResponse>) => string
|
|
282
|
+
interceptors: Interceptors<TRequest, TResponse>
|
|
283
|
+
createClient: (config?: ClientConfig<TRequest, TResponse>) => ClientInstance<TRequest, TResponse>
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Thrown for a non-2xx response, so a resolved call always means success.
|
|
288
|
+
*/
|
|
289
|
+
export class ResponseError<TError = unknown, TRequest = Request, TResponse = Response> extends Error {
|
|
290
|
+
data: TError
|
|
291
|
+
status: number
|
|
292
|
+
statusText: string
|
|
293
|
+
contentType: string | undefined
|
|
294
|
+
request: TRequest
|
|
295
|
+
response: TResponse
|
|
296
|
+
|
|
297
|
+
constructor(config: { data: TError; status: number; statusText: string; contentType?: string; request: TRequest; response: TResponse }) {
|
|
298
|
+
super(`Request failed with status ${config.status}${config.statusText ? ` ${config.statusText}` : ''}`)
|
|
299
|
+
this.name = 'ResponseError'
|
|
300
|
+
this.data = config.data
|
|
301
|
+
this.status = config.status
|
|
302
|
+
this.statusText = config.statusText
|
|
303
|
+
this.contentType = config.contentType
|
|
304
|
+
this.request = config.request
|
|
305
|
+
this.response = config.response
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export type ResponseErrorConfig<TError = unknown> = ResponseError<TError>
|
|
310
|
+
|
|
311
|
+
function serializeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
|
312
|
+
if (!headers) return {}
|
|
313
|
+
const entries = Array.isArray(headers) ? headers : Object.entries(headers)
|
|
314
|
+
const result: Record<string, string> = {}
|
|
315
|
+
for (const [key, value] of entries) {
|
|
316
|
+
if (value === undefined || value === null) continue
|
|
317
|
+
result[key] = typeof value === 'string' ? value : typeof value === 'object' ? JSON.stringify(value) : String(value)
|
|
318
|
+
}
|
|
319
|
+
return result
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function mergeHeaders(...sources: Array<HeadersInit | undefined>): Record<string, string> {
|
|
323
|
+
return Object.assign({}, ...sources.map(serializeHeaders))
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function getHeader(headers: Record<string, string>, name: string): string | undefined {
|
|
327
|
+
const key = Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase())
|
|
328
|
+
return key ? headers[key] : undefined
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function hasHeader(headers: Record<string, string>, name: string): boolean {
|
|
332
|
+
return Object.keys(headers).some((k) => k.toLowerCase() === name.toLowerCase())
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Joins the URL parts, interpolates URL-encoded `{param}` segments, and appends the serialized query, shared by the send path and `getUrl`.
|
|
337
|
+
*/
|
|
338
|
+
function serializeUrl({
|
|
339
|
+
parts,
|
|
340
|
+
pathParams,
|
|
341
|
+
search,
|
|
342
|
+
pathSerializer = defaultPathSerializer,
|
|
343
|
+
pathStyles,
|
|
344
|
+
}: {
|
|
345
|
+
parts: Array<string | undefined>
|
|
346
|
+
pathParams: Record<string, unknown>
|
|
347
|
+
search: string
|
|
348
|
+
pathSerializer?: PathSerializer
|
|
349
|
+
pathStyles?: Record<string, PathParamStyle>
|
|
350
|
+
}): string {
|
|
351
|
+
const path = parts
|
|
352
|
+
.filter(Boolean)
|
|
353
|
+
.join('')
|
|
354
|
+
.replace(/\{([^{}]+)\}/g, (_, key: string) => pathSerializer({ name: key, value: pathParams[key], options: pathStyles?.[key] }))
|
|
355
|
+
return path + (search ? `?${search}` : '')
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Creates a transport-agnostic interceptor channel that runs interceptors in registration order.
|
|
360
|
+
*/
|
|
361
|
+
export function createInterceptorStack<T>(): InterceptorStack<T> {
|
|
362
|
+
let entries: Array<{ id: number; fn: InterceptorFn<T> }> = []
|
|
363
|
+
let counter = 0
|
|
364
|
+
return {
|
|
365
|
+
use(fn) {
|
|
366
|
+
const id = ++counter
|
|
367
|
+
entries.push({ id, fn })
|
|
368
|
+
return id
|
|
369
|
+
},
|
|
370
|
+
eject(id) {
|
|
371
|
+
entries = entries.filter((entry) => entry.id !== id)
|
|
372
|
+
},
|
|
373
|
+
update(id, fn) {
|
|
374
|
+
const entry = entries.find((item) => item.id === id)
|
|
375
|
+
if (entry) entry.fn = fn
|
|
376
|
+
},
|
|
377
|
+
async run(value) {
|
|
378
|
+
let result = value
|
|
379
|
+
for (const entry of entries) {
|
|
380
|
+
result = await entry.fn(result)
|
|
381
|
+
}
|
|
382
|
+
return result
|
|
383
|
+
},
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Walks the per-operation security in order and places the first resolved token on the request, mutating `headers` / `query` in place.
|
|
389
|
+
*/
|
|
390
|
+
export async function resolveAuth(params: {
|
|
391
|
+
security: Array<Auth> | undefined
|
|
392
|
+
auth: AuthResolver | undefined
|
|
393
|
+
headers: Record<string, string>
|
|
394
|
+
query: Record<string, unknown>
|
|
395
|
+
}): Promise<void> {
|
|
396
|
+
const { security, auth, headers, query } = params
|
|
397
|
+
if (!security?.length || auth === undefined) return
|
|
398
|
+
|
|
399
|
+
for (const scheme of security) {
|
|
400
|
+
const token = typeof auth === 'function' ? await auth(scheme) : auth
|
|
401
|
+
if (token === undefined) continue
|
|
402
|
+
|
|
403
|
+
if (scheme.type === 'apiKey') {
|
|
404
|
+
const name = scheme.name ?? 'Authorization'
|
|
405
|
+
if (scheme.in === 'query') {
|
|
406
|
+
if (query[name] === undefined) query[name] = token
|
|
407
|
+
} else if (scheme.in === 'cookie') {
|
|
408
|
+
headers.Cookie = [headers.Cookie, `${name}=${token}`].filter(Boolean).join('; ')
|
|
409
|
+
} else if (!hasHeader(headers, name)) {
|
|
410
|
+
headers[name] = token
|
|
411
|
+
}
|
|
412
|
+
} else if (!hasHeader(headers, 'Authorization')) {
|
|
413
|
+
headers.Authorization = scheme.scheme === 'basic' ? `Basic ${btoa(token)}` : `Bearer ${token}`
|
|
414
|
+
}
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function runValidator<T>(validator: Validator<T> | undefined, value: T): Promise<T> {
|
|
420
|
+
if (!validator) return value
|
|
421
|
+
return validateStandardSchema(validator, value)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The base media type of a `Content-Type` value, lowercased and stripped of any `; charset=...` parameters.
|
|
426
|
+
*/
|
|
427
|
+
function baseContentType(value: string | null | undefined): string | undefined {
|
|
428
|
+
if (!value) return undefined
|
|
429
|
+
return value.split(';')[0]!.trim().toLowerCase() || undefined
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Reads the negotiated response content type from the response headers as a base media type.
|
|
434
|
+
*/
|
|
435
|
+
function getResponseContentType(headers: Headers | Record<string, string> | undefined): string | undefined {
|
|
436
|
+
if (!headers) return undefined
|
|
437
|
+
const value = headers instanceof Headers ? headers.get('Content-Type') : (headers['Content-Type'] ?? headers['content-type'])
|
|
438
|
+
return baseContentType(value)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Normalizes the `contentType` option to its `{ request, response }` form, treating a bare string as the request content type.
|
|
443
|
+
*/
|
|
444
|
+
function resolveContentType(contentType: ContentType | undefined): { request?: string; response?: string } {
|
|
445
|
+
if (typeof contentType === 'string') return { request: contentType }
|
|
446
|
+
return contentType ?? {}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The per-concern serializers for a call, the per-call serializer winning over the client's and
|
|
451
|
+
* falling back to the defaults.
|
|
452
|
+
*/
|
|
453
|
+
function resolveSerializers({ config, requestConfig }: { config: { serializer?: Serializers }; requestConfig: { serializer?: Serializers } }) {
|
|
454
|
+
return {
|
|
455
|
+
querySerializer: requestConfig.serializer?.query ?? config.serializer?.query ?? defaultQuerySerializer,
|
|
456
|
+
bodySerializer: requestConfig.serializer?.body ?? config.serializer?.body ?? defaultBodySerializer,
|
|
457
|
+
pathSerializer: requestConfig.serializer?.path ?? config.serializer?.path ?? defaultPathSerializer,
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Resolves everything a call needs before it touches the transport: merged headers with the
|
|
463
|
+
* negotiated content type, auth on headers or query, serialized cookies, the validated and
|
|
464
|
+
* serialized body, and the full URL.
|
|
465
|
+
*/
|
|
466
|
+
async function resolveRequest<TBody, TRequest, TResponse>({
|
|
467
|
+
config,
|
|
468
|
+
requestConfig,
|
|
469
|
+
}: {
|
|
470
|
+
config: ClientConfig<TRequest, TResponse>
|
|
471
|
+
requestConfig: RequestConfig<TBody, TRequest, TResponse>
|
|
472
|
+
}): Promise<{ request: ResolvedRequest; codecs: Record<string, Codec> }> {
|
|
473
|
+
const { querySerializer, bodySerializer, pathSerializer } = resolveSerializers({ config, requestConfig })
|
|
474
|
+
const codecs = { ...config.codecs, ...requestConfig.codecs }
|
|
475
|
+
|
|
476
|
+
const headers = mergeHeaders(config.headers, applyHeaderStyles(requestConfig.headers, requestConfig.styles?.header))
|
|
477
|
+
const { request: requestContentTypeOption, response: responseContentType } = resolveContentType(requestConfig.contentType)
|
|
478
|
+
const requestContentType = requestContentTypeOption ?? getHeader(headers, 'content-type')
|
|
479
|
+
if (responseContentType && !hasHeader(headers, 'accept')) {
|
|
480
|
+
headers['Accept'] = responseContentType
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const query: Record<string, unknown> = { ...((requestConfig.query ?? requestConfig.params) as Record<string, unknown> | undefined) }
|
|
484
|
+
|
|
485
|
+
await resolveAuth({
|
|
486
|
+
security: requestConfig.security,
|
|
487
|
+
auth: requestConfig.auth ?? config.auth,
|
|
488
|
+
headers,
|
|
489
|
+
query,
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
if (requestConfig.cookies) {
|
|
493
|
+
const cookie = serializeCookies(requestConfig.cookies, requestConfig.styles?.cookie)
|
|
494
|
+
if (cookie) headers.Cookie = [headers.Cookie, cookie].filter(Boolean).join('; ')
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const validatedBody = await runValidator(requestConfig.validator?.request, requestConfig.body)
|
|
498
|
+
const requestContentTypeBase = baseContentType(requestContentType)
|
|
499
|
+
const contentCodec = requestContentTypeBase ? codecs[requestContentTypeBase] : undefined
|
|
500
|
+
const usesDefaultBodySerializer = !contentCodec?.serialize && bodySerializer === defaultBodySerializer
|
|
501
|
+
const body = contentCodec?.serialize
|
|
502
|
+
? contentCodec.serialize(validatedBody, requestContentType)
|
|
503
|
+
: bodySerializer({ body: validatedBody, contentType: requestContentType, encoding: requestConfig.styles?.body })
|
|
504
|
+
// A FormData body must keep its Content-Type unset so the runtime appends the multipart boundary.
|
|
505
|
+
if (body instanceof FormData) {
|
|
506
|
+
for (const key of Object.keys(headers)) {
|
|
507
|
+
if (key.toLowerCase() === 'content-type') delete headers[key]
|
|
508
|
+
}
|
|
509
|
+
} else if (requestContentTypeOption) {
|
|
510
|
+
headers['Content-Type'] = requestContentTypeOption
|
|
511
|
+
} else if (usesDefaultBodySerializer && isDefaultJsonBody(validatedBody) && !hasHeader(headers, 'content-type')) {
|
|
512
|
+
headers['Content-Type'] = 'application/json'
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const url = serializeUrl({
|
|
516
|
+
parts: [requestConfig.baseURL ?? config.baseURL, requestConfig.url],
|
|
517
|
+
pathParams: requestConfig.path ?? {},
|
|
518
|
+
search: querySerializer(query, requestConfig.styles?.query),
|
|
519
|
+
pathSerializer,
|
|
520
|
+
pathStyles: requestConfig.styles?.path,
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
const options = config.options || requestConfig.options ? { ...config.options, ...requestConfig.options } : undefined
|
|
524
|
+
|
|
525
|
+
return {
|
|
526
|
+
codecs,
|
|
527
|
+
request: {
|
|
528
|
+
url,
|
|
529
|
+
method: (requestConfig.method ?? 'GET').toUpperCase(),
|
|
530
|
+
headers,
|
|
531
|
+
body,
|
|
532
|
+
signal: requestConfig.signal,
|
|
533
|
+
credentials: requestConfig.credentials,
|
|
534
|
+
options,
|
|
535
|
+
responseType: requestConfig.responseType,
|
|
536
|
+
},
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Turns a transport result into the call result: decodes the body through the matching codec,
|
|
542
|
+
* validates it, and throws a `ResponseError` (after running the error interceptors) for a non-2xx
|
|
543
|
+
* response under `throwOnError`.
|
|
544
|
+
*/
|
|
545
|
+
async function settleResult<TRequest, TResponse>({
|
|
546
|
+
result,
|
|
547
|
+
codecs,
|
|
548
|
+
throwOnError,
|
|
549
|
+
validator,
|
|
550
|
+
errorInterceptors,
|
|
551
|
+
}: {
|
|
552
|
+
result: TransportResult<unknown, TRequest, TResponse>
|
|
553
|
+
codecs: Record<string, Codec>
|
|
554
|
+
throwOnError: boolean
|
|
555
|
+
validator: { response?: Validator; error?: Validator } | undefined
|
|
556
|
+
errorInterceptors: InterceptorStack<ResponseError<unknown, TRequest, TResponse>>
|
|
557
|
+
}): Promise<CallResult<TRequest, TResponse>> {
|
|
558
|
+
const isSuccess = result.status >= 200 && result.status < 300
|
|
559
|
+
const contentType = result.contentType ?? getResponseContentType(result.headers)
|
|
560
|
+
let decoded = result.data
|
|
561
|
+
if (contentType) {
|
|
562
|
+
const codec = codecs[contentType]
|
|
563
|
+
if (codec?.deserialize) decoded = await codec.deserialize(result.data, contentType)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (isSuccess) {
|
|
567
|
+
const data = await runValidator(validator?.response, decoded)
|
|
568
|
+
return { status: result.status, data, error: undefined, contentType, request: result.request, response: result.response }
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const error = await runValidator(validator?.error, decoded)
|
|
572
|
+
if (throwOnError) {
|
|
573
|
+
const responseError = new ResponseError({
|
|
574
|
+
data: error,
|
|
575
|
+
status: result.status,
|
|
576
|
+
statusText: result.statusText,
|
|
577
|
+
contentType,
|
|
578
|
+
request: result.request,
|
|
579
|
+
response: result.response,
|
|
580
|
+
})
|
|
581
|
+
await errorInterceptors.run(responseError)
|
|
582
|
+
throw responseError
|
|
583
|
+
}
|
|
584
|
+
return { status: result.status, data: undefined, error, contentType, request: result.request, response: result.response }
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Builds the shared client core bound to a transport, exported by each plugin as `client` plus a `createClient` factory.
|
|
589
|
+
*/
|
|
590
|
+
export function createClientCore<TRequest = Request, TResponse = Response>(
|
|
591
|
+
options: { defaultTransport: Transport<TRequest, TResponse> } & ClientConfig<TRequest, TResponse>,
|
|
592
|
+
): ClientInstance<TRequest, TResponse> {
|
|
593
|
+
const { defaultTransport, ...initialConfig } = options
|
|
594
|
+
let config: ClientConfig<TRequest, TResponse> = { ...initialConfig }
|
|
595
|
+
|
|
596
|
+
const interceptors: Interceptors<TRequest, TResponse> = {
|
|
597
|
+
request: createInterceptorStack<ResolvedRequest>(),
|
|
598
|
+
response: createInterceptorStack<TransportResult<unknown, TRequest, TResponse>>(),
|
|
599
|
+
error: createInterceptorStack<ResponseError<unknown, TRequest, TResponse>>(),
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const client = (async <TBody = unknown>(requestConfig: RequestConfig<TBody, TRequest, TResponse>): Promise<CallResult<TRequest, TResponse>> => {
|
|
603
|
+
const transport = requestConfig.transport ?? config.transport ?? defaultTransport
|
|
604
|
+
const { request, codecs } = await resolveRequest({ config, requestConfig })
|
|
605
|
+
|
|
606
|
+
const resolvedRequest = await interceptors.request.run(request)
|
|
607
|
+
const result = await interceptors.response.run(await transport(resolvedRequest))
|
|
608
|
+
|
|
609
|
+
return settleResult({
|
|
610
|
+
result,
|
|
611
|
+
codecs,
|
|
612
|
+
throwOnError: requestConfig.throwOnError ?? config.throwOnError ?? true,
|
|
613
|
+
validator: requestConfig.validator,
|
|
614
|
+
errorInterceptors: interceptors.error,
|
|
615
|
+
})
|
|
616
|
+
}) as ClientInstance<TRequest, TResponse>
|
|
617
|
+
|
|
618
|
+
client.getConfig = () => config
|
|
619
|
+
client.setConfig = (next) => {
|
|
620
|
+
config = { ...config, ...next, headers: { ...serializeHeaders(config.headers), ...serializeHeaders(next.headers) } }
|
|
621
|
+
return config
|
|
622
|
+
}
|
|
623
|
+
client.getUrl = (requestConfig) => {
|
|
624
|
+
const { querySerializer, pathSerializer } = resolveSerializers({ config, requestConfig })
|
|
625
|
+
const query: Record<string, unknown> = { ...((requestConfig.query ?? requestConfig.params) as Record<string, unknown> | undefined) }
|
|
626
|
+
return serializeUrl({
|
|
627
|
+
parts: [requestConfig.baseURL ?? config.baseURL, requestConfig.url],
|
|
628
|
+
pathParams: requestConfig.path ?? {},
|
|
629
|
+
search: querySerializer(query, requestConfig.styles?.query),
|
|
630
|
+
pathSerializer,
|
|
631
|
+
pathStyles: requestConfig.styles?.path,
|
|
632
|
+
})
|
|
633
|
+
}
|
|
634
|
+
client.interceptors = interceptors
|
|
635
|
+
client.createClient = (next) => createClientCore({ defaultTransport, ...config, ...next })
|
|
636
|
+
|
|
637
|
+
return client
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Picks a `responseType` from a `Content-Type` header, or `undefined` when it is not recognized.
|
|
642
|
+
*/
|
|
643
|
+
function detectResponseType(contentType: string | null): ResponseType | undefined {
|
|
644
|
+
if (!contentType) return undefined
|
|
645
|
+
if (contentType.includes('text/event-stream')) return 'stream'
|
|
646
|
+
if (contentType.includes('application/json') || contentType.includes('text/json')) return 'json'
|
|
647
|
+
if (contentType.includes('text/')) return 'text'
|
|
648
|
+
if (contentType.includes('image/') || contentType.includes('application/octet-stream')) return 'blob'
|
|
649
|
+
return undefined
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Parses a `fetch` response body using the `responseType` (explicit or detected from the `Content-Type`), falling back to JSON-then-text.
|
|
654
|
+
*/
|
|
655
|
+
async function parseResponse(response: Response, responseType?: ResponseType): Promise<unknown> {
|
|
656
|
+
if (response.status === 204 || response.status === 205 || response.status === 304 || !response.body) {
|
|
657
|
+
return undefined
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
switch (responseType ?? detectResponseType(response.headers.get('Content-Type'))) {
|
|
661
|
+
case 'text':
|
|
662
|
+
case 'document':
|
|
663
|
+
return response.text()
|
|
664
|
+
case 'blob':
|
|
665
|
+
return response.blob()
|
|
666
|
+
case 'arraybuffer':
|
|
667
|
+
return response.arrayBuffer()
|
|
668
|
+
case 'stream':
|
|
669
|
+
return response.body ?? undefined
|
|
670
|
+
case 'json': {
|
|
671
|
+
// An empty body with a JSON content-type would make response.json() throw, so treat it as no data.
|
|
672
|
+
const body = await response.text()
|
|
673
|
+
return body ? JSON.parse(body) : undefined
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const text = await response.text()
|
|
678
|
+
if (!text) return undefined
|
|
679
|
+
try {
|
|
680
|
+
return JSON.parse(text)
|
|
681
|
+
} catch {
|
|
682
|
+
return text
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* The default transport that sends the resolved request through `globalThis.fetch` and returns the parsed body with the native request and response.
|
|
688
|
+
*/
|
|
689
|
+
const defaultTransport: Transport = async (request: ResolvedRequest): Promise<TransportResult> => {
|
|
690
|
+
const init: RequestInit = {
|
|
691
|
+
...request.options, // cache, mode, redirect, keepalive, duplex, next, …
|
|
692
|
+
method: request.method,
|
|
693
|
+
headers: request.headers,
|
|
694
|
+
body: request.body,
|
|
695
|
+
signal: request.signal,
|
|
696
|
+
}
|
|
697
|
+
if (request.credentials) init.credentials = request.credentials
|
|
698
|
+
|
|
699
|
+
const nativeRequest = new Request(request.url, init)
|
|
700
|
+
const response = await globalThis.fetch(nativeRequest)
|
|
701
|
+
const data = await parseResponse(response, request.responseType)
|
|
702
|
+
|
|
703
|
+
return {
|
|
704
|
+
data,
|
|
705
|
+
status: response.status,
|
|
706
|
+
statusText: response.statusText,
|
|
707
|
+
headers: response.headers,
|
|
708
|
+
contentType: getResponseContentType(response.headers),
|
|
709
|
+
request: nativeRequest,
|
|
710
|
+
response,
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* One decoded Server-Sent Event, with `data` parsed as JSON when valid and kept as the raw string otherwise.
|
|
716
|
+
*/
|
|
717
|
+
export type ServerSentEvent<TData = unknown> = {
|
|
718
|
+
data: TData
|
|
719
|
+
event?: string
|
|
720
|
+
id?: string
|
|
721
|
+
retry?: number
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async function* readBytes(stream: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>): AsyncGenerator<Uint8Array> {
|
|
725
|
+
if (!('getReader' in stream)) {
|
|
726
|
+
yield* stream
|
|
727
|
+
return
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const reader = stream.getReader()
|
|
731
|
+
try {
|
|
732
|
+
while (true) {
|
|
733
|
+
const { done, value } = await reader.read()
|
|
734
|
+
if (done) return
|
|
735
|
+
yield value
|
|
736
|
+
}
|
|
737
|
+
} finally {
|
|
738
|
+
await reader.cancel().catch(() => {})
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function parseEvent<TData>(raw: string): ServerSentEvent<TData> | undefined {
|
|
743
|
+
const data: Array<string> = []
|
|
744
|
+
const event: ServerSentEvent<TData> = { data: undefined as TData }
|
|
745
|
+
let seen = false
|
|
746
|
+
|
|
747
|
+
for (const line of raw.split('\n')) {
|
|
748
|
+
if (!line || line.startsWith(':')) continue
|
|
749
|
+
seen = true
|
|
750
|
+
const index = line.indexOf(':')
|
|
751
|
+
const field = index === -1 ? line : line.slice(0, index)
|
|
752
|
+
const value = index === -1 ? '' : line.slice(index + 1).replace(/^ /, '')
|
|
753
|
+
if (field === 'data') data.push(value)
|
|
754
|
+
else if (field === 'event') event.event = value
|
|
755
|
+
else if (field === 'id') event.id = value
|
|
756
|
+
else if (field === 'retry' && Number.isFinite(Number(value))) event.retry = Number(value)
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (!seen) return undefined
|
|
760
|
+
|
|
761
|
+
if (data.length) {
|
|
762
|
+
const joined = data.join('\n')
|
|
763
|
+
try {
|
|
764
|
+
event.data = JSON.parse(joined) as TData
|
|
765
|
+
} catch {
|
|
766
|
+
event.data = joined as TData
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return event
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Parses a `text/event-stream` body into typed Server-Sent Events, consumed with `for await` and stopped early by breaking the loop.
|
|
774
|
+
*/
|
|
775
|
+
export async function* parseEventStream<TData = unknown>(
|
|
776
|
+
stream: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
|
|
777
|
+
): AsyncGenerator<ServerSentEvent<TData>> {
|
|
778
|
+
const decoder = new TextDecoder()
|
|
779
|
+
const normalize = (text: string) => text.replace(/\r\n|\r/g, '\n')
|
|
780
|
+
let buffer = ''
|
|
781
|
+
|
|
782
|
+
for await (const chunk of readBytes(stream)) {
|
|
783
|
+
const blocks = normalize(buffer + decoder.decode(chunk, { stream: true })).split('\n\n')
|
|
784
|
+
buffer = blocks.pop() ?? ''
|
|
785
|
+
for (const block of blocks) {
|
|
786
|
+
const event = parseEvent<TData>(block)
|
|
787
|
+
if (event) yield event
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const event = parseEvent<TData>(normalize(buffer + decoder.decode()))
|
|
792
|
+
if (event) yield event
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* The resolved shape returned by a generated `text/event-stream` operation: the typed event
|
|
797
|
+
* `stream` plus the native `response`.
|
|
798
|
+
*/
|
|
799
|
+
export type EventStreamResult<TData = unknown, TResponse = Response> = {
|
|
800
|
+
stream: AsyncGenerator<ServerSentEvent<TData>>
|
|
801
|
+
response: TResponse
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Wraps a transport result whose `data` is a streaming body into an `EventStreamResult`, exposing
|
|
806
|
+
* the parsed events as a typed async iterator. Generated SSE operations call this.
|
|
807
|
+
*/
|
|
808
|
+
export async function toEventStream<TData = unknown>(result: Promise<{ data: unknown; response: Response }>): Promise<EventStreamResult<TData>> {
|
|
809
|
+
const { data, response } = await result
|
|
810
|
+
return {
|
|
811
|
+
response,
|
|
812
|
+
stream: parseEventStream<TData>(data as ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>),
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
export const client = createClientCore({ defaultTransport })
|
|
817
|
+
|
|
818
|
+
export const createClient = (config?: Parameters<typeof client.createClient>[0]) => client.createClient(config)
|