@ddd-ts-kit/validation 0.16.0 → 0.16.1

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 (3) hide show
  1. package/README.fr.md +372 -0
  2. package/README.md +172 -16
  3. package/package.json +2 -2
package/README.fr.md ADDED
@@ -0,0 +1,372 @@
1
+ [English](README.md) · **Français**
2
+ <!-- translation-of: README.md sha256:a856ad78d158765f -->
3
+
4
+ # @ddd-ts-kit/validation
5
+
6
+ Un seul contrat `Schema` pour la validation des entrées à la frontière, avec des adaptateurs pour
7
+ zod, class-validator, TypeBox et toute bibliothèque Standard Schema. Les routes, les DTO et le
8
+ générateur OpenAPI dépendent du contrat, si bien que la bibliothèque de validation peut changer
9
+ sans qu'on y touche.
10
+
11
+ Les règles métier n'ont pas leur place ici : elles vivent dans le domaine et n'importent jamais
12
+ de bibliothèque de validation.
13
+
14
+ ![Une requête HTTP à la frontière : ses params, query, headers et body passent chacun par le Schema que la route déclare ; quand chaque partie est analysée, le gestionnaire reçoit des valeurs typées et le domaine vérifie les règles métier ; quand une partie échoue, ses problèmes sont rassemblés avec le nom de la partie devant leur chemin, comme body.items.0.sku, et la requête HTTP est refusée avec une seule ValidationError, code VALIDATION_FAILED, traduite en réponse 422 avec la liste des problèmes ; les mêmes schémas donnent le JSON Schema du document OpenAPI](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/validation-package-at-the-boundary.fr.svg)
15
+
16
+ Un schéma vérifie la **forme** de ce qui entre : types, champs obligatoires, formats, longueurs.
17
+ Ce que le métier décide (un créneau dans le passé, une quantité au-delà du stock) est vérifié par
18
+ le domaine, quel que soit le point d'entrée :
19
+
20
+ <!-- checked sample -->
21
+ ```ts
22
+ import { z } from 'zod';
23
+ import { ConceptError } from '@ddd-ts-kit/kernel';
24
+ import { zodSchema } from '@ddd-ts-kit/validation/zod';
25
+
26
+ // Au lieu de : une règle métier cachée dans le schéma d'une route, qu'un job ou un consommateur ne rencontre jamais.
27
+ export const bookWithARule = zodSchema(
28
+ z.object({ startsAt: z.iso.datetime() }).refine((body) => Date.parse(body.startsAt) > Date.now(), 'The slot is in the past.'),
29
+ );
30
+
31
+ // Écrivez : la forme dans le schéma...
32
+ export const book = zodSchema(z.object({ startsAt: z.iso.datetime() }));
33
+
34
+ // ... et la règle dans le domaine, avec son propre code de refus.
35
+ export class SlotError extends ConceptError {
36
+ static inThePast(startsAt: Date) {
37
+ return SlotError.refusal('SLOT_IN_THE_PAST', `The slot at ${startsAt.toISOString()} is in the past.`, {
38
+ startsAt: startsAt.toISOString(),
39
+ });
40
+ }
41
+ }
42
+
43
+ export function startingAt(startsAt: Date, now: Date): Date {
44
+ if (startsAt <= now) {
45
+ throw SlotError.inThePast(startsAt);
46
+ }
47
+
48
+ return startsAt;
49
+ }
50
+ ```
51
+
52
+ ## Le contrat
53
+
54
+ ```ts
55
+ import { parseOrThrow, type Infer, type Schema } from '@ddd-ts-kit/validation';
56
+
57
+ interface Schema<T> {
58
+ parse(input: unknown): Result<T, ValidationIssue[]>;
59
+ toJsonSchema(): JsonSchema;
60
+ }
61
+
62
+ type ValidationIssue = { path: string; code: string; message: string }; // path : "items.0.sku"
63
+ ```
64
+
65
+ Un schéma est une valeur : une par entrée, confiée à la route ou à l'événement qui valide avec
66
+ elle. Il n'est jamais lié dans un conteneur, c'est donc une interface et non un port injectable
67
+ (une classe abstraite) : il n'y a rien à déclarer dans une racine de composition.
68
+
69
+ - `parse` ne lève jamais d'exception pour une entrée invalide : il renvoie `err(issues)`.
70
+ - `parseOrThrow(schema, input)` renvoie la valeur ou lève une `ValidationError`, une
71
+ `DomainError` de code `VALIDATION_FAILED` qui porte les `issues`.
72
+ - `Infer<typeof schema>` donne le type analysé.
73
+ - `customSchema(parse, jsonSchema?)` construit un schéma à partir d'une simple fonction.
74
+ - `sharedSchema(name, schema)` documente un schéma une seule fois, sous `name` (voir
75
+ [schémas partagés](#schémas-partagés)).
76
+
77
+ <!-- checked sample -->
78
+ ```ts
79
+ import { err, ok } from '@ddd-ts-kit/kernel';
80
+ import { ValidationError, customSchema, parseOrThrow, type Infer } from '@ddd-ts-kit/validation';
81
+
82
+ // Un schéma à partir d'une simple fonction : une vérification écrite à la main, ou un double dans un test.
83
+ const quantity = customSchema(
84
+ (input: unknown) =>
85
+ typeof input === 'number' && Number.isInteger(input) && input > 0
86
+ ? ok(input)
87
+ : err([{ path: '', code: 'not_a_quantity', message: 'A positive whole number is expected.' }]),
88
+ { type: 'integer', minimum: 1 },
89
+ );
90
+
91
+ type Quantity = Infer<typeof quantity>; // number
92
+
93
+ export const refused = quantity.parse('3'); // { ok: false, error: [{ path: '', code: 'not_a_quantity', ... }] }
94
+ export const three: Quantity = parseOrThrow(quantity, 3); // 3
95
+
96
+ try {
97
+ parseOrThrow(quantity, 0);
98
+ } catch (error) {
99
+ if (error instanceof ValidationError) {
100
+ console.log(error.code, error.issues); // VALIDATION_FAILED [{ path: '', code: 'not_a_quantity', ... }]
101
+ }
102
+ }
103
+ ```
104
+
105
+ Le point d'entrée racine n'importe aucun *package* tiers. Chaque adaptateur vit dans son propre
106
+ sous-chemin et sa bibliothèque est une dépendance *peer* facultative : n'installez que celle que
107
+ vous utilisez.
108
+
109
+ ## À la route
110
+
111
+ Une route de `@ddd-ts-kit/http` nomme un schéma pour chaque partie de la requête HTTP qu'elle
112
+ lit. Chaque partie est analysée avant l'exécution du gestionnaire (handler), les problèmes de
113
+ toutes les parties sont renvoyés ensemble, et le chemin de chaque problème commence par sa
114
+ partie :
115
+
116
+ <!-- checked sample -->
117
+ ```ts
118
+ import { z } from 'zod';
119
+ import { defineRoute, executeRoute, type RawRequest } from '@ddd-ts-kit/http';
120
+ import { zodSchema } from '@ddd-ts-kit/validation/zod';
121
+
122
+ const placeOrder = defineRoute(
123
+ {
124
+ method: 'POST',
125
+ path: '/orders',
126
+ operationId: 'placeOrder',
127
+ summary: 'Place an order',
128
+ request: {
129
+ body: zodSchema(z.object({ items: z.array(z.object({ sku: z.string().min(1), quantity: z.number().int().positive() })).min(1) })),
130
+ },
131
+ responses: { 201: { description: 'The order is placed.', body: zodSchema(z.object({ lines: z.number() }), { io: 'output' }) } },
132
+ security: false,
133
+ },
134
+ async ({ body }) => ({ status: 201, body: { lines: body.items.length } }), // body est typé : { items: { sku, quantity }[] }
135
+ );
136
+
137
+ const request = (body: unknown): RawRequest => ({ params: {}, query: {}, headers: {}, body, raw: undefined });
138
+
139
+ export const placed = await executeRoute(placeOrder, request({ items: [{ sku: 'A-1', quantity: 2 }] })); // 201, { lines: 1 }
140
+ export const refused = await executeRoute(placeOrder, request({ items: [{ sku: 'A-1', quantity: 0 }] }));
141
+ // 422, un corps problem+json avec le code VALIDATION_FAILED et
142
+ // issues: [{ path: 'body.items.0.quantity', code: 'too_small', message: 'Too small: expected number to be >0' }]
143
+ ```
144
+
145
+ Le gestionnaire ne voit jamais une requête HTTP invalide, et le document OpenAPI de la route
146
+ décrit le même corps, à partir de `toJsonSchema()`.
147
+
148
+ ## zod
149
+
150
+ ```sh
151
+ npm i zod
152
+ ```
153
+
154
+ <!-- checked sample -->
155
+ ```ts
156
+ import { z } from 'zod';
157
+ import { zodSchema } from '@ddd-ts-kit/validation/zod';
158
+
159
+ declare const input: unknown; // ce que portait la requête HTTP
160
+
161
+ const placeOrder = zodSchema(
162
+ z.object({
163
+ customerId: z.string().uuid(),
164
+ items: z.array(z.object({ sku: z.string().min(1), quantity: z.number().int().positive() })).min(1),
165
+ }),
166
+ );
167
+
168
+ const result = placeOrder.parse(input);
169
+ if (!result.ok) {
170
+ console.log(result.error); // [{ path: 'items.0.sku', code: 'too_small', message: '...' }]
171
+ }
172
+ ```
173
+
174
+ Le JSON Schema vient de `z.toJSONSchema` de zod 4 (draft 2020-12, utilisable tel quel dans
175
+ OpenAPI 3.1). Il décrit le côté entrée par défaut ; passez `{ io: 'output' }` pour un corps de
176
+ réponse. Un type que JSON Schema ne sait pas représenter (une `Date`, un `bigint`) est écrit `{}`
177
+ plutôt que de faire échouer la génération.
178
+
179
+ ## class-validator
180
+
181
+ ```sh
182
+ npm i class-validator class-transformer class-validator-jsonschema reflect-metadata
183
+ ```
184
+
185
+ <!-- checked sample: legacy-decorators -->
186
+ ```ts
187
+ import { Type } from 'class-transformer';
188
+ import { ArrayMinSize, IsInt, IsString, IsUUID, Min, ValidateNested } from 'class-validator';
189
+ import { classSchema } from '@ddd-ts-kit/validation/class-validator';
190
+
191
+ class OrderItemDto {
192
+ @IsString() sku = '';
193
+ @IsInt() @Min(1) quantity = 0;
194
+ }
195
+
196
+ class PlaceOrderDto {
197
+ @IsUUID() customerId = '';
198
+
199
+ @ValidateNested({ each: true })
200
+ @Type(() => OrderItemDto)
201
+ @ArrayMinSize(1)
202
+ items: OrderItemDto[] = [];
203
+ }
204
+
205
+ export const placeOrder = classSchema(PlaceOrderDto);
206
+ ```
207
+
208
+ `parse` renvoie une instance de la classe. `whitelist` et `forbidNonWhitelisted` sont activés par
209
+ défaut : une propriété non déclarée est un problème. Modifiez-le avec
210
+ `classSchema(Dto, { validator: { forbidNonWhitelisted: false }, transform: { ... } })`. Les
211
+ erreurs imbriquées sont aplaties (`items.0.quantity`), un problème par contrainte en échec, le
212
+ nom de la contrainte en `code`. Le JSON Schema est en draft 2020-12 et porte sous `$defs` les
213
+ classes imbriquées atteignables depuis la classe.
214
+
215
+ Exige `experimentalDecorators` dans le `tsconfig.json` du projet.
216
+
217
+ Une classe DTO déclarée une fois est réutilisée entière (`classSchema(SlotDto, { shared: true })`,
218
+ ou imbriquée avec `@ValidateNested()` et `@Type`), étendue (`class BookBody extends SlotDto { ... }`)
219
+ ou en partie, par trois utilitaires qui copient les contraintes, les types imbriqués et les
220
+ transformations des propriétés qu'ils gardent :
221
+
222
+ <!-- checked sample: legacy-decorators -->
223
+ ```ts
224
+ import { IsEmail, IsIn, IsISO8601, IsString, IsUUID } from 'class-validator';
225
+ import { omitClass, partialClass, pickClass } from '@ddd-ts-kit/validation/class-validator';
226
+
227
+ class AppointmentDto {
228
+ @IsUUID() patientId = '';
229
+ @IsISO8601() startsAt = '';
230
+ @IsISO8601() endsAt = '';
231
+ @IsIn(['booked', 'cancelled']) status = 'booked';
232
+ }
233
+
234
+ class PatientDto {
235
+ @IsString() name = '';
236
+ @IsEmail() email = '';
237
+ }
238
+
239
+ export class RescheduleBody extends pickClass(AppointmentDto, ['startsAt', 'endsAt']) {}
240
+ export class BookBody extends omitClass(AppointmentDto, ['status']) {}
241
+ export class EditPatientBody extends partialClass(PatientDto) {} // chaque propriété facultative, vérifiée quand elle est présente
242
+ ```
243
+
244
+ La classe qui étend un utilitaire nomme la forme dans le contrat. Garder une propriété qui ne
245
+ porte aucune contrainte est refusé : la validation la retirerait comme inconnue. Une classe
246
+ dérivée imbriquée dans une autre par `@Type` déclare au moins une propriété qui lui est propre :
247
+ class-validator-jsonschema ne documente une classe qu'à partir de ses propres décorateurs.
248
+
249
+ ## TypeBox
250
+
251
+ ```sh
252
+ npm i typebox
253
+ ```
254
+
255
+ <!-- checked sample -->
256
+ ```ts
257
+ import Type from 'typebox';
258
+ import { typeboxSchema } from '@ddd-ts-kit/validation/typebox';
259
+
260
+ export const slot = Type.Object({ startsAt: Type.String({ format: 'date-time' }), minutes: Type.Integer({ default: 30 }) }, { $id: 'Slot' });
261
+ export const book = typeboxSchema(Type.Object({ patientId: Type.String({ minLength: 1 }), slot }));
262
+ export const page = typeboxSchema(Type.Object({ limit: Type.Integer() }), { coerce: true }); // chaînes de requête
263
+ ```
264
+
265
+ Le schéma est compilé une fois (`typebox/compile`). `parse` remplit les valeurs par défaut et
266
+ retire les propriétés que le schéma ne déclare pas, comme le fait zod, puis vérifie ; le code du
267
+ problème est le mot-clé JSON Schema qui a échoué (`required`, `format`, `minLength`...).
268
+ `{ coerce: true }` convertit d'abord le texte vers les types déclarés, pour les paramètres de
269
+ chemin et les chaînes de requête. Un schéma TypeBox est déjà du JSON Schema : `toJsonSchema()` le
270
+ renvoie, un schéma imbriqué construit avec un `$id` étant déplacé sous `$defs` et référencé, pour
271
+ qu'il n'arrive qu'une fois dans le contrat ; un schéma qui a son propre `$id` est partagé. Une
272
+ propriété manquante est signalée à son propre chemin (`slot.startsAt`), comme zod la signale, et
273
+ deux schémas différents sous un même `$id` sont refusés. `Type.Pick`, `Type.Omit` et
274
+ `Type.Partial` réutilisent un schéma en partie, comme une nouvelle forme ;
275
+ `Type.Intersect([slot, Type.Object({...})])` l'étend et garde la partie partagée sous forme de
276
+ référence (`allOf: [{ $ref: Slot }, ...]`), tandis que `Type.Interface([slot], {...})` écrit une
277
+ nouvelle forme à plat. (`Type.Composite` appartient à `@sinclair/typebox` 0.34 et n'existe pas
278
+ dans TypeBox 1.)
279
+ Écrit pour le *package* `typebox`, version 1 (le successeur de `@sinclair/typebox`).
280
+
281
+ ## Standard Schema (valibot, arktype, ...)
282
+
283
+ <!-- checked sample -->
284
+ ```ts
285
+ import * as v from 'valibot';
286
+ import { standardSchema } from '@ddd-ts-kit/validation/standard-schema';
287
+
288
+ export const placeOrder = standardSchema(
289
+ v.object({ customerId: v.pipe(v.string(), v.uuid()) }),
290
+ // Facultatif : le JSON Schema du contrat, que cet adaptateur ne dérive pas.
291
+ { type: 'object', required: ['customerId'], properties: { customerId: { type: 'string', format: 'uuid' } } },
292
+ );
293
+ ```
294
+
295
+ L'adaptateur valide avec `~standard.validate` de Standard Schema v1. Le JSON Schema du contrat
296
+ est le second argument quand il est donné (écrit à la main, ou produit par l'exportateur de votre
297
+ bibliothèque) ; sinon, celui que la bibliothèque convertit elle-même quand elle implémente la
298
+ spécification compagne, Standard JSON Schema v1 (`~standard.jsonSchema` ; vérifié avec zod 4,
299
+ tandis qu'arktype et valibot par `toStandardJsonSchema` de `@valibot/to-json-schema` l'annoncent
300
+ mais ne sont pas vérifiés ici), côté entrée par défaut et
301
+ `standardSchema(schema, undefined, { io: 'output' })` pour une réponse ; sinon `{}`, que le
302
+ document OpenAPI affiche comme « n'importe quelle valeur ». Un schéma que la bibliothèque ne sait
303
+ pas écrire en JSON Schema (zod refuse `z.date()`, `z.coerce.date()`, `z.custom`) est lui aussi
304
+ documenté `{}` : passez son JSON Schema en second argument. La spécification ne porte aucun code
305
+ d'erreur, si bien que chaque problème a le code `invalid`. Un schéma qui valide de façon
306
+ asynchrone fait lever une exception à `parse` : les schémas asynchrones ne sont pas pris en
307
+ charge à la frontière.
308
+
309
+ ## Schémas partagés
310
+
311
+ Un schéma que plusieurs routes utilisent est déclaré une fois et documenté une fois : le
312
+ générateur OpenAPI de `@ddd-ts-kit/http` l'écrit dans `components.schemas` et y renvoie par
313
+ `$ref` depuis chaque route, entier ou imbriqué. Un schéma est partagé quand son JSON Schema est
314
+ une référence à l'une de ses propres définitions
315
+ (`{ $ref: '#/$defs/Slot', $defs: { Slot: ... } }`), la forme que chaque bibliothèque atteint à sa
316
+ façon :
317
+
318
+ ![Un schéma partagé sous le nom Slot : son JSON Schema est une référence à sa propre définition ; les routes reschedule et book l'utilisent, l'une entier et l'autre imbriqué dans son corps ; le générateur OpenAPI écrit Slot une seule fois sous components.schemas et y renvoie par $ref depuis les deux routes, au lieu de deux copies](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/validation-package-shared-schemas.fr.svg)
319
+
320
+ <!-- checked sample -->
321
+ ```ts
322
+ import Type from 'typebox';
323
+ import * as v from 'valibot';
324
+ import { z } from 'zod';
325
+ import { sharedSchema } from '@ddd-ts-kit/validation';
326
+ import { classSchema } from '@ddd-ts-kit/validation/class-validator';
327
+ import { standardSchema } from '@ddd-ts-kit/validation/standard-schema';
328
+ import { typeboxSchema } from '@ddd-ts-kit/validation/typebox';
329
+ import { zodSchema } from '@ddd-ts-kit/validation/zod';
330
+
331
+ declare class PatientDto {
332
+ name: string;
333
+ email: string;
334
+ }
335
+
336
+ // zod : un id dans ses métadonnées, gardé quand le schéma est imbriqué dans un autre
337
+ const slot = z.object({ startsAt: z.iso.datetime(), endsAt: z.iso.datetime() }).meta({ id: 'Slot' });
338
+ export const reschedule = zodSchema(slot);
339
+ export const book = zodSchema(z.object({ patientId: z.uuid(), slot }));
340
+
341
+ // class-validator : le nom de la classe
342
+ export const patient = classSchema(PatientDto, { shared: true });
343
+
344
+ // TypeBox : un $id dans ses options, gardé quand le schéma est imbriqué dans un autre
345
+ const money = Type.Object({ cents: Type.Integer(), currency: Type.String() }, { $id: 'Money' });
346
+ export const price = typeboxSchema(Type.Object({ amount: money }));
347
+
348
+ // n'importe quel schéma, ceux de Standard Schema compris
349
+ const amountSchema = v.object({ cents: v.pipe(v.number(), v.integer()) });
350
+ export const amount = sharedSchema('Amount', standardSchema(amountSchema));
351
+ ```
352
+
353
+ `sharedNameOf(jsonSchema)` renvoie le nom sous lequel un JSON Schema est partagé, s'il y en a
354
+ un :
355
+
356
+ <!-- checked sample -->
357
+ ```ts
358
+ import { customSchema, sharedNameOf, sharedSchema } from '@ddd-ts-kit/validation';
359
+
360
+ const amount = sharedSchema('Amount', customSchema((input) => ({ ok: true, value: input }), { type: 'integer' }));
361
+
362
+ export const jsonSchema = amount.toJsonSchema(); // { $ref: '#/$defs/Amount', $defs: { Amount: { type: 'integer' } } }
363
+ export const name = sharedNameOf(jsonSchema); // 'Amount'
364
+ ```
365
+
366
+ `schemaBehind.of(schema)` renvoie le schéma qui a été donné à `sharedSchema`, pour que ce qu'un
367
+ autre *package* y a attaché (les champs que réserve `withFieldAccess` de `@ddd-ts-kit/http`)
368
+ vaille aussi pour le schéma partagé.
369
+
370
+ Un schéma partagé de la couche présentation vit là où `generate schema` l'écrit : sous
371
+ `presentation/http/schemas/` de son contexte, ou sous `presentation/` du noyau partagé quand
372
+ plusieurs contextes répondent avec lui ; jamais dans le domaine.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ **English** · [Français](README.fr.md)
2
+
1
3
  # @ddd-ts-kit/validation
2
4
 
3
5
  One `Schema` contract for input validation at the boundary, with adapters for zod,
@@ -6,6 +8,44 @@ on the contract, so the validation library can change without touching them.
6
8
 
7
9
  Business rules do not belong here: they live in the domain and never import a validation library.
8
10
 
11
+ ![A request at the boundary: its params, query, headers and body each go through the Schema the route declares; when every part parses, the handler receives typed values and the domain checks the business rules; when a part fails, its issues are collected with the name of the part in front of their path, such as body.items.0.sku, and the request is refused with one ValidationError, code VALIDATION_FAILED, answered 422 with the list of issues; the same schemas give the JSON Schema of the OpenAPI document](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/validation-package-at-the-boundary.svg)
12
+
13
+ A schema checks the **shape** of what comes in: types, required fields, formats, lengths. What
14
+ the business decides (a slot in the past, a quantity above the stock) is checked by the domain,
15
+ whatever the entry point:
16
+
17
+ <!-- checked sample -->
18
+ ```ts
19
+ import { z } from 'zod';
20
+ import { ConceptError } from '@ddd-ts-kit/kernel';
21
+ import { zodSchema } from '@ddd-ts-kit/validation/zod';
22
+
23
+ // Instead of: a business rule hidden in the schema of one route, which a job or a consumer never meets.
24
+ export const bookWithARule = zodSchema(
25
+ z.object({ startsAt: z.iso.datetime() }).refine((body) => Date.parse(body.startsAt) > Date.now(), 'The slot is in the past.'),
26
+ );
27
+
28
+ // Write: the shape in the schema...
29
+ export const book = zodSchema(z.object({ startsAt: z.iso.datetime() }));
30
+
31
+ // ...and the rule in the domain, with its own refusal code.
32
+ export class SlotError extends ConceptError {
33
+ static inThePast(startsAt: Date) {
34
+ return SlotError.refusal('SLOT_IN_THE_PAST', `The slot at ${startsAt.toISOString()} is in the past.`, {
35
+ startsAt: startsAt.toISOString(),
36
+ });
37
+ }
38
+ }
39
+
40
+ export function startingAt(startsAt: Date, now: Date): Date {
41
+ if (startsAt <= now) {
42
+ throw SlotError.inThePast(startsAt);
43
+ }
44
+
45
+ return startsAt;
46
+ }
47
+ ```
48
+
9
49
  ## The contract
10
50
 
11
51
  ```ts
@@ -28,22 +68,90 @@ class): there is nothing to declare in a composition root.
28
68
  with code `VALIDATION_FAILED` and the `issues`.
29
69
  - `Infer<typeof schema>` gives the parsed type.
30
70
  - `customSchema(parse, jsonSchema?)` builds a schema from a plain function.
31
-
32
71
  - `sharedSchema(name, schema)` documents a schema once, under `name` (see [shared schemas](#shared-schemas)).
33
72
 
73
+ <!-- checked sample -->
74
+ ```ts
75
+ import { err, ok } from '@ddd-ts-kit/kernel';
76
+ import { ValidationError, customSchema, parseOrThrow, type Infer } from '@ddd-ts-kit/validation';
77
+
78
+ // A schema from a plain function: a check written by hand, or a double in a test.
79
+ const quantity = customSchema(
80
+ (input: unknown) =>
81
+ typeof input === 'number' && Number.isInteger(input) && input > 0
82
+ ? ok(input)
83
+ : err([{ path: '', code: 'not_a_quantity', message: 'A positive whole number is expected.' }]),
84
+ { type: 'integer', minimum: 1 },
85
+ );
86
+
87
+ type Quantity = Infer<typeof quantity>; // number
88
+
89
+ export const refused = quantity.parse('3'); // { ok: false, error: [{ path: '', code: 'not_a_quantity', ... }] }
90
+ export const three: Quantity = parseOrThrow(quantity, 3); // 3
91
+
92
+ try {
93
+ parseOrThrow(quantity, 0);
94
+ } catch (error) {
95
+ if (error instanceof ValidationError) {
96
+ console.log(error.code, error.issues); // VALIDATION_FAILED [{ path: '', code: 'not_a_quantity', ... }]
97
+ }
98
+ }
99
+ ```
100
+
34
101
  The root entry imports no third-party package. Each adapter lives in its own subpath and its
35
102
  library is an optional peer dependency: install only the one you use.
36
103
 
104
+ ## At the route
105
+
106
+ A route of `@ddd-ts-kit/http` names a schema for each part of the request it reads. Every part
107
+ is parsed before the handler runs, the issues of all parts are answered together, and each
108
+ issue path starts with its part:
109
+
110
+ <!-- checked sample -->
111
+ ```ts
112
+ import { z } from 'zod';
113
+ import { defineRoute, executeRoute, type RawRequest } from '@ddd-ts-kit/http';
114
+ import { zodSchema } from '@ddd-ts-kit/validation/zod';
115
+
116
+ const placeOrder = defineRoute(
117
+ {
118
+ method: 'POST',
119
+ path: '/orders',
120
+ operationId: 'placeOrder',
121
+ summary: 'Place an order',
122
+ request: {
123
+ body: zodSchema(z.object({ items: z.array(z.object({ sku: z.string().min(1), quantity: z.number().int().positive() })).min(1) })),
124
+ },
125
+ responses: { 201: { description: 'The order is placed.', body: zodSchema(z.object({ lines: z.number() }), { io: 'output' }) } },
126
+ security: false,
127
+ },
128
+ async ({ body }) => ({ status: 201, body: { lines: body.items.length } }), // body is typed: { items: { sku, quantity }[] }
129
+ );
130
+
131
+ const request = (body: unknown): RawRequest => ({ params: {}, query: {}, headers: {}, body, raw: undefined });
132
+
133
+ export const placed = await executeRoute(placeOrder, request({ items: [{ sku: 'A-1', quantity: 2 }] })); // 201, { lines: 1 }
134
+ export const refused = await executeRoute(placeOrder, request({ items: [{ sku: 'A-1', quantity: 0 }] }));
135
+ // 422, a problem with the code VALIDATION_FAILED and
136
+ // issues: [{ path: 'body.items.0.quantity', code: 'too_small', message: 'Too small: expected number to be >0' }]
137
+ ```
138
+
139
+ The handler never sees an invalid request, and the OpenAPI document of the route describes the
140
+ same body, from `toJsonSchema()`.
141
+
37
142
  ## zod
38
143
 
39
144
  ```sh
40
145
  npm i zod
41
146
  ```
42
147
 
148
+ <!-- checked sample -->
43
149
  ```ts
44
150
  import { z } from 'zod';
45
151
  import { zodSchema } from '@ddd-ts-kit/validation/zod';
46
152
 
153
+ declare const input: unknown; // what the request carried
154
+
47
155
  const placeOrder = zodSchema(
48
156
  z.object({
49
157
  customerId: z.string().uuid(),
@@ -68,6 +176,7 @@ failing the generation.
68
176
  npm i class-validator class-transformer class-validator-jsonschema reflect-metadata
69
177
  ```
70
178
 
179
+ <!-- checked sample: legacy-decorators -->
71
180
  ```ts
72
181
  import { Type } from 'class-transformer';
73
182
  import { ArrayMinSize, IsInt, IsString, IsUUID, Min, ValidateNested } from 'class-validator';
@@ -87,7 +196,7 @@ class PlaceOrderDto {
87
196
  items: OrderItemDto[] = [];
88
197
  }
89
198
 
90
- const placeOrder = classSchema(PlaceOrderDto);
199
+ export const placeOrder = classSchema(PlaceOrderDto);
91
200
  ```
92
201
 
93
202
  `parse` returns an instance of the class. `whitelist` and `forbidNonWhitelisted` are on by
@@ -104,12 +213,26 @@ with `@ValidateNested()` and `@Type`), extended (`class BookBody extends SlotDto
104
213
  part, through three helpers that copy the constraints, the nested types and the transformations
105
214
  of the properties they keep:
106
215
 
216
+ <!-- checked sample: legacy-decorators -->
107
217
  ```ts
218
+ import { IsEmail, IsIn, IsISO8601, IsString, IsUUID } from 'class-validator';
108
219
  import { omitClass, partialClass, pickClass } from '@ddd-ts-kit/validation/class-validator';
109
220
 
110
- class RescheduleBody extends pickClass(AppointmentDto, ['startsAt', 'endsAt']) {}
111
- class BookBody extends omitClass(AppointmentDto, ['status']) {}
112
- class EditPatientBody extends partialClass(PatientDto) {} // every property optional, checked when present
221
+ class AppointmentDto {
222
+ @IsUUID() patientId = '';
223
+ @IsISO8601() startsAt = '';
224
+ @IsISO8601() endsAt = '';
225
+ @IsIn(['booked', 'cancelled']) status = 'booked';
226
+ }
227
+
228
+ class PatientDto {
229
+ @IsString() name = '';
230
+ @IsEmail() email = '';
231
+ }
232
+
233
+ export class RescheduleBody extends pickClass(AppointmentDto, ['startsAt', 'endsAt']) {}
234
+ export class BookBody extends omitClass(AppointmentDto, ['status']) {}
235
+ export class EditPatientBody extends partialClass(PatientDto) {} // every property optional, checked when present
113
236
  ```
114
237
 
115
238
  The class that extends a helper names the shape in the contract. Picking a property that carries
@@ -123,13 +246,14 @@ documents a class only from its own decorators.
123
246
  npm i typebox
124
247
  ```
125
248
 
249
+ <!-- checked sample -->
126
250
  ```ts
127
251
  import Type from 'typebox';
128
252
  import { typeboxSchema } from '@ddd-ts-kit/validation/typebox';
129
253
 
130
- const slot = Type.Object({ startsAt: Type.String({ format: 'date-time' }), minutes: Type.Integer({ default: 30 }) }, { $id: 'Slot' });
131
- const book = typeboxSchema(Type.Object({ patientId: Type.String({ minLength: 1 }), slot }));
132
- const page = typeboxSchema(Type.Object({ limit: Type.Integer() }), { coerce: true }); // query strings
254
+ export const slot = Type.Object({ startsAt: Type.String({ format: 'date-time' }), minutes: Type.Integer({ default: 30 }) }, { $id: 'Slot' });
255
+ export const book = typeboxSchema(Type.Object({ patientId: Type.String({ minLength: 1 }), slot }));
256
+ export const page = typeboxSchema(Type.Object({ limit: Type.Integer() }), { coerce: true }); // query strings
133
257
  ```
134
258
 
135
259
  The schema is compiled once (`typebox/compile`). `parse` fills the defaults and drops the
@@ -148,11 +272,12 @@ Written for the `typebox` package, version 1 (the successor of `@sinclair/typebo
148
272
 
149
273
  ## Standard Schema (valibot, arktype, ...)
150
274
 
275
+ <!-- checked sample -->
151
276
  ```ts
152
277
  import * as v from 'valibot';
153
278
  import { standardSchema } from '@ddd-ts-kit/validation/standard-schema';
154
279
 
155
- const placeOrder = standardSchema(
280
+ export const placeOrder = standardSchema(
156
281
  v.object({ customerId: v.pipe(v.string(), v.uuid()) }),
157
282
  // Optional: the JSON Schema of the contract, which this adapter does not derive.
158
283
  { type: 'object', required: ['customerId'], properties: { customerId: { type: 'string', format: 'uuid' } } },
@@ -179,26 +304,57 @@ route, whole or nested. A schema is shared when its JSON Schema is a reference t
179
304
  definitions (`{ $ref: '#/$defs/Slot', $defs: { Slot: ... } }`), the form each library reaches its
180
305
  own way:
181
306
 
307
+ ![A schema shared under the name Slot: its JSON Schema is a reference to its own definition; the routes reschedule and book use it, one whole and one nested in its body; the OpenAPI generator writes Slot once under components.schemas and points at it with $ref from both routes, instead of two copies](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/validation-package-shared-schemas.svg)
308
+
309
+ <!-- checked sample -->
182
310
  ```ts
311
+ import Type from 'typebox';
312
+ import * as v from 'valibot';
313
+ import { z } from 'zod';
314
+ import { sharedSchema } from '@ddd-ts-kit/validation';
315
+ import { classSchema } from '@ddd-ts-kit/validation/class-validator';
316
+ import { standardSchema } from '@ddd-ts-kit/validation/standard-schema';
317
+ import { typeboxSchema } from '@ddd-ts-kit/validation/typebox';
318
+ import { zodSchema } from '@ddd-ts-kit/validation/zod';
319
+
320
+ declare class PatientDto {
321
+ name: string;
322
+ email: string;
323
+ }
324
+
183
325
  // zod: an id in its metadata, kept when the schema is nested in another one
184
326
  const slot = z.object({ startsAt: z.iso.datetime(), endsAt: z.iso.datetime() }).meta({ id: 'Slot' });
185
- const reschedule = zodSchema(slot);
186
- const book = zodSchema(z.object({ patientId: z.uuid(), slot }));
327
+ export const reschedule = zodSchema(slot);
328
+ export const book = zodSchema(z.object({ patientId: z.uuid(), slot }));
187
329
 
188
330
  // class-validator: the name of the class
189
- const patient = classSchema(PatientDto, { shared: true });
331
+ export const patient = classSchema(PatientDto, { shared: true });
190
332
 
191
333
  // TypeBox: an $id in its options, kept when the schema is nested in another one
192
334
  const money = Type.Object({ cents: Type.Integer(), currency: Type.String() }, { $id: 'Money' });
335
+ export const price = typeboxSchema(Type.Object({ amount: money }));
193
336
 
194
337
  // any schema, the Standard Schema ones included
195
- const amount = sharedSchema('Amount', standardSchema(amountSchema));
338
+ const amountSchema = v.object({ cents: v.pipe(v.number(), v.integer()) });
339
+ export const amount = sharedSchema('Amount', standardSchema(amountSchema));
340
+ ```
341
+
342
+ `sharedNameOf(jsonSchema)` answers the name a JSON Schema is shared under, if any:
343
+
344
+ <!-- checked sample -->
345
+ ```ts
346
+ import { customSchema, sharedNameOf, sharedSchema } from '@ddd-ts-kit/validation';
347
+
348
+ const amount = sharedSchema('Amount', customSchema((input) => ({ ok: true, value: input }), { type: 'integer' }));
349
+
350
+ export const jsonSchema = amount.toJsonSchema(); // { $ref: '#/$defs/Amount', $defs: { Amount: { type: 'integer' } } }
351
+ export const name = sharedNameOf(jsonSchema); // 'Amount'
196
352
  ```
197
353
 
198
- `sharedNameOf(jsonSchema)` answers the name a JSON Schema is shared under, if any.
199
354
  `schemaBehind.of(schema)` answers the schema `sharedSchema` was given, so that what another
200
355
  package attached to it (the fields `withFieldAccess` of `@ddd-ts-kit/http` reserves) holds for
201
- the shared one too. A shared
202
- schema of the presentation layer lives where `generate schema` writes it: under
356
+ the shared one too.
357
+
358
+ A shared schema of the presentation layer lives where `generate schema` writes it: under
203
359
  `presentation/http/schemas/` of its context, or under `presentation/` of the shared kernel when
204
360
  several contexts answer with it; never in the domain.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ddd-ts-kit/validation",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "One Schema contract for input validation, with adapters for zod, class-validator, TypeBox and Standard Schema.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,7 +42,7 @@
42
42
  "dist"
43
43
  ],
44
44
  "dependencies": {
45
- "@ddd-ts-kit/kernel": "0.16.0"
45
+ "@ddd-ts-kit/kernel": "0.16.1"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "class-transformer": ">=0.5.0",