@dphonys/nuxt-handler-validation 0.1.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/LICENSE +21 -0
- package/README.md +421 -0
- package/dist/module.d.mts +12 -0
- package/dist/module.json +12 -0
- package/dist/module.mjs +22 -0
- package/dist/runtime/server/index.d.ts +34 -0
- package/dist/runtime/server/index.js +12 -0
- package/dist/runtime/server/lib/issues.d.ts +9 -0
- package/dist/runtime/server/lib/issues.js +27 -0
- package/dist/runtime/server/lib/sources.d.ts +20 -0
- package/dist/runtime/server/lib/sources.js +31 -0
- package/dist/runtime/server/lib/validate.d.ts +18 -0
- package/dist/runtime/server/lib/validate.js +106 -0
- package/dist/runtime/server/tsconfig.json +3 -0
- package/dist/runtime/shared/error-marker.d.ts +16 -0
- package/dist/runtime/shared/error-marker.js +16 -0
- package/dist/runtime/types/index.d.ts +65 -0
- package/dist/runtime/types/index.js +0 -0
- package/dist/runtime/types/internal.d.ts +32 -0
- package/dist/runtime/types/internal.js +0 -0
- package/dist/types.d.mts +3 -0
- package/package.json +87 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Petr Honys
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
# Nuxt Handler Validation
|
|
2
|
+
|
|
3
|
+
Declare a Nitro handler's request schemas once, and receive the validated,
|
|
4
|
+
fully-typed values in the handler's second parameter - route params, query,
|
|
5
|
+
headers and body, checked by any [Standard Schema](https://standardschema.dev)
|
|
6
|
+
library _before_ the handler body runs. Failures answer with one stable,
|
|
7
|
+
documented wire shape, identical in development and production.
|
|
8
|
+
|
|
9
|
+
One sentence for the whole model: **a source slot takes a schema, or a tuple of
|
|
10
|
+
schemas; reuse is exporting a schema value; composition is writing a tuple.**
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
pnpm add @dphonys/nuxt-handler-validation
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
export default defineNuxtConfig({
|
|
20
|
+
modules: ['@dphonys/nuxt-handler-validation'],
|
|
21
|
+
})
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
That is the only setup step - the module has **zero options**. Nothing about a
|
|
25
|
+
route's validation is configured; it is declared, in the route.
|
|
26
|
+
|
|
27
|
+
Bring your own schema library. Anything implementing Standard Schema works -
|
|
28
|
+
[zod](https://zod.dev), [valibot](https://valibot.dev),
|
|
29
|
+
[arktype](https://arktype.io), and others - nothing is bundled for you.
|
|
30
|
+
|
|
31
|
+
**Requirements:** Nuxt `>=4.5.1 <5.0.0`, Node 22.19+ / 24.11+ / 26+.
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// server/api/users/[id].post.ts
|
|
37
|
+
import * as v from 'valibot'
|
|
38
|
+
import { z } from 'zod'
|
|
39
|
+
|
|
40
|
+
export default defineValidatedEventHandler(
|
|
41
|
+
{
|
|
42
|
+
validate: {
|
|
43
|
+
routerParams: v.object({ id: v.pipe(v.string(), v.transform(Number)) }),
|
|
44
|
+
query: z.object({ page: z.coerce.number() }),
|
|
45
|
+
body: z.object({
|
|
46
|
+
name: z.string(),
|
|
47
|
+
tags: z.string().transform((s) => s.split(',')),
|
|
48
|
+
}),
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
async (event, { routerParams, query, body }) => {
|
|
52
|
+
// routerParams: { id: number }
|
|
53
|
+
// query: { page: number }
|
|
54
|
+
// body: { name: string, tags: string[] }
|
|
55
|
+
return updateUser(routerParams.id, body)
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
- **Schemas nest under `validate`**, keyed by source. The four sources are
|
|
61
|
+
`routerParams`, `query`, `headers` and `body`.
|
|
62
|
+
- **Validated values arrive eagerly and fully typed in the second parameter**,
|
|
63
|
+
each typed as its schema's _output_ - so coercions and transforms land in the
|
|
64
|
+
handler already applied. Undeclared sources are **absent** from that
|
|
65
|
+
parameter, not `unknown` and not optional; reading one is a compile error
|
|
66
|
+
naming the key.
|
|
67
|
+
- **Mix libraries freely.** zod and valibot can sit in one declaration, or even
|
|
68
|
+
in one composed tuple. Async schemas are supported; the wrapper awaits them.
|
|
69
|
+
- **Your return type flows to Nitro's typed routes unchanged.** The wrapper
|
|
70
|
+
returns a plain h3 `EventHandler`, so `$fetch('/api/users/1')` infers the
|
|
71
|
+
response exactly as it would with `defineEventHandler`. Nothing to unwrap.
|
|
72
|
+
- `defineValidatedEventHandler` and `recognizeValidationError` are
|
|
73
|
+
**auto-imported inside `server/`**, the same ambient position as
|
|
74
|
+
`defineEventHandler`. Import them from
|
|
75
|
+
`@dphonys/nuxt-handler-validation/server` where auto-imports do not reach -
|
|
76
|
+
Nitro plugins and tasks, tests, non-Nuxt Nitro apps,
|
|
77
|
+
`imports.autoImport: false`. That entry carries the runtime and depends on
|
|
78
|
+
h3, so never import it from client code; use
|
|
79
|
+
`@dphonys/nuxt-handler-validation/types` for types in app code.
|
|
80
|
+
|
|
81
|
+
### The second parameter is the only door
|
|
82
|
+
|
|
83
|
+
Each source is read **once** per request, before your handler body runs.
|
|
84
|
+
Calling `readBody(event)` afterwards hands back h3's memoized _unvalidated_
|
|
85
|
+
parse - not what your schema produced. Read what you declared from the second
|
|
86
|
+
parameter and nothing has to be re-parsed or re-checked.
|
|
87
|
+
|
|
88
|
+
## Reusing and composing schemas
|
|
89
|
+
|
|
90
|
+
A reusable unit is a **plain schema value**. There is no definer, no set, no
|
|
91
|
+
group, and no name to register - a Standard Schema already travels as an
|
|
92
|
+
export, and it carries no source keys to misspell.
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
// server/validation/listing.ts
|
|
96
|
+
import * as v from 'valibot'
|
|
97
|
+
import { z } from 'zod'
|
|
98
|
+
|
|
99
|
+
export const pagination = z.object({
|
|
100
|
+
page: z.coerce.number(),
|
|
101
|
+
size: z.coerce.number(),
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
export const sorting = v.object({ sort: v.picklist(['asc', 'desc']) })
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Give a source a tuple to compose several schemas onto it:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
// server/api/users/index.get.ts
|
|
111
|
+
import { pagination, sorting } from '~~/server/validation/listing'
|
|
112
|
+
import { profileBody } from '~~/server/validation/profile'
|
|
113
|
+
|
|
114
|
+
export default defineValidatedEventHandler(
|
|
115
|
+
{ validate: { query: [pagination, sorting], body: profileBody } },
|
|
116
|
+
async (event, { query, body }) => {
|
|
117
|
+
// query: { page: number, size: number, sort: 'asc' | 'desc' }
|
|
118
|
+
return listUsers(query, body)
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
- **Every element parses the whole raw source, in order**, and you receive the
|
|
124
|
+
merge of their outputs. The wire is untouched - the client still sends a flat
|
|
125
|
+
`?page=1&size=20&sort=asc`, and an issue's `path` stays relative to that raw
|
|
126
|
+
source.
|
|
127
|
+
- **The merged type is one flat record**, not a chain of `&`. It reads on hover
|
|
128
|
+
the way the runtime value looks, and it is spellable as a declared return
|
|
129
|
+
type.
|
|
130
|
+
- **Issues aggregate within a source.** An early element's failure does not
|
|
131
|
+
stop the later ones, so reordering a tuple never changes what a client sees.
|
|
132
|
+
Elements run sequentially, so async schemas are deterministic.
|
|
133
|
+
- **A tuple, not an array.** A widened `StandardSchemaV1[]` cannot say how many
|
|
134
|
+
schemas it holds, so its merge could not be typed. Write the array literal
|
|
135
|
+
inline, or add `as const`.
|
|
136
|
+
- **A lone schema and a one-element tuple are identical.** `x` and `[x]`
|
|
137
|
+
deliver the same value, and neither has anything to merge - so a lone
|
|
138
|
+
schema's output may be anything, primitives and unions included.
|
|
139
|
+
- **A set spanning several sources is just an object of schema values.** Export
|
|
140
|
+
`{ headers, query }` from one file and let each route say which slot each
|
|
141
|
+
schema fills.
|
|
142
|
+
|
|
143
|
+
### Two rules for composed sources
|
|
144
|
+
|
|
145
|
+
Composing two or more schemas onto one source has exactly two compile-time
|
|
146
|
+
rules, both reported **at the offending source key, at the declaration**:
|
|
147
|
+
|
|
148
|
+
1. **Every composed output must be an object** - not a primitive, an array or a
|
|
149
|
+
function. The test is structural, so an interface-typed output (a
|
|
150
|
+
`z.custom<ThirdParty>()`, a hand-written schema) is not falsely refused.
|
|
151
|
+
2. **Their output keys must be pairwise disjoint.** An intersection would lie
|
|
152
|
+
about which value survives, so an overlap is refused. Merge at the schema
|
|
153
|
+
library level instead (`.extend`, `v.intersect`, …) or rename the key.
|
|
154
|
+
Composing the _same_ schema twice counts as an overlap.
|
|
155
|
+
|
|
156
|
+
A composed value satisfies each part structurally - `helper(query)` typed off
|
|
157
|
+
`pagination` alone still typechecks - but the other elements' keys ride along.
|
|
158
|
+
The promise is "existing keys do not change", not "the shape does not change".
|
|
159
|
+
|
|
160
|
+
## What each source receives
|
|
161
|
+
|
|
162
|
+
Sources are handed to your schemas exactly as h3 yields them. Nothing is
|
|
163
|
+
normalized or coerced on the way, because every conversion belongs in the
|
|
164
|
+
schema where the rest of your parsing rules live.
|
|
165
|
+
|
|
166
|
+
- **Order is guaranteed and fail-fast:
|
|
167
|
+
`routerParams -> query -> headers -> body`.** Cheap-and-sync first,
|
|
168
|
+
stream-consuming last, so an invalid route param spares the body parse. There
|
|
169
|
+
is no aggregate mode. Multiple issues _within_ one source still arrive
|
|
170
|
+
together, so a form with two bad fields needs one round trip.
|
|
171
|
+
- **Query** values are `string | string[]`; duplicate keys become arrays. All
|
|
172
|
+
coercion belongs in your schema (`z.coerce.number()` and friends).
|
|
173
|
+
- **Headers** have lowercase keys, with multi-values joined by `", "`. No
|
|
174
|
+
case-insensitivity or splitting magic is added.
|
|
175
|
+
- **Route params** arrive URL-decoded. A catch-all is one slash-joined decoded
|
|
176
|
+
string, under key `_` for an anonymous `[...].ts`.
|
|
177
|
+
- **A method that cannot carry a body validates `undefined`.** Outside h3's
|
|
178
|
+
payload methods (`PATCH`, `POST`, `PUT`, `DELETE`) the body read is skipped
|
|
179
|
+
entirely, so a method-agnostic route file that declares a body keeps working
|
|
180
|
+
for `GET` instead of answering h3's bare `405`.
|
|
181
|
+
- **An empty body validates `undefined`** too. Express "optional body" in the
|
|
182
|
+
schema, where all other optionality lives.
|
|
183
|
+
- **A body the request made unreadable fails like any other bad input**: any
|
|
184
|
+
`4xx` from the body read becomes exactly one issue,
|
|
185
|
+
`{ "source": "body", "message": "Request body could not be parsed", "path": [] }`.
|
|
186
|
+
The message never echoes body content. `5xx` and non-HTTP throws - a dropped
|
|
187
|
+
connection, a stream error - propagate untouched rather than being dressed up
|
|
188
|
+
as validation issues.
|
|
189
|
+
|
|
190
|
+
The body is read through h3's own validated-body door
|
|
191
|
+
(`readBody(event, { strict: true })`), so today
|
|
192
|
+
`application/x-www-form-urlencoded` arrives as an object of `string | string[]`
|
|
193
|
+
(HTML form posts validate for free), `text/*` arrives as the raw string, and
|
|
194
|
+
everything else parses strictly as JSON. **That branching is h3's, described
|
|
195
|
+
here rather than promised** - h3 v2 keeps none of it.
|
|
196
|
+
|
|
197
|
+
## When validation fails: the wire shape
|
|
198
|
+
|
|
199
|
+
A failing request answers `400` with one fixed shape - **no options, and
|
|
200
|
+
identical in development and production**:
|
|
201
|
+
|
|
202
|
+
```jsonc
|
|
203
|
+
{
|
|
204
|
+
"statusCode": 400,
|
|
205
|
+
"statusMessage": "Validation Error",
|
|
206
|
+
"message": "...", // a short human summary; nothing may parse it
|
|
207
|
+
"data": {
|
|
208
|
+
"issues": [
|
|
209
|
+
{ "source": "query", "message": "Expected number", "path": ["page"] },
|
|
210
|
+
],
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
- **Status is `400`, not configurable.** A fixed status is what makes the shape
|
|
216
|
+
stable and documentable.
|
|
217
|
+
- **Every issue is projected to `{ source, message, path }` - nothing else.**
|
|
218
|
+
Raw Standard Schema issues are not JSON-safe and can carry the request's own
|
|
219
|
+
input, so the projection is built by construction rather than by filtering:
|
|
220
|
+
vendor extras are dropped and `path` normalizes to `Array<string | number>`.
|
|
221
|
+
The projection **is** the sanitization, which is why there is no redaction
|
|
222
|
+
option and no production branch.
|
|
223
|
+
- Those four keys are this package's. The envelope around them is Nitro's - it
|
|
224
|
+
adds `url`, and a `stack` in development.
|
|
225
|
+
|
|
226
|
+
### Rendering issues on the client
|
|
227
|
+
|
|
228
|
+
`ValidationIssue` and `ValidationErrorData` come from
|
|
229
|
+
`@dphonys/nuxt-handler-validation/types`, which is type-only and safe to import
|
|
230
|
+
from components.
|
|
231
|
+
|
|
232
|
+
```vue
|
|
233
|
+
<script setup lang="ts">
|
|
234
|
+
import type { ValidationErrorData } from '@dphonys/nuxt-handler-validation/types'
|
|
235
|
+
import type { FetchError } from 'ofetch'
|
|
236
|
+
|
|
237
|
+
const fieldErrors = ref<Record<string, string>>({})
|
|
238
|
+
|
|
239
|
+
async function submit(body: unknown) {
|
|
240
|
+
fieldErrors.value = {}
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
await $fetch('/api/users', { method: 'POST', body })
|
|
244
|
+
} catch (error) {
|
|
245
|
+
const failure = error as FetchError<{ data?: ValidationErrorData }>
|
|
246
|
+
|
|
247
|
+
for (const issue of failure.data?.data?.issues ?? []) {
|
|
248
|
+
fieldErrors.value[issue.path.join('.')] = issue.message
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
</script>
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
**Note the double `data`.** `FetchError.data` is the whole error body, and this
|
|
256
|
+
package's payload is that body's `data` - so issues sit at
|
|
257
|
+
`err.data.data.issues`, and there is nothing at `err.data.issues`. Both `data`s
|
|
258
|
+
are the framework's.
|
|
259
|
+
|
|
260
|
+
## Observability
|
|
261
|
+
|
|
262
|
+
`recognizeValidationError` answers the issues a validation failure raised, or
|
|
263
|
+
`undefined` for "not a validation failure". It reads a symbol marker on the
|
|
264
|
+
error and nothing else - never `data`, never `cause` at any depth - and the
|
|
265
|
+
marker never reaches the wire.
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
// server/plugins/observability.ts
|
|
269
|
+
export default defineNitroPlugin((nitroApp) => {
|
|
270
|
+
nitroApp.hooks.hook('error', (error) => {
|
|
271
|
+
// A request that failed validation: expected, not a bug.
|
|
272
|
+
if (recognizeValidationError(error)) return
|
|
273
|
+
|
|
274
|
+
report(error)
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
The same predicate works in Sentry's `beforeSend` over
|
|
280
|
+
`hint.originalException`. It returns a value rather than being a type
|
|
281
|
+
predicate, so it pairs with the sibling package's `recognizeKnownError` in one
|
|
282
|
+
hook.
|
|
283
|
+
|
|
284
|
+
**Only a `400` validation failure is marked** - including the body read this
|
|
285
|
+
package absorbs into a single `body` issue. Every developer mistake the package
|
|
286
|
+
raises carries no marker, so the recipe above still reports all of them.
|
|
287
|
+
|
|
288
|
+
**What a marked error tells you, exactly:** it was raised by this package **in
|
|
289
|
+
this process**, and never arrived over a fetch. It is _not_ necessarily this
|
|
290
|
+
route's own declaration failing - an `H3Error` propagates in-process by
|
|
291
|
+
identity, so a directly called handler or shared function running the wrapper's
|
|
292
|
+
validation delivers an indistinguishable marked error. It also does not
|
|
293
|
+
guarantee the request answered `400`: an SWR revalidation fires the hook while
|
|
294
|
+
the response was served `200` from cache. Nitro types the hook's event argument
|
|
295
|
+
as optional, so write the hook against the error itself.
|
|
296
|
+
|
|
297
|
+
## Troubleshooting
|
|
298
|
+
|
|
299
|
+
### `satisfies`, never `: ValidationSchemas`
|
|
300
|
+
|
|
301
|
+
This is the one footgun worth memorizing. Annotating a declaration compiles,
|
|
302
|
+
but delivers no readable sources:
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
// Wrong: `validated.query` is a compile error, even though query is declared.
|
|
306
|
+
const schemas: ValidationSchemas = { query: pagination }
|
|
307
|
+
|
|
308
|
+
// Right: the inferred literal is what the second parameter is computed from.
|
|
309
|
+
const schemas = { query: pagination } satisfies ValidationSchemas
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
The annotation throws away the very value the inference needed. The second
|
|
313
|
+
parameter is mapped over the declaration's **inferred** keys, and annotating
|
|
314
|
+
replaces the literal's one known key with the interface's four optional ones -
|
|
315
|
+
so nothing is guaranteed and nothing is delivered. Leave the literal inline, or
|
|
316
|
+
use `satisfies`.
|
|
317
|
+
|
|
318
|
+
### Compile errors this package writes itself
|
|
319
|
+
|
|
320
|
+
A typo'd or stray key is rejected **even when it sits beside valid ones** -
|
|
321
|
+
`{ query: q, boyd: schema }` is a compile error, not a body that silently never
|
|
322
|
+
validates. The wrapper has one signature, so no rejection collapses into a
|
|
323
|
+
`TS2769: No overload matches this call` paragraph; every diagnostic lands at the
|
|
324
|
+
key that caused it. Three sentences are the whole surface:
|
|
325
|
+
|
|
326
|
+
```text
|
|
327
|
+
every schema composed on one source must produce an object output - not a primitive, an array or a function
|
|
328
|
+
schemas composed on one source must produce disjoint output keys - merge them in your schema library instead
|
|
329
|
+
'boyd' is not a validation source - the sources are routerParams, query, headers and body
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
A value that is not a schema is rejected at the offending property, and reading
|
|
333
|
+
an undeclared source in the handler reports as
|
|
334
|
+
`Property 'body' does not exist on type 'ValidatedContext<…>'`.
|
|
335
|
+
|
|
336
|
+
### Runtime errors for what the types cannot see
|
|
337
|
+
|
|
338
|
+
For plain-JS callers, `any`-typed schemas, and runtime values no type can
|
|
339
|
+
predict, the runtime mirrors the same rules. **None of these is marked**, so
|
|
340
|
+
`recognizeValidationError` returns `undefined` and your observability hook
|
|
341
|
+
still reports every one.
|
|
342
|
+
|
|
343
|
+
**At route evaluation**, before any request is served, a plain `Error`: a source
|
|
344
|
+
slot holding something that is not a Standard Schema, naming the source and the
|
|
345
|
+
element index. The route never becomes servable - which is what keeps a `null`
|
|
346
|
+
in a slot from becoming an unattributed
|
|
347
|
+
`TypeError: Cannot read properties of null (reading '~standard')` on every
|
|
348
|
+
request.
|
|
349
|
+
|
|
350
|
+
**Per request**, as a plain unmarked `500` naming the source:
|
|
351
|
+
|
|
352
|
+
| what happened | detected |
|
|
353
|
+
| ------------------------------------------------------------------------- | -------------------------------------------- |
|
|
354
|
+
| an element output that is not a plain object at merge time | after the tuple ran; names the element index |
|
|
355
|
+
| an element that reported neither a value nor any issue (`{ issues: [] }`) | after the tuple ran; names the element index |
|
|
356
|
+
| a source declared with an empty tuple, so nothing validated it | at merge time |
|
|
357
|
+
|
|
358
|
+
These are latent by nature - such a route serves `200`s until a request reaches
|
|
359
|
+
the case, and what a schema answers is unknowable without running it. The rule
|
|
360
|
+
behind all three: **a declared source must be delivered as something every
|
|
361
|
+
element actually contributed to.**
|
|
362
|
+
|
|
363
|
+
### Edges the compile-time guard does not catch
|
|
364
|
+
|
|
365
|
+
- **Overlap between outputs carrying an index signature is allowed.**
|
|
366
|
+
`z.looseObject`, `z.record` and any passthrough output widen `keyof` to
|
|
367
|
+
`string`, so the guard cannot compare them against a sibling's without
|
|
368
|
+
refusing every merge they appear in. Colliding keys are decided by the
|
|
369
|
+
runtime's later-wins spread, in tuple order.
|
|
370
|
+
- **An `any`-typed element is accepted, and takes the whole slot with it.** The
|
|
371
|
+
slot's delivered type becomes `any` - the honest report, since an element that
|
|
372
|
+
promises nothing cannot be merged into a promise.
|
|
373
|
+
- **Exotic object outputs pass the compile gate.** A `Date`, a `Map` or any
|
|
374
|
+
class instance _is_ structurally an object, so the declaration is accepted;
|
|
375
|
+
the runtime merge refuses it with the `500` above, because its meaning lives
|
|
376
|
+
outside its own enumerable keys.
|
|
377
|
+
- **A malformed `validate` object itself is an untyped `TypeError`.** The guard
|
|
378
|
+
covers what is _inside_ `validate`, not a `null` in the object's place.
|
|
379
|
+
|
|
380
|
+
## Turning the module off
|
|
381
|
+
|
|
382
|
+
`handlerValidation: false` disables the module - a real off-switch for the day
|
|
383
|
+
an auto-import collision needs isolating. It is not an options bag: a stray key
|
|
384
|
+
such as `handlerValidation: { channelToken: 'x' }` is a compile error.
|
|
385
|
+
|
|
386
|
+
## API reference
|
|
387
|
+
|
|
388
|
+
Runtime, from `@dphonys/nuxt-handler-validation/server`, both auto-imported
|
|
389
|
+
inside `server/`:
|
|
390
|
+
|
|
391
|
+
| Export | Role |
|
|
392
|
+
| ----------------------------------------------- | --------------------------------------------------------------------------------------- |
|
|
393
|
+
| `defineValidatedEventHandler({ validate }, fn)` | The wrapper. One signature. Returns a plain h3 `EventHandler`. |
|
|
394
|
+
| `recognizeValidationError(error)` | Observability predicate, process-side only. Returns `ValidationErrorData \| undefined`. |
|
|
395
|
+
|
|
396
|
+
Types, from `@dphonys/nuxt-handler-validation/types` - type-only, safe to
|
|
397
|
+
import from app code: `ValidationSchemas`, `SourceSchemas`, `ValidationSource`,
|
|
398
|
+
`ValidatedContext<S>`, `SourceValue<T>`, `MergedOutput<T>`, `OutputOf<S>`,
|
|
399
|
+
`ValidationIssue`, `ValidationErrorData`.
|
|
400
|
+
|
|
401
|
+
**On the name.** `defineValidatedEventHandler` mirrors the _current_ vanilla
|
|
402
|
+
`defineEventHandler`, so its role is obvious on sight. It is deliberately not
|
|
403
|
+
h3 v2's `defineValidatedHandler` - taking that name now would squat on the one
|
|
404
|
+
h3 will auto-import the day Nuxt ships it. Expect a rename at that alignment
|
|
405
|
+
point.
|
|
406
|
+
|
|
407
|
+
## Repository development
|
|
408
|
+
|
|
409
|
+
From the repository root:
|
|
410
|
+
|
|
411
|
+
```sh
|
|
412
|
+
pnpm --filter @dphonys/nuxt-handler-validation dev
|
|
413
|
+
pnpm --filter @dphonys/nuxt-handler-validation typecheck
|
|
414
|
+
pnpm --filter @dphonys/nuxt-handler-validation test
|
|
415
|
+
pnpm --filter @dphonys/nuxt-handler-validation build
|
|
416
|
+
pnpm --filter @dphonys/nuxt-handler-validation publint
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
## License
|
|
420
|
+
|
|
421
|
+
Licensed under the [MIT License](./LICENSE).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import * as _nuxt_schema from '@nuxt/schema';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The module has zero options. `Record<string, never>` rather than an open
|
|
5
|
+
* empty type, so a stray key in `handlerValidation` is a compile error while
|
|
6
|
+
* `{}` and the `false` off-switch still type-check.
|
|
7
|
+
*/
|
|
8
|
+
type ModuleOptions = Record<string, never>;
|
|
9
|
+
declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
|
|
10
|
+
|
|
11
|
+
export { _default as default };
|
|
12
|
+
export type { ModuleOptions };
|
package/dist/module.json
ADDED
package/dist/module.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { defineNuxtModule, createResolver, addServerImports } from '@nuxt/kit';
|
|
2
|
+
|
|
3
|
+
const module$1 = defineNuxtModule({
|
|
4
|
+
meta: {
|
|
5
|
+
name: "nuxt-handler-validation",
|
|
6
|
+
configKey: "handlerValidation",
|
|
7
|
+
// The ceiling is the only guard against the h3 v2 / Nitro 3 line.
|
|
8
|
+
compatibility: { nuxt: ">=4.5.1 <5.0.0" }
|
|
9
|
+
},
|
|
10
|
+
defaults: {},
|
|
11
|
+
setup() {
|
|
12
|
+
const serverEntry = createResolver(import.meta.url).resolve(
|
|
13
|
+
"./runtime/server/index"
|
|
14
|
+
);
|
|
15
|
+
addServerImports([
|
|
16
|
+
{ name: "defineValidatedEventHandler", from: serverEntry },
|
|
17
|
+
{ name: "recognizeValidationError", from: serverEntry }
|
|
18
|
+
]);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export { module$1 as default };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { EventHandler, EventHandlerRequest, EventHandlerResponse, H3Event } from 'h3';
|
|
2
|
+
import type { ValidatedContext, ValidationErrorData, ValidationSchemas } from '../types/index.js';
|
|
3
|
+
import type { ValidationSchemasGuard } from '../types/internal.js';
|
|
4
|
+
/**
|
|
5
|
+
* Declare what a route validates, and get the validated values eagerly in the
|
|
6
|
+
* handler's second parameter. Undeclared sources are absent from it rather than
|
|
7
|
+
* `unknown`, and the returned handler is an ordinary h3 `EventHandler`.
|
|
8
|
+
*
|
|
9
|
+
* Sources validate in the order `routerParams -> query -> headers -> body`,
|
|
10
|
+
* fail-fast across sources: the first failure answers `400` and no later source
|
|
11
|
+
* is read, while issues within one source arrive together. The second parameter
|
|
12
|
+
* is the only door to the validated values - reading the body again with
|
|
13
|
+
* `readBody` yields h3's memoized unvalidated parse.
|
|
14
|
+
*/
|
|
15
|
+
export declare function defineValidatedEventHandler<const S extends ValidationSchemas, Response extends EventHandlerResponse, Request extends EventHandlerRequest = EventHandlerRequest>(options: {
|
|
16
|
+
validate: S & ValidationSchemasGuard<S>;
|
|
17
|
+
}, handler: (event: H3Event<Request>, validated: ValidatedContext<S>) => Response): EventHandler<Request, Response>;
|
|
18
|
+
/**
|
|
19
|
+
* The issues a validation failure raised, or `undefined` for "not a validation
|
|
20
|
+
* failure" - for a Nitro `error` hook or a Sentry `beforeSend`.
|
|
21
|
+
*
|
|
22
|
+
* ```ts
|
|
23
|
+
* nitroApp.hooks.hook('error', (error) => {
|
|
24
|
+
* if (recognizeValidationError(error)) return
|
|
25
|
+
* report(error)
|
|
26
|
+
* })
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* Only a `400` validation failure is marked - every developer mistake this
|
|
30
|
+
* package raises carries no marker, so the early return cannot swallow a bug.
|
|
31
|
+
* The marker is a non-serialized symbol: recognition works on the live server
|
|
32
|
+
* error, never on a payload that already crossed the wire.
|
|
33
|
+
*/
|
|
34
|
+
export declare function recognizeValidationError(error: unknown): ValidationErrorData | undefined;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineEventHandler } from "h3";
|
|
2
|
+
import { readValidationMarker } from "../shared/error-marker.js";
|
|
3
|
+
import { sourcePlan, validatedContext } from "./lib/validate.js";
|
|
4
|
+
export function defineValidatedEventHandler(options, handler) {
|
|
5
|
+
const plan = sourcePlan(options.validate);
|
|
6
|
+
return defineEventHandler(
|
|
7
|
+
async (event) => handler(event, await validatedContext(event, plan))
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
export function recognizeValidationError(error) {
|
|
11
|
+
return readValidationMarker(error);
|
|
12
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
2
|
+
import type { ValidationSource } from '../../types/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* The one failure this package answers with: `400`, one fixed shape, identical
|
|
5
|
+
* in dev and prod, over one source's projected issues. It is also the only
|
|
6
|
+
* raise in the package that marks its error - what reaches here is a client's
|
|
7
|
+
* bad input, so an observability hook may skip it.
|
|
8
|
+
*/
|
|
9
|
+
export declare function raiseValidationError(source: ValidationSource, issues: readonly StandardSchemaV1.Issue[]): never;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createError } from "h3";
|
|
2
|
+
import { markValidationError } from "../../shared/error-marker.js";
|
|
3
|
+
function projectIssues(source, issues) {
|
|
4
|
+
return issues.map((issue) => ({
|
|
5
|
+
source,
|
|
6
|
+
message: issue.message,
|
|
7
|
+
path: projectPath(issue.path)
|
|
8
|
+
}));
|
|
9
|
+
}
|
|
10
|
+
function projectPath(path) {
|
|
11
|
+
if (path === void 0) return [];
|
|
12
|
+
return Array.from(path, (segment) => {
|
|
13
|
+
const key = segment !== null && typeof segment === "object" ? segment.key : segment;
|
|
14
|
+
return typeof key === "string" || typeof key === "number" ? key : String(key);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export function raiseValidationError(source, issues) {
|
|
18
|
+
const data = { issues: projectIssues(source, issues) };
|
|
19
|
+
const error = createError({
|
|
20
|
+
statusCode: 400,
|
|
21
|
+
statusMessage: "Validation Error",
|
|
22
|
+
message: `Validation failed for ${source}`,
|
|
23
|
+
data
|
|
24
|
+
});
|
|
25
|
+
markValidationError(error, data.issues);
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { H3Event } from 'h3';
|
|
2
|
+
import type { ValidationSource } from '../../types/index.js';
|
|
3
|
+
/** How one source is taken off the event. */
|
|
4
|
+
export type SourceReader = (event: H3Event) => unknown;
|
|
5
|
+
type SourceWalk<Remaining extends ValidationSource = ValidationSource> = [
|
|
6
|
+
Remaining
|
|
7
|
+
] extends [never] ? readonly [] : {
|
|
8
|
+
[S in Remaining]: readonly [
|
|
9
|
+
readonly [source: S, read: SourceReader],
|
|
10
|
+
...SourceWalk<Exclude<Remaining, S>>
|
|
11
|
+
];
|
|
12
|
+
}[Remaining];
|
|
13
|
+
/**
|
|
14
|
+
* The four sources in the order they validate - the promise that a bad route
|
|
15
|
+
* param means the body is never read. Each reaches its schema exactly as h3
|
|
16
|
+
* yields it: route params decoded and a catch-all slash-joined under one key,
|
|
17
|
+
* query values as `string | string[]`, header keys lowercased.
|
|
18
|
+
*/
|
|
19
|
+
export declare const SOURCE_WALK: SourceWalk;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { getQuery, getRequestHeaders, getRouterParams, readBody } from "h3";
|
|
2
|
+
import { raiseValidationError } from "./issues.js";
|
|
3
|
+
const PAYLOAD_METHODS = /* @__PURE__ */ new Set([
|
|
4
|
+
"PATCH",
|
|
5
|
+
"POST",
|
|
6
|
+
"PUT",
|
|
7
|
+
"DELETE"
|
|
8
|
+
]);
|
|
9
|
+
const UNPARSEABLE_BODY_MESSAGE = "Request body could not be parsed";
|
|
10
|
+
function isClientError(error) {
|
|
11
|
+
if (typeof error !== "object" || error === null || !("statusCode" in error)) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
const { statusCode } = error;
|
|
15
|
+
return typeof statusCode === "number" && statusCode >= 400 && statusCode < 500;
|
|
16
|
+
}
|
|
17
|
+
async function readBodyForValidation(event) {
|
|
18
|
+
if (!PAYLOAD_METHODS.has(event.method)) return void 0;
|
|
19
|
+
try {
|
|
20
|
+
return await readBody(event, { strict: true });
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (!isClientError(error)) throw error;
|
|
23
|
+
raiseValidationError("body", [{ message: UNPARSEABLE_BODY_MESSAGE }]);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export const SOURCE_WALK = [
|
|
27
|
+
["routerParams", (event) => getRouterParams(event, { decode: true })],
|
|
28
|
+
["query", (event) => getQuery(event)],
|
|
29
|
+
["headers", (event) => getRequestHeaders(event)],
|
|
30
|
+
["body", readBodyForValidation]
|
|
31
|
+
];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
2
|
+
import type { H3Event } from 'h3';
|
|
3
|
+
import type { ValidationSchemas, ValidationSource } from '../../types/index.js';
|
|
4
|
+
import type { SourceReader } from './sources.js';
|
|
5
|
+
interface SourcePlan {
|
|
6
|
+
readonly source: ValidationSource;
|
|
7
|
+
readonly read: SourceReader;
|
|
8
|
+
readonly schemas: readonly StandardSchemaV1[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Resolve a declaration, once, when the route file is evaluated. Walking
|
|
12
|
+
* `SOURCE_WALK` rather than the declaration's own keys is what makes the
|
|
13
|
+
* fail-fast order the package's promise instead of the author's key order.
|
|
14
|
+
*/
|
|
15
|
+
export declare function sourcePlan(schemas: ValidationSchemas): readonly SourcePlan[];
|
|
16
|
+
/** Run one request through the plan, in the plan's order. */
|
|
17
|
+
export declare function validatedContext(event: H3Event, plan: readonly SourcePlan[]): Promise<Record<string, unknown>>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createError } from "h3";
|
|
2
|
+
import { raiseValidationError } from "./issues.js";
|
|
3
|
+
import { SOURCE_WALK } from "./sources.js";
|
|
4
|
+
export function sourcePlan(schemas) {
|
|
5
|
+
const plan = [];
|
|
6
|
+
for (const [source, read] of SOURCE_WALK) {
|
|
7
|
+
const slot = schemas[source];
|
|
8
|
+
if (slot === void 0) continue;
|
|
9
|
+
const elements = [slot].flat();
|
|
10
|
+
for (const [position, element] of elements.entries()) {
|
|
11
|
+
if (!isStandardSchema(element)) raiseUnschemaedSource(source, position);
|
|
12
|
+
}
|
|
13
|
+
plan.push({ source, read, schemas: elements });
|
|
14
|
+
}
|
|
15
|
+
return plan;
|
|
16
|
+
}
|
|
17
|
+
function isStandardSchema(value) {
|
|
18
|
+
if (typeof value !== "object" || value === null) return false;
|
|
19
|
+
if (!("~standard" in value)) return false;
|
|
20
|
+
const standard = value["~standard"];
|
|
21
|
+
return typeof standard === "object" && standard !== null && "validate" in standard && typeof standard.validate === "function";
|
|
22
|
+
}
|
|
23
|
+
function raiseUnschemaedSource(source, position) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`[nuxt-handler-validation] cannot validate ${source}: the value at index ${position} is not a Standard Schema. A source slot holds a schema or a non-empty tuple of them - every element must carry a '~standard' property.`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
export async function validatedContext(event, plan) {
|
|
29
|
+
const validated = {};
|
|
30
|
+
for (const { source, read, schemas } of plan) {
|
|
31
|
+
validated[source] = await validatedValueFor(
|
|
32
|
+
source,
|
|
33
|
+
schemas,
|
|
34
|
+
await read(event)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return validated;
|
|
38
|
+
}
|
|
39
|
+
async function validatedValueFor(source, schemas, raw) {
|
|
40
|
+
const issues = [];
|
|
41
|
+
const outputs = [];
|
|
42
|
+
let unreportedAt;
|
|
43
|
+
for (const [position, schema] of schemas.entries()) {
|
|
44
|
+
const result = await schema["~standard"].validate(raw);
|
|
45
|
+
if (result.issues === void 0) {
|
|
46
|
+
outputs.push(result.value);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (result.issues.length === 0) unreportedAt ??= position;
|
|
50
|
+
else issues.push(...result.issues);
|
|
51
|
+
}
|
|
52
|
+
if (issues.length > 0) raiseValidationError(source, issues);
|
|
53
|
+
if (unreportedAt !== void 0) raiseUnreportedFailure(source, unreportedAt);
|
|
54
|
+
return mergeOutputs(source, outputs);
|
|
55
|
+
}
|
|
56
|
+
function mergeOutputs(source, outputs) {
|
|
57
|
+
if (outputs.length === 0) raiseUnvalidatedSource(source);
|
|
58
|
+
if (outputs.length === 1) return outputs[0];
|
|
59
|
+
const merged = {};
|
|
60
|
+
for (const [position, value] of outputs.entries()) {
|
|
61
|
+
if (!isPlainObject(value)) raiseUnmergeableOutput(source, position, value);
|
|
62
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
63
|
+
Object.defineProperty(merged, key, {
|
|
64
|
+
value: entry,
|
|
65
|
+
writable: true,
|
|
66
|
+
enumerable: true,
|
|
67
|
+
configurable: true
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return merged;
|
|
72
|
+
}
|
|
73
|
+
function isPlainObject(value) {
|
|
74
|
+
if (typeof value !== "object" || value === null) return false;
|
|
75
|
+
const prototype = Object.getPrototypeOf(value);
|
|
76
|
+
return prototype === Object.prototype || prototype === null;
|
|
77
|
+
}
|
|
78
|
+
function raiseSourceFault(message) {
|
|
79
|
+
throw createError({
|
|
80
|
+
statusCode: 500,
|
|
81
|
+
message: `[nuxt-handler-validation] ${message}`
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
function raiseUnmergeableOutput(source, position, value) {
|
|
85
|
+
raiseSourceFault(
|
|
86
|
+
`cannot merge the validated ${source}: the schema at index ${position} produced ${describeValue(value)}. Schemas composed on one source merge their outputs, so every element of the tuple must produce a plain object.`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
function raiseUnreportedFailure(source, position) {
|
|
90
|
+
raiseSourceFault(
|
|
91
|
+
`cannot deliver the validated ${source}: the schema at index ${position} reported a failure with no issues. A Standard Schema answers with a value or with at least one issue, so this result names neither an output to deliver nor a reason to reject the request.`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
function raiseUnvalidatedSource(source) {
|
|
95
|
+
raiseSourceFault(
|
|
96
|
+
`cannot deliver the validated ${source}: no schema ran for it. A source slot holds a schema or a non-empty tuple of them - remove the key instead of declaring it empty.`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
function describeValue(value) {
|
|
100
|
+
if (value === null) return "null";
|
|
101
|
+
if (Array.isArray(value)) return "an array";
|
|
102
|
+
if (typeof value === "object") {
|
|
103
|
+
return `an instance of ${value.constructor?.name ?? "an anonymous class"}`;
|
|
104
|
+
}
|
|
105
|
+
return `a value of type ${typeof value}`;
|
|
106
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { H3Error } from 'h3';
|
|
2
|
+
import type { ValidationErrorData, ValidationIssue } from '../types/index.js';
|
|
3
|
+
export declare const VALIDATION_ERROR_KEY: unique symbol;
|
|
4
|
+
/**
|
|
5
|
+
* Hang the marker on a validation failure, so an observability hook can tell a
|
|
6
|
+
* client's bad input from a bug. Non-enumerable and symbol-keyed, so it
|
|
7
|
+
* survives none of the copies the error takes on its way out; and it is this
|
|
8
|
+
* function's own snapshot, sharing no object with the enumerable `data` a
|
|
9
|
+
* middleware downstream can edit.
|
|
10
|
+
*
|
|
11
|
+
* The carrier must be an `H3Error`: h3's `createError` is the identity function
|
|
12
|
+
* for values passing its `__h3_error__` duck check, while any other thrown
|
|
13
|
+
* value is re-wrapped into a fresh error that copies no symbols.
|
|
14
|
+
*/
|
|
15
|
+
export declare function markValidationError(error: H3Error, issues: readonly ValidationIssue[]): void;
|
|
16
|
+
export declare function readValidationMarker(error: unknown): ValidationErrorData | undefined;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const VALIDATION_ERROR_KEY = Symbol.for(
|
|
2
|
+
"@dphonys/nuxt-handler-validation:error"
|
|
3
|
+
);
|
|
4
|
+
export function markValidationError(error, issues) {
|
|
5
|
+
const marked = {
|
|
6
|
+
issues: issues.map((issue) => ({ ...issue, path: [...issue.path] }))
|
|
7
|
+
};
|
|
8
|
+
Object.defineProperty(error, VALIDATION_ERROR_KEY, {
|
|
9
|
+
value: marked,
|
|
10
|
+
enumerable: false
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
export function readValidationMarker(error) {
|
|
14
|
+
const marker = error?.[VALIDATION_ERROR_KEY];
|
|
15
|
+
return typeof marker === "object" && marker !== null && Array.isArray(marker.issues) ? marker : void 0;
|
|
16
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
2
|
+
/** The four sources, in the settled fail-fast order. Every key is optional. */
|
|
3
|
+
export interface ValidationSchemas {
|
|
4
|
+
routerParams?: SourceSchemas;
|
|
5
|
+
query?: SourceSchemas;
|
|
6
|
+
headers?: SourceSchemas;
|
|
7
|
+
body?: SourceSchemas;
|
|
8
|
+
}
|
|
9
|
+
/** `'routerParams' | 'query' | 'headers' | 'body'`. */
|
|
10
|
+
export type ValidationSource = keyof ValidationSchemas;
|
|
11
|
+
/**
|
|
12
|
+
* What one source slot accepts: a schema, or a non-empty tuple of schemas that
|
|
13
|
+
* each parse the same raw source and whose outputs merge. A tuple, not an
|
|
14
|
+
* array, because a widened `StandardSchemaV1[]` cannot say how many schemas it
|
|
15
|
+
* holds and the merged output could not be typed.
|
|
16
|
+
*/
|
|
17
|
+
export type SourceSchemas = StandardSchemaV1 | readonly [StandardSchemaV1, ...StandardSchemaV1[]];
|
|
18
|
+
/** A schema's output (`InferOutput`), so transforms land already applied. */
|
|
19
|
+
export type OutputOf<Schema> = Schema extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<Schema> : never;
|
|
20
|
+
type Flattened<T> = string extends keyof T ? T : number extends keyof T ? T : symbol extends keyof T ? T : {
|
|
21
|
+
[K in keyof T]: T[K];
|
|
22
|
+
};
|
|
23
|
+
type Merged<Head, Rest> = Head extends unknown ? Rest extends unknown ? Flattened<Head & Rest> : never : never;
|
|
24
|
+
/**
|
|
25
|
+
* A composed tuple's delivered value: element by element rather than through
|
|
26
|
+
* `UnionToIntersection`, so a schema outputting a union of objects stays a
|
|
27
|
+
* union through the merge.
|
|
28
|
+
*/
|
|
29
|
+
export type MergedOutput<T> = T extends readonly [
|
|
30
|
+
infer Head extends StandardSchemaV1,
|
|
31
|
+
...infer Rest
|
|
32
|
+
] ? Rest extends readonly [StandardSchemaV1, ...StandardSchemaV1[]] ? Merged<OutputOf<Head>, MergedOutput<Rest>> : OutputOf<Head> : never;
|
|
33
|
+
/** One slot's delivered value: a lone schema's output, or the tuple's merge. */
|
|
34
|
+
export type SourceValue<T> = T extends readonly [
|
|
35
|
+
StandardSchemaV1,
|
|
36
|
+
...StandardSchemaV1[]
|
|
37
|
+
] ? MergedOutput<T> : OutputOf<T>;
|
|
38
|
+
/**
|
|
39
|
+
* The handler's second parameter: exactly the sources the declaration
|
|
40
|
+
* guarantees, each typed as its slot's delivered value. A key is guaranteed
|
|
41
|
+
* only when its slot type cannot be `undefined`, so a declaration annotated
|
|
42
|
+
* `const schemas: ValidationSchemas = { query }` guarantees nothing and
|
|
43
|
+
* delivers no sources; use `satisfies ValidationSchemas` instead.
|
|
44
|
+
*/
|
|
45
|
+
export type ValidatedContext<S extends ValidationSchemas> = {
|
|
46
|
+
[K in Extract<keyof S, ValidationSource> as undefined extends S[K] ? never : K]: SourceValue<S[K]>;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* One projected issue - the whole of what a client is told about a rejected
|
|
50
|
+
* value. Raw Standard Schema issues never reach it.
|
|
51
|
+
*/
|
|
52
|
+
export interface ValidationIssue {
|
|
53
|
+
source: ValidationSource;
|
|
54
|
+
message: string;
|
|
55
|
+
path: Array<string | number>;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The h3 error's `data` payload on the wire, and what
|
|
59
|
+
* `recognizeValidationError` hands a hook. A fetched failure sits at
|
|
60
|
+
* `err.data.data.issues`.
|
|
61
|
+
*/
|
|
62
|
+
export interface ValidationErrorData {
|
|
63
|
+
issues: ValidationIssue[];
|
|
64
|
+
}
|
|
65
|
+
export {};
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
2
|
+
import type { OutputOf, ValidationSource } from './index.js';
|
|
3
|
+
/**
|
|
4
|
+
* A rule the declaration broke. Nothing the author wrote can satisfy it, so the
|
|
5
|
+
* diagnostic prints at the offending source key with this message inside it.
|
|
6
|
+
*/
|
|
7
|
+
export interface ValidationDeclarationError<Msg extends string> {
|
|
8
|
+
'validation-declaration-error': Msg;
|
|
9
|
+
}
|
|
10
|
+
/** The keys of any member of a union - distributes, unlike bare `keyof`. */
|
|
11
|
+
type KeysOfUnion<T> = T extends unknown ? keyof T : never;
|
|
12
|
+
type NamedKey<K> = string extends K ? never : number extends K ? never : symbol extends K ? never : K;
|
|
13
|
+
type NamedKeys<T> = NamedKey<KeysOfUnion<T>>;
|
|
14
|
+
type IsAny<T> = 0 extends 1 & T ? true : false;
|
|
15
|
+
type ElementOutputs<T extends readonly StandardSchemaV1[]> = {
|
|
16
|
+
[I in keyof T]: OutputOf<T[I]>;
|
|
17
|
+
};
|
|
18
|
+
type HasKeyOverlap<Outputs extends readonly unknown[]> = Outputs extends readonly [
|
|
19
|
+
infer Head,
|
|
20
|
+
...infer Rest extends readonly unknown[]
|
|
21
|
+
] ? [NamedKeys<Head> & NamedKeys<Rest[number]>] extends [never] ? HasKeyOverlap<Rest> : true : false;
|
|
22
|
+
type IsMergeableOutput<O> = IsAny<O> extends true ? true : O extends object ? O extends readonly unknown[] | ((...args: never[]) => unknown) ? false : true : false;
|
|
23
|
+
type ComposableSlot<T> = T extends readonly [StandardSchemaV1] ? unknown : T extends readonly [StandardSchemaV1, ...StandardSchemaV1[]] ? [IsMergeableOutput<OutputOf<T[number]>>] extends [true] ? HasKeyOverlap<ElementOutputs<T>> extends true ? ValidationDeclarationError<'schemas composed on one source must produce disjoint output keys - merge them in your schema library instead'> : unknown : ValidationDeclarationError<'every schema composed on one source must produce an object output - not a primitive, an array or a function'> : unknown;
|
|
24
|
+
/**
|
|
25
|
+
* The guard the `validate` parameter intersects with, so a misspelled key
|
|
26
|
+
* beside a valid one is a compile error at that key rather than a source that
|
|
27
|
+
* silently never validates.
|
|
28
|
+
*/
|
|
29
|
+
export type ValidationSchemasGuard<S> = {
|
|
30
|
+
[K in keyof S]: K extends ValidationSource ? ComposableSlot<S[K]> : K extends string ? ValidationDeclarationError<`'${K}' is not a validation source - the sources are routerParams, query, headers and body`> : never;
|
|
31
|
+
};
|
|
32
|
+
export {};
|
|
File without changes
|
package/dist/types.d.mts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dphonys/nuxt-handler-validation",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Declare a Nitro handler's request schemas once and receive the validated, fully-typed values in the handler's second parameter.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"nuxt",
|
|
7
|
+
"nuxt-handler-validation",
|
|
8
|
+
"nuxt-module"
|
|
9
|
+
],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/DPHonys/dph-nuxt-stuff.git",
|
|
14
|
+
"directory": "packages/nuxt-handler-validation"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "./dist/module.mjs",
|
|
21
|
+
"typesVersions": {
|
|
22
|
+
"*": {
|
|
23
|
+
".": [
|
|
24
|
+
"./dist/types.d.mts"
|
|
25
|
+
],
|
|
26
|
+
"types": [
|
|
27
|
+
"./dist/runtime/types/index.d.ts"
|
|
28
|
+
],
|
|
29
|
+
"server": [
|
|
30
|
+
"./dist/runtime/server/index.d.ts"
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"exports": {
|
|
35
|
+
".": {
|
|
36
|
+
"types": "./dist/types.d.mts",
|
|
37
|
+
"import": "./dist/module.mjs"
|
|
38
|
+
},
|
|
39
|
+
"./types": {
|
|
40
|
+
"types": "./dist/runtime/types/index.d.ts",
|
|
41
|
+
"import": "./dist/runtime/types/index.js"
|
|
42
|
+
},
|
|
43
|
+
"./server": {
|
|
44
|
+
"types": "./dist/runtime/server/index.d.ts",
|
|
45
|
+
"import": "./dist/runtime/server/index.js"
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@nuxt/kit": "^4.5.1",
|
|
53
|
+
"@standard-schema/spec": "^1.1.0",
|
|
54
|
+
"h3": "^1.15.11"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@nuxt/devtools": "^3.3.1",
|
|
58
|
+
"@nuxt/module-builder": "^1.0.3",
|
|
59
|
+
"@nuxt/schema": "^4.5.1",
|
|
60
|
+
"@nuxt/test-utils": "^4.1.0",
|
|
61
|
+
"@types/node": "latest",
|
|
62
|
+
"nuxt": "^4.5.1",
|
|
63
|
+
"publint": "^0.3.22",
|
|
64
|
+
"typescript": "^5.9.3",
|
|
65
|
+
"valibot": "^1.4.2",
|
|
66
|
+
"vitest": "^4.1.10",
|
|
67
|
+
"vue-tsc": "^3.3.8",
|
|
68
|
+
"zod": "^4.4.3"
|
|
69
|
+
},
|
|
70
|
+
"engines": {
|
|
71
|
+
"node": "^22.19.0 || ^24.11.0 || >=26.0.0"
|
|
72
|
+
},
|
|
73
|
+
"scripts": {
|
|
74
|
+
"build": "nuxt-module-build build",
|
|
75
|
+
"dev": "pnpm run dev:prepare && nuxt dev playground",
|
|
76
|
+
"dev:build": "nuxt build playground",
|
|
77
|
+
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt prepare playground",
|
|
78
|
+
"lint": "eslint .",
|
|
79
|
+
"prebuild": "nuxt-module-build prepare",
|
|
80
|
+
"pretest": "nuxt-module-build prepare",
|
|
81
|
+
"pretypecheck": "pnpm run build",
|
|
82
|
+
"publint": "publint",
|
|
83
|
+
"test": "vitest run",
|
|
84
|
+
"test:watch": "vitest watch",
|
|
85
|
+
"typecheck": "nuxt prepare playground && vue-tsc --noEmit && vue-tsc --noEmit --project playground/tsconfig.json"
|
|
86
|
+
}
|
|
87
|
+
}
|