@kubb/plugin-fetch 5.0.0-beta.80 → 5.0.0-beta.81

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubb/plugin-fetch",
3
- "version": "5.0.0-beta.80",
3
+ "version": "5.0.0-beta.81",
4
4
  "description": "Generate a slim, type-safe HTTP client with Kubb based on the native Fetch api.",
5
5
  "keywords": [
6
6
  "api-client",
@@ -49,11 +49,11 @@
49
49
  "registry": "https://registry.npmjs.org/"
50
50
  },
51
51
  "dependencies": {
52
- "@kubb/ast": "5.0.0-beta.79",
53
- "@kubb/core": "5.0.0-beta.79",
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/ast": "5.0.0-beta.80",
53
+ "@kubb/core": "5.0.0-beta.80",
54
+ "@kubb/renderer-jsx": "5.0.0-beta.80",
55
+ "@kubb/plugin-ts": "5.0.0-beta.81",
56
+ "@kubb/plugin-zod": "5.0.0-beta.81"
57
57
  },
58
58
  "devDependencies": {
59
59
  "typescript": "^6.0.3",
@@ -62,7 +62,7 @@
62
62
  "@internals/utils": "0.0.0"
63
63
  },
64
64
  "peerDependencies": {
65
- "@kubb/renderer-jsx": "5.0.0-beta.79"
65
+ "@kubb/renderer-jsx": "5.0.0-beta.80"
66
66
  },
67
67
  "engines": {
68
68
  "node": ">=22"
package/src/plugin.ts CHANGED
@@ -80,6 +80,7 @@ export const pluginFetch = definePlugin<PluginFetch>((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 pluginFetch = definePlugin<PluginFetch>((options) => {
91
92
  baseName: 'client.ts',
92
93
  path: path.resolve(root, '.kubb/client.ts'),
93
94
  copy: fetchClientTemplatePath,
94
- footer: baseURL ? `client.setConfig({ baseURL: ${JSON.stringify(baseURL)} })` : undefined,
95
+ footer: baseURLExpression ? `client.setConfig({ baseURL: ${baseURLExpression} })` : undefined,
95
96
  })
96
97
 
97
98
  ctx.injectFile({
@@ -387,16 +387,23 @@ export async function resolveAuth(params: {
387
387
  const { security, auth, headers, query } = params
388
388
  if (!security?.length || auth === undefined) return
389
389
 
390
+ // An explicit caller-set header wins over the resolved token.
391
+ const hasHeader = (name: string) => Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase())
392
+
390
393
  for (const scheme of security) {
391
394
  const token = typeof auth === 'function' ? await auth(scheme) : auth
392
395
  if (token === undefined) continue
393
396
 
394
397
  if (scheme.type === 'apiKey') {
395
398
  const name = scheme.name ?? 'Authorization'
396
- if (scheme.in === 'query') query[name] = token
397
- else if (scheme.in === 'cookie') headers.Cookie = [headers.Cookie, `${name}=${token}`].filter(Boolean).join('; ')
398
- else headers[name] = token
399
- } else {
399
+ if (scheme.in === 'query') {
400
+ if (query[name] === undefined) query[name] = token
401
+ } else if (scheme.in === 'cookie') {
402
+ headers.Cookie = [headers.Cookie, `${name}=${token}`].filter(Boolean).join('; ')
403
+ } else if (!hasHeader(name)) {
404
+ headers[name] = token
405
+ }
406
+ } else if (!hasHeader('Authorization')) {
400
407
  headers.Authorization = scheme.scheme === 'basic' ? `Basic ${btoa(token)}` : `Bearer ${token}`
401
408
  }
402
409
  return
@@ -434,73 +441,80 @@ function resolveContentType(contentType: ContentType | undefined): { request?: s
434
441
  }
435
442
 
436
443
  /**
437
- * Builds the shared client core bound to a transport, exported by each plugin as `client` plus a `createClient` factory.
444
+ * The per-concern serializers for a call, the per-call serializer winning over the client's and
445
+ * falling back to the defaults.
438
446
  */
439
- export function createClientCore<TRequest = Request, TResponse = Response>(
440
- options: { defaultTransport: Transport<TRequest, TResponse> } & ClientConfig<TRequest, TResponse>,
441
- ): ClientInstance<TRequest, TResponse> {
442
- const { defaultTransport, ...initialConfig } = options
443
- let config: ClientConfig<TRequest, TResponse> = { ...initialConfig }
447
+ function resolveSerializers({ config, requestConfig }: { config: { serializer?: Serializers }; requestConfig: { serializer?: Serializers } }) {
448
+ return {
449
+ querySerializer: requestConfig.serializer?.query ?? config.serializer?.query ?? defaultQuerySerializer,
450
+ bodySerializer: requestConfig.serializer?.body ?? config.serializer?.body ?? defaultBodySerializer,
451
+ pathSerializer: requestConfig.serializer?.path ?? config.serializer?.path ?? defaultPathSerializer,
452
+ }
453
+ }
444
454
 
445
- const interceptors: Interceptors<TRequest, TResponse> = {
446
- request: createInterceptorStack<ResolvedRequest>(),
447
- response: createInterceptorStack<TransportResult<unknown, TRequest, TResponse>>(),
448
- error: createInterceptorStack<ResponseError<unknown, TRequest, TResponse>>(),
455
+ /**
456
+ * Resolves everything a call needs before it touches the transport: merged headers with the
457
+ * negotiated content type, auth on headers or query, serialized cookies, the validated and
458
+ * serialized body, and the full URL.
459
+ */
460
+ async function resolveRequest<TBody, TRequest, TResponse>({
461
+ config,
462
+ requestConfig,
463
+ }: {
464
+ config: ClientConfig<TRequest, TResponse>
465
+ requestConfig: RequestConfig<TBody, TRequest, TResponse>
466
+ }): Promise<{ request: ResolvedRequest; codecs: Record<string, Codec> }> {
467
+ const { querySerializer, bodySerializer, pathSerializer } = resolveSerializers({ config, requestConfig })
468
+ const codecs = { ...config.codecs, ...requestConfig.codecs }
469
+
470
+ const headers = mergeHeaders(config.headers, applyHeaderStyles(requestConfig.headers, requestConfig.styles?.header))
471
+ const { request: requestContentTypeOption, response: responseContentType } = resolveContentType(requestConfig.contentType)
472
+ const requestContentType = requestContentTypeOption ?? headers['Content-Type'] ?? headers['content-type']
473
+ if (responseContentType && headers['Accept'] === undefined && headers['accept'] === undefined) {
474
+ headers['Accept'] = responseContentType
449
475
  }
450
476
 
451
- const client = (async <TBody = unknown>(requestConfig: RequestConfig<TBody, TRequest, TResponse>): Promise<CallResult<TRequest, TResponse>> => {
452
- const transport = requestConfig.transport ?? config.transport ?? defaultTransport
453
- const querySerializer = requestConfig.serializer?.query ?? config.serializer?.query ?? defaultQuerySerializer
454
- const bodySerializer = requestConfig.serializer?.body ?? config.serializer?.body ?? defaultBodySerializer
455
- const pathSerializer = requestConfig.serializer?.path ?? config.serializer?.path ?? defaultPathSerializer
456
- const codecs = { ...config.codecs, ...requestConfig.codecs }
457
-
458
- const headers = mergeHeaders(config.headers, applyHeaderStyles(requestConfig.headers, requestConfig.styles?.header))
459
- const { request: requestContentTypeOption, response: responseContentType } = resolveContentType(requestConfig.contentType)
460
- const requestContentType = requestContentTypeOption ?? headers['Content-Type'] ?? headers['content-type']
461
- if (responseContentType && headers['Accept'] === undefined && headers['accept'] === undefined) {
462
- headers['Accept'] = responseContentType
463
- }
477
+ const query: Record<string, unknown> = { ...((requestConfig.query ?? requestConfig.params) as Record<string, unknown> | undefined) }
464
478
 
465
- const query: Record<string, unknown> = { ...((requestConfig.query ?? requestConfig.params) as Record<string, unknown> | undefined) }
479
+ await resolveAuth({
480
+ security: requestConfig.security,
481
+ auth: requestConfig.auth ?? config.auth,
482
+ headers,
483
+ query,
484
+ })
466
485
 
467
- await resolveAuth({
468
- security: requestConfig.security,
469
- auth: requestConfig.auth ?? config.auth,
470
- headers,
471
- query,
472
- })
486
+ if (requestConfig.cookies) {
487
+ const cookie = serializeCookies(requestConfig.cookies, requestConfig.styles?.cookie)
488
+ if (cookie) headers.Cookie = [headers.Cookie, cookie].filter(Boolean).join('; ')
489
+ }
473
490
 
474
- if (requestConfig.cookies) {
475
- const cookie = serializeCookies(requestConfig.cookies, requestConfig.styles?.cookie)
476
- if (cookie) headers.Cookie = [headers.Cookie, cookie].filter(Boolean).join('; ')
477
- }
491
+ const validatedBody = await runValidator(requestConfig.validator?.request, requestConfig.body)
492
+ const requestContentTypeBase = baseContentType(requestContentType)
493
+ const contentCodec = requestContentTypeBase ? codecs[requestContentTypeBase] : undefined
494
+ const body = contentCodec?.serialize
495
+ ? contentCodec.serialize(validatedBody, requestContentType)
496
+ : bodySerializer({ body: validatedBody, contentType: requestContentType, encoding: requestConfig.styles?.body })
497
+ // A FormData body must keep its Content-Type unset so the runtime appends the multipart boundary.
498
+ if (body instanceof FormData) {
499
+ delete headers['Content-Type']
500
+ delete headers['content-type']
501
+ } else if (requestContentTypeOption) {
502
+ headers['Content-Type'] = requestContentTypeOption
503
+ }
478
504
 
479
- const rawBody = requestConfig.body
480
- const validatedBody = await runValidator(requestConfig.validator?.request, rawBody)
481
- const requestContentTypeBase = baseContentType(requestContentType)
482
- const contentCodec = requestContentTypeBase ? codecs[requestContentTypeBase] : undefined
483
- const body = contentCodec?.serialize
484
- ? contentCodec.serialize(validatedBody, requestContentType)
485
- : bodySerializer({ body: validatedBody, contentType: requestContentType, encoding: requestConfig.styles?.body })
486
- // A FormData body must keep its Content-Type unset so the runtime appends the multipart boundary.
487
- if (body instanceof FormData) {
488
- delete headers['Content-Type']
489
- delete headers['content-type']
490
- } else if (requestContentTypeOption) {
491
- headers['Content-Type'] = requestContentTypeOption
492
- }
493
- const url = serializeUrl({
494
- parts: [config.baseURL, requestConfig.baseURL, requestConfig.url],
495
- pathParams: requestConfig.path ?? {},
496
- search: querySerializer(query, requestConfig.styles?.query),
497
- pathSerializer,
498
- pathStyles: requestConfig.styles?.path,
499
- })
505
+ const url = serializeUrl({
506
+ parts: [requestConfig.baseURL ?? config.baseURL, requestConfig.url],
507
+ pathParams: requestConfig.path ?? {},
508
+ search: querySerializer(query, requestConfig.styles?.query),
509
+ pathSerializer,
510
+ pathStyles: requestConfig.styles?.path,
511
+ })
500
512
 
501
- const options = config.options || requestConfig.options ? { ...config.options, ...requestConfig.options } : undefined
513
+ const options = config.options || requestConfig.options ? { ...config.options, ...requestConfig.options } : undefined
502
514
 
503
- let resolvedRequest: ResolvedRequest = {
515
+ return {
516
+ codecs,
517
+ request: {
504
518
  url,
505
519
  method: (requestConfig.method ?? 'GET').toUpperCase(),
506
520
  headers,
@@ -509,48 +523,86 @@ export function createClientCore<TRequest = Request, TResponse = Response>(
509
523
  credentials: requestConfig.credentials,
510
524
  options,
511
525
  responseType: requestConfig.responseType,
512
- }
513
- resolvedRequest = await interceptors.request.run(resolvedRequest)
514
-
515
- let result = await transport(resolvedRequest)
516
- result = await interceptors.response.run(result)
517
-
518
- const isSuccess = result.status >= 200 && result.status < 300
519
- const throwOnError = requestConfig.throwOnError ?? config.throwOnError ?? true
520
-
521
- const contentType = result.contentType ?? getResponseContentType(result.headers)
522
- let decoded = result.data
523
- if (contentType) {
524
- const codec = codecs[contentType]
525
- if (codec?.deserialize) decoded = await codec.deserialize(result.data, contentType)
526
- }
526
+ },
527
+ }
528
+ }
527
529
 
528
- const parsedErrorData = !isSuccess ? await runValidator(requestConfig.validator?.error, decoded) : undefined
529
-
530
- if (!isSuccess && throwOnError) {
531
- const error = new ResponseError({
532
- data: parsedErrorData,
533
- status: result.status,
534
- statusText: result.statusText,
535
- contentType,
536
- request: result.request,
537
- response: result.response,
538
- })
539
- await interceptors.error.run(error)
540
- throw error
541
- }
530
+ /**
531
+ * Turns a transport result into the call result: decodes the body through the matching codec,
532
+ * validates it, and throws a `ResponseError` (after running the error interceptors) for a non-2xx
533
+ * response under `throwOnError`.
534
+ */
535
+ async function settleResult<TRequest, TResponse>({
536
+ result,
537
+ codecs,
538
+ throwOnError,
539
+ validator,
540
+ errorInterceptors,
541
+ }: {
542
+ result: TransportResult<unknown, TRequest, TResponse>
543
+ codecs: Record<string, Codec>
544
+ throwOnError: boolean
545
+ validator: { response?: Validator; error?: Validator } | undefined
546
+ errorInterceptors: InterceptorStack<ResponseError<unknown, TRequest, TResponse>>
547
+ }): Promise<CallResult<TRequest, TResponse>> {
548
+ const isSuccess = result.status >= 200 && result.status < 300
549
+ const contentType = result.contentType ?? getResponseContentType(result.headers)
550
+ let decoded = result.data
551
+ if (contentType) {
552
+ const codec = codecs[contentType]
553
+ if (codec?.deserialize) decoded = await codec.deserialize(result.data, contentType)
554
+ }
542
555
 
543
- const data = isSuccess ? await runValidator(requestConfig.validator?.response, decoded) : undefined
544
- const error = isSuccess ? undefined : parsedErrorData
556
+ if (isSuccess) {
557
+ const data = await runValidator(validator?.response, decoded)
558
+ return { status: result.status, data, error: undefined, contentType, request: result.request, response: result.response }
559
+ }
545
560
 
546
- return {
561
+ const error = await runValidator(validator?.error, decoded)
562
+ if (throwOnError) {
563
+ const responseError = new ResponseError({
564
+ data: error,
547
565
  status: result.status,
548
- data,
549
- error,
566
+ statusText: result.statusText,
550
567
  contentType,
551
568
  request: result.request,
552
569
  response: result.response,
553
- }
570
+ })
571
+ await errorInterceptors.run(responseError)
572
+ throw responseError
573
+ }
574
+ return { status: result.status, data: undefined, error, contentType, request: result.request, response: result.response }
575
+ }
576
+
577
+ /**
578
+ * Builds the shared client core bound to a transport, exported by each plugin as `client` plus a `createClient` factory.
579
+ */
580
+ export function createClientCore<TRequest = Request, TResponse = Response>(
581
+ options: { defaultTransport: Transport<TRequest, TResponse> } & ClientConfig<TRequest, TResponse>,
582
+ ): ClientInstance<TRequest, TResponse> {
583
+ const { defaultTransport, ...initialConfig } = options
584
+ let config: ClientConfig<TRequest, TResponse> = { ...initialConfig }
585
+
586
+ const interceptors: Interceptors<TRequest, TResponse> = {
587
+ request: createInterceptorStack<ResolvedRequest>(),
588
+ response: createInterceptorStack<TransportResult<unknown, TRequest, TResponse>>(),
589
+ error: createInterceptorStack<ResponseError<unknown, TRequest, TResponse>>(),
590
+ }
591
+
592
+ const client = (async <TBody = unknown>(requestConfig: RequestConfig<TBody, TRequest, TResponse>): Promise<CallResult<TRequest, TResponse>> => {
593
+ const transport = requestConfig.transport ?? config.transport ?? defaultTransport
594
+ const { request, codecs } = await resolveRequest({ config, requestConfig })
595
+
596
+ const resolvedRequest = await interceptors.request.run(request)
597
+ const result = await interceptors.response.run(await transport(resolvedRequest))
598
+
599
+ return settleResult({
600
+ result,
601
+ codecs,
602
+ throwOnError: requestConfig.throwOnError ?? config.throwOnError ?? true,
603
+ validator: requestConfig.validator,
604
+ errorInterceptors: interceptors.error,
605
+ })
554
606
  }) as ClientInstance<TRequest, TResponse>
555
607
 
556
608
  client.getConfig = () => config
@@ -559,11 +611,10 @@ export function createClientCore<TRequest = Request, TResponse = Response>(
559
611
  return config
560
612
  }
561
613
  client.getUrl = (requestConfig) => {
562
- const querySerializer = requestConfig.serializer?.query ?? config.serializer?.query ?? defaultQuerySerializer
563
- const pathSerializer = requestConfig.serializer?.path ?? config.serializer?.path ?? defaultPathSerializer
614
+ const { querySerializer, pathSerializer } = resolveSerializers({ config, requestConfig })
564
615
  const query: Record<string, unknown> = { ...((requestConfig.query ?? requestConfig.params) as Record<string, unknown> | undefined) }
565
616
  return serializeUrl({
566
- parts: [config.baseURL, requestConfig.baseURL, requestConfig.url],
617
+ parts: [requestConfig.baseURL ?? config.baseURL, requestConfig.url],
567
618
  pathParams: requestConfig.path ?? {},
568
619
  search: querySerializer(query, requestConfig.styles?.query),
569
620
  pathSerializer,
@@ -607,7 +658,7 @@ async function parseResponse(response: Response, responseType?: ResponseType): P
607
658
  case 'stream':
608
659
  return response.body ?? undefined
609
660
  case 'json': {
610
- // An empty body with a JSON content-type would make response.json() throw; treat it as no data.
661
+ // An empty body with a JSON content-type would make response.json() throw, so treat it as no data.
611
662
  const body = await response.text()
612
663
  return body ? JSON.parse(body) : undefined
613
664
  }
@@ -622,6 +673,34 @@ async function parseResponse(response: Response, responseType?: ResponseType): P
622
673
  }
623
674
  }
624
675
 
676
+ /**
677
+ * The default transport that sends the resolved request through `globalThis.fetch` and returns the parsed body with the native request and response.
678
+ */
679
+ const defaultTransport: Transport = async (request: ResolvedRequest): Promise<TransportResult> => {
680
+ const init: RequestInit = {
681
+ ...request.options, // cache, mode, redirect, keepalive, duplex, next, …
682
+ method: request.method,
683
+ headers: request.headers,
684
+ body: request.body,
685
+ signal: request.signal,
686
+ }
687
+ if (request.credentials) init.credentials = request.credentials
688
+
689
+ const nativeRequest = new Request(request.url, init)
690
+ const response = await globalThis.fetch(nativeRequest)
691
+ const data = await parseResponse(response, request.responseType)
692
+
693
+ return {
694
+ data,
695
+ status: response.status,
696
+ statusText: response.statusText,
697
+ headers: response.headers,
698
+ contentType: getResponseContentType(response.headers),
699
+ request: nativeRequest,
700
+ response,
701
+ }
702
+ }
703
+
625
704
  /**
626
705
  * One decoded Server-Sent Event, with `data` parsed as JSON when valid and kept as the raw string otherwise.
627
706
  */
@@ -724,34 +803,6 @@ export async function toEventStream<TData = unknown>(result: Promise<{ data: unk
724
803
  }
725
804
  }
726
805
 
727
- /**
728
- * The default transport that sends the resolved request through `globalThis.fetch` and returns the parsed body with the native request and response.
729
- */
730
- const defaultTransport: Transport = async (request: ResolvedRequest): Promise<TransportResult> => {
731
- const init: RequestInit = {
732
- ...request.options, // cache, mode, redirect, keepalive, duplex, next, …
733
- method: request.method,
734
- headers: request.headers,
735
- body: request.body,
736
- signal: request.signal,
737
- }
738
- if (request.credentials) init.credentials = request.credentials
739
-
740
- const nativeRequest = new Request(request.url, init)
741
- const response = await globalThis.fetch(nativeRequest)
742
- const data = await parseResponse(response, request.responseType)
743
-
744
- return {
745
- data,
746
- status: response.status,
747
- statusText: response.statusText,
748
- headers: response.headers,
749
- contentType: getResponseContentType(response.headers),
750
- request: nativeRequest,
751
- response,
752
- }
753
- }
754
-
755
806
  export const client = createClientCore({ defaultTransport })
756
807
 
757
808
  export const createClient = (config?: Parameters<typeof client.createClient>[0]) => client.createClient(config)