@kubb/plugin-fetch 5.9.0-canary.20261024T172532 → 5.9.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.
@@ -162,16 +162,19 @@ export type ContentType = string | {
162
162
  * A Standard Schema validator (zod, valibot, arktype) that parses a value before it is sent or after
163
163
  * it is received. `runValidator` runs it through `validateStandardSchema`. Wired through the per-call
164
164
  * `validator.request` / `validator.response` / `validator.error` hooks (`error` runs on the error body when a
165
- * non-2xx call does not throw).
165
+ * non-2xx call does not throw), and `validator.path` / `validator.query` / `validator.headers` for the
166
+ * request params.
166
167
  */
167
168
  export type Validator<T = unknown> = StandardSchemaValidator<T>;
168
169
  /**
169
- * The failing body and the call it came from, handed to `onValidationError` alongside the `ParseError`.
170
- * `direction` says which slot rejected it: the request body, the success body, or the error body.
170
+ * The failing value and the call it came from, handed to `onValidationError` alongside the `ParseError`.
171
+ * `direction` says which side rejected it: the request, the success body, or the error body. `source`
172
+ * says which part of it: the body, or one of the request param groups.
171
173
  */
172
174
  export type ValidationErrorContext = {
173
175
  value: unknown;
174
176
  direction: 'request' | 'response' | 'error';
177
+ source: 'body' | 'path' | 'query' | 'headers';
175
178
  method?: string;
176
179
  url?: string;
177
180
  status?: number;
@@ -194,6 +197,7 @@ export type Auth = {
194
197
  scheme?: 'bearer' | 'basic';
195
198
  name?: string;
196
199
  in?: 'header' | 'query' | 'cookie';
200
+ scopes?: Array<string>;
197
201
  };
198
202
  /**
199
203
  * The raw token a consumer returns for a scheme (or `user:password` for basic), or `undefined` to skip it.
@@ -235,6 +239,9 @@ export type RequestConfig<TBody = unknown, TRequest = Request, TResponse = Respo
235
239
  codecs?: Record<string, Codec>;
236
240
  validator?: {
237
241
  request?: Validator;
242
+ path?: Validator;
243
+ query?: Validator;
244
+ headers?: Validator;
238
245
  response?: Validator;
239
246
  error?: Validator;
240
247
  };
@@ -323,6 +330,7 @@ export type Interceptors<TRequest = Request, TResponse = Response> = {
323
330
  response: InterceptorStack<TransportResult<unknown, TRequest, TResponse>, RequestConfig<unknown, TRequest, TResponse>>;
324
331
  error: InterceptorStack<ResponseError<unknown, TRequest, TResponse>, RequestConfig<unknown, TRequest, TResponse>>;
325
332
  };
333
+ export type UrlConfig = Pick<RequestConfig, 'baseURL' | 'url' | 'path' | 'query' | 'params' | 'serializer' | 'styles'>;
326
334
  /**
327
335
  * A client instance: the callable send plus configuration, interceptors, and an isolated
328
336
  * `createClient` factory bound to the same transport.
@@ -331,7 +339,7 @@ export type ClientInstance<TRequest = Request, TResponse = Response> = {
331
339
  <TBody = unknown>(config: RequestConfig<TBody, TRequest, TResponse>): Promise<CallResult<TRequest, TResponse>>;
332
340
  getConfig: () => ClientConfig<TRequest, TResponse>;
333
341
  setConfig: (config: ClientConfig<TRequest, TResponse>) => ClientConfig<TRequest, TResponse>;
334
- getUrl: <TBody = unknown>(config: RequestConfig<TBody, TRequest, TResponse>) => string;
342
+ getUrl: (config: UrlConfig) => string;
335
343
  interceptors: Interceptors<TRequest, TResponse>;
336
344
  createClient: (config?: ClientConfig<TRequest, TResponse>) => ClientInstance<TRequest, TResponse>;
337
345
  };
@@ -48,4 +48,18 @@ export declare class ParseError extends Error {
48
48
  * const pet = await validateStandardSchema(PetSchema, rawData)
49
49
  */
50
50
  export declare function validateStandardSchema<TOutput>(schema: StandardSchemaValidator<TOutput>, value: unknown): Promise<TOutput>;
51
+ /**
52
+ * Wraps an object schema so its keys match case-insensitively.
53
+ * Keys the schema does not declare pass through untouched.
54
+ * The declared keys are validated with `schema` under their declared names.
55
+ * Every key, and every issue path, keeps the casing the caller used.
56
+ *
57
+ * @param schema Standard Schema-compatible object schema.
58
+ * @param entries The record of keys the passed object schema validates.
59
+ * @example
60
+ * ```ts
61
+ * validator: { headers: toHeadersStandardSchema(putEventHeadersSchema, putEventHeadersSchema.shape) }
62
+ * ```
63
+ */
64
+ export declare function toHeadersStandardSchema(schema: StandardSchemaValidator, entries: Record<string, unknown>): StandardSchemaValidator;
51
65
  //#endregion
package/docs/options.md CHANGED
@@ -10,7 +10,7 @@ Pass these options to `pluginFetch()`. Shared options link to [Shared plugin opt
10
10
  | [`group`](https://kubb.dev/docs/5.x/reference/plugin-options#group) | Split output by tag or path: `type`, `name`. | None |
11
11
  | [`baseURL`](#baseurl) | Base URL prepended to every request. | None |
12
12
  | [`throwOnErrorDefault`](#throwonerrordefault) | Default error behavior and return type for generated operations. | `true` |
13
- | [`validator`](#validator) | Validate request and response bodies with Zod. | `false` |
13
+ | [`validator`](#validator) | Validate request bodies, params, and responses with Zod. | `false` |
14
14
  | [`sdk`](#sdk) | Generate a class-based SDK instead of functions. | None |
15
15
  | [`returnType`](#returntype) | Shape of the value a generated call resolves to. | `'full'` |
16
16
  | [`include`](https://kubb.dev/docs/5.x/reference/plugin-options#include) | Keep only operations that match. | None |
@@ -48,13 +48,13 @@ if (result.status !== 200) console.error(result.status, result.error)
48
48
 
49
49
  ### validator
50
50
 
51
- Validates request and response bodies with schemas from `@kubb/plugin-zod`, so add `pluginZod()` too. An invalid body makes the generated function throw a `ParseError`.
51
+ Validates request bodies, path, query, and header params, and response bodies with schemas from `@kubb/plugin-zod`, so add `pluginZod()` too. An invalid value makes the generated function throw a `ParseError`.
52
52
 
53
- | | |
54
- | -------- | --------------------------------------------------------- |
55
- | Type | `false \| 'zod' \| { request?: 'zod'; response?: 'zod' }` |
56
- | Required | `false` |
57
- | Default | `false` |
53
+ | | |
54
+ | -------- | ------------------------------------------------------------------------- |
55
+ | Type | `false \| 'zod' \| { request?: 'zod'; params?: 'zod'; response?: 'zod' }` |
56
+ | Required | `false` |
57
+ | Default | `false` |
58
58
 
59
59
  ::field-group
60
60
 
@@ -66,8 +66,8 @@ Default value. Does no validation and returns the response cast to the generated
66
66
  Validates the success response body, and the error body when a non-2xx call does not throw.
67
67
  :::
68
68
 
69
- :::field{name="{ request?: 'zod', response?: 'zod' }"}
70
- Opts in per direction. `request` validates the request body before the call, and `response` validates the response body after it.
69
+ :::field{name="{ request?: 'zod', params?: 'zod', response?: 'zod' }"}
70
+ Opts in per direction. `request` validates the request body and `params` validates the path, query, and header params before the call. `response` validates the response body after it.
71
71
  :::
72
72
 
73
73
  ::
@@ -84,11 +84,17 @@ export default defineConfig({
84
84
  plugins: [
85
85
  pluginTs(),
86
86
  pluginZod(),
87
- pluginFetch({ validator: { request: 'zod', response: 'zod' } }),
87
+ pluginFetch({ validator: { request: 'zod', params: 'zod', response: 'zod' } }),
88
88
  ],
89
89
  })
90
90
  ```
91
91
 
92
+ `params` runs each group through its generated `<operation>PathSchema`, `QuerySchema`, or `HeadersSchema`, and sends what the schema returns. A codec param such as a `Date` goes out in its wire form, and an invalid param fails before the request. Values added by `auth` are not validated.
93
+
94
+ Only the params your OpenAPI spec declares are sent. Path and query keys the spec does not declare are stripped, so a key such as an undocumented filter or a cache-buster never reaches the URL. Declare it in the spec, or leave `params` off. Headers are the exception: client-level `headers` (such as `Authorization` or tracing headers) are merged with the call's headers before validation, so headers the spec does not declare pass through unchanged, and declared header params match in any casing.
95
+
96
+ Pair `request` and `params` with `pluginZod({ inferred: 'direction' })` when the schemas use codecs, so the request types accept the domain values (such as a `Date`) that the validators encode. See [`inferred`](https://kubb.dev/extensions/kubb-labs/plugin-zod/options#inferred).
97
+
92
98
  ### sdk
93
99
 
94
100
  Generates a class-based SDK. Each instance takes its own client config, so environments can use separate clients. Leave `sdk` unset to keep the standalone functions that query plugins call.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubb/plugin-fetch",
3
- "version": "5.9.0-canary.20261024T172532",
3
+ "version": "5.9.0",
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",
@@ -80,12 +80,12 @@
80
80
  "registry": "https://registry.npmjs.org/"
81
81
  },
82
82
  "dependencies": {
83
- "@kubb/plugin-ts": "^5.2.0-canary.20261024T172532",
84
- "@kubb/plugin-zod": "^5.7.0-canary.20261024T172532"
83
+ "@kubb/plugin-ts": "^5.1.0",
84
+ "@kubb/plugin-zod": "^5.7.0"
85
85
  },
86
86
  "devDependencies": {
87
- "@internals/client": "0.1.0-canary.20261024T172532",
88
- "@internals/shared": "0.1.0-canary.20261024T172532",
87
+ "@internals/client": "0.0.0",
88
+ "@internals/shared": "0.0.0",
89
89
  "kubb": "^5.13.0",
90
90
  "typescript": "^7.0.2"
91
91
  },
@@ -161,17 +161,20 @@ export type ContentType = string | { request?: string; response?: string }
161
161
  * A Standard Schema validator (zod, valibot, arktype) that parses a value before it is sent or after
162
162
  * it is received. `runValidator` runs it through `validateStandardSchema`. Wired through the per-call
163
163
  * `validator.request` / `validator.response` / `validator.error` hooks (`error` runs on the error body when a
164
- * non-2xx call does not throw).
164
+ * non-2xx call does not throw), and `validator.path` / `validator.query` / `validator.headers` for the
165
+ * request params.
165
166
  */
166
167
  export type Validator<T = unknown> = StandardSchemaValidator<T>
167
168
 
168
169
  /**
169
- * The failing body and the call it came from, handed to `onValidationError` alongside the `ParseError`.
170
- * `direction` says which slot rejected it: the request body, the success body, or the error body.
170
+ * The failing value and the call it came from, handed to `onValidationError` alongside the `ParseError`.
171
+ * `direction` says which side rejected it: the request, the success body, or the error body. `source`
172
+ * says which part of it: the body, or one of the request param groups.
171
173
  */
172
174
  export type ValidationErrorContext = {
173
175
  value: unknown
174
176
  direction: 'request' | 'response' | 'error'
177
+ source: 'body' | 'path' | 'query' | 'headers'
175
178
  method?: string
176
179
  url?: string
177
180
  status?: number
@@ -192,6 +195,7 @@ export type Auth = {
192
195
  scheme?: 'bearer' | 'basic'
193
196
  name?: string
194
197
  in?: 'header' | 'query' | 'cookie'
198
+ scopes?: Array<string>
195
199
  }
196
200
 
197
201
  /**
@@ -233,7 +237,7 @@ export type RequestConfig<TBody = unknown, TRequest = Request, TResponse = Respo
233
237
  transport?: Transport<TRequest, TResponse>
234
238
  serializer?: Serializers
235
239
  codecs?: Record<string, Codec>
236
- validator?: { request?: Validator; response?: Validator; error?: Validator }
240
+ validator?: { request?: Validator; path?: Validator; query?: Validator; headers?: Validator; response?: Validator; error?: Validator }
237
241
  onValidationError?: ValidationErrorHandler
238
242
  security?: Array<Auth>
239
243
  auth?: AuthResolver
@@ -333,6 +337,8 @@ export type Interceptors<TRequest = Request, TResponse = Response> = {
333
337
  error: InterceptorStack<ResponseError<unknown, TRequest, TResponse>, RequestConfig<unknown, TRequest, TResponse>>
334
338
  }
335
339
 
340
+ export type UrlConfig = Pick<RequestConfig, 'baseURL' | 'url' | 'path' | 'query' | 'params' | 'serializer' | 'styles'>
341
+
336
342
  /**
337
343
  * A client instance: the callable send plus configuration, interceptors, and an isolated
338
344
  * `createClient` factory bound to the same transport.
@@ -341,7 +347,7 @@ export type ClientInstance<TRequest = Request, TResponse = Response> = {
341
347
  <TBody = unknown>(config: RequestConfig<TBody, TRequest, TResponse>): Promise<CallResult<TRequest, TResponse>>
342
348
  getConfig: () => ClientConfig<TRequest, TResponse>
343
349
  setConfig: (config: ClientConfig<TRequest, TResponse>) => ClientConfig<TRequest, TResponse>
344
- getUrl: <TBody = unknown>(config: RequestConfig<TBody, TRequest, TResponse>) => string
350
+ getUrl: (config: UrlConfig) => string
345
351
  interceptors: Interceptors<TRequest, TResponse>
346
352
  createClient: (config?: ClientConfig<TRequest, TResponse>) => ClientInstance<TRequest, TResponse>
347
353
  }
@@ -391,9 +397,14 @@ export class ResponseError<TError = unknown, TRequest = Request, TResponse = Res
391
397
 
392
398
  export type ResponseErrorConfig<TError = unknown> = ResponseError<TError>
393
399
 
400
+ function headerEntries(headers: object): Array<[string, unknown]> {
401
+ if (Array.isArray(headers)) return headers
402
+ return headers instanceof Headers ? [...headers] : Object.entries(headers)
403
+ }
404
+
394
405
  function serializeHeaders(headers: HeadersInit | undefined): Record<string, string> {
395
406
  if (!headers) return {}
396
- const entries = Array.isArray(headers) ? headers : Object.entries(headers)
407
+ const entries = headerEntries(headers)
397
408
  const result: Record<string, string> = {}
398
409
  for (const [key, value] of entries) {
399
410
  if (value === undefined || value === null) continue
@@ -402,8 +413,22 @@ function serializeHeaders(headers: HeadersInit | undefined): Record<string, stri
402
413
  return result
403
414
  }
404
415
 
405
- function mergeHeaders(...sources: Array<HeadersInit | undefined>): Record<string, string> {
406
- return Object.assign({}, ...sources.map(serializeHeaders))
416
+ function collectHeadersCaseInsensitive(...sources: Array<unknown>): Record<string, unknown> {
417
+ const keys = new Map<string, string>()
418
+ const result: Record<string, unknown> = {}
419
+ for (const source of sources) {
420
+ if (!source) continue
421
+ const entries = headerEntries(source)
422
+ for (const [key, value] of entries) {
423
+ if (value === undefined || value === null) continue
424
+ const lowerCase = key.toLowerCase()
425
+ const previous = keys.get(lowerCase)
426
+ if (previous !== undefined) delete result[previous]
427
+ keys.set(lowerCase, key)
428
+ result[key] = value
429
+ }
430
+ }
431
+ return result
407
432
  }
408
433
 
409
434
  function getHeader(headers: Record<string, string>, name: string): string | undefined {
@@ -572,15 +597,29 @@ async function resolveRequest<TBody, TRequest, TResponse>({
572
597
  }): Promise<{ request: ResolvedRequest; codecs: Record<string, Codec> }> {
573
598
  const { querySerializer, bodySerializer, pathSerializer } = resolveSerializers({ config, requestConfig })
574
599
  const codecs = { ...config.codecs, ...requestConfig.codecs }
600
+ const onValidationError = requestConfig.onValidationError ?? config.onValidationError
601
+ const validationContext = { direction: 'request', method: requestConfig.method, url: requestConfig.url } as const
602
+
603
+ // Params are encoded before anything reads them: headers before styling, query before auth adds its values.
604
+ const paramValues = {
605
+ path: { ...(requestConfig.path as Record<string, unknown> | undefined) },
606
+ query: { ...((requestConfig.query ?? requestConfig.params) as Record<string, unknown> | undefined) },
607
+ headers: collectHeadersCaseInsensitive(config.headers, requestConfig.headers),
608
+ }
609
+ const [validatedPath, validatedQuery, validatedHeaders] = await Promise.all(
610
+ (['path', 'query', 'headers'] as const).map((source) =>
611
+ runValidator({ validator: requestConfig.validator?.[source], value: paramValues[source], context: { ...validationContext, source }, onValidationError }),
612
+ ),
613
+ )
575
614
 
576
- const headers = mergeHeaders(config.headers, applyHeaderStyles(requestConfig.headers as HeadersInit | undefined, requestConfig.styles?.header))
615
+ const headers = serializeHeaders(applyHeaderStyles(validatedHeaders as HeadersInit | undefined, requestConfig.styles?.header))
577
616
  const { request: requestContentTypeOption, response: responseContentType } = resolveContentType(requestConfig.contentType)
578
617
  const requestContentType = requestContentTypeOption ?? getHeader(headers, 'content-type')
579
618
  if (responseContentType && !hasHeader(headers, 'accept')) {
580
619
  headers['Accept'] = responseContentType
581
620
  }
582
621
 
583
- const query: Record<string, unknown> = { ...((requestConfig.query ?? requestConfig.params) as Record<string, unknown> | undefined) }
622
+ const query: Record<string, unknown> = { ...(validatedQuery as Record<string, unknown> | undefined) }
584
623
 
585
624
  await resolveAuth({
586
625
  security: requestConfig.security,
@@ -597,8 +636,8 @@ async function resolveRequest<TBody, TRequest, TResponse>({
597
636
  const validatedBody = await runValidator({
598
637
  validator: requestConfig.validator?.request,
599
638
  value: requestConfig.body,
600
- context: { direction: 'request', method: requestConfig.method, url: requestConfig.url },
601
- onValidationError: requestConfig.onValidationError ?? config.onValidationError,
639
+ context: { ...validationContext, source: 'body' },
640
+ onValidationError,
602
641
  })
603
642
  const requestContentTypeBase = baseContentType(requestContentType)
604
643
  const contentCodec = requestContentTypeBase ? codecs[requestContentTypeBase] : undefined
@@ -619,7 +658,7 @@ async function resolveRequest<TBody, TRequest, TResponse>({
619
658
 
620
659
  const url = serializeUrl({
621
660
  parts: [requestConfig.baseURL ?? config.baseURL, requestConfig.url],
622
- pathParams: (requestConfig.path ?? {}) as Record<string, unknown>,
661
+ pathParams: (validatedPath ?? {}) as Record<string, unknown>,
623
662
  search: querySerializer(query, requestConfig.styles?.query),
624
663
  pathSerializer,
625
664
  pathStyles: requestConfig.styles?.path,
@@ -680,7 +719,7 @@ async function settleResult<TRequest, TResponse>({
680
719
  const data = await runValidator({
681
720
  validator: validator?.response,
682
721
  value: decoded,
683
- context: { direction: 'response', ...validationContext },
722
+ context: { direction: 'response', source: 'body', ...validationContext },
684
723
  onValidationError,
685
724
  })
686
725
  return { status: result.status, data, error: undefined, contentType, request: result.request, response: result.response }
@@ -689,7 +728,7 @@ async function settleResult<TRequest, TResponse>({
689
728
  const error = await runValidator({
690
729
  validator: validator?.error,
691
730
  value: decoded,
692
- context: { direction: 'error', ...validationContext },
731
+ context: { direction: 'error', source: 'body', ...validationContext },
693
732
  onValidationError,
694
733
  })
695
734
  if (throwOnError) {
@@ -52,3 +52,58 @@ export async function validateStandardSchema<TOutput>(schema: StandardSchemaVali
52
52
  }
53
53
  return (result as { value: TOutput }).value
54
54
  }
55
+
56
+ /**
57
+ * Wraps an object schema so its keys match case-insensitively.
58
+ * Keys the schema does not declare pass through untouched.
59
+ * The declared keys are validated with `schema` under their declared names.
60
+ * Every key, and every issue path, keeps the casing the caller used.
61
+ *
62
+ * @param schema Standard Schema-compatible object schema.
63
+ * @param entries The record of keys the passed object schema validates.
64
+ * @example
65
+ * ```ts
66
+ * validator: { headers: toHeadersStandardSchema(putEventHeadersSchema, putEventHeadersSchema.shape) }
67
+ * ```
68
+ */
69
+ export function toHeadersStandardSchema(schema: StandardSchemaValidator, entries: Record<string, unknown>): StandardSchemaValidator {
70
+ const declared = new Map(Object.keys(entries).map((name) => [name.toLowerCase(), name]))
71
+
72
+ function validate(value: unknown): StandardSchemaResult<unknown> | Promise<StandardSchemaResult<unknown>> {
73
+ if (typeof value !== 'object' || value === null) {
74
+ return { issues: [{ message: 'Expected an object' }] }
75
+ }
76
+
77
+ const originalKeys = new Map<string, string>()
78
+ const declaredValues: Record<string, unknown> = {}
79
+ const undeclaredValues: Record<string, unknown> = {}
80
+ for (const [key, entry] of Object.entries(value)) {
81
+ const name = declared.get(key.toLowerCase())
82
+ if (name === undefined) {
83
+ undeclaredValues[key] = entry
84
+ continue
85
+ }
86
+ originalKeys.set(name, key)
87
+ declaredValues[name] = entry
88
+ }
89
+
90
+ const toOriginal = (key: string) => originalKeys.get(key) ?? key
91
+ const settle = (result: StandardSchemaResult<unknown>): StandardSchemaResult<unknown> => {
92
+ if (result.issues) {
93
+ return {
94
+ issues: result.issues.map((issue): StandardSchemaIssue => ({
95
+ ...issue,
96
+ path: issue.path?.map((segment, index) => (index === 0 && typeof segment === 'string' ? toOriginal(segment) : segment)),
97
+ })),
98
+ }
99
+ }
100
+ const validated = Object.entries((result.value ?? {}) as Record<string, unknown>).map(([key, entry]) => [toOriginal(key), entry])
101
+ return { value: { ...undeclaredValues, ...Object.fromEntries(validated) } }
102
+ }
103
+
104
+ const result = schema['~standard'].validate(declaredValues)
105
+ return result instanceof Promise ? result.then(settle) : settle(result)
106
+ }
107
+
108
+ return { '~standard': { validate } }
109
+ }