@kubb/plugin-client 5.12.0 → 6.0.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.
Files changed (72) hide show
  1. package/README.md +1 -1
  2. package/dist/client-Dq0z61hu.d.ts +554 -0
  3. package/dist/index.cjs +1378 -784
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +114 -74
  6. package/dist/index.d.ts +114 -74
  7. package/dist/index.js +1376 -786
  8. package/dist/index.js.map +1 -1
  9. package/dist/serializers-BRgfPGr1.d.ts +119 -0
  10. package/dist/{standardSchema-BShJWz2i.d.ts → standardSchema-CGuiSK7F.d.ts} +15 -37
  11. package/dist/templates/client.d.ts +3 -2
  12. package/dist/templates/core.d.ts +3 -3
  13. package/dist/templates/mediaTypes/formData.d.ts +5 -0
  14. package/dist/templates/mediaTypes/urlencoded.d.ts +5 -0
  15. package/dist/templates/mediaTypes.d.ts +2 -0
  16. package/dist/templates/serializers/cookies.d.ts +5 -0
  17. package/dist/templates/serializers/headers.d.ts +5 -0
  18. package/dist/templates/serializers/path.d.ts +5 -0
  19. package/dist/templates/serializers/pathStyles.d.ts +5 -0
  20. package/dist/templates/serializers/query.d.ts +5 -0
  21. package/dist/templates/serializers/queryStyles.d.ts +14 -0
  22. package/dist/templates/serializers.d.ts +2 -2
  23. package/dist/templates/sse.d.ts +20 -113
  24. package/dist/templates/standardSchema.d.ts +1 -1
  25. package/docs/guides/authentication.md +9 -1
  26. package/docs/guides/migrate-to-v6.md +139 -0
  27. package/docs/guides/validate-requests-and-responses.md +8 -3
  28. package/docs/guides/write-your-client.md +117 -11
  29. package/docs/options.md +2 -2
  30. package/docs/overview.md +1 -1
  31. package/kubb-extension.json +1 -1
  32. package/package.json +6 -5
  33. package/src/builders/callConfig.ts +80 -0
  34. package/src/builders/contentType.ts +35 -0
  35. package/src/builders/generics.ts +29 -0
  36. package/src/builders/operationBody.ts +43 -0
  37. package/src/builders/operationData.ts +64 -0
  38. package/src/builders/returnStatement.ts +103 -0
  39. package/src/builders/security.ts +125 -0
  40. package/src/builders/signature.ts +71 -0
  41. package/src/builders/styles.ts +94 -0
  42. package/src/builders/validator.ts +84 -0
  43. package/src/builders/validatorOptions.ts +53 -0
  44. package/src/clientTypes.ts +195 -0
  45. package/src/components/ClientRuntime.tsx +97 -0
  46. package/src/components/Operation.tsx +72 -0
  47. package/src/components/SdkClient.tsx +100 -0
  48. package/src/components/SdkFacade.tsx +35 -0
  49. package/src/generators/clientGenerator.tsx +91 -6
  50. package/src/generators/sdkGenerator.tsx +209 -0
  51. package/src/index.ts +3 -1
  52. package/src/macros.ts +9 -0
  53. package/src/plugin.ts +7 -8
  54. package/src/resolver.ts +28 -0
  55. package/src/setup.ts +90 -0
  56. package/src/types.ts +4 -2
  57. package/templates/client.ts +2 -1
  58. package/templates/core.ts +735 -286
  59. package/templates/mediaTypes/formData.ts +59 -0
  60. package/templates/mediaTypes/urlencoded.ts +33 -0
  61. package/templates/mediaTypes.ts +147 -0
  62. package/templates/serializers/cookies.ts +58 -0
  63. package/templates/serializers/headers.ts +35 -0
  64. package/templates/serializers/path.ts +19 -0
  65. package/templates/serializers/pathStyles.ts +27 -0
  66. package/templates/serializers/query.ts +21 -0
  67. package/templates/serializers/queryStyles.ts +107 -0
  68. package/templates/serializers.ts +48 -364
  69. package/templates/sse.ts +87 -178
  70. package/templates/standardSchema.ts +16 -37
  71. package/dist/client-BxO6Dygu.d.ts +0 -355
  72. package/dist/serializers-Ds8zZwOO.d.ts +0 -190
@@ -0,0 +1,59 @@
1
+ import { isEncodedBody } from '../mediaTypes.ts'
2
+ import type { MediaType } from '../mediaTypes.ts'
3
+ import { isJsonMediaType, jsonReplacer, notNullish, toValue } from '../serializers.ts'
4
+ import type { BodyEncoding } from '../serializers.ts'
5
+
6
+ function appendFormDataValue({
7
+ formData,
8
+ key,
9
+ value,
10
+ contentType,
11
+ }: {
12
+ formData: FormData
13
+ key: string
14
+ value: unknown
15
+ contentType?: string | undefined
16
+ }): void {
17
+ if (value === undefined || value === null) return
18
+ if (value instanceof Blob) {
19
+ formData.append(key, value)
20
+ return
21
+ }
22
+ // A JSON part sends a scalar as JSON too, so a string arrives quoted.
23
+ if ((typeof value === 'object' && !(value instanceof Date)) || isJsonMediaType(contentType)) {
24
+ const encoded = JSON.stringify(value, jsonReplacer)
25
+ // A part's media type can only be set by wrapping the value in a typed Blob.
26
+ formData.append(key, contentType ? new Blob([encoded], { type: contentType }) : encoded)
27
+ return
28
+ }
29
+ formData.append(key, toValue(value))
30
+ }
31
+
32
+ const formDelimiters: Partial<Record<string, string>> = { spaceDelimited: ' ', pipeDelimited: '|' }
33
+
34
+ function toFormData(body: Record<string, unknown>, encoding: Record<string, BodyEncoding> | undefined): FormData {
35
+ const formData = new FormData()
36
+ for (const [key, value] of Object.entries(body)) {
37
+ const { contentType, style, explode } = encoding?.[key] ?? {}
38
+ // `explode: false`, the default for the delimited styles, sends an array of scalars as one part, its items joined by the style's delimiter.
39
+ if (
40
+ Array.isArray(value) &&
41
+ !(explode ?? (style !== 'spaceDelimited' && style !== 'pipeDelimited')) &&
42
+ value.every((item) => typeof item !== 'object' || item === null || item instanceof Date)
43
+ ) {
44
+ const delimiter = formDelimiters[style ?? 'form'] ?? ','
45
+ formData.append(key, value.filter(notNullish).map(toValue).join(delimiter))
46
+ continue
47
+ }
48
+ for (const item of Array.isArray(value) ? value : [value]) appendFormDataValue({ formData, key, value: item, contentType })
49
+ }
50
+ return formData
51
+ }
52
+
53
+ /**
54
+ * The `multipart/form-data` handler, which follows `config.styles.body`.
55
+ */
56
+ export const multipartFormData: MediaType = {
57
+ encode: (body, { config }) => (isEncodedBody(body) ? body : toFormData(body as Record<string, unknown>, config.styles?.body)),
58
+ decode: (response) => response.formData(),
59
+ }
@@ -0,0 +1,33 @@
1
+ import { isEncodedBody } from '../mediaTypes.ts'
2
+ import type { MediaType } from '../mediaTypes.ts'
3
+ import { isJsonMediaType, jsonReplacer, serializeDefaultQueryParam } from '../serializers.ts'
4
+ import type { BodyEncoding } from '../serializers.ts'
5
+ import { serializeStyledQueryParam } from '../serializers/queryStyles.ts'
6
+
7
+ function serializeUrlencodedBody(body: Record<string, unknown>, encoding: Record<string, BodyEncoding>): string {
8
+ const parts: Array<string> = []
9
+ for (const [key, value] of Object.entries(body)) {
10
+ const propertyEncoding = encoding[key]
11
+ // OpenAPI ignores `contentType` once a style field is set.
12
+ const styled = propertyEncoding?.style !== undefined || propertyEncoding?.explode !== undefined || propertyEncoding?.allowReserved !== undefined
13
+ if (value !== undefined && value !== null && !styled && isJsonMediaType(propertyEncoding?.contentType)) {
14
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(JSON.stringify(value, jsonReplacer))}`)
15
+ continue
16
+ }
17
+ parts.push(...(propertyEncoding ? serializeStyledQueryParam({ key, value, options: propertyEncoding }) : serializeDefaultQueryParam(key, value)))
18
+ }
19
+ return parts.join('&')
20
+ }
21
+
22
+ /**
23
+ * The `application/x-www-form-urlencoded` handler, which follows `config.styles.body`.
24
+ */
25
+ export const urlencoded: MediaType = {
26
+ encode: (body, { config }) => {
27
+ if (isEncodedBody(body)) return body
28
+ const encoding = config.styles?.body
29
+ const encoded = serializeUrlencodedBody(body as Record<string, unknown>, encoding ?? {})
30
+ return encoding ? encoded : new URLSearchParams(encoded)
31
+ },
32
+ decode: (response) => response.formData(),
33
+ }
@@ -0,0 +1,147 @@
1
+ import type { EventStreamOptions, RequestConfig } from './core.ts'
2
+ import { baseContentType, jsonReplacer } from './serializers.ts'
3
+ import type { RequestBody } from './serializers.ts'
4
+ import { ParseError } from './standardSchema.ts'
5
+
6
+ /**
7
+ * What a media type handler gets next to the body.
8
+ */
9
+ export type MediaTypeContext = {
10
+ contentType: string
11
+ /**
12
+ * The response status, or `undefined` while a request body is encoded.
13
+ */
14
+ status?: number | undefined
15
+ signal?: AbortSignal | undefined
16
+ config: Omit<RequestConfig, 'client' | 'transport'> & EventStreamOptions
17
+ /**
18
+ * Where a stream records the last event ID it read, for `StreamResult.lastEventId`.
19
+ */
20
+ cursor?: { lastEventId: string } | undefined
21
+ }
22
+
23
+ /**
24
+ * Encodes and decodes bodies of one media type. Every method is optional.
25
+ */
26
+ export type MediaType = {
27
+ encode?: (body: unknown, context: MediaTypeContext) => RequestBody | undefined
28
+ decode?: (response: Response, context: MediaTypeContext) => unknown
29
+ /**
30
+ * Reads a 2xx body item by item. Each item is validated with `validator.response`.
31
+ */
32
+ stream?: (body: ReadableStream<Uint8Array>, context: MediaTypeContext) => AsyncIterable<unknown>
33
+ }
34
+
35
+ /**
36
+ * Handlers keyed by media type. A key may be a pattern such as `application/*+json` or `text/*`.
37
+ */
38
+ export type MediaTypes = Record<string, MediaType>
39
+
40
+ /**
41
+ * Picks the handler for a `Content-Type`: the exact type, then its `+suffix`, then `type/*`, then the catch-all. With `use`, a
42
+ * handler without that method is skipped.
43
+ */
44
+ export function resolveMediaType({
45
+ mediaTypes,
46
+ contentType,
47
+ use,
48
+ }: {
49
+ mediaTypes: MediaTypes
50
+ contentType: string | null | undefined
51
+ use?: keyof MediaType
52
+ }): MediaType | undefined {
53
+ const base = baseContentType(contentType)
54
+ const [type, subtype = ''] = base?.split('/') ?? []
55
+ const suffix = subtype.includes('+') ? subtype.slice(subtype.lastIndexOf('+')) : undefined
56
+ const keys = base ? [base, suffix ? `${type}/*${suffix}` : undefined, `${type}/*`, '*/*'] : ['*/*']
57
+ for (const key of keys) {
58
+ const mediaType = key ? mediaTypes[key] : undefined
59
+ if (mediaType && (!use || mediaType[use])) return mediaType
60
+ }
61
+ return undefined
62
+ }
63
+
64
+ /**
65
+ * Whether a body is already encoded, so every built-in media type sends it as is.
66
+ */
67
+ export function isEncodedBody(body: unknown): body is RequestBody {
68
+ return (
69
+ typeof body === 'string' ||
70
+ body instanceof Blob ||
71
+ body instanceof ArrayBuffer ||
72
+ ArrayBuffer.isView(body) ||
73
+ body instanceof FormData ||
74
+ body instanceof URLSearchParams ||
75
+ body instanceof ReadableStream
76
+ )
77
+ }
78
+
79
+ function encodeOrJson(body: unknown, { contentType }: MediaTypeContext): RequestBody {
80
+ if (isEncodedBody(body)) return body
81
+ // XML, YAML, and CSV are never JSON, so a value for one must not go out as JSON under its label.
82
+ const mediaType = baseContentType(contentType)
83
+ if (mediaType && /[/+](xml|yaml|x-yaml|csv)$/.test(mediaType)) {
84
+ throw new TypeError(`Cannot encode the body as ${mediaType}, register mediaTypes['${mediaType}'].encode or pass the encoded body as a string.`)
85
+ }
86
+ return JSON.stringify(body, jsonReplacer)
87
+ }
88
+
89
+ /**
90
+ * Parses JSON text, throwing a `ParseError` that names the content type when it is not valid JSON.
91
+ */
92
+ export function parseJson(text: string, contentType: string | undefined): unknown {
93
+ try {
94
+ return JSON.parse(text)
95
+ } catch (error) {
96
+ throw new ParseError({ message: `Invalid JSON in a ${baseContentType(contentType)} body`, issues: [{ message: String(error) }], cause: error })
97
+ }
98
+ }
99
+
100
+ const json: MediaType = {
101
+ // A string is JSON-encoded too, so it arrives as a JSON string.
102
+ encode: (body) => (isEncodedBody(body) && typeof body !== 'string' ? body : JSON.stringify(body, jsonReplacer)),
103
+ decode: async (response, { contentType }) => {
104
+ const text = await response.text()
105
+ return text ? parseJson(text, contentType) : undefined
106
+ },
107
+ }
108
+
109
+ const text: MediaType = {
110
+ encode: encodeOrJson,
111
+ decode: async (response) => (await response.text()) || undefined,
112
+ }
113
+
114
+ const binary: MediaType = {
115
+ encode: encodeOrJson,
116
+ decode: (response) => response.blob(),
117
+ }
118
+
119
+ const catchAll: MediaType = {
120
+ encode: encodeOrJson,
121
+ // A server may label a plain message such as `Pet deleted` with any type, so non-JSON stays text.
122
+ decode: async (response) => {
123
+ const body = await response.text()
124
+ if (!body) return undefined
125
+ try {
126
+ return JSON.parse(body)
127
+ } catch {
128
+ return body
129
+ }
130
+ },
131
+ }
132
+
133
+ /**
134
+ * The media types every client knows: JSON, text, binary, and a catch-all that sends JSON and reads JSON or text.
135
+ */
136
+ export const defaultMediaTypes: MediaTypes = {
137
+ 'application/json': json,
138
+ 'application/*+json': json,
139
+ 'text/json': json,
140
+ 'text/*': text,
141
+ 'application/octet-stream': binary,
142
+ 'application/pdf': binary,
143
+ 'image/*': binary,
144
+ 'audio/*': binary,
145
+ 'video/*': binary,
146
+ '*/*': catchAll,
147
+ }
@@ -0,0 +1,58 @@
1
+ import { isJsonMediaType, isRecord, jsonReplacer, notNullish, toValue } from '../serializers.ts'
2
+ import type { CookieSerializer } from '../serializers.ts'
3
+
4
+ // RFC 6265 cookie-octets, which the `cookie` style sends unescaped.
5
+ const cookieOctets = /^[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]*$/
6
+ // RFC 6265 cookie-name, an RFC 7230 token: an `=` would move the name/value split.
7
+ const cookieToken = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
8
+
9
+ function rawCookieValue(name: string, item: unknown): string {
10
+ const value = toValue(item)
11
+ // The message leaves the value out, since cookies often carry session tokens.
12
+ if (!cookieOctets.test(value)) throw new TypeError(`Cookie ${name} has characters the cookie style cannot send, escape it first.`)
13
+ return value
14
+ }
15
+
16
+ function rawCookieName(name: string, key: string): string {
17
+ if (!cookieToken.test(key)) throw new TypeError(`Cookie ${name} has a key the cookie style cannot send as a cookie name, escape it first.`)
18
+ return key
19
+ }
20
+
21
+ function serializeCookie({ name, value, explode, raw }: { name: string; value: unknown; explode: boolean; raw: boolean }): string {
22
+ const encode = (item: unknown) => (raw ? rawCookieValue(name, item) : encodeURIComponent(toValue(item)))
23
+ if (Array.isArray(value)) {
24
+ const items = value.filter(notNullish).map(encode)
25
+ return explode ? items.map((item) => `${name}=${item}`).join('; ') : `${name}=${items.join(',')}`
26
+ }
27
+ if (isRecord(value)) {
28
+ const entries = Object.entries(value).filter(([, item]) => notNullish(item))
29
+ // An exploded key becomes a cookie name, so the cookie style checks it as a token.
30
+ if (explode) return entries.map(([key, item]) => `${raw ? rawCookieName(name, key) : encodeURIComponent(key)}=${encode(item)}`).join('; ')
31
+ return `${name}=${entries
32
+ .flatMap(([key, item]) => [key, item])
33
+ .map(encode)
34
+ .join(',')}`
35
+ }
36
+ return `${name}=${encode(value)}`
37
+ }
38
+
39
+ /**
40
+ * Joins cookie parameters into a `Cookie` header value with the OpenAPI `form` style, or the `cookie` style when a parameter sets it.
41
+ */
42
+ export const serializeCookies: CookieSerializer = (cookies, styles) => {
43
+ const parts: Array<string> = []
44
+ for (const [name, value] of Object.entries(cookies)) {
45
+ if (value === undefined || value === null) continue
46
+ const style = styles?.[name]
47
+ if (isJsonMediaType(style?.contentType)) {
48
+ parts.push(`${name}=${encodeURIComponent(JSON.stringify(value, jsonReplacer))}`)
49
+ continue
50
+ }
51
+ // OpenAPI 3.2 defaults `explode` to true for the `cookie` style.
52
+ const raw = style?.style === 'cookie'
53
+ const cookie = serializeCookie({ name, value, explode: style?.explode ?? raw, raw })
54
+ // An empty exploded array or object has no pairs, so it adds no `; ` segment.
55
+ if (cookie) parts.push(cookie)
56
+ }
57
+ return parts.join('; ')
58
+ }
@@ -0,0 +1,35 @@
1
+ import { isJsonMediaType, isRecord, jsonReplacer, notNullish, toValue } from '../serializers.ts'
2
+ import type { HeaderSerializer, HeaderValue } from '../serializers.ts'
3
+
4
+ function serializeHeaderValue(value: unknown, explode: boolean): string {
5
+ if (Array.isArray(value)) return value.filter(notNullish).map(toValue).join(',')
6
+ if (!isRecord(value)) return toValue(value)
7
+ const entries = Object.entries(value).filter(([, item]) => notNullish(item))
8
+ if (explode) return entries.map(([key, item]) => `${key}=${toValue(item)}`).join(',')
9
+ return entries
10
+ .flatMap(([key, item]) => [key, item])
11
+ .map(toValue)
12
+ .join(',')
13
+ }
14
+
15
+ // Header values must be Latin-1, so non-ASCII characters become JSON escapes.
16
+ function toAsciiJson(value: unknown): string {
17
+ return JSON.stringify(value, jsonReplacer).replace(/[\u007f-\uffff]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`)
18
+ }
19
+
20
+ /**
21
+ * Serializes array and object headers with the OpenAPI `simple` style.
22
+ */
23
+ export const serializeHeaders: HeaderSerializer = (headers, styles) => {
24
+ // A `Headers` instance only holds strings, so there is nothing to style.
25
+ if (!headers || headers instanceof Headers) return headers
26
+ const entries = Array.isArray(headers) ? headers : Object.entries(headers)
27
+ return entries.map(([key, value]) => {
28
+ // Without an entry the parameter uses the OpenAPI default: `simple`, no explode.
29
+ const style = styles?.[key] ?? {}
30
+ if (value === undefined || value === null) return [key, value] as [string, HeaderValue]
31
+ if (isJsonMediaType(style.contentType)) return [key, toAsciiJson(value)] as [string, HeaderValue]
32
+ if (typeof value !== 'object') return [key, value] as [string, HeaderValue]
33
+ return [key, serializeHeaderValue(value, style.explode ?? false)] as [string, HeaderValue]
34
+ })
35
+ }
@@ -0,0 +1,19 @@
1
+ import { encodeComponent, isRecord } from '../serializers.ts'
2
+ import type { PathSerializer } from '../serializers.ts'
3
+
4
+ /**
5
+ * The path serializer for the `simple` style, which `serializeStyledPath` also uses.
6
+ */
7
+ export const serializePath: PathSerializer = ({ name, value, options }) => {
8
+ if (value === undefined || value === null) return ''
9
+ if (options?.style && options.style !== 'simple') {
10
+ throw new TypeError(`Cannot send path parameter ${name} with the ${options.style} style, register serializer.path on the client.`)
11
+ }
12
+ if (Array.isArray(value)) return value.map(encodeComponent).join(',')
13
+ if (isRecord(value)) {
14
+ return Object.entries(value)
15
+ .map(([key, item]) => `${encodeComponent(key)}${options?.explode ? '=' : ','}${encodeComponent(item)}`)
16
+ .join(',')
17
+ }
18
+ return encodeComponent(value)
19
+ }
@@ -0,0 +1,27 @@
1
+ import { encodeComponent as encode, isRecord } from '../serializers.ts'
2
+ import type { PathSerializer } from '../serializers.ts'
3
+ import { serializePath } from './path.ts'
4
+
5
+ /**
6
+ * The path serializer for the OpenAPI `label` and `matrix` styles. A `simple` parameter uses `serializePath`.
7
+ */
8
+ export const serializeStyledPath: PathSerializer = ({ name, value, options }) => {
9
+ const style = options?.style ?? 'simple'
10
+ if (style === 'simple' || value === undefined || value === null) return serializePath({ name, value, options })
11
+ const explode = options?.explode ?? false
12
+ const label = style === 'label'
13
+ if (Array.isArray(value)) {
14
+ const items = value.map(encode)
15
+ if (label) return `.${items.join(explode ? '.' : ',')}`
16
+ return explode ? items.map((item) => `;${name}=${item}`).join('') : `;${name}=${items.join(',')}`
17
+ }
18
+ if (isRecord(value)) {
19
+ const members = Object.entries(value).map(([key, item]) => `${encode(key)}${explode ? '=' : ','}${encode(item)}`)
20
+ if (label) return `.${members.join(explode ? '.' : ',')}`
21
+ return explode ? members.map((member) => `;${member}`).join('') : `;${name}=${members.join(',')}`
22
+ }
23
+ const encoded = encode(value)
24
+ // RFC 6570 drops the `=` of an empty matrix value: `;color`.
25
+ if (label) return `.${encoded}`
26
+ return encoded ? `;${name}=${encoded}` : `;${name}`
27
+ }
@@ -0,0 +1,21 @@
1
+ import { encodeComponent, isRecord, notNullish, serializeDefaultQueryParam } from '../serializers.ts'
2
+ import type { QuerySerializer } from '../serializers.ts'
3
+
4
+ /**
5
+ * The query serializer for a spec without styled query parameters: arrays repeat their key, objects use `form` with explode.
6
+ */
7
+ export const serializeQuery: QuerySerializer = (params, options) => {
8
+ const parts: Array<string> = []
9
+ for (const [key, value] of Object.entries(params)) {
10
+ if (options?.[key]) throw new TypeError(`Cannot send query parameter ${key} with a style, register serializer.query on the client.`)
11
+ // `URLSearchParams` writes a space as `+`, form encoding for bodies. A query follows RFC 6570: `%20`.
12
+ parts.push(
13
+ ...(isRecord(value)
14
+ ? Object.entries(value)
15
+ .filter(([, item]) => notNullish(item))
16
+ .map(([prop, item]) => `${encodeComponent(prop)}=${encodeComponent(item)}`)
17
+ : serializeDefaultQueryParam(key, value).map((part) => part.replaceAll('+', '%20'))),
18
+ )
19
+ }
20
+ return parts.join('&')
21
+ }
@@ -0,0 +1,107 @@
1
+ import { baseContentType, encodeComponent, isJsonMediaType, isRecord, jsonReplacer, notNullish, serializeDefaultQueryParam, toValue } from '../serializers.ts'
2
+ import type { QueryParamStyle, QuerySerializer, QueryStyle } from '../serializers.ts'
3
+
4
+ const queryDelimiters: Record<QueryStyle, string> = { form: ',', spaceDelimited: '%20', pipeDelimited: '%7C', deepObject: ',' }
5
+
6
+ // `allowReserved` keeps reserved characters and percent-encoded triples, but `#`, `&`, and `+` would end the query or change a form value.
7
+ function encodeReserved(value: unknown): string {
8
+ return encodeURI(toValue(value))
9
+ .replace(/%25([0-9A-Fa-f]{2})/g, '%$1')
10
+ .replace(/[#&+]/g, encodeURIComponent)
11
+ }
12
+
13
+ // OpenAPI defaults `explode` to false for the delimited styles. A deepObject array of scalars, which OpenAPI leaves undefined, keeps repeating its key.
14
+ function defaultExplode(options: QueryParamStyle): boolean {
15
+ return options.style !== 'spaceDelimited' && options.style !== 'pipeDelimited'
16
+ }
17
+
18
+ function serializeDeepObject({ key, value, encode }: { key: string; value: unknown; encode: (value: unknown) => string }): Array<string> {
19
+ if (value === undefined || value === null) return []
20
+ if (Array.isArray(value)) return value.flatMap((item, index) => serializeDeepObject({ key: `${key}[${index}]`, value: item, encode }))
21
+ if (isRecord(value)) {
22
+ return Object.entries(value)
23
+ .filter(([, item]) => notNullish(item))
24
+ .flatMap(([prop, item]) => serializeDeepObject({ key: `${key}[${prop}]`, value: item, encode }))
25
+ }
26
+ return [`${encode(key)}=${encode(value)}`]
27
+ }
28
+
29
+ function serializeStyledQueryArray({
30
+ key,
31
+ value,
32
+ options,
33
+ encode,
34
+ }: {
35
+ key: string
36
+ value: Array<unknown>
37
+ options: QueryParamStyle
38
+ encode: (value: unknown) => string
39
+ }): Array<string> {
40
+ const items = value.filter(notNullish)
41
+ if (options.explode ?? defaultExplode(options)) return items.map((item) => `${encode(key)}=${encode(item)}`)
42
+ return [`${encode(key)}=${items.map(encode).join(queryDelimiters[options.style ?? 'form'])}`]
43
+ }
44
+
45
+ function serializeStyledQueryObject({
46
+ key,
47
+ value,
48
+ options,
49
+ encode,
50
+ }: {
51
+ key: string
52
+ value: Record<string, unknown>
53
+ options: QueryParamStyle
54
+ encode: (value: unknown) => string
55
+ }): Array<string> {
56
+ if ((options.style ?? 'form') === 'deepObject') return serializeDeepObject({ key, value, encode })
57
+ const entries = Object.entries(value).filter(([, item]) => notNullish(item))
58
+ if (options.explode ?? defaultExplode(options)) return entries.map(([prop, item]) => `${encode(prop)}=${encode(item)}`)
59
+ return [
60
+ `${encode(key)}=${entries
61
+ .flatMap(([prop, item]) => [prop, item])
62
+ .map(encode)
63
+ .join(queryDelimiters[options.style ?? 'form'])}`,
64
+ ]
65
+ }
66
+
67
+ /**
68
+ * One query parameter or form field with its OpenAPI style, as encoded pairs.
69
+ */
70
+ export function serializeStyledQueryParam({ key, value, options }: { key: string; value: unknown; options: QueryParamStyle }): Array<string> {
71
+ if (value === undefined || value === null) return []
72
+ const encode = options.allowReserved ? encodeReserved : encodeComponent
73
+ // OpenAPI leaves arrays under deepObject undefined. Objects in one are indexed, `items[0][price]=p`, as nested arrays are.
74
+ if (Array.isArray(value) && options.style === 'deepObject' && value.some(isRecord)) return serializeDeepObject({ key, value, encode })
75
+ if (Array.isArray(value)) return serializeStyledQueryArray({ key, value, options, encode })
76
+ if (isRecord(value)) return serializeStyledQueryObject({ key, value, options, encode })
77
+ return [`${encode(key)}=${encode(value)}`]
78
+ }
79
+
80
+ /**
81
+ * The query serializer for a spec with styled query parameters: an OpenAPI style, `allowReserved` or a JSON content
82
+ * type. Without a style entry, arrays repeat their key and objects use `form` with explode.
83
+ */
84
+ export const serializeStyledQuery: QuerySerializer = (params, options) => {
85
+ const parts: Array<string> = []
86
+ for (const [key, value] of Object.entries(params)) {
87
+ const paramOptions = options?.[key]
88
+ if (notNullish(value) && isJsonMediaType(paramOptions?.contentType)) {
89
+ const json = encodeComponent(JSON.stringify(value, jsonReplacer))
90
+ parts.push(paramOptions?.querystring ? json : `${encodeComponent(key)}=${json}`)
91
+ continue
92
+ }
93
+ // A string querystring is already in its media type, so it only needs percent-encoding, which form-urlencoded has.
94
+ if (paramOptions?.querystring && typeof value === 'string') {
95
+ parts.push(baseContentType(paramOptions.contentType) === 'application/x-www-form-urlencoded' ? value : encodeComponent(value))
96
+ continue
97
+ }
98
+ const styled = paramOptions ?? (isRecord(value) ? {} : undefined)
99
+ // `URLSearchParams` writes a space as `+`, form encoding for bodies. A query follows RFC 6570, as a styled one does: `%20`.
100
+ parts.push(
101
+ ...(styled
102
+ ? serializeStyledQueryParam({ key, value, options: styled })
103
+ : serializeDefaultQueryParam(key, value).map((part) => part.replaceAll('+', '%20'))),
104
+ )
105
+ }
106
+ return parts.join('&')
107
+ }