@nodefony/framework 10.0.0-beta.2 → 10.0.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/index.js +15 -20
- package/dist/nodefony/config/config.js +1 -2
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/controller/AdminApiController.js +45 -11
- package/dist/nodefony/controller/ApiKeyController.js +18 -10
- package/dist/nodefony/controller/BenchController.js +1 -2
- package/dist/nodefony/controller/IssuerMetadataController.js +1 -2
- package/dist/nodefony/controller/LoginPageController.js +117 -0
- package/dist/nodefony/controller/OAuth2Controller.js +133 -17
- package/dist/nodefony/controller/ProtectedResourceMetadataController.js +1 -2
- package/dist/nodefony/controller/SessionAuthController.js +50 -13
- package/dist/nodefony/controller/TokenAuthController.js +14 -8
- package/dist/nodefony/controller/TotpController.js +7 -8
- package/dist/nodefony/controller/WebAuthnController.js +28 -12
- package/dist/nodefony/controller/oauthAuthority.js +1 -2
- package/dist/nodefony/decorators/routerDecorators.js +268 -65
- package/dist/nodefony/service/AdminBroker.js +4 -6
- package/dist/nodefony/service/Eta.js +1 -2
- package/dist/nodefony/service/IdempotencyStore.js +4 -6
- package/dist/nodefony/service/router.js +97 -34
- package/dist/nodefony/src/Controller.js +71 -43
- package/dist/nodefony/src/FrameworkAdminApi.js +33 -11
- package/dist/nodefony/src/KernelAdminApi.js +82 -18
- package/dist/nodefony/src/PlaygroundAdminApi.js +72 -3
- package/dist/nodefony/src/RedisIdempotencyStore.js +46 -4
- package/dist/nodefony/src/Resolver.js +105 -36
- package/dist/nodefony/src/ResourceController.js +2 -3
- package/dist/nodefony/src/Route.js +54 -8
- package/dist/nodefony/src/SecurityApiController.js +23 -0
- package/dist/nodefony/src/SyslogAdminApi.js +1 -2
- package/dist/nodefony/src/Template.js +1 -2
- package/dist/nodefony/src/configMutation.js +1 -2
- package/dist/nodefony/src/docsReader.js +39 -23
- package/dist/nodefony/src/idempotency.js +14 -22
- package/dist/nodefony/src/idempotencyGc.js +1 -2
- package/dist/nodefony/src/idempotencyStoreRegistry.js +1 -2
- package/dist/nodefony/src/loginPage.js +430 -0
- package/dist/nodefony/src/requireAuthentication.js +35 -0
- package/dist/nodefony/src/routeSchema.js +81 -0
- package/dist/nodefony/src/scopeCatalog.js +1 -2
- package/dist/nodefony/src/singletonGuard.js +1 -2
- package/dist/nodefony/src/syslogFilters.js +1 -2
- package/dist/types/index.d.ts +8 -12
- package/dist/types/nodefony/controller/AdminApiController.d.ts +12 -0
- package/dist/types/nodefony/controller/ApiKeyController.d.ts +2 -2
- package/dist/types/nodefony/controller/LoginPageController.d.ts +32 -0
- package/dist/types/nodefony/controller/OAuth2Controller.d.ts +67 -8
- package/dist/types/nodefony/controller/SessionAuthController.d.ts +46 -8
- package/dist/types/nodefony/controller/TokenAuthController.d.ts +6 -4
- package/dist/types/nodefony/controller/TotpController.d.ts +2 -2
- package/dist/types/nodefony/controller/WebAuthnController.d.ts +12 -4
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +221 -35
- package/dist/types/nodefony/interfaces/IController.d.ts +2 -3
- package/dist/types/nodefony/interfaces/IRoute.d.ts +1 -2
- package/dist/types/nodefony/service/router.d.ts +40 -1
- package/dist/types/nodefony/src/Controller.d.ts +54 -17
- package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +12 -0
- package/dist/types/nodefony/src/Resolver.d.ts +44 -1
- package/dist/types/nodefony/src/ResourceController.d.ts +1 -1
- package/dist/types/nodefony/src/Route.d.ts +21 -1
- package/dist/types/nodefony/src/SecurityApiController.d.ts +17 -0
- package/dist/types/nodefony/src/docsReader.d.ts +10 -6
- package/dist/types/nodefony/src/idempotency.d.ts +12 -10
- package/dist/types/nodefony/src/loginPage.d.ts +144 -0
- package/dist/types/nodefony/src/requireAuthentication.d.ts +29 -0
- package/dist/types/nodefony/src/routeSchema.d.ts +112 -0
- package/docs/admin.md +83 -78
- package/docs/controller.md +112 -111
- package/docs/decorateurs.md +212 -163
- package/docs/idempotence.md +137 -135
- package/docs/routing.md +96 -95
- package/docs/templates.md +55 -55
- package/package.json +10 -13
- package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
package/docs/decorateurs.md
CHANGED
|
@@ -49,10 +49,10 @@ flowchart TD
|
|
|
49
49
|
|
|
50
50
|
1. **À l'import**, chaque décorateur appelle `Reflect.defineMetadata` et rend la main. Zéro route
|
|
51
51
|
créée, zéro service résolu.
|
|
52
|
-
2. **Au montage**, `controller()` (`routerDecorators.ts
|
|
53
|
-
objets `Route` ; `controllers()` (`routerDecorators.ts
|
|
52
|
+
2. **Au montage**, `controller()` (`routerDecorators.ts#controller`) relit ces métadonnées et fabrique les
|
|
53
|
+
objets `Route` ; `controllers()` (`routerDecorators.ts#controllers`) accroche le contrôleur au module sur
|
|
54
54
|
le hook `onBoot` du kernel.
|
|
55
|
-
3. **À la première requête** de chaque route, `resolveActionMeta()` (`routerDecorators.ts
|
|
55
|
+
3. **À la première requête** de chaque route, `resolveActionMeta()` (`routerDecorators.ts#resolveActionMeta`)
|
|
56
56
|
consolide toutes les étiquettes de l'action en **un objet figé** posé sur la route. Les requêtes
|
|
57
57
|
suivantes ne lisent plus aucune métadonnée.
|
|
58
58
|
|
|
@@ -100,21 +100,23 @@ décorateur supprime la double vérité** : la déclaration vit sur l'action qu'
|
|
|
100
100
|
|
|
101
101
|
### Le contrat TypeScript — décorateurs _legacy_, et pourquoi ça compte
|
|
102
102
|
|
|
103
|
-
Nodefony utilise le format **legacy** de TypeScript : `experimentalDecorators: true`
|
|
104
|
-
`
|
|
105
|
-
|
|
103
|
+
Nodefony utilise le format **legacy** de TypeScript : `experimentalDecorators: true` (dans le
|
|
104
|
+
`tsconfig.json` racine, repris par le module — `framework/tsconfig.json`). Ce n'est pas un détail
|
|
105
|
+
historique : c'est ce qui rend possibles les **décorateurs de paramètre** (`@Param`, `@Body`…, et
|
|
106
|
+
`@Inject("nom")` côté conteneur) — le format standard ES ne les propose pas.
|
|
106
107
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
`emitDecoratorMetadata`, en revanche, vaut **`false`** : Nodefony ne lit aucun type émis par le
|
|
109
|
+
compilateur. Une dépendance de constructeur se **nomme** par `@Inject("nom")`
|
|
110
|
+
(`kernelDecorator.ts#Inject`) ; un paramètre non nommé reçoit l'argument positionnel, ou
|
|
111
|
+
`undefined`, quel que soit son type ([ADR-0018](../../../../../docs/adr/0018-decorateurs-sans-metadonnees-de-type.md)).
|
|
112
|
+
Ne le repasser à `true` que pour une bibliothèque tierce qui lit les métadonnées `design:*`.
|
|
111
113
|
|
|
112
|
-
Concrètement, dans une app générée par `nodefony create app`, ces
|
|
113
|
-
`tsconfig.json`. Tu n'as rien à faire — sauf si tu pars d'un `tsconfig` à toi : sans
|
|
114
|
-
décorateurs ne compilent pas.
|
|
114
|
+
Concrètement, dans une app générée par `nodefony create app`, ces options sont **déjà** dans le
|
|
115
|
+
`tsconfig.json`. Tu n'as rien à faire — sauf si tu pars d'un `tsconfig` à toi : sans
|
|
116
|
+
`experimentalDecorators`, les décorateurs ne compilent pas.
|
|
115
117
|
|
|
116
118
|
> [!WARNING]
|
|
117
|
-
> `reflect-metadata` doit être chargé **avant** tout décorateur. `routerDecorators.ts
|
|
119
|
+
> `reflect-metadata` doit être chargé **avant** tout décorateur. `routerDecorators.ts` l'importe
|
|
118
120
|
> pour toi dès que tu importes un décorateur du framework — mais si tu écris ton propre décorateur
|
|
119
121
|
> dans un fichier chargé plus tôt, mets-y `import "reflect-metadata";` en tête.
|
|
120
122
|
|
|
@@ -124,16 +126,16 @@ Trois partis pris expliquent la forme de cette surface, et un développeur qui l
|
|
|
124
126
|
jamais surprendre.
|
|
125
127
|
|
|
126
128
|
**1 — Un décorateur n'écrit QUE des métadonnées.** Aucun décorateur du framework ne contient de
|
|
127
|
-
logique de sécurité, de session ou d'idempotence. `IsGranted()` (`routerDecorators.ts
|
|
129
|
+
logique de sécurité, de session ou d'idempotence. `IsGranted()` (`routerDecorators.ts#IsGranted`) pose une
|
|
128
130
|
clause ; c'est le `Resolver` qui appellera le moteur d'autorisation, **résolu par son nom** dans le
|
|
129
|
-
conteneur (`Resolver._enforceSecurity()`, `Resolver.ts
|
|
131
|
+
conteneur (`Resolver._enforceSecurity()`, `Resolver.ts#Resolver._enforceSecurity`). Pourquoi ce détour : `@nodefony/framework`
|
|
130
132
|
ne dépend **pas** de `@nodefony/security` — sans ça, les deux modules formeraient un cycle. Le prix à
|
|
131
133
|
payer est visible : une route gardée alors que le module `security` est absent renvoie **403**, pas
|
|
132
|
-
une erreur de démarrage (fail-closed, `Resolver.ts
|
|
134
|
+
une erreur de démarrage (fail-closed, `Resolver.ts#Resolver._enforceSecurity`).
|
|
133
135
|
|
|
134
136
|
**2 — Tout est figé une fois, puis relu en O(1).** Les métadonnées de l'action sont consolidées au
|
|
135
|
-
premier passage dans `computeActionMeta()` (`routerDecorators.ts
|
|
136
|
-
L'objet `RouteActionMeta` (`routerDecorators.ts
|
|
137
|
+
premier passage dans `computeActionMeta()` (`routerDecorators.ts#computeActionMeta`) puis gelées sur la route.
|
|
138
|
+
L'objet `RouteActionMeta` (`routerDecorators.ts#RouteActionMeta`) est **partagé par toutes les requêtes** — le
|
|
137
139
|
framework ne le mute jamais, et ton code non plus. Une action non décorée obtient des champs à `null`,
|
|
138
140
|
ce qui vaut **zéro branche** dans le chemin chaud.
|
|
139
141
|
|
|
@@ -274,7 +276,7 @@ Six familles, **36 décorateurs**, un seul fichier source. Le tableau de synthè
|
|
|
274
276
|
| **Méthodes HTTP** | Quand l'action répond | `@Get` `@Post` `@Put` `@Patch` `@Delete` `@Options` `@Head` `@All` |
|
|
275
277
|
| **Paramètres** | Ce que l'action reçoit en arguments | `@Param` `@Query` `@Body` `@Headers` `@Cookie` `@Session` `@CurrentUser` `@Req` `@Res` `@UploadedFile` `@UploadedFiles` |
|
|
276
278
|
| **Réponse** | Statut, en-têtes, redirection | `@HttpCode` `@Header` `@Redirect` |
|
|
277
|
-
| **Sécurité** | Qui passe, qui décide, quelles défenses | `@IsGranted` `@RequireScope` `@Anonymous` `@BypassFirewall` `@Csp` `@CsrfProtect` `@CsrfExempt` |
|
|
279
|
+
| **Sécurité** | Qui passe, qui décide, quelles défenses | `@IsGranted` `@RequireScope` `@RequireAuthentication` `@Anonymous` `@BypassFirewall` `@Csp` `@CsrfProtect` `@CsrfExempt` |
|
|
278
280
|
| **Cycle de la requête** | Session, anti-rejeu | `@UseSession` `@Idempotent` |
|
|
279
281
|
|
|
280
282
|
> Tous s'importent depuis `"@nodefony/framework"` — jamais par un chemin relatif interne.
|
|
@@ -284,43 +286,43 @@ Six familles, **36 décorateurs**, un seul fichier source. Le tableau de synthè
|
|
|
284
286
|
<!-- prettier-ignore -->
|
|
285
287
|
| Décorateur | Cible | Effet | Exemple |
|
|
286
288
|
| --- | --- | --- | --- |
|
|
287
|
-
| `@controllers([…])` | **module** | Rattache des contrôleurs au module sur le hook `onBoot` ; sans lui, aucune route n'est servie (`controllers()`, `routerDecorators.ts
|
|
288
|
-
| `@controller("/prefix")` | **classe** | Pose le préfixe d'URL **et déclenche la création des routes** de la classe (`controller()`, `routerDecorators.ts
|
|
289
|
-
| `@route(nom, options)` | méthode | Forme complète : nom explicite, chemin, `requirements`, `defaults`, hôte (`route()`, `routerDecorators.ts
|
|
290
|
-
| `@Domain(motif \| motifs)` | **dual** | Restreint la route (ou la classe) à un ou plusieurs vhosts ; hors domaine → **403** (`Domain()`, `routerDecorators.ts
|
|
291
|
-
| `@Scope("request")` | **classe** | Une instance de contrôleur par requête — par connexion en WebSocket — au lieu de l'instance partagée par défaut (`Scope()`, `routerDecorators.ts
|
|
289
|
+
| `@controllers([…])` | **module** | Rattache des contrôleurs au module sur le hook `onBoot` ; sans lui, aucune route n'est servie (`controllers()`, `routerDecorators.ts#controllers`) | `@controllers([BookController])` |
|
|
290
|
+
| `@controller("/prefix")` | **classe** | Pose le préfixe d'URL **et déclenche la création des routes** de la classe (`controller()`, `routerDecorators.ts#controller`) | `@controller("/api/books")` |
|
|
291
|
+
| `@route(nom, options)` | méthode | Forme complète : nom explicite, chemin, `requirements`, `defaults`, hôte (`route()`, `routerDecorators.ts#route`) | `@route("ws-echo", { path: "/echo", requirements: { methods: ["WEBSOCKET"] } })` |
|
|
292
|
+
| `@Domain(motif \| motifs)` | **dual** | Restreint la route (ou la classe) à un ou plusieurs vhosts ; hors domaine → **403** (`Domain()`, `routerDecorators.ts#Domain`) | `@Domain("*.cdn.example.com")` |
|
|
293
|
+
| `@Scope("request")` | **classe** | Une instance de contrôleur par requête — par connexion en WebSocket — au lieu de l'instance partagée par défaut (`Scope()`, `routerDecorators.ts#Scope`) | `@Scope("request")` |
|
|
292
294
|
|
|
293
295
|
**`@controller` est le déclencheur.** Il relit les métadonnées posées par `@route`/`@Get`/… puis les
|
|
294
|
-
**efface** (`Reflect.deleteMetadata`, `routerDecorators.ts
|
|
296
|
+
**efface** (`Reflect.deleteMetadata`, `routerDecorators.ts#controllers`) : une classe ne se monte qu'une
|
|
295
297
|
fois. Il traite au passage la route « magique » `path: "*"` en **dernier**, quel que soit son ordre
|
|
296
|
-
d'écriture (`hasMagic`, `routerDecorators.ts
|
|
298
|
+
d'écriture (`hasMagic`, `routerDecorators.ts#controller`) — sinon un attrape-tout masquerait les routes précises.
|
|
297
299
|
|
|
298
300
|
**Le singleton — le défaut — est un contrat.** L'instance étant partagée, l'action ne doit lire ni
|
|
299
301
|
écrire **aucun** état de requête sur `this` : tout passe par les arguments décorés et les accesseurs,
|
|
300
|
-
qui retrouvent la requête courante via l'ALS (`ControllerScope`, `Controller.ts
|
|
302
|
+
qui retrouvent la requête courante via l'ALS (`ControllerScope`, `Controller.ts#ControllerScope`). `@Scope("request")`
|
|
301
303
|
rend une instance par requête : pour injecter un service de portée `request`, préparer chaque
|
|
302
304
|
requête dans `initialize()`, ou garder un état sur `this`.
|
|
303
305
|
|
|
304
306
|
> [!NOTE]
|
|
305
307
|
> Le core `nodefony` exporte lui aussi un `Scope` (les portées du conteneur d'injection). Celui des
|
|
306
308
|
> contrôleurs s'importe **depuis `@nodefony/framework`** — l'homonymie est signalée dans le code
|
|
307
|
-
> (`routerDecorators.ts
|
|
309
|
+
> (`routerDecorators.ts#Scope`).
|
|
308
310
|
|
|
309
311
|
### Méthodes HTTP
|
|
310
312
|
|
|
311
|
-
Toutes les fabriques sortent du même moule, `httpMethodDecorator()` (`routerDecorators.ts
|
|
313
|
+
Toutes les fabriques sortent du même moule, `httpMethodDecorator()` (`routerDecorators.ts#httpMethodDecorator`) :
|
|
312
314
|
elles nomment la route automatiquement `ClasseName::methode` et posent `requirements.methods`.
|
|
313
315
|
|
|
314
|
-
| Décorateur | Méthode filtrée | Ancre
|
|
315
|
-
| ------------------------ | --------------- |
|
|
316
|
-
| `@Get(path?, opts?)` | `GET` | `Get` (`routerDecorators.ts
|
|
317
|
-
| `@Post(path?, opts?)` | `POST` | `Post` (`routerDecorators.ts
|
|
318
|
-
| `@Put(path?, opts?)` | `PUT` | `Put` (`routerDecorators.ts
|
|
319
|
-
| `@Delete(path?, opts?)` | `DELETE` | `Delete` (`routerDecorators.ts
|
|
320
|
-
| `@Patch(path?, opts?)` | `PATCH` | `Patch` (`routerDecorators.ts
|
|
321
|
-
| `@Options(path?, opts?)` | `OPTIONS` | `Options` (`routerDecorators.ts
|
|
322
|
-
| `@Head(path?, opts?)` | `HEAD` | `Head` (`routerDecorators.ts
|
|
323
|
-
| `@All(path?, opts?)` | **aucune** | `All()` (`routerDecorators.ts
|
|
316
|
+
| Décorateur | Méthode filtrée | Ancre | Exemple |
|
|
317
|
+
| ------------------------ | --------------- | ----------------------------------------- | ------------------- |
|
|
318
|
+
| `@Get(path?, opts?)` | `GET` | `Get` (`routerDecorators.ts#Get`) | `@Get("/{id}")` |
|
|
319
|
+
| `@Post(path?, opts?)` | `POST` | `Post` (`routerDecorators.ts#Post`) | `@Post("")` |
|
|
320
|
+
| `@Put(path?, opts?)` | `PUT` | `Put` (`routerDecorators.ts#Put`) | `@Put("/{id}")` |
|
|
321
|
+
| `@Delete(path?, opts?)` | `DELETE` | `Delete` (`routerDecorators.ts#Delete`) | `@Delete("/{id}")` |
|
|
322
|
+
| `@Patch(path?, opts?)` | `PATCH` | `Patch` (`routerDecorators.ts#Patch`) | `@Patch("/{id}")` |
|
|
323
|
+
| `@Options(path?, opts?)` | `OPTIONS` | `Options` (`routerDecorators.ts#Options`) | `@Options("/{id}")` |
|
|
324
|
+
| `@Head(path?, opts?)` | `HEAD` | `Head` (`routerDecorators.ts#Head`) | `@Head("/{id}")` |
|
|
325
|
+
| `@All(path?, opts?)` | **aucune** | `All()` (`routerDecorators.ts#All`) | `@All("/proxy/*")` |
|
|
324
326
|
|
|
325
327
|
Deux points qu'un dev découvre sinon à ses dépens :
|
|
326
328
|
|
|
@@ -332,8 +334,8 @@ Deux points qu'un dev découvre sinon à ses dépens :
|
|
|
332
334
|
méthodes, ne serait-ce que pour l'en-tête `Allow`.
|
|
333
335
|
|
|
334
336
|
Le second argument accepte les options de route non redondantes — `Omit<RouteOptions, "path" | "method">`
|
|
335
|
-
(`routerDecorators.ts
|
|
336
|
-
(`RouteOptions`, `Route.ts
|
|
337
|
+
(`routerDecorators.ts#route`), soit `defaults`, `requirements`, `host`, `bypassFirewall`
|
|
338
|
+
(`RouteOptions`, `Route.ts#RouteOptions`) :
|
|
337
339
|
|
|
338
340
|
```typescript
|
|
339
341
|
@Get("/{page}", { defaults: { page: "1" }, requirements: { scheme: "https" } })
|
|
@@ -342,42 +344,43 @@ async index(@Param("page") page: string) { /* … */ }
|
|
|
342
344
|
|
|
343
345
|
### Paramètres — ce que l'action reçoit
|
|
344
346
|
|
|
345
|
-
Onze décorateurs, tous produits par `paramDecoratorFactory()` (`routerDecorators.ts
|
|
347
|
+
Onze décorateurs, tous produits par `paramDecoratorFactory()` (`routerDecorators.ts#paramDecoratorFactory`) sauf
|
|
346
348
|
`@Body`, qui accepte une option supplémentaire. Chacun pose `{ source, key, index }` ; la valeur est
|
|
347
|
-
calculée par `resolveParamArg()` (`routerDecorators.ts
|
|
349
|
+
calculée par `resolveParamArg()` (`routerDecorators.ts#resolveParamArg`), une fonction **pure** — ce qui la rend
|
|
348
350
|
testable sans démarrer de serveur.
|
|
349
351
|
|
|
350
|
-
| Décorateur | Sans clé renvoie… | Avec clé renvoie… | Ancre
|
|
351
|
-
| ------------------- | ------------------------------------ | ------------------------------------------ |
|
|
352
|
-
| `@Param("id")` | toutes les variables d'URL (objet) | la variable d'URL nommée | `Param` (`routerDecorators.ts
|
|
353
|
-
| `@Query("q")` | toute la query string | un paramètre de la query string | `Query` (`routerDecorators.ts
|
|
354
|
-
| `@Body("field")` | le corps parsé entier | un champ du corps parsé | `Body()` (`routerDecorators.ts
|
|
355
|
-
| `@Headers("x-foo")` | tous les en-têtes de requête | un en-tête (**lookup en minuscules**) | `Headers` (`routerDecorators.ts
|
|
356
|
-
| `@Cookie("sid")` | la map des cookies | un cookie (objet `Cookie`, champ `.value`) | `Cookie` (`routerDecorators.ts
|
|
357
|
-
| `@Session("user")` | l'objet `Session` vivant | `session.get(clé)` | `Session` (`routerDecorators.ts
|
|
358
|
-
| `@CurrentUser()` | l'utilisateur résolu par le firewall | — | `CurrentUser` (`routerDecorators.ts
|
|
359
|
-
| `@Req()` | la requête brute du contexte | — | `Req` (`routerDecorators.ts
|
|
360
|
-
| `@Res()` | la réponse du contexte | — | `Res` (`routerDecorators.ts
|
|
361
|
-
| `@UploadedFile()` | le **premier** fichier téléversé | — | `UploadedFile` (`routerDecorators.ts
|
|
362
|
-
| `@UploadedFiles()` | tous les fichiers téléversés | — | `UploadedFiles` (`routerDecorators.ts
|
|
363
|
-
|
|
364
|
-
La liste des sources possibles est fermée et typée : `ParamSource` (`routerDecorators.ts
|
|
352
|
+
| Décorateur | Sans clé renvoie… | Avec clé renvoie… | Ancre |
|
|
353
|
+
| ------------------- | ------------------------------------ | ------------------------------------------ | ----------------------------------------------------- |
|
|
354
|
+
| `@Param("id")` | toutes les variables d'URL (objet) | la variable d'URL nommée | `Param` (`routerDecorators.ts#Param`) |
|
|
355
|
+
| `@Query("q")` | toute la query string | un paramètre de la query string | `Query` (`routerDecorators.ts#Query`) |
|
|
356
|
+
| `@Body("field")` | le corps parsé entier | un champ du corps parsé | `Body()` (`routerDecorators.ts#Body`) |
|
|
357
|
+
| `@Headers("x-foo")` | tous les en-têtes de requête | un en-tête (**lookup en minuscules**) | `Headers` (`routerDecorators.ts#Headers`) |
|
|
358
|
+
| `@Cookie("sid")` | la map des cookies | un cookie (objet `Cookie`, champ `.value`) | `Cookie` (`routerDecorators.ts#Cookie`) |
|
|
359
|
+
| `@Session("user")` | l'objet `Session` vivant | `session.get(clé)` | `Session` (`routerDecorators.ts#Session`) |
|
|
360
|
+
| `@CurrentUser()` | l'utilisateur résolu par le firewall | — | `CurrentUser` (`routerDecorators.ts#CurrentUser`) |
|
|
361
|
+
| `@Req()` | la requête brute du contexte | — | `Req` (`routerDecorators.ts#Req`) |
|
|
362
|
+
| `@Res()` | la réponse du contexte | — | `Res` (`routerDecorators.ts#Res`) |
|
|
363
|
+
| `@UploadedFile()` | le **premier** fichier téléversé | — | `UploadedFile` (`routerDecorators.ts#UploadedFile`) |
|
|
364
|
+
| `@UploadedFiles()` | tous les fichiers téléversés | — | `UploadedFiles` (`routerDecorators.ts#UploadedFiles`) |
|
|
365
|
+
|
|
366
|
+
La liste des sources possibles est fermée et typée : `ParamSource` (`routerDecorators.ts#ParamSource`).
|
|
365
367
|
|
|
366
368
|
#### Trois comportements à connaître
|
|
367
369
|
|
|
368
370
|
**`@CurrentUser` lit l'ALS, jamais un argument caché.** La valeur vient de `RequestContext.getUser()`
|
|
369
|
-
(`routerDecorators.ts
|
|
370
|
-
justificatif (mot de passe, jeton). Hors zone authentifiée, la valeur est `undefined` —
|
|
371
|
-
n'authentifie rien, il expose ce qui a déjà
|
|
371
|
+
(`routerDecorators.ts#CurrentUser`) : l'utilisateur posé par le firewall. C'est **l'utilisateur**, jamais le
|
|
372
|
+
justificatif (mot de passe, jeton). Hors zone authentifiée, la valeur est `undefined` — y compris
|
|
373
|
+
pour l'anonyme d'une zone qui l'accepte — : le décorateur n'authentifie rien, il expose ce qui a déjà
|
|
374
|
+
été prouvé. Sans décorateur, la même valeur se lit par `this.user` dans le contrôleur.
|
|
372
375
|
|
|
373
376
|
**`@Session` active la session à lui seul.** La simple présence d'un paramètre `@Session` vaut
|
|
374
|
-
déclaration d'intention : `resolveSessionIntent()` (`routerDecorators.ts
|
|
377
|
+
déclaration d'intention : `resolveSessionIntent()` (`routerDecorators.ts#resolveSessionIntent`) la détecte et pose
|
|
375
378
|
l'intent, exactement comme `@UseSession()`. Une route sans l'un ni l'autre ne paie aucune session.
|
|
376
379
|
|
|
377
380
|
**`@Body({ stream: true })` court-circuite le parsing.** Pour un gros téléversement (vidéo,
|
|
378
381
|
sauvegarde), on injecte le **flux brut** de la requête au lieu du corps chargé en mémoire ; le
|
|
379
382
|
pipeline saute alors le parsing pour cette route, décision prise en amont par
|
|
380
|
-
`routeExpectsBodyStream()` (`routerDecorators.ts
|
|
383
|
+
`routeExpectsBodyStream()` (`routerDecorators.ts#routeExpectsBodyStream`) :
|
|
381
384
|
|
|
382
385
|
```typescript
|
|
383
386
|
@Post("/upload")
|
|
@@ -389,79 +392,125 @@ async upload(@Body({ stream: true }) stream: NodeJS.ReadableStream) {
|
|
|
389
392
|
|
|
390
393
|
> [!TIP]
|
|
391
394
|
> L'ordre d'écriture des paramètres décorés n'a aucune importance : chaque valeur est placée à son
|
|
392
|
-
> **index déclaré** par `buildParamArgs()` (`routerDecorators.ts
|
|
395
|
+
> **index déclaré** par `buildParamArgs()` (`routerDecorators.ts#buildParamArgs`), et les trous restent
|
|
393
396
|
> `undefined`. Tu peux mélanger décorés et non décorés — les non décorés reçoivent `undefined`.
|
|
394
397
|
|
|
395
|
-
|
|
398
|
+
### Routes à schéma — l'entrée validée ET typée
|
|
396
399
|
|
|
397
|
-
|
|
398
|
-
|
|
400
|
+
Un décorateur de paramètre **affirme** un type sans le vérifier : `@Body() dto: CreateOrder` compile,
|
|
401
|
+
et un client peut envoyer autre chose. L'option `schema` des décorateurs de route (`@route`, `@Get`,
|
|
402
|
+
`@Post`…) **déclare** le contrat de l'action, partie par partie, et le framework le fait respecter
|
|
403
|
+
avant de l'exécuter (ADR-0018) :
|
|
399
404
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
405
|
+
```typescript
|
|
406
|
+
import { Post, type RouteInput } from "@nodefony/framework";
|
|
407
|
+
import { z } from "zod";
|
|
408
|
+
|
|
409
|
+
const postParams = z.object({ id: z.coerce.number().int().positive() });
|
|
410
|
+
const postBody = z.object({ title: z.string().min(3) });
|
|
404
411
|
|
|
405
|
-
|
|
412
|
+
@Post("/posts/{id}", { schema: { params: postParams, body: postBody } })
|
|
413
|
+
update(input: RouteInput<{ params: typeof postParams; body: typeof postBody }>) {
|
|
414
|
+
return { id: input.params.id, title: input.body.title }; // id : number
|
|
415
|
+
}
|
|
416
|
+
```
|
|
406
417
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
418
|
+
| Partie | Ce qu'elle valide |
|
|
419
|
+
| --------- | ---------------------------------------------------------------------------------------- |
|
|
420
|
+
| `params` | les variables de l'URL (`/posts/{id}`) |
|
|
421
|
+
| `query` | la query — sur le pont WebSocket, celle du chemin **invoqué** |
|
|
422
|
+
| `body` | le corps **décodé** (JSON, formulaire) — sur le pont WebSocket, celui porté par la trame |
|
|
423
|
+
| `headers` | les en-têtes, clés en minuscules — sur WebSocket, ceux de la poignée de main |
|
|
424
|
+
|
|
425
|
+
**Ce que fait le framework, dans l'ordre :**
|
|
426
|
+
|
|
427
|
+
- chaque partie déclarée est un [Standard Schema](https://standardschema.dev) — Zod, Valibot,
|
|
428
|
+
ArkType… ; l'interface est recopiée au cœur (`runtime/standardSchema.ts#IStandardSchemaV1`), aucune
|
|
429
|
+
dépendance n'est imposée ;
|
|
430
|
+
- la validation a lieu **après** les gardes (`@IsGranted` : un 403 ne révèle pas le contrat) et
|
|
431
|
+
**avant** l'instanciation du contrôleur : une entrée invalide ne construit rien
|
|
432
|
+
(`Resolver.ts#Resolver.executeAction`) ;
|
|
433
|
+
- elle est **synchrone** tant que chaque validateur l'est ; un raffinement asynchrone (« ce slug
|
|
434
|
+
est-il libre ? ») la fait attendre, et seulement lui (`routeSchema.ts#validateRouteInput`) ;
|
|
435
|
+
- les quatre parties sont jugées ensemble : un échec rend **toutes** les anomalies en une fois, dans
|
|
436
|
+
une `SchemaValidationError` (`runtime/standardSchema.ts#SchemaValidationError`) — **422**, la même
|
|
437
|
+
forme qu'une `ZodError`, sur HTTP (`error.fields`) comme sur le pont WebSocket (`data.fields`).
|
|
438
|
+
Chaque champ porte sa partie : `{ "field": "title", "in": "body", "message": "…" }` ;
|
|
439
|
+
- l'action reçoit **un seul argument**, `input`, qui porte toujours les quatre parties : la
|
|
440
|
+
**sortie** du schéma (coercitions, valeurs par défaut) pour une partie déclarée, la valeur brute
|
|
441
|
+
pour les autres.
|
|
442
|
+
|
|
443
|
+
**La signature est vérifiée à la compilation.** Un `input` dont un champ diverge du schéma, ou un
|
|
444
|
+
second argument, ne compile pas (`routerDecorators.ts#IHttpMethodDecorator`). L'identité se lit par
|
|
445
|
+
`this.user`, pas par `@CurrentUser`.
|
|
446
|
+
|
|
447
|
+
**Exclusif des décorateurs de paramètre.** Une action à schéma reçoit un argument unique : la mêler de
|
|
448
|
+
`@Body`, `@Query`… est refusé **au démarrage**, par une erreur qui nomme la route
|
|
449
|
+
(`routerDecorators.ts#assertRouteSchemaDeclaration`, appelé par `Router.setController`). Même refus pour
|
|
450
|
+
une partie inconnue (`bodies`) et pour une valeur qui n'est pas un Standard Schema.
|
|
451
|
+
|
|
452
|
+
**Un gros corps en flux : `bodyStream: true`.** Il remplace `@Body({ stream: true })` : `input.body`
|
|
453
|
+
est alors le flux brut, jamais lu en mémoire. `bodyStream` et `schema.body` s'excluent — un flux non
|
|
454
|
+
lu ne se valide pas —, et `bodyStream` **exige** un schéma : sans `input`, le flux ne serait livré à
|
|
455
|
+
personne. La compilation refuse les deux fautes, le démarrage aussi ; une action sans schéma demande
|
|
456
|
+
son flux par `@Body({ stream: true })`.
|
|
412
457
|
|
|
413
458
|
```typescript
|
|
414
|
-
|
|
415
|
-
|
|
459
|
+
const uploadQuery = z.object({ name: z.string() });
|
|
460
|
+
|
|
461
|
+
@Post("/upload", { schema: { query: uploadQuery }, bodyStream: true })
|
|
462
|
+
async upload(input: RouteInput<{ query: typeof uploadQuery }, true>) {
|
|
463
|
+
await pipeline(input.body, createWriteStream(`/data/${input.query.name}`));
|
|
416
464
|
}
|
|
417
465
|
```
|
|
418
466
|
|
|
419
|
-
**
|
|
420
|
-
|
|
421
|
-
|
|
467
|
+
**Des octets se valident tels quels.** La valeur passée au schéma est celle que le transport a
|
|
468
|
+
**décodée** — ni recopiée, ni re-sérialisée : `z.instanceof(Uint8Array)` valide un corps binaire
|
|
469
|
+
sans copie, ce qui tiendra quand la socket portera du binaire.
|
|
470
|
+
|
|
471
|
+
**Sans schéma, rien ne change** — et rien n'est alloué de plus : la route garde ses décorateurs de
|
|
472
|
+
paramètre, et le Resolver ne paie qu'une comparaison.
|
|
473
|
+
|
|
474
|
+
**Une route créée par programme** (`Router.createRoute`) n'accepte ni `schema` ni `bodyStream` : ils
|
|
475
|
+
se rangent sur la méthode par un décorateur, et les ignorer en silence mentirait — l'appel est refusé.
|
|
476
|
+
|
|
477
|
+
#### Et une entité ?
|
|
478
|
+
|
|
479
|
+
`nodefony create entity` génère les deux étages : la route déclare `createPostSchema` /
|
|
480
|
+
`updatePostSchema` (422 à la porte, avant tout appel au service), et le service les revalide dans
|
|
481
|
+
`beforeCreate` / `beforeUpdate` (`orm-core/nodefony/src/AbstractCrudService.ts#AbstractCrudService.create`)
|
|
482
|
+
pour ses autres portes — CLI, actions temps réel —, où une règle asynchrone (unicité en base) reste
|
|
483
|
+
possible. Le schéma d'entrée ne décrit pas la table : ni `id` ni horodatages n'y figurent, et les
|
|
484
|
+
champs inconnus sont **retirés** — un client qui glisserait `{ "role": "admin" }` ne s'auto-promeut pas.
|
|
485
|
+
|
|
486
|
+
#### Sans schéma, le corps n'est pas validé
|
|
487
|
+
|
|
488
|
+
`@Body()` injecte le corps **tel qu'il a été lu**. Pour un cas isolé hors route à schéma, le geste
|
|
489
|
+
reste le schéma en tête d'action — une `ZodError` qui remonte devient le même **422**, reconnu par sa
|
|
490
|
+
forme (`http/nodefony/service/error-renderer.ts#toValidationFields`) et non par `instanceof` :
|
|
422
491
|
|
|
423
492
|
```typescript
|
|
424
493
|
@Post("/subscribe")
|
|
425
494
|
subscribe(@Body() body: unknown) {
|
|
426
495
|
const dto = subscribeSchema.parse(body); // lève → 422 + fields
|
|
427
|
-
return
|
|
496
|
+
return { ok: true, email: dto.email };
|
|
428
497
|
}
|
|
429
498
|
```
|
|
430
499
|
|
|
431
|
-
Le rendu est assuré par `toValidationFields()` (`http/nodefony/service/error-renderer.ts:126`), qui
|
|
432
|
-
reconnaît l'erreur **par sa forme** (`name` + `issues`) et non par `instanceof` — une application
|
|
433
|
-
qui embarque sa propre copie de zod est donc servie pareil. Le client reçoit **422** (RFC 9110
|
|
434
|
-
§15.5.21 : le corps est lisible, c'est son contenu qui viole le contrat) et la liste des champs
|
|
435
|
-
fautifs, avec pour chacun son message et la règle qui a échoué.
|
|
436
|
-
|
|
437
|
-
**Comment typer le paramètre**, puisque le décorateur ne promet rien :
|
|
438
|
-
|
|
439
|
-
| Écriture | Ce que ça annonce | Verdict |
|
|
440
|
-
| ----------------------------- | --------------------------------------------------- | ----------------------- |
|
|
441
|
-
| `@Body() b: Partial<PostRow>` | la ligne de **table** — `id` et horodatages compris | promet trop |
|
|
442
|
-
| `@Body() b: unknown` | rien, honnêtement | juste, mais peu commode |
|
|
443
|
-
| `@Body() b: CreatePost` | le contrat d'**entrée**, `z.infer` du schéma | ✅ à préférer |
|
|
444
|
-
|
|
445
|
-
`CreatePost` et `UpdatePost` sont générés à côté du schéma (`nodefony/entity/Post.schema.ts`) : le
|
|
446
|
-
type et la validation dérivent de la même source, ils ne peuvent donc pas diverger. Un schéma
|
|
447
|
-
d'entrée ne décrit d'ailleurs pas la table — ni `id` ni horodatages n'y figurent, ils sont posés par
|
|
448
|
-
le serveur —, et zod **retire** les champs inconnus : un client qui glisserait `{ "role": "admin" }`
|
|
449
|
-
ne s'auto-promeut pas.
|
|
450
|
-
|
|
451
500
|
### Réponse — statut, en-têtes, redirection
|
|
452
501
|
|
|
453
|
-
| Décorateur | Cible | Effet
|
|
454
|
-
| ------------------------- | ------- |
|
|
455
|
-
| `@HttpCode(201)` | méthode | Fixe le statut **avant** l'exécution de l'action (`HttpCode()`, `routerDecorators.ts
|
|
456
|
-
| `@Header("X-Foo", "bar")` | méthode | Ajoute un en-tête ; **s'empile** (plusieurs `@Header` cumulent, `routerDecorators.ts
|
|
457
|
-
| `@Redirect("/url", 302)` | méthode | Redirige **si** l'action ne renvoie rien (`Redirect()`, `routerDecorators.ts
|
|
502
|
+
| Décorateur | Cible | Effet | Exemple |
|
|
503
|
+
| ------------------------- | ------- | ----------------------------------------------------------------------------------------------- | ------------------------------------- |
|
|
504
|
+
| `@HttpCode(201)` | méthode | Fixe le statut **avant** l'exécution de l'action (`HttpCode()`, `routerDecorators.ts#HttpCode`) | `@HttpCode(204)` |
|
|
505
|
+
| `@Header("X-Foo", "bar")` | méthode | Ajoute un en-tête ; **s'empile** (plusieurs `@Header` cumulent, `routerDecorators.ts#Header`) | `@Header("Cache-Control","no-store")` |
|
|
506
|
+
| `@Redirect("/url", 302)` | méthode | Redirige **si** l'action ne renvoie rien (`Redirect()`, `routerDecorators.ts#Redirect`) | `@Redirect("/login", 302)` |
|
|
458
507
|
|
|
459
|
-
Les deux premiers sont appliqués par `Resolver._applyResponseMeta()` (`Resolver.ts
|
|
508
|
+
Les deux premiers sont appliqués par `Resolver._applyResponseMeta()` (`Resolver.ts#Resolver._applyResponseMeta`) **avant**
|
|
460
509
|
l'appel de l'action : ton code peut donc les écraser ensuite (`this.renderJson(data, 202)` gagne).
|
|
461
510
|
|
|
462
511
|
`@Redirect` a une subtilité utile : si l'action **retourne un objet** portant `url` (et
|
|
463
512
|
éventuellement `statusCode`), cet objet **prend le dessus** sur les valeurs du décorateur
|
|
464
|
-
(`Resolver._handleRedirect()`, `Resolver.ts
|
|
513
|
+
(`Resolver._handleRedirect()`, `Resolver.ts#Resolver._handleRedirect`) — la cible peut donc être calculée à l'exécution :
|
|
465
514
|
|
|
466
515
|
```typescript
|
|
467
516
|
@Get("/go")
|
|
@@ -482,20 +531,21 @@ async go(@Query("to") to?: string) {
|
|
|
482
531
|
Sept décorateurs, **tous duals** (classe ou méthode) et **tous sans logique** : ils posent une
|
|
483
532
|
étiquette que le `Resolver` ou le firewall consommera.
|
|
484
533
|
|
|
485
|
-
| Décorateur
|
|
486
|
-
|
|
|
487
|
-
| `@IsGranted(attr \| attrs, { subject })`
|
|
488
|
-
| `@RequireScope(scope \| scopes)`
|
|
489
|
-
| `@
|
|
490
|
-
| `@
|
|
491
|
-
| `@
|
|
492
|
-
| `@
|
|
493
|
-
| `@
|
|
534
|
+
| Décorateur | Effet | Ancre |
|
|
535
|
+
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
|
536
|
+
| `@IsGranted(attr \| attrs, { subject })` | Exige un attribut (rôle `ROLE_*` ou règle métier). Tableau = **OU** ; empilés = **ET** ; refus → **403** | `IsGranted()` (`routerDecorators.ts#IsGranted`) |
|
|
537
|
+
| `@RequireScope(scope \| scopes)` | Exige un scope `api:action` d'un **jeton machine** ; no-op pour une session humaine | `RequireScope()` (`routerDecorators.ts#RequireScope`) |
|
|
538
|
+
| `@RequireAuthentication({ maxAge, amr })` | Exige une connexion **récente** (`maxAge`, s) et/ou faite avec certains moyens (`amr` RFC 8176, ex. `otp`) ; évalué après `@IsGranted` ; refus → **401** RFC 9470 | `RequireAuthentication()` (`routerDecorators.ts#RequireAuthentication`) |
|
|
539
|
+
| `@Anonymous()` | Rend l'action publique : annule l'autorisation **et** l'authentification (le « permitAll ») | `Anonymous()` (`routerDecorators.ts#Anonymous`) |
|
|
540
|
+
| `@BypassFirewall` | Court-circuite le firewall (sonde de liveness, webhook signé, endpoint de login). **Sans parenthèses** | `BypassFirewall` (`routerDecorators.ts#BypassFirewall`) |
|
|
541
|
+
| `@Csp({ "frame-src": [...] })` | Ajoute des directives CSP **à cette réponse** ; classe + méthode fusionnent additivement | `Csp()` (`routerDecorators.ts#Csp`) |
|
|
542
|
+
| `@CsrfProtect()` | Opt-**in** au jeton anti-CSRF (double-submit signé) en plus de la défense globale | `CsrfProtect` (`routerDecorators.ts#CsrfProtect`) |
|
|
543
|
+
| `@CsrfExempt()` | Opt-**out** de la défense CSRF **en gardant** l'authentification (webhook, POST cross-origin légitime) | `CsrfExempt` (`routerDecorators.ts#CsrfExempt`) |
|
|
494
544
|
|
|
495
545
|
#### Rôles et scopes — deux axes, un seul verdict
|
|
496
546
|
|
|
497
547
|
`@IsGranted` et `@RequireScope` écrivent dans **deux jeux de métadonnées distincts**, puis
|
|
498
|
-
`computeSecurityRequirement()` (`routerDecorators.ts
|
|
548
|
+
`computeSecurityRequirement()` (`routerDecorators.ts#computeSecurityRequirement`) les fusionne en une exigence unique dont
|
|
499
549
|
toutes les clauses sont en **ET**. Une seule chaîne d'application côté `Resolver`, deux jurés
|
|
500
550
|
différents côté `security` (le voteur de rôles, le voteur de scopes).
|
|
501
551
|
|
|
@@ -531,7 +581,7 @@ Trois façons d'ouvrir une porte, trois portées — les confondre coûte cher :
|
|
|
531
581
|
| `@CsrfExempt()` | **conservée** | **conservée** | ignorée | webhook signé, API cross-origin |
|
|
532
582
|
|
|
533
583
|
`@Anonymous()` pose en réalité **deux** marqueurs : « pas d'autorisation » et « pas de firewall »
|
|
534
|
-
(`routerDecorators.ts
|
|
584
|
+
(`routerDecorators.ts#Anonymous`) — c'est un `@BypassFirewall` doublé d'une annulation des clauses
|
|
535
585
|
héritées de la classe.
|
|
536
586
|
|
|
537
587
|
> [!CAUTION]
|
|
@@ -542,17 +592,17 @@ héritées de la classe.
|
|
|
542
592
|
|
|
543
593
|
### Cycle de la requête — session et anti-rejeu
|
|
544
594
|
|
|
545
|
-
| Décorateur | Cible | Effet
|
|
546
|
-
| ---------------------------- | ----- |
|
|
547
|
-
| `@UseSession({ readOnly? })` | dual | Déclare le besoin d'une session serveur ; **méthode > classe** (`UseSession()`, `routerDecorators.ts
|
|
548
|
-
| `@Idempotent({ required? })` | dual | Protège une mutation du double effet via `Idempotency-Key` (`Idempotent()`, `routerDecorators.ts
|
|
595
|
+
| Décorateur | Cible | Effet | Ancre |
|
|
596
|
+
| ---------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------- |
|
|
597
|
+
| `@UseSession({ readOnly? })` | dual | Déclare le besoin d'une session serveur ; **méthode > classe** (`UseSession()`, `routerDecorators.ts#UseSession`) | `@UseSession({ readOnly: true })` |
|
|
598
|
+
| `@Idempotent({ required? })` | dual | Protège une mutation du double effet via `Idempotency-Key` (`Idempotent()`, `routerDecorators.ts#Idempotent`) | `@Idempotent()` |
|
|
549
599
|
|
|
550
600
|
**`@UseSession` est la seule façon d'ouvrir une session** (avec un paramètre `@Session`, ou la reprise
|
|
551
601
|
d'un cookie existant). Il n'existe plus de « démarrer partout » global : une route qui ne déclare rien
|
|
552
602
|
ne coûte aucune lecture de stockage. Une seule option : `readOnly` (lire sans jamais persister —
|
|
553
603
|
zéro écriture). La régénération de l'identifiant après authentification est faite par le login
|
|
554
604
|
(`AuthFlow`), pas par une option du décorateur.
|
|
555
|
-
La forme exacte est celle de `SessionIntent` (`ISession.ts
|
|
605
|
+
La forme exacte est celle de `SessionIntent` (`ISession.ts#SessionIntent.readOnly`).
|
|
556
606
|
|
|
557
607
|
**`@Idempotent` est strict par défaut** : une mutation sans `Idempotency-Key` reçoit **400**. Le mode
|
|
558
608
|
souple s'obtient par `@Idempotent({ required: false })` — sans effet en WebSocket, toujours strict
|
|
@@ -565,15 +615,14 @@ identité et les stockages distribués sont traités dans la page dédiée →
|
|
|
565
615
|
Ils ne viennent pas de `@nodefony/framework`, mais complètent la même DX ; on les cite pour éviter les
|
|
566
616
|
recherches inutiles.
|
|
567
617
|
|
|
568
|
-
| Décorateur | Paquet | Rôle
|
|
569
|
-
| ----------------------------------- | -------------------- |
|
|
570
|
-
| `@services([…])` | `nodefony` | Déclare les services d'un module (`services()`, `kernelDecorator.ts
|
|
571
|
-
| `@injectable()` | `nodefony` | Rend une classe résoluble par le conteneur (`injectable()`, `kernelDecorator.ts
|
|
572
|
-
| `@
|
|
573
|
-
| `@
|
|
574
|
-
| `@
|
|
575
|
-
| `@
|
|
576
|
-
| `@RealtimeInbound("méthode")` | `@nodefony/realtime` | Traite un message entrant typé (`RealtimeInbound()`, `realtimeDecorators.ts:231`) |
|
|
618
|
+
| Décorateur | Paquet | Rôle |
|
|
619
|
+
| ----------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------- |
|
|
620
|
+
| `@services([…])` | `nodefony` | Déclare les services d'un module (`services()`, `kernelDecorator.ts#services`) |
|
|
621
|
+
| `@injectable()` | `nodefony` | Rend une classe résoluble par le conteneur (`injectable()`, `kernelDecorator.ts#injectable`) |
|
|
622
|
+
| `@Inject("nom")` | `nodefony` | Injecte un service à une position de constructeur (`kernelDecorator.ts#Inject`) |
|
|
623
|
+
| `@RealtimeAction("méthode")` | `@nodefony/realtime` | Expose une action JSON-RPC sur socket (`RealtimeAction()`, `realtimeDecorators.ts#RealtimeAction`) |
|
|
624
|
+
| `@RealtimeChannel("canal", policy)` | `@nodefony/realtime` | Déclare un canal temps réel et sa politique (`RealtimeChannel()`, `realtimeDecorators.ts#RealtimeChannel`) |
|
|
625
|
+
| `@RealtimeInbound("méthode")` | `@nodefony/realtime` | Traite un message entrant typé (`RealtimeInbound()`, `realtimeDecorators.ts#RealtimeInbound`) |
|
|
577
626
|
|
|
578
627
|
Injection et portées → [injection-portees](../../../../../docs/architecture/injection-portees.md) ·
|
|
579
628
|
socket → [realtime](../../realtime/docs/index.md).
|
|
@@ -588,7 +637,7 @@ socket → [realtime](../../realtime/docs/index.md).
|
|
|
588
637
|
## 🔌 HTTP et WebSocket — les mêmes décorateurs
|
|
589
638
|
|
|
590
639
|
Un contrôleur ne change pas de forme selon le transport : ce sont les `requirements.methods` qui
|
|
591
|
-
déclarent le canal, `WEBSOCKET` étant une pseudo-méthode du type `HTTPMethod` (`Context.ts
|
|
640
|
+
déclarent le canal, `WEBSOCKET` étant une pseudo-méthode du type `HTTPMethod` (`Context.ts#HTTPMethod`).
|
|
592
641
|
|
|
593
642
|
```typescript
|
|
594
643
|
@controller("/ws/chat")
|
|
@@ -620,11 +669,11 @@ Trois faits à retenir :
|
|
|
620
669
|
`@route`, ou via `@All("/x", { requirements: { methods: ["WEBSOCKET"] } })` si tu préfères la forme
|
|
621
670
|
courte. Les fabriques `@Get`/`@Post`… **fusionnent** les méthodes déclarées avec la leur :
|
|
622
671
|
`@Get("/x", { requirements: { methods: ["WEBSOCKET"] } })` sert GET **et** WebSocket
|
|
623
|
-
(`routerDecorators.ts
|
|
672
|
+
(`routerDecorators.ts#Get`).
|
|
624
673
|
- **Les décorateurs de paramètre fonctionnent pareil.** Pour une invocation par socket, le corps de
|
|
625
674
|
la mutation voyage dans l'ALS et **prime** sur le corps HTTP (vide dans ce cas) — c'est traité dans
|
|
626
|
-
`resolveParamArg()` (`routerDecorators.ts
|
|
627
|
-
pas celle du handshake (`Resolver._buildParamArgs()`, `Resolver.ts
|
|
675
|
+
`resolveParamArg()` (`routerDecorators.ts#resolveParamArg`), et `@Query` lit la query du chemin **invoqué**,
|
|
676
|
+
pas celle du handshake (`Resolver._buildParamArgs()`, `Resolver.ts#Resolver._buildParamArgs`).
|
|
628
677
|
- **Les gardes s'appliquent identiquement.** `@IsGranted` protège une action joignable par socket
|
|
629
678
|
exactement comme une action HTTP : la décision est prise avant l'instanciation, quel que soit le
|
|
630
679
|
transport.
|
|
@@ -640,16 +689,16 @@ pas toutes identiques — c'est la source d'erreur n°1.
|
|
|
640
689
|
<!-- prettier-ignore -->
|
|
641
690
|
| Sujet | Règle | Ancre |
|
|
642
691
|
| --- | --- | --- |
|
|
643
|
-
| `@Domain` | option `host` de la route > méthode > classe | `controller()` (`routerDecorators.ts
|
|
644
|
-
| `@BypassFirewall` | **cumulatif** : `true` de la route, de la méthode ou de la classe suffit | `routerDecorators.ts
|
|
645
|
-
| `@UseSession` | méthode > classe (fusion des champs) | `resolveSessionIntent()` (`routerDecorators.ts
|
|
646
|
-
| `@Idempotent` | méthode > classe | `computeIdempotent()` (`routerDecorators.ts
|
|
647
|
-
| `@IsGranted` / `@RequireScope` | **cumul en ET** : classe **plus** méthode | `computeSecurityRequirement()` (`routerDecorators.ts
|
|
648
|
-
| `@Anonymous` | méthode → annule tout ce que la classe a posé | `routerDecorators.ts
|
|
649
|
-
| `@Csp` | fusion **additive** classe + méthode (sources concaténées) | `mergeCspDirectives()` (`routerDecorators.ts
|
|
650
|
-
| `@CsrfProtect` / `@CsrfExempt` | OU logique : classe **ou** méthode suffit | `computeActionMeta()` (`routerDecorators.ts
|
|
651
|
-
| `@Header` | s'empile (plusieurs en-têtes) ; même clé → dernier écrit gagne | `Header()` (`routerDecorators.ts
|
|
652
|
-
| `@HttpCode` | un seul par action (le dernier posé écrase) | `HttpCode()` (`routerDecorators.ts
|
|
692
|
+
| `@Domain` | option `host` de la route > méthode > classe | `controller()` (`routerDecorators.ts#controller`) |
|
|
693
|
+
| `@BypassFirewall` | **cumulatif** : `true` de la route, de la méthode ou de la classe suffit | `routerDecorators.ts#BypassFirewall` |
|
|
694
|
+
| `@UseSession` | méthode > classe (fusion des champs) | `resolveSessionIntent()` (`routerDecorators.ts#resolveSessionIntent`) |
|
|
695
|
+
| `@Idempotent` | méthode > classe | `computeIdempotent()` (`routerDecorators.ts#computeIdempotent`) |
|
|
696
|
+
| `@IsGranted` / `@RequireScope` | **cumul en ET** : classe **plus** méthode | `computeSecurityRequirement()` (`routerDecorators.ts#computeSecurityRequirement`) |
|
|
697
|
+
| `@Anonymous` | méthode → annule tout ce que la classe a posé | `routerDecorators.ts#Anonymous` |
|
|
698
|
+
| `@Csp` | fusion **additive** classe + méthode (sources concaténées) | `mergeCspDirectives()` (`routerDecorators.ts#mergeCspDirectives`) |
|
|
699
|
+
| `@CsrfProtect` / `@CsrfExempt` | OU logique : classe **ou** méthode suffit | `computeActionMeta()` (`routerDecorators.ts#computeActionMeta`) |
|
|
700
|
+
| `@Header` | s'empile (plusieurs en-têtes) ; même clé → dernier écrit gagne | `Header()` (`routerDecorators.ts#Header`) |
|
|
701
|
+
| `@HttpCode` | un seul par action (le dernier posé écrase) | `HttpCode()` (`routerDecorators.ts#HttpCode`) |
|
|
653
702
|
|
|
654
703
|
### Où placer les décorateurs de classe
|
|
655
704
|
|
|
@@ -692,7 +741,7 @@ sequenceDiagram
|
|
|
692
741
|
RS->>RS: requêtes suivantes : lecture O(1), 0 Reflect
|
|
693
742
|
```
|
|
694
743
|
|
|
695
|
-
Le snapshot `RouteActionMeta` (`routerDecorators.ts
|
|
744
|
+
Le snapshot `RouteActionMeta` (`routerDecorators.ts#RouteActionMeta`) regroupe **tout** ce que les décorateurs
|
|
696
745
|
ont dit de l'action :
|
|
697
746
|
|
|
698
747
|
<!-- prettier-ignore -->
|
|
@@ -710,12 +759,12 @@ ont dit de l'action :
|
|
|
710
759
|
|
|
711
760
|
Le `Resolver` consomme ce snapshot dans un ordre qui a du sens sécurité :
|
|
712
761
|
**garde d'abord, instanciation ensuite**. `security !== null` déclenche
|
|
713
|
-
`_enforceSecurity()` (`Resolver.ts
|
|
762
|
+
`_enforceSecurity()` (`Resolver.ts#Resolver._enforceSecurity`) **avant** `newController()` — un `403` n'instancie pas le
|
|
714
763
|
contrôleur et n'exécute pas son `initialize()`. Puis viennent les arguments
|
|
715
|
-
(`_buildParamArgs()`, `Resolver.ts
|
|
716
|
-
(`_applyResponseMeta()`, `Resolver.ts
|
|
764
|
+
(`_buildParamArgs()`, `Resolver.ts#Resolver._buildParamArgs`), les métadonnées de réponse
|
|
765
|
+
(`_applyResponseMeta()`, `Resolver.ts#Resolver._applyResponseMeta`), l'action, et enfin la redirection éventuelle.
|
|
717
766
|
|
|
718
|
-
Un usage cold path mérite d'être connu : `extractActionScopes()` (`routerDecorators.ts
|
|
767
|
+
Un usage cold path mérite d'être connu : `extractActionScopes()` (`routerDecorators.ts#extractActionScopes`) parcourt
|
|
719
768
|
les routes au démarrage pour bâtir le **catalogue des scopes déclarés** — le formulaire de création
|
|
720
769
|
de clés API dans Studio propose les scopes réellement utilisés par le code, jamais une liste
|
|
721
770
|
maintenue à part.
|
|
@@ -724,26 +773,26 @@ maintenue à part.
|
|
|
724
773
|
|
|
725
774
|
Un décorateur non employé doit coûter **zéro**. C'est tenu par trois mécanismes vérifiables :
|
|
726
775
|
|
|
727
|
-
- **Lecture unique.** `resolveActionMeta()` (`routerDecorators.ts
|
|
776
|
+
- **Lecture unique.** `resolveActionMeta()` (`routerDecorators.ts#resolveActionMeta`) mémorise le snapshot sur la
|
|
728
777
|
route au premier passage — ensuite, plus aucun appel `Reflect.getMetadata` ni `Object.entries` par
|
|
729
778
|
requête. Le même schéma vaut pour la détection du flux brut
|
|
730
|
-
(`routeExpectsBodyStream()`, `routerDecorators.ts
|
|
779
|
+
(`routeExpectsBodyStream()`, `routerDecorators.ts#routeExpectsBodyStream`).
|
|
731
780
|
- **`null` plutôt que structure vide.** Une action sans garde a `security: null` : le `Resolver` teste
|
|
732
|
-
un `null` et passe — ni résolution de service, ni `await`, ni allocation (`Resolver.ts
|
|
781
|
+
un `null` et passe — ni résolution de service, ni `await`, ni allocation (`Resolver.ts#Resolver._enforceActionSecurity`). Idem
|
|
733
782
|
pour `idempotent`, `cspDirectives`, `paramsMeta`.
|
|
734
783
|
- **Objets gelés et partagés.** Les exigences de sécurité et d'idempotence sont créées **une fois** et
|
|
735
|
-
`Object.freeze`-ées (`routerDecorators.ts
|
|
784
|
+
`Object.freeze`-ées (`routerDecorators.ts#computeSecurityRequirement`, `routerDecorators.ts#computeCspDirectives`) : une seule instance pour la durée de vie
|
|
736
785
|
du processus, quelle que soit la charge. Corollaire : ne les mute jamais.
|
|
737
786
|
|
|
738
787
|
Coût résiduel côté montage seulement : la reconstruction de la pile d'appels dans `route()`
|
|
739
|
-
(`stackTrace`, `routerDecorators.ts
|
|
788
|
+
(`stackTrace`, `routerDecorators.ts#route`) pour retrouver le fichier source. Elle a lieu **à l'import**, une fois par
|
|
740
789
|
route, jamais pendant une requête.
|
|
741
790
|
|
|
742
791
|
## 🧩 Extension — écrire son propre décorateur
|
|
743
792
|
|
|
744
793
|
Le module montre le patron à suivre : un décorateur maison **ne fait qu'écrire une métadonnée**, et
|
|
745
794
|
un point du pipeline la relit. Pour un simple drapeau dual (classe + méthode), le framework fournit
|
|
746
|
-
déjà la fabrique `booleanMarkerDecorator()` (`routerDecorators.ts
|
|
795
|
+
déjà la fabrique `booleanMarkerDecorator()` (`routerDecorators.ts#booleanMarkerDecorator`), dont `@CsrfProtect` et
|
|
747
796
|
`@CsrfExempt` sont les deux usages.
|
|
748
797
|
|
|
749
798
|
Le squelette d'un drapeau maison, en dehors du framework :
|
|
@@ -773,7 +822,7 @@ export function Audited() {
|
|
|
773
822
|
Deux invariants à respecter, tirés du code du module :
|
|
774
823
|
|
|
775
824
|
1. **Classe → constructeur, méthode → prototype keyé par nom.** C'est la convention de toutes les
|
|
776
|
-
métadonnées du fichier (`routerDecorators.ts
|
|
825
|
+
métadonnées du fichier (`routerDecorators.ts#IsGranted` pour `@IsGranted`) ; s'en écarter rend la
|
|
777
826
|
fusion classe/méthode impossible.
|
|
778
827
|
2. **Aucune I/O, aucun service, aucun import lourd dans le décorateur.** Il s'exécute à l'import, hors
|
|
779
828
|
de tout kernel : y résoudre un service planterait le simple fait de charger le fichier.
|
|
@@ -796,7 +845,7 @@ L'écran **Routes** (`/nodefony/routes`) et le point d'API `/nodefony/framework/
|
|
|
796
845
|
| Symptôme | Cause (dans le code) | Correction |
|
|
797
846
|
| --- | --- | --- |
|
|
798
847
|
| `404` sur une route pourtant décorée | Contrôleur jamais importé, ou absent de `@controllers([…])` | L'ajouter au tableau `@controllers` du module |
|
|
799
|
-
| `Action « remove » … : ce nom est RÉSERVÉ` au démarrage — ou `TS2416` au build | L'action reprend le nom d'un membre de `Controller` : la classe étend `Service`, qui expose déjà `remove(name): boolean` ([`Service.ts
|
|
848
|
+
| `Action « remove » … : ce nom est RÉSERVÉ` au démarrage — ou `TS2416` au build | L'action reprend le nom d'un membre de `Controller` : la classe étend `Service`, qui expose déjà `remove(name): boolean` ([`Service.ts#Service.remove`](../../../../nodefony/src/Service.ts)), `set`, `get`, `clean`… Le décorateur refuse le nom avant que le conflit n'atteigne le compilateur. | Renommer l'action (`destroy`, `deleteOne`…). Le nom d'une action est libre : c'est le chemin du décorateur qui fait l'URL. |
|
|
800
849
|
| `404` après avoir déplacé `@controller` sous `@Domain` | `@controller` monte les routes ; les décorateurs lus au montage doivent être **sous** | Remettre `@controller` en **premier** (le plus haut) |
|
|
801
850
|
| Le vhost de `@Domain` classe est ignoré | `@Domain` placé **au-dessus** de `@controller` → posé trop tard | Placer `@Domain` sous `@controller` |
|
|
802
851
|
| `@BypassFirewall` n'ouvre rien | Écrit **avec** parenthèses — c'est un drapeau, pas une fabrique | `@BypassFirewall` (sans `()`) |
|