@kubb/plugin-client 5.10.0-canary.20261105T164114 → 5.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/client-CiHb_Cfg.d.ts +345 -0
- package/dist/index.cjs +288 -143
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +290 -145
- package/dist/index.js.map +1 -1
- package/dist/serializers-Ds8zZwOO.d.ts +190 -0
- package/dist/standardSchema-BShJWz2i.d.ts +104 -0
- package/dist/templates/client.d.ts +2 -0
- package/dist/templates/core.d.ts +3 -0
- package/dist/templates/serializers.d.ts +2 -0
- package/dist/templates/sse.d.ts +124 -0
- package/dist/templates/standardSchema.d.ts +2 -0
- package/docs/guides/write-your-client.md +1 -1
- package/docs/overview.md +1 -1
- package/kubb-extension.json +1 -1
- package/package.json +17 -8
- package/templates/client.ts +5 -0
- package/templates/core.ts +694 -0
- package/templates/serializers.ts +493 -0
- package/templates/sse.ts +274 -0
- package/templates/standardSchema.ts +162 -0
package/templates/sse.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import type { DataShape, NativeRequest, NativeResponse, Options, RequestResult, SuccessOf } from './client.ts'
|
|
2
|
+
import { isValidationEnabled, runValidator, type StandardSchemaValidator, type ValidateOption, type ValidationErrorHandler } from './standardSchema.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One decoded Server-Sent Event. `data` is parsed as JSON when valid and kept as the raw string otherwise,
|
|
6
|
+
* `event` is `message` when the server sent no `event` field, and `id` is the last event ID the stream has set.
|
|
7
|
+
*/
|
|
8
|
+
export type ServerSentEvent<TData = unknown> = {
|
|
9
|
+
data: TData
|
|
10
|
+
event: string
|
|
11
|
+
id?: string
|
|
12
|
+
retry?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** An event the document types by name: the member's own `event` and `data`, plus the `id` and `retry` any event may carry. */
|
|
16
|
+
export type TypedEvent<TEvent> = TEvent & Pick<ServerSentEvent, 'id' | 'retry'>
|
|
17
|
+
|
|
18
|
+
/** Per-call options of a streamed operation, passed next to its request params. */
|
|
19
|
+
export type EventStreamOptions = {
|
|
20
|
+
/**
|
|
21
|
+
* The largest number of characters, line breaks included, one event may hold before the blank line that ends it.
|
|
22
|
+
* A larger event throws a `RangeError` and cancels the stream. Set `Infinity` to turn the limit off.
|
|
23
|
+
* @default 16777216
|
|
24
|
+
*/
|
|
25
|
+
maxEventSize?: number
|
|
26
|
+
/**
|
|
27
|
+
* Ends the stream when an event's raw `data` equals this value, such as `[DONE]`.
|
|
28
|
+
*/
|
|
29
|
+
terminator?: string
|
|
30
|
+
/**
|
|
31
|
+
* Maps each event before validation.
|
|
32
|
+
*/
|
|
33
|
+
transform?: (event: ServerSentEvent) => ServerSentEvent
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The options of a streamed operation: the call's `Options` with the per-call stream options, as `Options` is for a regular call. */
|
|
37
|
+
export type StreamOptions<TData extends DataShape, ThrowOnError extends boolean = true> = Options<TData, ThrowOnError> & EventStreamOptions
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The validation settings a call or its client may set.
|
|
41
|
+
*/
|
|
42
|
+
type ValidationSettings = { validate?: ValidateOption; onValidationError?: ValidationErrorHandler }
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What the client resolves a streamed call to: the body as a stream or a decoded value, its content type, and the native response.
|
|
46
|
+
*/
|
|
47
|
+
export type EventStreamCall<TResponse> = { data: unknown; error?: unknown; status?: number; contentType?: string; request?: NativeRequest; response: TResponse }
|
|
48
|
+
|
|
49
|
+
/** The payload wrapped in `ServerSentEvent`, or the item itself when the document types it as a whole event. */
|
|
50
|
+
type StreamEvent<TData> = TData extends { event: string } ? TData : ServerSentEvent<TData>
|
|
51
|
+
|
|
52
|
+
/** Parsed events with the fields a `RequestResult` carries. `TData` is the payload, or the typed event. Iterating the result reads `stream`. */
|
|
53
|
+
export type EventStreamResult<TData = unknown, TResponse = NativeResponse> = AsyncIterable<StreamEvent<TData>> & {
|
|
54
|
+
stream: AsyncGenerator<StreamEvent<TData>>
|
|
55
|
+
/** The same events as `stream` on a 2xx response, so `.unwrap()` and `returnType: 'data'` return them. */
|
|
56
|
+
data: AsyncGenerator<StreamEvent<TData>> | undefined
|
|
57
|
+
status: number | undefined
|
|
58
|
+
/** The error body of a failed call with `throwOnError: false`, whose `stream` then yields nothing. */
|
|
59
|
+
error: unknown
|
|
60
|
+
contentType: string | undefined
|
|
61
|
+
request: NativeRequest | undefined
|
|
62
|
+
response: TResponse
|
|
63
|
+
/**
|
|
64
|
+
* The last event ID the server sent, to resume from with a `Last-Event-ID` header on a new call. Empty until the server sends `id`.
|
|
65
|
+
*/
|
|
66
|
+
readonly lastEventId: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type StreamParts<TData> = AsyncIterable<StreamEvent<TData>> & { stream: AsyncGenerator<StreamEvent<TData>>; readonly lastEventId: string }
|
|
70
|
+
|
|
71
|
+
type WithEvents<TResult, TData> = TResult extends { error: undefined } ? Omit<TResult, 'data'> & { data: AsyncGenerator<StreamEvent<TData>> } : TResult
|
|
72
|
+
|
|
73
|
+
/** A stream call's result, as `RequestResult` is a regular call's: `status` narrows `error`, and the events are its `data`. Branching on `ThrowOnError` lets a generated call cast to it. */
|
|
74
|
+
export type StreamResult<TResponses, ThrowOnError extends boolean = true, TData = SuccessOf<TResponses>> = ThrowOnError extends true
|
|
75
|
+
? WithEvents<RequestResult<TResponses, true>, TData> & StreamParts<TData>
|
|
76
|
+
: WithEvents<RequestResult<TResponses, false>, TData> & StreamParts<TData>
|
|
77
|
+
|
|
78
|
+
/** What a stream operation returns with `returnType: 'data'`, as `UnwrappedResult` is for a regular call. */
|
|
79
|
+
export type UnwrappedStreamResult<TResponses, ThrowOnError extends boolean = true, TData = SuccessOf<TResponses>> = ThrowOnError extends true
|
|
80
|
+
? AsyncGenerator<StreamEvent<TData>>
|
|
81
|
+
: StreamResult<TResponses, false, TData>
|
|
82
|
+
|
|
83
|
+
/** The last event ID, shared with the result so it can be read after the loop. */
|
|
84
|
+
type Cursor = { lastEventId: string }
|
|
85
|
+
|
|
86
|
+
async function* readBytes(stream: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>): AsyncGenerator<Uint8Array> {
|
|
87
|
+
if (!('getReader' in stream)) {
|
|
88
|
+
yield* stream
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const reader = stream.getReader()
|
|
93
|
+
try {
|
|
94
|
+
while (true) {
|
|
95
|
+
const { done, value } = await reader.read()
|
|
96
|
+
if (done) return
|
|
97
|
+
yield value
|
|
98
|
+
}
|
|
99
|
+
} finally {
|
|
100
|
+
await reader.cancel().catch(() => {})
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseData<TData>(raw: string): TData {
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(raw) as TData
|
|
107
|
+
} catch {
|
|
108
|
+
return raw as TData
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Parses a `text/event-stream` body as the WHATWG event stream specification does: an event is dispatched at its blank line
|
|
114
|
+
* when it holds `data`, `event` defaults to `message`, `id` carries across events until an empty `id` resets it, `retry` needs
|
|
115
|
+
* all digits, and an unfinished event at the end is dropped. `data` is parsed as JSON when it is valid and kept as text
|
|
116
|
+
* otherwise. Consume with `for await`, and break the loop to cancel the stream.
|
|
117
|
+
*/
|
|
118
|
+
type EventStreamInput = ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>
|
|
119
|
+
|
|
120
|
+
export async function* parseEventStream<TData = unknown>(
|
|
121
|
+
input: EventStreamInput | (Pick<EventStreamOptions, 'maxEventSize' | 'terminator'> & { stream: EventStreamInput; cursor?: Cursor }),
|
|
122
|
+
): AsyncGenerator<ServerSentEvent<TData>> {
|
|
123
|
+
// The stream alone is the form earlier releases took.
|
|
124
|
+
const { stream, maxEventSize = 16 * 1024 * 1024, terminator, cursor = { lastEventId: '' } } = isStreamBody(input) ? { stream: input } : input
|
|
125
|
+
const decoder = new TextDecoder()
|
|
126
|
+
const tooLarge = `Server-sent event exceeds maxEventSize of ${maxEventSize} characters`
|
|
127
|
+
let type = ''
|
|
128
|
+
let data: Array<string> = []
|
|
129
|
+
let retry: number | undefined
|
|
130
|
+
let size = 0
|
|
131
|
+
let line = ''
|
|
132
|
+
let endsWithCR = false
|
|
133
|
+
let pendingId = cursor.lastEventId
|
|
134
|
+
|
|
135
|
+
const readField = (part: string) => {
|
|
136
|
+
const index = part.indexOf(':')
|
|
137
|
+
const name = index === -1 ? part : part.slice(0, index)
|
|
138
|
+
const value = index === -1 ? '' : part.slice(index + 1).replace(/^ /, '')
|
|
139
|
+
switch (name) {
|
|
140
|
+
case 'data':
|
|
141
|
+
data.push(value)
|
|
142
|
+
break
|
|
143
|
+
case 'event':
|
|
144
|
+
type = value
|
|
145
|
+
break
|
|
146
|
+
case 'id':
|
|
147
|
+
if (!value.includes('\u0000')) pendingId = value
|
|
148
|
+
break
|
|
149
|
+
case 'retry':
|
|
150
|
+
if (/^\d+$/.test(value)) retry = Number(value)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for await (const chunk of readBytes(stream)) {
|
|
155
|
+
const decoded = decoder.decode(chunk, { stream: true })
|
|
156
|
+
if (!decoded) continue
|
|
157
|
+
// A CRLF split across chunks already ended its line on the `\r`.
|
|
158
|
+
const text: string = endsWithCR && decoded.startsWith('\n') ? decoded.slice(1) : decoded
|
|
159
|
+
endsWithCR = text.endsWith('\r')
|
|
160
|
+
|
|
161
|
+
const parts = text.split(/\r\n|\r|\n/)
|
|
162
|
+
parts[0] = line + parts[0]
|
|
163
|
+
line = parts.pop() ?? ''
|
|
164
|
+
for (const part of parts) {
|
|
165
|
+
if (!part) {
|
|
166
|
+
// The ID is committed at the blank line, so a drop inside an event does not report an ID past the last full event.
|
|
167
|
+
cursor.lastEventId = pendingId
|
|
168
|
+
if (data.length) {
|
|
169
|
+
const raw = data.join('\n')
|
|
170
|
+
if (raw === terminator) return
|
|
171
|
+
yield {
|
|
172
|
+
data: parseData<TData>(raw),
|
|
173
|
+
event: type || 'message',
|
|
174
|
+
...(cursor.lastEventId ? { id: cursor.lastEventId } : {}),
|
|
175
|
+
...(retry !== undefined ? { retry } : {}),
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
type = ''
|
|
179
|
+
data = []
|
|
180
|
+
retry = undefined
|
|
181
|
+
size = 0
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
// A comment line is dropped and does not count toward the event, but must fit the limit on its own.
|
|
185
|
+
if (part.startsWith(':')) {
|
|
186
|
+
if (part.length + 1 > maxEventSize) throw new RangeError(tooLarge)
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
// A finished line counts one more character for its line break.
|
|
190
|
+
if (size + part.length + 1 > maxEventSize) throw new RangeError(tooLarge)
|
|
191
|
+
size += part.length + 1
|
|
192
|
+
readField(part)
|
|
193
|
+
}
|
|
194
|
+
if ((line.startsWith(':') ? line.length : size + line.length) > maxEventSize) throw new RangeError(tooLarge)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isStreamBody(data: unknown): data is ReadableStream<Uint8Array> | AsyncIterable<Uint8Array> {
|
|
199
|
+
return !!data && typeof data === 'object' && (Symbol.asyncIterator in data || 'getReader' in data)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Reads a call as typed Server-Sent Events, transformed and validated one by one, until the terminator or the end of the
|
|
204
|
+
* stream. A successful call whose body is not a stream, such as a decoded JSON answer, throws a `TypeError` with the body
|
|
205
|
+
* as its `cause`. A dropped connection ends the loop with the transport's error; `lastEventId` on the result is the ID
|
|
206
|
+
* to resume from.
|
|
207
|
+
*/
|
|
208
|
+
export async function toEventStream<TData = unknown, TResponse = NativeResponse>(
|
|
209
|
+
call: Promise<EventStreamCall<TResponse>>,
|
|
210
|
+
{
|
|
211
|
+
client,
|
|
212
|
+
config: options = {},
|
|
213
|
+
...meta
|
|
214
|
+
}: {
|
|
215
|
+
/**
|
|
216
|
+
* The client the call went through, for the validation settings a call does not set itself.
|
|
217
|
+
*/
|
|
218
|
+
client?: { getConfig: () => ValidationSettings }
|
|
219
|
+
config?: EventStreamOptions & ValidationSettings
|
|
220
|
+
/**
|
|
221
|
+
* `event` when the document types each event with its own `event` and `data`, or `data` when it types only the payload.
|
|
222
|
+
* @default 'data'
|
|
223
|
+
*/
|
|
224
|
+
shape?: 'event' | 'data'
|
|
225
|
+
/**
|
|
226
|
+
* Validates each event (`shape: 'event'`) or its `data` (`shape: 'data'`), after `transform`.
|
|
227
|
+
*/
|
|
228
|
+
validator?: StandardSchemaValidator
|
|
229
|
+
} = {},
|
|
230
|
+
): Promise<EventStreamResult<TData, TResponse>> {
|
|
231
|
+
const defaults = client?.getConfig() ?? {}
|
|
232
|
+
const onValidationError = options.onValidationError ?? defaults.onValidationError
|
|
233
|
+
const validator = isValidationEnabled(options.validate ?? defaults.validate, 'response') ? meta.validator : undefined
|
|
234
|
+
const cursor: Cursor = { lastEventId: '' }
|
|
235
|
+
const { data: body, error, status, contentType, request, response } = await call
|
|
236
|
+
const ok = status === undefined || (status >= 200 && status < 300)
|
|
237
|
+
// A transport decodes a JSON answer, so a success that is not a stream would otherwise end as an empty stream.
|
|
238
|
+
// A 204 or empty answer has no body, `undefined` or `null`, and still ends as an empty stream.
|
|
239
|
+
if (ok && body != null && !isStreamBody(body)) {
|
|
240
|
+
// `cause` is assigned, since the constructor option needs the ES2022 library a generated client cannot assume.
|
|
241
|
+
throw Object.assign(new TypeError(`Expected a text/event-stream body, got ${contentType ?? 'a body without a content type'}`), { cause: body })
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const validateEvent = async (event: ServerSentEvent): Promise<ServerSentEvent> => {
|
|
245
|
+
if (!validator) return event
|
|
246
|
+
const value = meta.shape === 'event' ? event : event.data
|
|
247
|
+
const validated = await runValidator({ validator, value, context: { direction: 'response', source: 'body' }, onValidationError })
|
|
248
|
+
// The schema may not declare `id` and `retry`, so the parsed value is merged over the event instead of replacing it.
|
|
249
|
+
return meta.shape === 'event' ? { ...event, ...(validated as object) } : { ...event, data: validated }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function* events(): AsyncGenerator<StreamEvent<TData>> {
|
|
253
|
+
if (!isStreamBody(body)) return
|
|
254
|
+
// A consumer `break` or a failed event closes this loop, which cancels the reader.
|
|
255
|
+
for await (const event of parseEventStream({ stream: body, maxEventSize: options.maxEventSize, terminator: options.terminator, cursor })) {
|
|
256
|
+
yield (await validateEvent(options.transform ? options.transform(event) : event)) as StreamEvent<TData>
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const stream = events()
|
|
261
|
+
return {
|
|
262
|
+
stream,
|
|
263
|
+
data: ok ? stream : undefined,
|
|
264
|
+
status,
|
|
265
|
+
error,
|
|
266
|
+
contentType,
|
|
267
|
+
request,
|
|
268
|
+
response,
|
|
269
|
+
get lastEventId() {
|
|
270
|
+
return cursor.lastEventId
|
|
271
|
+
},
|
|
272
|
+
[Symbol.asyncIterator]: () => stream,
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Standard Schema-compatible validator: a minimal duck-type covering Zod v3/v4, valibot, and
|
|
3
|
+
* arktype schemas. Only the `~standard.validate` method is required at runtime.
|
|
4
|
+
*/
|
|
5
|
+
export type StandardSchemaValidator<TOutput = unknown> = {
|
|
6
|
+
readonly '~standard': {
|
|
7
|
+
validate(value: unknown): StandardSchemaResult<TOutput> | Promise<StandardSchemaResult<TOutput>>
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The two possible outcomes of a Standard Schema `validate` call. A successful result carries
|
|
13
|
+
* `value`; a failed result carries `issues`.
|
|
14
|
+
*/
|
|
15
|
+
export type StandardSchemaResult<TOutput> = { readonly value: TOutput; readonly issues?: undefined } | { readonly issues: ReadonlyArray<StandardSchemaIssue> }
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One validation issue from a Standard Schema `validate` call.
|
|
19
|
+
*/
|
|
20
|
+
export type StandardSchemaIssue = {
|
|
21
|
+
readonly message?: string
|
|
22
|
+
readonly path?: ReadonlyArray<PropertyKey | { readonly key: PropertyKey }>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Thrown by `validateStandardSchema` when validation fails. Carries the raw `issues` array from
|
|
27
|
+
* the schema's `validate` result so callers receive a uniform error shape regardless of which
|
|
28
|
+
* schema library is in use.
|
|
29
|
+
*/
|
|
30
|
+
export class ParseError extends Error {
|
|
31
|
+
readonly issues: ReadonlyArray<StandardSchemaIssue>
|
|
32
|
+
|
|
33
|
+
constructor({ issues, message }: { issues: ReadonlyArray<StandardSchemaIssue>; message?: string }) {
|
|
34
|
+
super(message ?? 'Validation failed')
|
|
35
|
+
this.name = 'ParseError'
|
|
36
|
+
this.issues = issues
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Validates `value` against a Standard Schema-compatible `schema`. Returns the parsed output on
|
|
42
|
+
* success; throws `ParseError` with the schema's `issues` on failure. Handles both sync and async
|
|
43
|
+
* `validate` implementations transparently.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* const pet = await validateStandardSchema(PetSchema, rawData)
|
|
47
|
+
*/
|
|
48
|
+
export async function validateStandardSchema<TOutput>(schema: StandardSchemaValidator<TOutput>, value: unknown): Promise<TOutput> {
|
|
49
|
+
const result = await schema['~standard'].validate(value)
|
|
50
|
+
if (result.issues) {
|
|
51
|
+
throw new ParseError({ issues: result.issues })
|
|
52
|
+
}
|
|
53
|
+
return (result as { value: TOutput }).value
|
|
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
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Identifies the value and request that failed validation in `onValidationError`.
|
|
113
|
+
*/
|
|
114
|
+
export type ValidationErrorContext = {
|
|
115
|
+
value: unknown
|
|
116
|
+
direction: 'request' | 'response' | 'error'
|
|
117
|
+
source: 'body' | 'path' | 'query' | 'headers'
|
|
118
|
+
method?: string
|
|
119
|
+
url?: string
|
|
120
|
+
status?: number
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Return `{ value }` to substitute a failed value, including `undefined`. Returning nothing rethrows the `ParseError`.
|
|
125
|
+
*/
|
|
126
|
+
export type ValidationErrorHandler = (error: ParseError, context: ValidationErrorContext) => { value: unknown } | void | Promise<{ value: unknown } | void>
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Turns generated validation on or off. `false` skips every validator, an object skips only the directions set to `false`:
|
|
130
|
+
* `request` is the body, `params` are path, query, and header params, `response` and `error` are the response bodies.
|
|
131
|
+
* @default true
|
|
132
|
+
*/
|
|
133
|
+
export type ValidateOption = boolean | { request?: boolean; params?: boolean; response?: boolean; error?: boolean }
|
|
134
|
+
|
|
135
|
+
export function isValidationEnabled(validate: ValidateOption | undefined, direction: 'request' | 'params' | 'response' | 'error'): boolean {
|
|
136
|
+
return typeof validate === 'object' ? validate[direction] !== false : validate !== false
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Runs a validator when there is one, handing a `ParseError` to `onValidationError` first, which may substitute the value.
|
|
141
|
+
*/
|
|
142
|
+
export async function runValidator<T>({
|
|
143
|
+
validator,
|
|
144
|
+
value,
|
|
145
|
+
context,
|
|
146
|
+
onValidationError,
|
|
147
|
+
}: {
|
|
148
|
+
validator: StandardSchemaValidator<T> | undefined
|
|
149
|
+
value: T
|
|
150
|
+
context: Omit<ValidationErrorContext, 'value'>
|
|
151
|
+
onValidationError: ValidationErrorHandler | undefined
|
|
152
|
+
}): Promise<T> {
|
|
153
|
+
if (!validator) return value
|
|
154
|
+
try {
|
|
155
|
+
return await validateStandardSchema(validator, value)
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (!onValidationError || !(error instanceof ParseError)) throw error
|
|
158
|
+
const handled = await onValidationError(error, { ...context, value })
|
|
159
|
+
if (!handled) throw error
|
|
160
|
+
return handled.value as T
|
|
161
|
+
}
|
|
162
|
+
}
|