@kubb/plugin-client 5.10.0-canary.20261105T164114 → 5.10.0
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 +1 -1
- package/dist/client-CiHb_Cfg.d.ts +345 -0
- package/dist/index.cjs +288 -143
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +290 -145
- package/dist/index.js.map +1 -1
- package/dist/serializers-Ds8zZwOO.d.ts +190 -0
- package/dist/standardSchema-BShJWz2i.d.ts +104 -0
- package/dist/templates/client.d.ts +2 -0
- package/dist/templates/core.d.ts +3 -0
- package/dist/templates/serializers.d.ts +2 -0
- package/dist/templates/sse.d.ts +124 -0
- package/dist/templates/standardSchema.d.ts +2 -0
- package/docs/guides/write-your-client.md +1 -1
- package/docs/overview.md +1 -1
- package/kubb-extension.json +1 -1
- package/package.json +17 -8
- package/templates/client.ts +5 -0
- package/templates/core.ts +694 -0
- package/templates/serializers.ts +493 -0
- package/templates/sse.ts +274 -0
- package/templates/standardSchema.ts +162 -0
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
import type { NativeRequest, NativeResponse } from './client.ts'
|
|
2
|
+
import {
|
|
3
|
+
applyHeaderStyles,
|
|
4
|
+
baseContentType,
|
|
5
|
+
defaultBodySerializer,
|
|
6
|
+
defaultPathSerializer,
|
|
7
|
+
defaultQuerySerializer,
|
|
8
|
+
isDefaultJsonBody,
|
|
9
|
+
serializeCookies,
|
|
10
|
+
} from './serializers.ts'
|
|
11
|
+
import type { HeadersInit, HttpMethod, PathParamStyle, PathSerializer, RequestBody, Serializers, Styles } from './serializers.ts'
|
|
12
|
+
import { isValidationEnabled, runValidator, type StandardSchemaValidator } from './standardSchema.ts'
|
|
13
|
+
import type { ValidateOption, ValidationErrorHandler } from './standardSchema.ts'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* HTTP status codes treated as a success, everything else is an error.
|
|
17
|
+
*/
|
|
18
|
+
export type SuccessStatusCode = '200' | '201' | '202' | '203' | '204' | '205' | '206' | '207' | '208' | '226'
|
|
19
|
+
|
|
20
|
+
export type SuccessOf<TResponses> = TResponses[Extract<keyof TResponses, SuccessStatusCode>]
|
|
21
|
+
|
|
22
|
+
export type ErrorOf<TResponses> = TResponses[Exclude<keyof TResponses, SuccessStatusCode>]
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Converts a response record's string status key to its numeric literal, leaving non-numeric keys like `default` as `number`.
|
|
26
|
+
*/
|
|
27
|
+
export type ToStatusNumber<TStatus> = TStatus extends `${infer TNumber extends number}` ? TNumber : number
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Unwraps content-type variants to keep the bare body on `error`.
|
|
31
|
+
*/
|
|
32
|
+
export type DataOf<T> = T extends { contentType: string; data: infer TData } ? TData : T
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Keeps `contentType` beside `data` so switching on it narrows the body.
|
|
36
|
+
*/
|
|
37
|
+
export type SuccessVariant<TStatus, TEntry, TRequest, TResponse> = TEntry extends { contentType: string; data: unknown }
|
|
38
|
+
? TEntry extends { contentType: infer TContentType; data: infer TData }
|
|
39
|
+
? { status: ToStatusNumber<TStatus>; data: TData; error: undefined; contentType: TContentType; request: TRequest; response: TResponse }
|
|
40
|
+
: never
|
|
41
|
+
: { status: ToStatusNumber<TStatus>; data: TEntry; error: undefined; contentType: string | undefined; request: TRequest; response: TResponse }
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Narrows `data` or `error` when switching on the numeric status.
|
|
45
|
+
*/
|
|
46
|
+
export type ResultByStatus<TResponses, TStatus extends keyof TResponses, TRequest, TResponse> = TStatus extends SuccessStatusCode
|
|
47
|
+
? SuccessVariant<TStatus, TResponses[TStatus], TRequest, TResponse>
|
|
48
|
+
: {
|
|
49
|
+
status: ToStatusNumber<TStatus>
|
|
50
|
+
data: undefined
|
|
51
|
+
error: DataOf<TResponses[TStatus]>
|
|
52
|
+
contentType: string | undefined
|
|
53
|
+
request: TRequest
|
|
54
|
+
response: TResponse
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type ResultUnion<TResponses, TRequest, TResponse> = {
|
|
58
|
+
[TStatus in keyof TResponses]: ResultByStatus<TResponses, TStatus, TRequest, TResponse>
|
|
59
|
+
}[keyof TResponses]
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Selects success statuses without letting untyped error payloads widen `data`.
|
|
63
|
+
*/
|
|
64
|
+
export type SuccessResultUnion<TResponses, TRequest, TResponse> = {
|
|
65
|
+
[TStatus in Extract<keyof TResponses, SuccessStatusCode>]: ResultByStatus<TResponses, TStatus, TRequest, TResponse>
|
|
66
|
+
}[Extract<keyof TResponses, SuccessStatusCode>]
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Narrows `data` and `error` by status. With `throwOnError`, includes only success variants.
|
|
70
|
+
*/
|
|
71
|
+
export type RequestResult<TResponses, ThrowOnError extends boolean = true, TRequest = NativeRequest, TResponse = NativeResponse> = ThrowOnError extends true
|
|
72
|
+
? [SuccessResultUnion<TResponses, TRequest, TResponse>] extends [never]
|
|
73
|
+
? {
|
|
74
|
+
status: number
|
|
75
|
+
data: SuccessOf<TResponses>
|
|
76
|
+
error: undefined
|
|
77
|
+
contentType: string | undefined
|
|
78
|
+
request: TRequest
|
|
79
|
+
response: TResponse
|
|
80
|
+
}
|
|
81
|
+
: SuccessResultUnion<TResponses, TRequest, TResponse>
|
|
82
|
+
: [ResultUnion<TResponses, TRequest, TResponse>] extends [never]
|
|
83
|
+
? { status: number; data: undefined; error: undefined; contentType: string | undefined; request: TRequest; response: TResponse }
|
|
84
|
+
: ResultUnion<TResponses, TRequest, TResponse>
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A `RequestResult` promise with an extra `unwrap()` method that resolves to the success body.
|
|
88
|
+
*/
|
|
89
|
+
export type Unwrappable<T extends { data: unknown; error: unknown }> = Promise<T> & {
|
|
90
|
+
unwrap: () => Promise<Extract<T, { error: undefined }>['data']>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Attaches `unwrap()` to a result promise, which rejects with `error` when the result carried one.
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* ```ts
|
|
98
|
+
* const { data, error } = await getPetById({ path: { petId: 1 } })
|
|
99
|
+
* ```
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```ts
|
|
103
|
+
* const pet = await getPetById({ path: { petId: 1 } }).unwrap()
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
export function withUnwrap<T extends { data: unknown; error: unknown }>(promise: Promise<T>): Unwrappable<T> {
|
|
107
|
+
const unwrappable = promise as Unwrappable<T>
|
|
108
|
+
unwrappable.unwrap = () =>
|
|
109
|
+
promise.then((result) => {
|
|
110
|
+
if (result.error !== undefined) throw result.error
|
|
111
|
+
return result.data as Extract<T, { error: undefined }>['data']
|
|
112
|
+
})
|
|
113
|
+
return unwrappable
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The shape a generated operation returns with `returnType: 'data'`: the success body when
|
|
118
|
+
* `throwOnError` is true, or the full `RequestResult` when it is false.
|
|
119
|
+
*/
|
|
120
|
+
export type UnwrappedResult<TResponses, ThrowOnError extends boolean = true, TRequest = NativeRequest, TResponse = NativeResponse> = ThrowOnError extends true
|
|
121
|
+
? RequestResult<TResponses, true, TRequest, TResponse>['data']
|
|
122
|
+
: RequestResult<TResponses, ThrowOnError, TRequest, TResponse>
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Returns the success body when `throwOnError` is true, or the full result when it is false.
|
|
126
|
+
*/
|
|
127
|
+
export function unwrapResult<T extends { data: unknown; error: unknown }>(promise: Promise<T>, throwOnError: boolean | undefined): Promise<T | T['data']> {
|
|
128
|
+
return promise.then((result) => ((throwOnError ?? true) ? result.data : result))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type DataShape = { body?: unknown; cookies?: unknown; headers?: unknown; path?: unknown; query?: unknown }
|
|
132
|
+
|
|
133
|
+
export type RequestCredentials = 'omit' | 'same-origin' | 'include'
|
|
134
|
+
export type ResponseType = 'arraybuffer' | 'blob' | 'document' | 'json' | 'text' | 'stream' | 'formdata'
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Decodes a response body for a codec's media type.
|
|
138
|
+
*/
|
|
139
|
+
export type Deserializer<T = unknown> = (raw: unknown, contentType: string) => T | Promise<T>
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Encodes a request body for a codec's media type.
|
|
143
|
+
*/
|
|
144
|
+
export type ContentBodySerializer = (body: unknown, contentType?: string) => RequestBody | undefined
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Encodes requests or decodes responses for a media type. Either direction can be omitted.
|
|
148
|
+
*/
|
|
149
|
+
export type Codec = {
|
|
150
|
+
serialize?: ContentBodySerializer
|
|
151
|
+
deserialize?: Deserializer
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A string sets the request content type. The object form can also set `Accept`.
|
|
156
|
+
*/
|
|
157
|
+
export type ContentType = string | { request?: string; response?: string }
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Validates request bodies and params, success bodies, or non-throwing error bodies with Standard Schema.
|
|
161
|
+
*/
|
|
162
|
+
export type Validator<T = unknown> = StandardSchemaValidator<T>
|
|
163
|
+
|
|
164
|
+
export type { ValidateOption, ValidationErrorContext, ValidationErrorHandler } from './standardSchema.ts'
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A resolved security scheme carried on each generated call's `security` array and passed to the `auth` resolver.
|
|
168
|
+
*/
|
|
169
|
+
export type Auth = {
|
|
170
|
+
type: 'http' | 'apiKey' | 'oauth2' | 'openIdConnect'
|
|
171
|
+
scheme?: 'bearer' | 'basic'
|
|
172
|
+
name?: string
|
|
173
|
+
in?: 'header' | 'query' | 'cookie'
|
|
174
|
+
scopes?: Array<string>
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Return `user:password` for basic auth, or `undefined` to skip a scheme.
|
|
179
|
+
*/
|
|
180
|
+
export type AuthToken = string | undefined
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Resolves the token for a security scheme, either a static token or a callback called per scheme until one returns a token.
|
|
184
|
+
*/
|
|
185
|
+
export type AuthResolver = AuthToken | ((auth: Auth) => AuthToken | Promise<AuthToken>)
|
|
186
|
+
|
|
187
|
+
export type RequestConfig<TBody = unknown, TRequest = NativeRequest, TResponse = NativeResponse> = {
|
|
188
|
+
baseURL?: string
|
|
189
|
+
url?: string
|
|
190
|
+
method?: HttpMethod
|
|
191
|
+
path?: unknown
|
|
192
|
+
query?: unknown
|
|
193
|
+
/** Alias of `query`, kept for callers that pass the query under this name. */
|
|
194
|
+
params?: unknown
|
|
195
|
+
cookies?: unknown
|
|
196
|
+
body?: TBody
|
|
197
|
+
headers?: unknown
|
|
198
|
+
styles?: Styles
|
|
199
|
+
signal?: AbortSignal
|
|
200
|
+
credentials?: RequestCredentials
|
|
201
|
+
options?: Record<string, unknown>
|
|
202
|
+
contentType?: ContentType
|
|
203
|
+
responseType?: ResponseType
|
|
204
|
+
throwOnError?: boolean
|
|
205
|
+
client?: ClientInstance<TRequest, TResponse>
|
|
206
|
+
transport?: Transport<TRequest, TResponse>
|
|
207
|
+
serializer?: Serializers
|
|
208
|
+
codecs?: Record<string, Codec>
|
|
209
|
+
validator?: { request?: Validator; path?: Validator; query?: Validator; headers?: Validator; response?: Validator; error?: Validator }
|
|
210
|
+
onValidationError?: ValidationErrorHandler
|
|
211
|
+
validate?: ValidateOption
|
|
212
|
+
security?: Array<Auth>
|
|
213
|
+
auth?: AuthResolver
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Combines client options with the operation's typed request params and body.
|
|
218
|
+
*/
|
|
219
|
+
export type Options<TData extends DataShape, ThrowOnError extends boolean = true, TRequest = NativeRequest, TResponse = NativeResponse> = Omit<
|
|
220
|
+
RequestConfig<unknown, TRequest, TResponse>,
|
|
221
|
+
keyof DataShape | 'url'
|
|
222
|
+
> &
|
|
223
|
+
TData & {
|
|
224
|
+
client?: ClientInstance<TRequest, TResponse>
|
|
225
|
+
throwOnError?: ThrowOnError
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Client-level configuration shared by every call an instance makes, overridden by the per-call `RequestConfig`.
|
|
230
|
+
*/
|
|
231
|
+
export type ClientConfig<TRequest = NativeRequest, TResponse = NativeResponse> = {
|
|
232
|
+
baseURL?: string
|
|
233
|
+
headers?: HeadersInit
|
|
234
|
+
credentials?: RequestCredentials
|
|
235
|
+
options?: Record<string, unknown>
|
|
236
|
+
throwOnError?: boolean
|
|
237
|
+
transport?: Transport<TRequest, TResponse>
|
|
238
|
+
serializer?: Serializers
|
|
239
|
+
codecs?: Record<string, Codec>
|
|
240
|
+
onValidationError?: ValidationErrorHandler
|
|
241
|
+
validate?: ValidateOption
|
|
242
|
+
auth?: AuthResolver
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The normalized request the transport receives, with all serialization, auth, and header work already done.
|
|
247
|
+
*/
|
|
248
|
+
export type ResolvedRequest = {
|
|
249
|
+
url: string
|
|
250
|
+
method: string
|
|
251
|
+
headers: Record<string, string>
|
|
252
|
+
body?: RequestBody
|
|
253
|
+
signal?: AbortSignal
|
|
254
|
+
credentials?: RequestCredentials
|
|
255
|
+
options?: Record<string, unknown>
|
|
256
|
+
responseType?: ResponseType
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* What a transport returns: the parsed body plus the native request and response objects.
|
|
261
|
+
*/
|
|
262
|
+
export type TransportResult<TData = unknown, TRequest = NativeRequest, TResponse = NativeResponse> = {
|
|
263
|
+
data: TData
|
|
264
|
+
status: number
|
|
265
|
+
statusText: string
|
|
266
|
+
headers: Headers
|
|
267
|
+
contentType?: string
|
|
268
|
+
request: TRequest
|
|
269
|
+
response: TResponse
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The per-plugin send, supplied to `createClientCore` as `defaultTransport`.
|
|
274
|
+
*/
|
|
275
|
+
export type Transport<TRequest = NativeRequest, TResponse = NativeResponse> = (
|
|
276
|
+
request: ResolvedRequest,
|
|
277
|
+
) => Promise<TransportResult<unknown, TRequest, TResponse>>
|
|
278
|
+
|
|
279
|
+
export type CallResult<TRequest = NativeRequest, TResponse = NativeResponse> = {
|
|
280
|
+
status: number
|
|
281
|
+
data: unknown
|
|
282
|
+
error: unknown
|
|
283
|
+
contentType: string | undefined
|
|
284
|
+
request: TRequest
|
|
285
|
+
response: TResponse
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export type InterceptorFn<T, TContext = never> = (value: T, context?: TContext) => T | Promise<T>
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* A single interceptor channel with a transport-agnostic `use` / `eject` / `update` API.
|
|
292
|
+
*/
|
|
293
|
+
export type InterceptorStack<T, TContext = never> = {
|
|
294
|
+
use: (fn: InterceptorFn<T, TContext>) => number
|
|
295
|
+
eject: (id: number) => void
|
|
296
|
+
update: (id: number, fn: InterceptorFn<T, TContext>) => void
|
|
297
|
+
run: (value: T, context?: TContext) => Promise<T>
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export type Interceptors<TRequest = NativeRequest, TResponse = NativeResponse> = {
|
|
301
|
+
request: InterceptorStack<ResolvedRequest, RequestConfig<unknown, TRequest, TResponse>>
|
|
302
|
+
response: InterceptorStack<TransportResult<unknown, TRequest, TResponse>, RequestConfig<unknown, TRequest, TResponse>>
|
|
303
|
+
error: InterceptorStack<ResponseError<unknown, TRequest, TResponse>, RequestConfig<unknown, TRequest, TResponse>>
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export type UrlConfig = Pick<RequestConfig, 'baseURL' | 'url' | 'path' | 'query' | 'params' | 'serializer' | 'styles'>
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* A client instance: the callable send plus configuration, interceptors, and an isolated
|
|
310
|
+
* `createClient` factory bound to the same transport.
|
|
311
|
+
*/
|
|
312
|
+
export type ClientInstance<TRequest = NativeRequest, TResponse = NativeResponse> = {
|
|
313
|
+
<TBody = unknown>(config: RequestConfig<TBody, TRequest, TResponse>): Promise<CallResult<TRequest, TResponse>>
|
|
314
|
+
getConfig: () => ClientConfig<TRequest, TResponse>
|
|
315
|
+
setConfig: (config: ClientConfig<TRequest, TResponse>) => ClientConfig<TRequest, TResponse>
|
|
316
|
+
getUrl: (config: UrlConfig) => string
|
|
317
|
+
interceptors: Interceptors<TRequest, TResponse>
|
|
318
|
+
createClient: (config?: ClientConfig<TRequest, TResponse>) => ClientInstance<TRequest, TResponse>
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Thrown for a non-2xx response, so a resolved call always means success.
|
|
323
|
+
*/
|
|
324
|
+
export class ResponseError<TError = unknown, TRequest = NativeRequest, TResponse = NativeResponse> extends Error {
|
|
325
|
+
data: TError
|
|
326
|
+
status: number
|
|
327
|
+
statusText: string
|
|
328
|
+
contentType: string | undefined
|
|
329
|
+
request: TRequest
|
|
330
|
+
response: TResponse
|
|
331
|
+
|
|
332
|
+
constructor(config: {
|
|
333
|
+
data: TError
|
|
334
|
+
status: number
|
|
335
|
+
statusText: string
|
|
336
|
+
contentType?: string
|
|
337
|
+
request: TRequest
|
|
338
|
+
response: TResponse
|
|
339
|
+
method?: string
|
|
340
|
+
url?: string
|
|
341
|
+
}) {
|
|
342
|
+
// The query and hash are left out: sensitive query parameters or fragments must not reach logs.
|
|
343
|
+
const cleanUrl = config.url?.split(/[?#]/)[0]
|
|
344
|
+
const target = [config.method, cleanUrl].filter(Boolean).join(' ')
|
|
345
|
+
const statusText = config.statusText?.trim()
|
|
346
|
+
super(`${target ? `${target} failed` : 'Request failed'} with status ${config.status}${statusText ? ` ${statusText}` : ''}`)
|
|
347
|
+
this.name = 'ResponseError'
|
|
348
|
+
this.data = config.data
|
|
349
|
+
this.status = config.status
|
|
350
|
+
this.statusText = config.statusText
|
|
351
|
+
this.contentType = config.contentType
|
|
352
|
+
this.request = config.request
|
|
353
|
+
this.response = config.response
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Matches on `name`, not `instanceof`: every generated client bundles its own `ResponseError` class.
|
|
358
|
+
*/
|
|
359
|
+
static is(error: unknown): error is ResponseError<unknown, unknown, unknown> {
|
|
360
|
+
return error instanceof Error && error.name === 'ResponseError'
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export type ResponseErrorConfig<TError = unknown> = ResponseError<TError>
|
|
365
|
+
|
|
366
|
+
function headerEntries(headers: object): Array<[string, unknown]> {
|
|
367
|
+
if (Array.isArray(headers)) return headers
|
|
368
|
+
return headers instanceof Headers ? [...headers] : Object.entries(headers)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function serializeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
|
372
|
+
if (!headers) return {}
|
|
373
|
+
const entries = headerEntries(headers)
|
|
374
|
+
const result: Record<string, string> = {}
|
|
375
|
+
for (const [key, value] of entries) {
|
|
376
|
+
if (value === undefined || value === null) continue
|
|
377
|
+
result[key] = typeof value === 'string' ? value : typeof value === 'object' ? JSON.stringify(value) : String(value)
|
|
378
|
+
}
|
|
379
|
+
return result
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function collectHeadersCaseInsensitive(...sources: Array<unknown>): Record<string, unknown> {
|
|
383
|
+
const keys = new Map<string, string>()
|
|
384
|
+
const result: Record<string, unknown> = {}
|
|
385
|
+
for (const source of sources) {
|
|
386
|
+
if (!source) continue
|
|
387
|
+
const entries = headerEntries(source)
|
|
388
|
+
for (const [key, value] of entries) {
|
|
389
|
+
if (value === undefined || value === null) continue
|
|
390
|
+
const lowerCase = key.toLowerCase()
|
|
391
|
+
const previous = keys.get(lowerCase)
|
|
392
|
+
if (previous !== undefined) delete result[previous]
|
|
393
|
+
keys.set(lowerCase, key)
|
|
394
|
+
result[key] = value
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return result
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function getHeader(headers: Record<string, string>, name: string): string | undefined {
|
|
401
|
+
const key = Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase())
|
|
402
|
+
return key ? headers[key] : undefined
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function serializeUrl({
|
|
406
|
+
parts,
|
|
407
|
+
pathParams,
|
|
408
|
+
search,
|
|
409
|
+
pathSerializer = defaultPathSerializer,
|
|
410
|
+
pathStyles,
|
|
411
|
+
}: {
|
|
412
|
+
parts: Array<string | undefined>
|
|
413
|
+
pathParams: Record<string, unknown>
|
|
414
|
+
search: string
|
|
415
|
+
pathSerializer?: PathSerializer
|
|
416
|
+
pathStyles?: Record<string, PathParamStyle>
|
|
417
|
+
}): string {
|
|
418
|
+
const path = parts
|
|
419
|
+
.filter(Boolean)
|
|
420
|
+
.join('')
|
|
421
|
+
.replace(/\{([^{}]+)\}/g, (_, key: string) => pathSerializer({ name: key, value: pathParams[key], options: pathStyles?.[key] }))
|
|
422
|
+
return path + (search ? `?${search}` : '')
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Creates a transport-agnostic interceptor channel that runs interceptors in registration order.
|
|
427
|
+
*/
|
|
428
|
+
export function createInterceptorStack<T, TContext = never>(): InterceptorStack<T, TContext> {
|
|
429
|
+
let entries: Array<{ id: number; fn: InterceptorFn<T, TContext> }> = []
|
|
430
|
+
let counter = 0
|
|
431
|
+
return {
|
|
432
|
+
use(fn) {
|
|
433
|
+
const id = ++counter
|
|
434
|
+
entries.push({ id, fn })
|
|
435
|
+
return id
|
|
436
|
+
},
|
|
437
|
+
eject(id) {
|
|
438
|
+
entries = entries.filter((entry) => entry.id !== id)
|
|
439
|
+
},
|
|
440
|
+
update(id, fn) {
|
|
441
|
+
const entry = entries.find((item) => item.id === id)
|
|
442
|
+
if (entry) entry.fn = fn
|
|
443
|
+
},
|
|
444
|
+
async run(value, context) {
|
|
445
|
+
let result = value
|
|
446
|
+
for (const entry of entries) {
|
|
447
|
+
result = await entry.fn(result, context)
|
|
448
|
+
}
|
|
449
|
+
return result
|
|
450
|
+
},
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Walks the per-operation security in order and places the first resolved token on the request, mutating `headers` / `query` in place.
|
|
456
|
+
*/
|
|
457
|
+
export async function resolveAuth(params: {
|
|
458
|
+
security: Array<Auth> | undefined
|
|
459
|
+
auth: AuthResolver | undefined
|
|
460
|
+
headers: Record<string, string>
|
|
461
|
+
query: Record<string, unknown>
|
|
462
|
+
}): Promise<void> {
|
|
463
|
+
const { security, auth, headers, query } = params
|
|
464
|
+
if (!security?.length || auth === undefined) return
|
|
465
|
+
|
|
466
|
+
for (const scheme of security) {
|
|
467
|
+
const token = typeof auth === 'function' ? await auth(scheme) : auth
|
|
468
|
+
if (token === undefined) continue
|
|
469
|
+
|
|
470
|
+
if (scheme.type !== 'apiKey') {
|
|
471
|
+
if (getHeader(headers, 'Authorization') === undefined) {
|
|
472
|
+
headers['Authorization'] = scheme.scheme === 'basic' ? `Basic ${btoa(String.fromCharCode(...new TextEncoder().encode(token)))}` : `Bearer ${token}`
|
|
473
|
+
}
|
|
474
|
+
return
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const name = scheme.name ?? 'Authorization'
|
|
478
|
+
if (scheme.in === 'query') {
|
|
479
|
+
if (query[name] === undefined) query[name] = token
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
if (scheme.in === 'cookie') {
|
|
483
|
+
headers['Cookie'] = [headers['Cookie'], `${name}=${token}`].filter(Boolean).join('; ')
|
|
484
|
+
return
|
|
485
|
+
}
|
|
486
|
+
if (getHeader(headers, name) === undefined) headers[name] = token
|
|
487
|
+
return
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** A body a transport left as a stream: a web `ReadableStream` or an async iterable such as a Node readable. */
|
|
492
|
+
export function isStreamed(data: unknown): data is ReadableStream<Uint8Array> | AsyncIterable<Uint8Array | string> {
|
|
493
|
+
return data instanceof ReadableStream || (!!data && typeof data === 'object' && Symbol.asyncIterator in data)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** The media type of the `Content-Type` response header, without its parameters. */
|
|
497
|
+
export function getResponseContentType(headers: Headers | Record<string, string> | undefined): string | undefined {
|
|
498
|
+
if (!headers) return undefined
|
|
499
|
+
const value = headers instanceof Headers ? headers.get('Content-Type') : (headers['Content-Type'] ?? headers['content-type'])
|
|
500
|
+
return baseContentType(value)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function resolveSerializers({ config, requestConfig }: { config: { serializer?: Serializers }; requestConfig: { serializer?: Serializers } }) {
|
|
504
|
+
return {
|
|
505
|
+
querySerializer: requestConfig.serializer?.query ?? config.serializer?.query ?? defaultQuerySerializer,
|
|
506
|
+
bodySerializer: requestConfig.serializer?.body ?? config.serializer?.body ?? defaultBodySerializer,
|
|
507
|
+
pathSerializer: requestConfig.serializer?.path ?? config.serializer?.path ?? defaultPathSerializer,
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function resolveRequest<TBody, TRequest, TResponse>({
|
|
512
|
+
config,
|
|
513
|
+
requestConfig,
|
|
514
|
+
}: {
|
|
515
|
+
config: ClientConfig<TRequest, TResponse>
|
|
516
|
+
requestConfig: RequestConfig<TBody, TRequest, TResponse>
|
|
517
|
+
}): Promise<{ request: ResolvedRequest; codecs: Record<string, Codec>; validate: ValidateOption | undefined }> {
|
|
518
|
+
const { querySerializer, bodySerializer, pathSerializer } = resolveSerializers({ config, requestConfig })
|
|
519
|
+
const codecs = { ...config.codecs, ...requestConfig.codecs }
|
|
520
|
+
const onValidationError = requestConfig.onValidationError ?? config.onValidationError
|
|
521
|
+
const validate = requestConfig.validate ?? config.validate
|
|
522
|
+
const validationContext = { direction: 'request', method: requestConfig.method, url: requestConfig.url } as const
|
|
523
|
+
|
|
524
|
+
// Params are encoded before anything reads them: headers before styling, query before auth adds its values.
|
|
525
|
+
const paramValues = {
|
|
526
|
+
path: { ...(requestConfig.path as Record<string, unknown> | undefined) },
|
|
527
|
+
query: { ...((requestConfig.query ?? requestConfig.params) as Record<string, unknown> | undefined) },
|
|
528
|
+
headers: collectHeadersCaseInsensitive(config.headers, requestConfig.headers),
|
|
529
|
+
}
|
|
530
|
+
const [validatedPath, validatedQuery, validatedHeaders] = await Promise.all(
|
|
531
|
+
(['path', 'query', 'headers'] as const).map((source) =>
|
|
532
|
+
runValidator({
|
|
533
|
+
validator: isValidationEnabled(validate, 'params') ? requestConfig.validator?.[source] : undefined,
|
|
534
|
+
value: paramValues[source],
|
|
535
|
+
context: { ...validationContext, source },
|
|
536
|
+
onValidationError,
|
|
537
|
+
}),
|
|
538
|
+
),
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
const headers = serializeHeaders(applyHeaderStyles(validatedHeaders as HeadersInit | undefined, requestConfig.styles?.header))
|
|
542
|
+
const contentType = requestConfig.contentType
|
|
543
|
+
const { request: requestContentTypeOption, response: responseContentType } = typeof contentType === 'string' ? { request: contentType } : (contentType ?? {})
|
|
544
|
+
const requestContentType = requestContentTypeOption ?? getHeader(headers, 'content-type')
|
|
545
|
+
if (responseContentType && getHeader(headers, 'accept') === undefined) {
|
|
546
|
+
headers['Accept'] = responseContentType
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const query: Record<string, unknown> = { ...(validatedQuery as Record<string, unknown> | undefined) }
|
|
550
|
+
|
|
551
|
+
await resolveAuth({
|
|
552
|
+
security: requestConfig.security,
|
|
553
|
+
auth: requestConfig.auth ?? config.auth,
|
|
554
|
+
headers,
|
|
555
|
+
query,
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
if (requestConfig.cookies) {
|
|
559
|
+
const cookie = serializeCookies(requestConfig.cookies as Record<string, unknown>, requestConfig.styles?.cookie)
|
|
560
|
+
if (cookie) headers['Cookie'] = [headers['Cookie'], cookie].filter(Boolean).join('; ')
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const validatedBody = await runValidator({
|
|
564
|
+
validator: isValidationEnabled(validate, 'request') ? requestConfig.validator?.request : undefined,
|
|
565
|
+
value: requestConfig.body,
|
|
566
|
+
context: { ...validationContext, source: 'body' },
|
|
567
|
+
onValidationError,
|
|
568
|
+
})
|
|
569
|
+
const requestContentTypeBase = baseContentType(requestContentType)
|
|
570
|
+
const contentCodec = requestContentTypeBase ? codecs[requestContentTypeBase] : undefined
|
|
571
|
+
const usesDefaultBodySerializer = !contentCodec?.serialize && bodySerializer === defaultBodySerializer
|
|
572
|
+
const body = contentCodec?.serialize
|
|
573
|
+
? contentCodec.serialize(validatedBody, requestContentType)
|
|
574
|
+
: bodySerializer({ body: validatedBody, contentType: requestContentType, encoding: requestConfig.styles?.body })
|
|
575
|
+
if (requestContentTypeOption) headers['Content-Type'] = requestContentTypeOption
|
|
576
|
+
if (usesDefaultBodySerializer && isDefaultJsonBody(validatedBody) && getHeader(headers, 'content-type') === undefined) {
|
|
577
|
+
headers['Content-Type'] = 'application/json'
|
|
578
|
+
}
|
|
579
|
+
// The transport must set the multipart boundary, even when a content type was explicitly provided.
|
|
580
|
+
if (body instanceof FormData) {
|
|
581
|
+
for (const key of Object.keys(headers)) {
|
|
582
|
+
if (key.toLowerCase() === 'content-type') delete headers[key]
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const url = serializeUrl({
|
|
587
|
+
parts: [requestConfig.baseURL ?? config.baseURL, requestConfig.url],
|
|
588
|
+
pathParams: (validatedPath ?? {}) as Record<string, unknown>,
|
|
589
|
+
search: querySerializer(query, requestConfig.styles?.query),
|
|
590
|
+
pathSerializer,
|
|
591
|
+
pathStyles: requestConfig.styles?.path,
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
const options = config.options || requestConfig.options ? { ...config.options, ...requestConfig.options } : undefined
|
|
595
|
+
|
|
596
|
+
return {
|
|
597
|
+
codecs,
|
|
598
|
+
validate,
|
|
599
|
+
request: {
|
|
600
|
+
url,
|
|
601
|
+
method: (requestConfig.method ?? 'GET').toUpperCase(),
|
|
602
|
+
headers,
|
|
603
|
+
body,
|
|
604
|
+
signal: requestConfig.signal,
|
|
605
|
+
credentials: requestConfig.credentials ?? config.credentials,
|
|
606
|
+
options,
|
|
607
|
+
responseType: requestConfig.responseType,
|
|
608
|
+
},
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Creates an isolated client using the supplied default transport.
|
|
614
|
+
*/
|
|
615
|
+
export function createClientCore<TRequest = NativeRequest, TResponse = NativeResponse>(
|
|
616
|
+
options: { defaultTransport: Transport<TRequest, TResponse> } & ClientConfig<TRequest, TResponse>,
|
|
617
|
+
): ClientInstance<TRequest, TResponse> {
|
|
618
|
+
const { defaultTransport, ...initialConfig } = options
|
|
619
|
+
let config: ClientConfig<TRequest, TResponse> = { ...initialConfig }
|
|
620
|
+
|
|
621
|
+
const interceptors: Interceptors<TRequest, TResponse> = {
|
|
622
|
+
request: createInterceptorStack<ResolvedRequest, RequestConfig<unknown, TRequest, TResponse>>(),
|
|
623
|
+
response: createInterceptorStack<TransportResult<unknown, TRequest, TResponse>, RequestConfig<unknown, TRequest, TResponse>>(),
|
|
624
|
+
error: createInterceptorStack<ResponseError<unknown, TRequest, TResponse>, RequestConfig<unknown, TRequest, TResponse>>(),
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const client = (async <TBody = unknown>(requestConfig: RequestConfig<TBody, TRequest, TResponse>): Promise<CallResult<TRequest, TResponse>> => {
|
|
628
|
+
const transport = requestConfig.transport ?? config.transport ?? defaultTransport
|
|
629
|
+
const { request, codecs, validate } = await resolveRequest({ config, requestConfig })
|
|
630
|
+
|
|
631
|
+
const resolvedRequest = await interceptors.request.run(request, requestConfig)
|
|
632
|
+
const result = await interceptors.response.run(await transport(resolvedRequest), requestConfig)
|
|
633
|
+
|
|
634
|
+
const throwOnError = requestConfig.throwOnError ?? config.throwOnError ?? true
|
|
635
|
+
const validator = requestConfig.validator
|
|
636
|
+
const onValidationError = requestConfig.onValidationError ?? config.onValidationError
|
|
637
|
+
const isSuccess = result.status >= 200 && result.status < 300
|
|
638
|
+
const direction = isSuccess ? 'response' : 'error'
|
|
639
|
+
const contentType = result.contentType ?? getResponseContentType(result.headers)
|
|
640
|
+
const codec = contentType ? codecs[contentType] : undefined
|
|
641
|
+
const decoded = contentType && codec?.deserialize ? await codec.deserialize(result.data, contentType) : result.data
|
|
642
|
+
// A streamed success body is validated event by event in `toEventStream`, not as a whole. A JSON answer to a streamed call is a whole body.
|
|
643
|
+
const streaming = isSuccess && resolvedRequest.responseType === 'stream' && isStreamed(decoded)
|
|
644
|
+
const data = await runValidator({
|
|
645
|
+
validator: isValidationEnabled(validate, direction) && !streaming ? validator?.[direction] : undefined,
|
|
646
|
+
value: decoded,
|
|
647
|
+
context: { direction, source: 'body', method: resolvedRequest.method, url: resolvedRequest.url, status: result.status },
|
|
648
|
+
onValidationError,
|
|
649
|
+
})
|
|
650
|
+
if (!isSuccess && throwOnError) {
|
|
651
|
+
const responseError = new ResponseError({
|
|
652
|
+
data,
|
|
653
|
+
status: result.status,
|
|
654
|
+
statusText: result.statusText,
|
|
655
|
+
contentType,
|
|
656
|
+
request: result.request,
|
|
657
|
+
response: result.response,
|
|
658
|
+
method: resolvedRequest.method,
|
|
659
|
+
url: resolvedRequest.url,
|
|
660
|
+
})
|
|
661
|
+
await interceptors.error.run(responseError, requestConfig)
|
|
662
|
+
throw responseError
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
status: result.status,
|
|
666
|
+
data: isSuccess ? data : undefined,
|
|
667
|
+
error: isSuccess ? undefined : data,
|
|
668
|
+
contentType,
|
|
669
|
+
request: result.request,
|
|
670
|
+
response: result.response,
|
|
671
|
+
}
|
|
672
|
+
}) as ClientInstance<TRequest, TResponse>
|
|
673
|
+
|
|
674
|
+
client.getConfig = () => config
|
|
675
|
+
client.setConfig = (next) => {
|
|
676
|
+
config = { ...config, ...next, headers: { ...serializeHeaders(config.headers), ...serializeHeaders(next.headers) } }
|
|
677
|
+
return config
|
|
678
|
+
}
|
|
679
|
+
client.getUrl = (requestConfig) => {
|
|
680
|
+
const { querySerializer, pathSerializer } = resolveSerializers({ config, requestConfig })
|
|
681
|
+
const query: Record<string, unknown> = { ...((requestConfig.query ?? requestConfig.params) as Record<string, unknown> | undefined) }
|
|
682
|
+
return serializeUrl({
|
|
683
|
+
parts: [requestConfig.baseURL ?? config.baseURL, requestConfig.url],
|
|
684
|
+
pathParams: (requestConfig.path ?? {}) as Record<string, unknown>,
|
|
685
|
+
search: querySerializer(query, requestConfig.styles?.query),
|
|
686
|
+
pathSerializer,
|
|
687
|
+
pathStyles: requestConfig.styles?.path,
|
|
688
|
+
})
|
|
689
|
+
}
|
|
690
|
+
client.interceptors = interceptors
|
|
691
|
+
client.createClient = (next) => createClientCore({ defaultTransport, ...config, ...next })
|
|
692
|
+
|
|
693
|
+
return client
|
|
694
|
+
}
|