@kubb/plugin-axios 5.0.0-beta.80 → 5.0.0-beta.84
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/dist/index.cjs +43 -44
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.js +8 -9
- package/dist/index.js.map +1 -1
- package/package.json +6 -8
- package/src/generators/clientGenerator.tsx +2 -2
- package/src/plugin.ts +4 -3
- package/src/types.ts +1 -1
- package/templates/axios.ts +283 -223
- package/templates/serializers.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubb/plugin-axios",
|
|
3
|
-
"version": "5.0.0-beta.
|
|
3
|
+
"version": "5.0.0-beta.84",
|
|
4
4
|
"description": "Generate a slim, type-safe HTTP client with Kubb based on axios.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"api-client",
|
|
@@ -49,22 +49,20 @@
|
|
|
49
49
|
"registry": "https://registry.npmjs.org/"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@kubb/
|
|
53
|
-
"@kubb/
|
|
54
|
-
"@kubb/renderer-jsx": "5.0.0-beta.79",
|
|
55
|
-
"@kubb/plugin-ts": "5.0.0-beta.80",
|
|
56
|
-
"@kubb/plugin-zod": "5.0.0-beta.80"
|
|
52
|
+
"@kubb/plugin-ts": "5.0.0-beta.84",
|
|
53
|
+
"@kubb/plugin-zod": "5.0.0-beta.84"
|
|
57
54
|
},
|
|
58
55
|
"devDependencies": {
|
|
59
56
|
"axios": "^1.18.1",
|
|
57
|
+
"kubb": "5.0.0-beta.84",
|
|
60
58
|
"typescript": "^6.0.3",
|
|
61
59
|
"@internals/client": "0.0.0",
|
|
62
60
|
"@internals/shared": "0.0.0",
|
|
63
61
|
"@internals/utils": "0.0.0"
|
|
64
62
|
},
|
|
65
63
|
"peerDependencies": {
|
|
66
|
-
"
|
|
67
|
-
"
|
|
64
|
+
"axios": "^1.7.2",
|
|
65
|
+
"kubb": "5.0.0-beta.84"
|
|
68
66
|
},
|
|
69
67
|
"peerDependenciesMeta": {
|
|
70
68
|
"axios": {
|
|
@@ -8,10 +8,10 @@ import {
|
|
|
8
8
|
type SecurityDocument,
|
|
9
9
|
} from '@internals/client'
|
|
10
10
|
import { isEventStream, operationFileEntry } from '@internals/shared'
|
|
11
|
-
import { ast, defineGenerator } from '
|
|
11
|
+
import { ast, defineGenerator } from 'kubb/kit'
|
|
12
12
|
import { pluginTsName } from '@kubb/plugin-ts'
|
|
13
13
|
import { pluginZodName } from '@kubb/plugin-zod'
|
|
14
|
-
import { File, jsxRenderer } from '
|
|
14
|
+
import { File, jsxRenderer } from 'kubb/jsx'
|
|
15
15
|
import type { PluginAxios } from '../types.ts'
|
|
16
16
|
|
|
17
17
|
/**
|
package/src/plugin.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import { createSdkGenerator, defaultMacros, isValidatorEnabled, resolverClient } from '@internals/client'
|
|
3
3
|
import { createGroupConfig } from '@internals/shared'
|
|
4
|
-
import { definePlugin } from '
|
|
4
|
+
import { definePlugin } from 'kubb/kit'
|
|
5
5
|
import { pluginTsName } from '@kubb/plugin-ts'
|
|
6
6
|
import { pluginZodName } from '@kubb/plugin-zod'
|
|
7
7
|
import { clientGenerator } from './generators/clientGenerator.tsx'
|
|
@@ -22,7 +22,7 @@ export const pluginAxiosName = 'plugin-axios' satisfies PluginAxios['name']
|
|
|
22
22
|
*
|
|
23
23
|
* @example
|
|
24
24
|
* ```ts
|
|
25
|
-
* import { defineConfig } from 'kubb'
|
|
25
|
+
* import { defineConfig } from 'kubb/config'
|
|
26
26
|
* import { pluginTs } from '@kubb/plugin-ts'
|
|
27
27
|
* import { pluginAxios } from '@kubb/plugin-axios'
|
|
28
28
|
*
|
|
@@ -80,6 +80,7 @@ export const pluginAxios = definePlugin<PluginAxios>((options) => {
|
|
|
80
80
|
ctx.addGenerator(...selectedGenerators)
|
|
81
81
|
|
|
82
82
|
const root = path.resolve(ctx.config.root, ctx.config.output.path)
|
|
83
|
+
const baseURLExpression = baseURL ? (baseURL.includes('${') ? `\`${baseURL.replaceAll('`', '\\`')}\`` : JSON.stringify(baseURL)) : undefined
|
|
83
84
|
|
|
84
85
|
ctx.injectFile({
|
|
85
86
|
baseName: 'serializers.ts',
|
|
@@ -91,7 +92,7 @@ export const pluginAxios = definePlugin<PluginAxios>((options) => {
|
|
|
91
92
|
baseName: 'client.ts',
|
|
92
93
|
path: path.resolve(root, '.kubb/client.ts'),
|
|
93
94
|
copy: axiosClientTemplatePath,
|
|
94
|
-
footer:
|
|
95
|
+
footer: baseURLExpression ? `client.setConfig({ baseURL: ${baseURLExpression} })` : undefined,
|
|
95
96
|
})
|
|
96
97
|
|
|
97
98
|
ctx.injectFile({
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PluginFactoryOptions } from '
|
|
1
|
+
import type { PluginFactoryOptions } from 'kubb/kit'
|
|
2
2
|
import type { Options, ResolvedOptions, ResolverClient } from '@internals/client'
|
|
3
3
|
|
|
4
4
|
export type { Options, ResolvedOptions, ResolverClient } from '@internals/client'
|
package/templates/axios.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import axios from 'axios'
|
|
2
2
|
import type { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
|
|
3
|
-
import { applyHeaderStyles, defaultBodySerializer, defaultPathSerializer, defaultQuerySerializer, serializeCookies } from './serializers'
|
|
3
|
+
import { applyHeaderStyles, defaultBodySerializer, defaultPathSerializer, defaultQuerySerializer, isDefaultJsonBody, serializeCookies } from './serializers'
|
|
4
4
|
import type { HeadersInit, PathParamStyle, PathSerializer, Serializers, Styles } from './serializers'
|
|
5
5
|
import { type StandardSchemaValidator, validateStandardSchema } from './standardSchema.ts'
|
|
6
6
|
|
|
@@ -254,108 +254,6 @@ export type ClientInstance<TRequest = AxiosRequestConfig, TResponse = AxiosRespo
|
|
|
254
254
|
/**
|
|
255
255
|
* Thrown for a non-2xx response, so a resolved call always means success.
|
|
256
256
|
*/
|
|
257
|
-
/**
|
|
258
|
-
* One decoded Server-Sent Event, with `data` parsed as JSON when valid and kept as the raw string otherwise.
|
|
259
|
-
*/
|
|
260
|
-
export type ServerSentEvent<TData = unknown> = {
|
|
261
|
-
data: TData
|
|
262
|
-
event?: string
|
|
263
|
-
id?: string
|
|
264
|
-
retry?: number
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
async function* readBytes(stream: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>): AsyncGenerator<Uint8Array> {
|
|
268
|
-
if (!('getReader' in stream)) {
|
|
269
|
-
yield* stream
|
|
270
|
-
return
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const reader = stream.getReader()
|
|
274
|
-
try {
|
|
275
|
-
while (true) {
|
|
276
|
-
const { done, value } = await reader.read()
|
|
277
|
-
if (done) return
|
|
278
|
-
yield value
|
|
279
|
-
}
|
|
280
|
-
} finally {
|
|
281
|
-
await reader.cancel().catch(() => {})
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
function parseEvent<TData>(raw: string): ServerSentEvent<TData> | undefined {
|
|
286
|
-
const data: Array<string> = []
|
|
287
|
-
const event: ServerSentEvent<TData> = { data: undefined as TData }
|
|
288
|
-
let seen = false
|
|
289
|
-
|
|
290
|
-
for (const line of raw.split('\n')) {
|
|
291
|
-
if (!line || line.startsWith(':')) continue
|
|
292
|
-
seen = true
|
|
293
|
-
const index = line.indexOf(':')
|
|
294
|
-
const field = index === -1 ? line : line.slice(0, index)
|
|
295
|
-
const value = index === -1 ? '' : line.slice(index + 1).replace(/^ /, '')
|
|
296
|
-
if (field === 'data') data.push(value)
|
|
297
|
-
else if (field === 'event') event.event = value
|
|
298
|
-
else if (field === 'id') event.id = value
|
|
299
|
-
else if (field === 'retry' && Number.isFinite(Number(value))) event.retry = Number(value)
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
if (!seen) return undefined
|
|
303
|
-
|
|
304
|
-
if (data.length) {
|
|
305
|
-
const joined = data.join('\n')
|
|
306
|
-
try {
|
|
307
|
-
event.data = JSON.parse(joined) as TData
|
|
308
|
-
} catch {
|
|
309
|
-
event.data = joined as TData
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
return event
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* Parses a `text/event-stream` body into typed Server-Sent Events, consumed with `for await` and stopped early by breaking the loop.
|
|
317
|
-
*/
|
|
318
|
-
export async function* parseEventStream<TData = unknown>(
|
|
319
|
-
stream: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
|
|
320
|
-
): AsyncGenerator<ServerSentEvent<TData>> {
|
|
321
|
-
const decoder = new TextDecoder()
|
|
322
|
-
const normalize = (text: string) => text.replace(/\r\n|\r/g, '\n')
|
|
323
|
-
let buffer = ''
|
|
324
|
-
|
|
325
|
-
for await (const chunk of readBytes(stream)) {
|
|
326
|
-
const blocks = normalize(buffer + decoder.decode(chunk, { stream: true })).split('\n\n')
|
|
327
|
-
buffer = blocks.pop() ?? ''
|
|
328
|
-
for (const block of blocks) {
|
|
329
|
-
const event = parseEvent<TData>(block)
|
|
330
|
-
if (event) yield event
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
const event = parseEvent<TData>(normalize(buffer + decoder.decode()))
|
|
335
|
-
if (event) yield event
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* The resolved shape returned by a generated `text/event-stream` operation: the typed event
|
|
340
|
-
* `stream` plus the native `response`.
|
|
341
|
-
*/
|
|
342
|
-
export type EventStreamResult<TData = unknown, TResponse = AxiosResponse> = {
|
|
343
|
-
stream: AsyncGenerator<ServerSentEvent<TData>>
|
|
344
|
-
response: TResponse
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* Wraps a transport result whose `data` is a streaming body into an `EventStreamResult`, exposing
|
|
349
|
-
* the parsed events as a typed async iterator. Generated SSE operations call this.
|
|
350
|
-
*/
|
|
351
|
-
export async function toEventStream<TData = unknown>(result: Promise<{ data: unknown; response: AxiosResponse }>): Promise<EventStreamResult<TData>> {
|
|
352
|
-
const { data, response } = await result
|
|
353
|
-
return {
|
|
354
|
-
response,
|
|
355
|
-
stream: parseEventStream<TData>(data as ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>),
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
257
|
export class ResponseError<TError = unknown, TRequest = AxiosRequestConfig, TResponse = AxiosResponse> extends Error {
|
|
360
258
|
data: TError
|
|
361
259
|
status: number
|
|
@@ -393,6 +291,15 @@ function mergeHeaders(...sources: Array<HeadersInit | undefined>): Record<string
|
|
|
393
291
|
return Object.assign({}, ...sources.map(serializeHeaders))
|
|
394
292
|
}
|
|
395
293
|
|
|
294
|
+
function getHeader(headers: Record<string, string>, name: string): string | undefined {
|
|
295
|
+
const key = Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase())
|
|
296
|
+
return key ? headers[key] : undefined
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function hasHeader(headers: Record<string, string>, name: string): boolean {
|
|
300
|
+
return Object.keys(headers).some((k) => k.toLowerCase() === name.toLowerCase())
|
|
301
|
+
}
|
|
302
|
+
|
|
396
303
|
/**
|
|
397
304
|
* Joins the URL parts, interpolates URL-encoded `{param}` segments, and appends the serialized query, backing `getUrl`.
|
|
398
305
|
*/
|
|
@@ -416,6 +323,32 @@ function serializeUrl({
|
|
|
416
323
|
return path + (search ? `?${search}` : '')
|
|
417
324
|
}
|
|
418
325
|
|
|
326
|
+
/**
|
|
327
|
+
* Wraps an axios interceptor registration behind the shared `use` / `eject` / `update` API, mapping a stable external id onto axios's own so `update` can swap a handler in place.
|
|
328
|
+
*/
|
|
329
|
+
function createInterceptorChannel<T>(register: (fn: InterceptorFn<T>) => number, ejectNative: (id: number) => void): InterceptorChannel<T> {
|
|
330
|
+
const ids = new Map<number, number>()
|
|
331
|
+
let counter = 0
|
|
332
|
+
return {
|
|
333
|
+
use(fn) {
|
|
334
|
+
const id = ++counter
|
|
335
|
+
ids.set(id, register(fn))
|
|
336
|
+
return id
|
|
337
|
+
},
|
|
338
|
+
eject(id) {
|
|
339
|
+
const nativeId = ids.get(id)
|
|
340
|
+
if (nativeId === undefined) return
|
|
341
|
+
ejectNative(nativeId)
|
|
342
|
+
ids.delete(id)
|
|
343
|
+
},
|
|
344
|
+
update(id, fn) {
|
|
345
|
+
const nativeId = ids.get(id)
|
|
346
|
+
if (nativeId !== undefined) ejectNative(nativeId)
|
|
347
|
+
ids.set(id, register(fn))
|
|
348
|
+
},
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
419
352
|
/**
|
|
420
353
|
* Walks the per-operation security in order and places the first resolved token on the request, mutating `headers` / `query` in place.
|
|
421
354
|
*/
|
|
@@ -434,10 +367,14 @@ export async function resolveAuth(params: {
|
|
|
434
367
|
|
|
435
368
|
if (scheme.type === 'apiKey') {
|
|
436
369
|
const name = scheme.name ?? 'Authorization'
|
|
437
|
-
if (scheme.in === 'query')
|
|
438
|
-
|
|
439
|
-
else
|
|
440
|
-
|
|
370
|
+
if (scheme.in === 'query') {
|
|
371
|
+
if (query[name] === undefined) query[name] = token
|
|
372
|
+
} else if (scheme.in === 'cookie') {
|
|
373
|
+
headers.Cookie = [headers.Cookie, `${name}=${token}`].filter(Boolean).join('; ')
|
|
374
|
+
} else if (!hasHeader(headers, name)) {
|
|
375
|
+
headers[name] = token
|
|
376
|
+
}
|
|
377
|
+
} else if (!hasHeader(headers, 'Authorization')) {
|
|
441
378
|
headers.Authorization = scheme.scheme === 'basic' ? `Basic ${btoa(token)}` : `Bearer ${token}`
|
|
442
379
|
}
|
|
443
380
|
return
|
|
@@ -449,32 +386,6 @@ async function runValidator<T>(validator: Validator<T> | undefined, value: T): P
|
|
|
449
386
|
return validateStandardSchema(validator, value)
|
|
450
387
|
}
|
|
451
388
|
|
|
452
|
-
/**
|
|
453
|
-
* Wraps an axios interceptor registration behind the shared `use` / `eject` / `update` API, mapping a stable external id onto axios's own so `update` can swap a handler in place.
|
|
454
|
-
*/
|
|
455
|
-
function createInterceptorChannel<T>(register: (fn: InterceptorFn<T>) => number, ejectNative: (id: number) => void): InterceptorChannel<T> {
|
|
456
|
-
const ids = new Map<number, number>()
|
|
457
|
-
let counter = 0
|
|
458
|
-
return {
|
|
459
|
-
use(fn) {
|
|
460
|
-
const id = ++counter
|
|
461
|
-
ids.set(id, register(fn))
|
|
462
|
-
return id
|
|
463
|
-
},
|
|
464
|
-
eject(id) {
|
|
465
|
-
const nativeId = ids.get(id)
|
|
466
|
-
if (nativeId === undefined) return
|
|
467
|
-
ejectNative(nativeId)
|
|
468
|
-
ids.delete(id)
|
|
469
|
-
},
|
|
470
|
-
update(id, fn) {
|
|
471
|
-
const nativeId = ids.get(id)
|
|
472
|
-
if (nativeId !== undefined) ejectNative(nativeId)
|
|
473
|
-
ids.set(id, register(fn))
|
|
474
|
-
},
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
|
|
478
389
|
/**
|
|
479
390
|
* The base media type of a `Content-Type` value, lowercased and stripped of any `; charset=...` parameters.
|
|
480
391
|
*/
|
|
@@ -500,6 +411,139 @@ function resolveContentType(contentType: ContentType | undefined): { request?: s
|
|
|
500
411
|
return contentType ?? {}
|
|
501
412
|
}
|
|
502
413
|
|
|
414
|
+
/**
|
|
415
|
+
* The per-concern serializers for a call, the per-call serializer winning over the client's and
|
|
416
|
+
* falling back to the defaults.
|
|
417
|
+
*/
|
|
418
|
+
function resolveSerializers({ config, requestConfig }: { config: { serializer?: Serializers }; requestConfig: { serializer?: Serializers } }) {
|
|
419
|
+
return {
|
|
420
|
+
querySerializer: requestConfig.serializer?.query ?? config.serializer?.query ?? defaultQuerySerializer,
|
|
421
|
+
bodySerializer: requestConfig.serializer?.body ?? config.serializer?.body ?? defaultBodySerializer,
|
|
422
|
+
pathSerializer: requestConfig.serializer?.path ?? config.serializer?.path ?? defaultPathSerializer,
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Resolves everything a call needs before it touches axios: merged headers with the negotiated
|
|
428
|
+
* content type, auth on headers or query, serialized cookies, the validated and serialized body,
|
|
429
|
+
* and the final axios request config with `throwOnError` riding `validateStatus`.
|
|
430
|
+
*/
|
|
431
|
+
async function resolveRequest<TBody, TRequest, TResponse>({
|
|
432
|
+
config,
|
|
433
|
+
requestConfig,
|
|
434
|
+
}: {
|
|
435
|
+
config: ClientConfig
|
|
436
|
+
requestConfig: RequestConfig<TBody, TRequest, TResponse>
|
|
437
|
+
}): Promise<{ axiosConfig: AxiosRequestConfig; codecs: Record<string, Codec>; throwOnError: boolean }> {
|
|
438
|
+
const { querySerializer, bodySerializer, pathSerializer } = resolveSerializers({ config, requestConfig })
|
|
439
|
+
const codecs = { ...config.codecs, ...requestConfig.codecs }
|
|
440
|
+
|
|
441
|
+
const headers = mergeHeaders(config.headers, applyHeaderStyles(requestConfig.headers, requestConfig.styles?.header))
|
|
442
|
+
const { request: requestContentTypeOption, response: responseContentType } = resolveContentType(requestConfig.contentType)
|
|
443
|
+
const requestContentType = requestContentTypeOption ?? getHeader(headers, 'content-type')
|
|
444
|
+
if (responseContentType && !hasHeader(headers, 'accept')) {
|
|
445
|
+
headers['Accept'] = responseContentType
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const query: Record<string, unknown> = { ...((requestConfig.query ?? requestConfig.params) as Record<string, unknown> | undefined) }
|
|
449
|
+
|
|
450
|
+
await resolveAuth({
|
|
451
|
+
security: requestConfig.security,
|
|
452
|
+
auth: requestConfig.auth ?? config.auth,
|
|
453
|
+
headers,
|
|
454
|
+
query,
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
if (requestConfig.cookies) {
|
|
458
|
+
const cookie = serializeCookies(requestConfig.cookies, requestConfig.styles?.cookie)
|
|
459
|
+
if (cookie) headers.Cookie = [headers.Cookie, cookie].filter(Boolean).join('; ')
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const validatedBody = await runValidator(requestConfig.validator?.request, requestConfig.body)
|
|
463
|
+
const requestContentTypeBase = baseContentType(requestContentType)
|
|
464
|
+
const contentCodec = requestContentTypeBase ? codecs[requestContentTypeBase] : undefined
|
|
465
|
+
const usesDefaultBodySerializer = !contentCodec?.serialize && bodySerializer === defaultBodySerializer
|
|
466
|
+
const body = contentCodec?.serialize
|
|
467
|
+
? contentCodec.serialize(validatedBody, requestContentType)
|
|
468
|
+
: bodySerializer({ body: validatedBody, contentType: requestContentType, encoding: requestConfig.styles?.body })
|
|
469
|
+
// A FormData body must keep its Content-Type unset so axios appends the multipart boundary.
|
|
470
|
+
if (body instanceof FormData) {
|
|
471
|
+
for (const key of Object.keys(headers)) {
|
|
472
|
+
if (key.toLowerCase() === 'content-type') delete headers[key]
|
|
473
|
+
}
|
|
474
|
+
} else if (requestContentTypeOption) {
|
|
475
|
+
headers['Content-Type'] = requestContentTypeOption
|
|
476
|
+
} else if (usesDefaultBodySerializer && isDefaultJsonBody(validatedBody) && !hasHeader(headers, 'content-type')) {
|
|
477
|
+
headers['Content-Type'] = 'application/json'
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const pathParams = requestConfig.path ?? {}
|
|
481
|
+
const url = (requestConfig.url ?? '').replace(/\{([^{}]+)\}/g, (_, key: string) =>
|
|
482
|
+
pathSerializer({ name: key, value: pathParams[key], options: requestConfig.styles?.path?.[key] }),
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
const throwOnError = requestConfig.throwOnError ?? config.throwOnError ?? true
|
|
486
|
+
const validateStatus = requestConfig.validateStatus ?? config.validateStatus ?? (throwOnError ? undefined : () => true)
|
|
487
|
+
|
|
488
|
+
const options = config.options || requestConfig.options ? { ...config.options, ...requestConfig.options } : undefined
|
|
489
|
+
|
|
490
|
+
const axiosConfig: AxiosRequestConfig = {
|
|
491
|
+
...options, // timeout, proxy, maxRedirects, decompress, onUploadProgress, …
|
|
492
|
+
url,
|
|
493
|
+
baseURL: requestConfig.baseURL ?? config.baseURL,
|
|
494
|
+
method: requestConfig.method ?? 'GET',
|
|
495
|
+
headers,
|
|
496
|
+
params: query,
|
|
497
|
+
paramsSerializer: (params) => querySerializer(params as Record<string, unknown>, requestConfig.styles?.query),
|
|
498
|
+
data: body,
|
|
499
|
+
transformRequest: (data) => data,
|
|
500
|
+
signal: requestConfig.signal,
|
|
501
|
+
responseType: requestConfig.responseType,
|
|
502
|
+
validateStatus,
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Only the fetch adapter exposes a streaming `response.data` (a ReadableStream) in the browser.
|
|
506
|
+
// The default XHR adapter buffers the whole body. Default streams to it, but respect an explicit adapter.
|
|
507
|
+
if (requestConfig.responseType === 'stream' && !axiosConfig.adapter) {
|
|
508
|
+
axiosConfig.adapter = 'fetch'
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return { axiosConfig, codecs, throwOnError }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Turns an axios response into the call result: decodes the body through the matching codec and
|
|
516
|
+
* validates the success or error body. A thrown axios error never reaches this, so `error` here is
|
|
517
|
+
* always the body of a non-2xx response that `validateStatus` let through.
|
|
518
|
+
*/
|
|
519
|
+
async function settleResponse<TRequest, TResponse>({
|
|
520
|
+
response,
|
|
521
|
+
codecs,
|
|
522
|
+
validator,
|
|
523
|
+
}: {
|
|
524
|
+
response: AxiosResponse
|
|
525
|
+
codecs: Record<string, Codec>
|
|
526
|
+
validator: { response?: Validator; error?: Validator } | undefined
|
|
527
|
+
}): Promise<CallResult<TRequest, TResponse>> {
|
|
528
|
+
const isSuccess = response.status >= 200 && response.status < 300
|
|
529
|
+
const contentType = getResponseContentType(response.headers as Record<string, unknown>)
|
|
530
|
+
let decoded: unknown = response.data
|
|
531
|
+
if (contentType) {
|
|
532
|
+
const codec = codecs[contentType]
|
|
533
|
+
if (codec?.deserialize) decoded = await codec.deserialize(response.data, contentType)
|
|
534
|
+
}
|
|
535
|
+
const data = isSuccess ? await runValidator(validator?.response, decoded) : undefined
|
|
536
|
+
const error = isSuccess ? undefined : await runValidator(validator?.error, decoded)
|
|
537
|
+
return {
|
|
538
|
+
status: response.status,
|
|
539
|
+
data,
|
|
540
|
+
error,
|
|
541
|
+
contentType,
|
|
542
|
+
request: response.config as TRequest,
|
|
543
|
+
response: response as TResponse,
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
503
547
|
/**
|
|
504
548
|
* Builds the shared client core bound to an axios instance (defaulting to `axios.create()`), with `throwOnError` riding axios's `validateStatus`.
|
|
505
549
|
*/
|
|
@@ -530,96 +574,11 @@ export function createClientCore<TRequest = AxiosRequestConfig, TResponse = Axio
|
|
|
530
574
|
|
|
531
575
|
const client = (async <TBody = unknown>(requestConfig: RequestConfig<TBody, TRequest, TResponse>): Promise<CallResult<TRequest, TResponse>> => {
|
|
532
576
|
const activeInstance = requestConfig.transport ?? config.transport ?? instance
|
|
533
|
-
const
|
|
534
|
-
const bodySerializer = requestConfig.serializer?.body ?? config.serializer?.body ?? defaultBodySerializer
|
|
535
|
-
const pathSerializer = requestConfig.serializer?.path ?? config.serializer?.path ?? defaultPathSerializer
|
|
536
|
-
const codecs = { ...config.codecs, ...requestConfig.codecs }
|
|
537
|
-
|
|
538
|
-
const headers = mergeHeaders(config.headers, applyHeaderStyles(requestConfig.headers, requestConfig.styles?.header))
|
|
539
|
-
const { request: requestContentTypeOption, response: responseContentType } = resolveContentType(requestConfig.contentType)
|
|
540
|
-
const requestContentType = requestContentTypeOption ?? headers['Content-Type'] ?? headers['content-type']
|
|
541
|
-
if (responseContentType && headers['Accept'] === undefined && headers['accept'] === undefined) {
|
|
542
|
-
headers['Accept'] = responseContentType
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
const query: Record<string, unknown> = { ...((requestConfig.query ?? requestConfig.params) as Record<string, unknown> | undefined) }
|
|
546
|
-
|
|
547
|
-
await resolveAuth({
|
|
548
|
-
security: requestConfig.security,
|
|
549
|
-
auth: requestConfig.auth ?? config.auth,
|
|
550
|
-
headers,
|
|
551
|
-
query,
|
|
552
|
-
})
|
|
553
|
-
|
|
554
|
-
if (requestConfig.cookies) {
|
|
555
|
-
const cookie = serializeCookies(requestConfig.cookies, requestConfig.styles?.cookie)
|
|
556
|
-
if (cookie) headers.Cookie = [headers.Cookie, cookie].filter(Boolean).join('; ')
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
const validatedBody = await runValidator(requestConfig.validator?.request, requestConfig.body)
|
|
560
|
-
const requestContentTypeBase = baseContentType(requestContentType)
|
|
561
|
-
const contentCodec = requestContentTypeBase ? codecs[requestContentTypeBase] : undefined
|
|
562
|
-
const body = contentCodec?.serialize
|
|
563
|
-
? contentCodec.serialize(validatedBody, requestContentType)
|
|
564
|
-
: bodySerializer({ body: validatedBody, contentType: requestContentType, encoding: requestConfig.styles?.body })
|
|
565
|
-
// A FormData body must keep its Content-Type unset so axios appends the multipart boundary.
|
|
566
|
-
if (body instanceof FormData) {
|
|
567
|
-
delete headers['Content-Type']
|
|
568
|
-
delete headers['content-type']
|
|
569
|
-
} else if (requestContentTypeOption) {
|
|
570
|
-
headers['Content-Type'] = requestContentTypeOption
|
|
571
|
-
}
|
|
572
|
-
const pathParams = requestConfig.path ?? {}
|
|
573
|
-
const url = (requestConfig.url ?? '').replace(/\{([^{}]+)\}/g, (_, key: string) =>
|
|
574
|
-
pathSerializer({ name: key, value: pathParams[key], options: requestConfig.styles?.path?.[key] }),
|
|
575
|
-
)
|
|
576
|
-
const baseURL = [config.baseURL, requestConfig.baseURL].filter(Boolean).join('') || undefined
|
|
577
|
-
|
|
578
|
-
const throwOnError = requestConfig.throwOnError ?? config.throwOnError ?? true
|
|
579
|
-
const validateStatus = requestConfig.validateStatus ?? config.validateStatus ?? (throwOnError ? undefined : () => true)
|
|
580
|
-
|
|
581
|
-
const options = config.options || requestConfig.options ? { ...config.options, ...requestConfig.options } : undefined
|
|
582
|
-
|
|
583
|
-
const axiosConfig: AxiosRequestConfig = {
|
|
584
|
-
...options, // timeout, proxy, maxRedirects, decompress, onUploadProgress, …
|
|
585
|
-
url,
|
|
586
|
-
baseURL,
|
|
587
|
-
method: requestConfig.method ?? 'GET',
|
|
588
|
-
headers,
|
|
589
|
-
params: query,
|
|
590
|
-
paramsSerializer: (params) => querySerializer(params as Record<string, unknown>, requestConfig.styles?.query),
|
|
591
|
-
data: body,
|
|
592
|
-
transformRequest: (data) => data,
|
|
593
|
-
signal: requestConfig.signal,
|
|
594
|
-
responseType: requestConfig.responseType,
|
|
595
|
-
validateStatus,
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
// Only the fetch adapter exposes a streaming `response.data` (a ReadableStream) in the browser;
|
|
599
|
-
// the default XHR adapter buffers the whole body. Default streams to it, but respect an explicit adapter.
|
|
600
|
-
if (requestConfig.responseType === 'stream' && !axiosConfig.adapter) {
|
|
601
|
-
axiosConfig.adapter = 'fetch'
|
|
602
|
-
}
|
|
577
|
+
const { axiosConfig, codecs, throwOnError } = await resolveRequest({ config, requestConfig })
|
|
603
578
|
|
|
604
579
|
try {
|
|
605
580
|
const response = await activeInstance.request<unknown, AxiosResponse>(axiosConfig)
|
|
606
|
-
|
|
607
|
-
const contentType = getResponseContentType(response.headers as Record<string, unknown>)
|
|
608
|
-
let decoded: unknown = response.data
|
|
609
|
-
if (contentType) {
|
|
610
|
-
const codec = codecs[contentType]
|
|
611
|
-
if (codec?.deserialize) decoded = await codec.deserialize(response.data, contentType)
|
|
612
|
-
}
|
|
613
|
-
const data = isSuccess ? await runValidator(requestConfig.validator?.response, decoded) : undefined
|
|
614
|
-
const error = isSuccess ? undefined : await runValidator(requestConfig.validator?.error, decoded)
|
|
615
|
-
return {
|
|
616
|
-
status: response.status,
|
|
617
|
-
data,
|
|
618
|
-
error,
|
|
619
|
-
contentType,
|
|
620
|
-
request: response.config as TRequest,
|
|
621
|
-
response: response as TResponse,
|
|
622
|
-
}
|
|
581
|
+
return await settleResponse({ response, codecs, validator: requestConfig.validator })
|
|
623
582
|
} catch (error) {
|
|
624
583
|
const axiosError = error as AxiosError
|
|
625
584
|
if (throwOnError && axiosError.response) {
|
|
@@ -642,11 +601,10 @@ export function createClientCore<TRequest = AxiosRequestConfig, TResponse = Axio
|
|
|
642
601
|
return config
|
|
643
602
|
}
|
|
644
603
|
client.getUrl = (requestConfig) => {
|
|
645
|
-
const querySerializer =
|
|
646
|
-
const pathSerializer = requestConfig.serializer?.path ?? config.serializer?.path ?? defaultPathSerializer
|
|
604
|
+
const { querySerializer, pathSerializer } = resolveSerializers({ config, requestConfig })
|
|
647
605
|
const query: Record<string, unknown> = { ...((requestConfig.query ?? requestConfig.params) as Record<string, unknown> | undefined) }
|
|
648
606
|
return serializeUrl({
|
|
649
|
-
parts: [
|
|
607
|
+
parts: [requestConfig.baseURL ?? config.baseURL, requestConfig.url],
|
|
650
608
|
pathParams: requestConfig.path ?? {},
|
|
651
609
|
search: querySerializer(query, requestConfig.styles?.query),
|
|
652
610
|
pathSerializer,
|
|
@@ -659,6 +617,108 @@ export function createClientCore<TRequest = AxiosRequestConfig, TResponse = Axio
|
|
|
659
617
|
return client
|
|
660
618
|
}
|
|
661
619
|
|
|
620
|
+
/**
|
|
621
|
+
* One decoded Server-Sent Event, with `data` parsed as JSON when valid and kept as the raw string otherwise.
|
|
622
|
+
*/
|
|
623
|
+
export type ServerSentEvent<TData = unknown> = {
|
|
624
|
+
data: TData
|
|
625
|
+
event?: string
|
|
626
|
+
id?: string
|
|
627
|
+
retry?: number
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function* readBytes(stream: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>): AsyncGenerator<Uint8Array> {
|
|
631
|
+
if (!('getReader' in stream)) {
|
|
632
|
+
yield* stream
|
|
633
|
+
return
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const reader = stream.getReader()
|
|
637
|
+
try {
|
|
638
|
+
while (true) {
|
|
639
|
+
const { done, value } = await reader.read()
|
|
640
|
+
if (done) return
|
|
641
|
+
yield value
|
|
642
|
+
}
|
|
643
|
+
} finally {
|
|
644
|
+
await reader.cancel().catch(() => {})
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function parseEvent<TData>(raw: string): ServerSentEvent<TData> | undefined {
|
|
649
|
+
const data: Array<string> = []
|
|
650
|
+
const event: ServerSentEvent<TData> = { data: undefined as TData }
|
|
651
|
+
let seen = false
|
|
652
|
+
|
|
653
|
+
for (const line of raw.split('\n')) {
|
|
654
|
+
if (!line || line.startsWith(':')) continue
|
|
655
|
+
seen = true
|
|
656
|
+
const index = line.indexOf(':')
|
|
657
|
+
const field = index === -1 ? line : line.slice(0, index)
|
|
658
|
+
const value = index === -1 ? '' : line.slice(index + 1).replace(/^ /, '')
|
|
659
|
+
if (field === 'data') data.push(value)
|
|
660
|
+
else if (field === 'event') event.event = value
|
|
661
|
+
else if (field === 'id') event.id = value
|
|
662
|
+
else if (field === 'retry' && Number.isFinite(Number(value))) event.retry = Number(value)
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (!seen) return undefined
|
|
666
|
+
|
|
667
|
+
if (data.length) {
|
|
668
|
+
const joined = data.join('\n')
|
|
669
|
+
try {
|
|
670
|
+
event.data = JSON.parse(joined) as TData
|
|
671
|
+
} catch {
|
|
672
|
+
event.data = joined as TData
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return event
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Parses a `text/event-stream` body into typed Server-Sent Events, consumed with `for await` and stopped early by breaking the loop.
|
|
680
|
+
*/
|
|
681
|
+
export async function* parseEventStream<TData = unknown>(
|
|
682
|
+
stream: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
|
|
683
|
+
): AsyncGenerator<ServerSentEvent<TData>> {
|
|
684
|
+
const decoder = new TextDecoder()
|
|
685
|
+
const normalize = (text: string) => text.replace(/\r\n|\r/g, '\n')
|
|
686
|
+
let buffer = ''
|
|
687
|
+
|
|
688
|
+
for await (const chunk of readBytes(stream)) {
|
|
689
|
+
const blocks = normalize(buffer + decoder.decode(chunk, { stream: true })).split('\n\n')
|
|
690
|
+
buffer = blocks.pop() ?? ''
|
|
691
|
+
for (const block of blocks) {
|
|
692
|
+
const event = parseEvent<TData>(block)
|
|
693
|
+
if (event) yield event
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const event = parseEvent<TData>(normalize(buffer + decoder.decode()))
|
|
698
|
+
if (event) yield event
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* The resolved shape returned by a generated `text/event-stream` operation: the typed event
|
|
703
|
+
* `stream` plus the native `response`.
|
|
704
|
+
*/
|
|
705
|
+
export type EventStreamResult<TData = unknown, TResponse = AxiosResponse> = {
|
|
706
|
+
stream: AsyncGenerator<ServerSentEvent<TData>>
|
|
707
|
+
response: TResponse
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Wraps a transport result whose `data` is a streaming body into an `EventStreamResult`, exposing
|
|
712
|
+
* the parsed events as a typed async iterator. Generated SSE operations call this.
|
|
713
|
+
*/
|
|
714
|
+
export async function toEventStream<TData = unknown>(result: Promise<{ data: unknown; response: AxiosResponse }>): Promise<EventStreamResult<TData>> {
|
|
715
|
+
const { data, response } = await result
|
|
716
|
+
return {
|
|
717
|
+
response,
|
|
718
|
+
stream: parseEventStream<TData>(data as ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>),
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
662
722
|
export const client = createClientCore()
|
|
663
723
|
|
|
664
724
|
export const createClient = (config?: Parameters<typeof client.createClient>[0]) => client.createClient(config)
|
package/templates/serializers.ts
CHANGED
|
@@ -122,6 +122,10 @@ function isFormBody(body: unknown): body is BodyInit {
|
|
|
122
122
|
)
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
export function isDefaultJsonBody(body: unknown): boolean {
|
|
126
|
+
return body !== undefined && body !== null && !isFormBody(body)
|
|
127
|
+
}
|
|
128
|
+
|
|
125
129
|
function appendFormDataValue({ formData, key, value, contentType }: { formData: FormData; key: string; value: unknown; contentType?: string }): void {
|
|
126
130
|
if (value === undefined || value === null) return
|
|
127
131
|
if (value instanceof Blob) formData.append(key, value)
|