@ddd-ts-kit/validation 0.15.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.
- package/README.fr.md +372 -0
- package/README.md +172 -16
- 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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
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
|
+

|
|
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
|
|
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.
|
|
202
|
-
|
|
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.
|
|
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.
|
|
45
|
+
"@ddd-ts-kit/kernel": "0.16.1"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
48
|
"class-transformer": ">=0.5.0",
|