@nodefony/framework 10.0.0-alpha.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/LICENSE +544 -0
- package/README.md +50 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +211 -0
- package/dist/nodefony/config/config.js +61 -0
- package/dist/nodefony/config/defineModuleConfig.js +36 -0
- package/dist/nodefony/controller/AdminApiController.js +163 -0
- package/dist/nodefony/controller/ApiKeyController.js +151 -0
- package/dist/nodefony/controller/BenchController.js +132 -0
- package/dist/nodefony/controller/IssuerMetadataController.js +148 -0
- package/dist/nodefony/controller/OAuth2Controller.js +133 -0
- package/dist/nodefony/controller/ProtectedResourceMetadataController.js +221 -0
- package/dist/nodefony/controller/SessionAuthController.js +141 -0
- package/dist/nodefony/controller/TokenAuthController.js +113 -0
- package/dist/nodefony/controller/TotpController.js +129 -0
- package/dist/nodefony/controller/WebAuthnController.js +242 -0
- package/dist/nodefony/controller/oauthAuthority.js +74 -0
- package/dist/nodefony/decorators/routerDecorators.js +967 -0
- package/dist/nodefony/interfaces/IAdminBroker.js +1 -0
- package/dist/nodefony/interfaces/IController.js +1 -0
- package/dist/nodefony/interfaces/IIdempotencyStore.js +1 -0
- package/dist/nodefony/interfaces/IResolver.js +1 -0
- package/dist/nodefony/interfaces/IRoute.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/service/AdminBroker.js +106 -0
- package/dist/nodefony/service/Eta.js +68 -0
- package/dist/nodefony/service/IdempotencyStore.js +136 -0
- package/dist/nodefony/service/router.js +243 -0
- package/dist/nodefony/src/Controller.js +515 -0
- package/dist/nodefony/src/FrameworkAdminApi.js +268 -0
- package/dist/nodefony/src/KernelAdminApi.js +1243 -0
- package/dist/nodefony/src/PlaygroundAdminApi.js +97 -0
- package/dist/nodefony/src/RedisIdempotencyStore.js +254 -0
- package/dist/nodefony/src/Resolver.js +416 -0
- package/dist/nodefony/src/ResourceController.js +148 -0
- package/dist/nodefony/src/Route.js +476 -0
- package/dist/nodefony/src/SyslogAdminApi.js +466 -0
- package/dist/nodefony/src/Template.js +15 -0
- package/dist/nodefony/src/configMutation.js +186 -0
- package/dist/nodefony/src/docsReader.js +929 -0
- package/dist/nodefony/src/idempotency.js +137 -0
- package/dist/nodefony/src/idempotencyGc.js +32 -0
- package/dist/nodefony/src/idempotencyStoreRegistry.js +36 -0
- package/dist/nodefony/src/scopeCatalog.js +40 -0
- package/dist/nodefony/src/syslogFilters.js +51 -0
- package/dist/types/index.d.ts +96 -0
- package/dist/types/nodefony/config/config.d.ts +41 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
- package/dist/types/nodefony/controller/AdminApiController.d.ts +70 -0
- package/dist/types/nodefony/controller/ApiKeyController.d.ts +49 -0
- package/dist/types/nodefony/controller/BenchController.d.ts +45 -0
- package/dist/types/nodefony/controller/IssuerMetadataController.d.ts +86 -0
- package/dist/types/nodefony/controller/OAuth2Controller.d.ts +81 -0
- package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +147 -0
- package/dist/types/nodefony/controller/SessionAuthController.d.ts +74 -0
- package/dist/types/nodefony/controller/TokenAuthController.d.ts +56 -0
- package/dist/types/nodefony/controller/TotpController.d.ts +42 -0
- package/dist/types/nodefony/controller/WebAuthnController.d.ts +123 -0
- package/dist/types/nodefony/controller/oauthAuthority.d.ts +54 -0
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +632 -0
- package/dist/types/nodefony/interfaces/IAdminBroker.d.ts +79 -0
- package/dist/types/nodefony/interfaces/IController.d.ts +38 -0
- package/dist/types/nodefony/interfaces/IIdempotencyStore.d.ts +1 -0
- package/dist/types/nodefony/interfaces/IResolver.d.ts +25 -0
- package/dist/types/nodefony/interfaces/IRoute.d.ts +31 -0
- package/dist/types/nodefony/interfaces/index.d.ts +5 -0
- package/dist/types/nodefony/service/AdminBroker.d.ts +37 -0
- package/dist/types/nodefony/service/Eta.d.ts +25 -0
- package/dist/types/nodefony/service/IdempotencyStore.d.ts +45 -0
- package/dist/types/nodefony/service/router.d.ts +53 -0
- package/dist/types/nodefony/src/Controller.d.ts +193 -0
- package/dist/types/nodefony/src/FrameworkAdminApi.d.ts +35 -0
- package/dist/types/nodefony/src/KernelAdminApi.d.ts +71 -0
- package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +98 -0
- package/dist/types/nodefony/src/RedisIdempotencyStore.d.ts +104 -0
- package/dist/types/nodefony/src/Resolver.d.ts +165 -0
- package/dist/types/nodefony/src/ResourceController.d.ts +171 -0
- package/dist/types/nodefony/src/Route.d.ts +192 -0
- package/dist/types/nodefony/src/SyslogAdminApi.d.ts +38 -0
- package/dist/types/nodefony/src/Template.d.ts +8 -0
- package/dist/types/nodefony/src/configMutation.d.ts +109 -0
- package/dist/types/nodefony/src/docsReader.d.ts +369 -0
- package/dist/types/nodefony/src/idempotency.d.ts +96 -0
- package/dist/types/nodefony/src/idempotencyGc.d.ts +30 -0
- package/dist/types/nodefony/src/idempotencyStoreRegistry.d.ts +59 -0
- package/dist/types/nodefony/src/scopeCatalog.d.ts +26 -0
- package/dist/types/nodefony/src/syslogFilters.d.ts +52 -0
- package/docs/admin.md +451 -0
- package/docs/controller.md +645 -0
- package/docs/decorateurs.md +845 -0
- package/docs/idempotence.md +741 -0
- package/docs/index.md +151 -0
- package/docs/routing.md +648 -0
- package/docs/templates.md +380 -0
- package/package.json +83 -0
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Décorateurs — la surface déclarative des contrôleurs"
|
|
3
|
+
navTitle: Décorateurs
|
|
4
|
+
lang: fr
|
|
5
|
+
module: "@nodefony/framework"
|
|
6
|
+
topic: decorateurs
|
|
7
|
+
section: "Cœur runtime"
|
|
8
|
+
audience: [developer]
|
|
9
|
+
tags: [decorateurs, controller, route, parametres, reponse, securite, websocket]
|
|
10
|
+
version: "doc"
|
|
11
|
+
status: stable
|
|
12
|
+
updated: 2026-07-19
|
|
13
|
+
source: "src/packages/@nodefony/framework/docs/decorateurs.md"
|
|
14
|
+
coverageModule: framework
|
|
15
|
+
coverageFiles: routerDecorators.ts,Resolver.ts,Route.ts
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# Décorateurs — la surface déclarative des contrôleurs
|
|
19
|
+
|
|
20
|
+
> Un contrôleur Nodefony ne s'enregistre pas, ne se configure pas, ne se branche pas : il se
|
|
21
|
+
> **décrit**. `@controller` dit où il vit, `@Get` dit quand il répond, `@Body` dit ce qu'il reçoit,
|
|
22
|
+
> `@HttpCode` dit comment il répond, `@IsGranted` dit qui a le droit. Cette page est **la table de
|
|
23
|
+
> référence** des 36 décorateurs du module : pour chacun, sa cible, son effet et un exemple court.
|
|
24
|
+
> Tout est ancré sur `nodefony/decorators/routerDecorators.ts` — le fichier unique qui les porte tous.
|
|
25
|
+
|
|
26
|
+
📍 [Documentation](../../../../../docs/index.md) › [Framework](index.md) › **Décorateurs**
|
|
27
|
+
|
|
28
|
+
## 🧠 Le modèle mental — trois temps, jamais confondus
|
|
29
|
+
|
|
30
|
+
C'est LA chose à comprendre : un décorateur **ne fait rien** au moment où tu l'écris. Il écrit une
|
|
31
|
+
étiquette. Trois moments distincts se partagent le travail, et chaque bizarrerie de la page découle
|
|
32
|
+
de ce découpage.
|
|
33
|
+
|
|
34
|
+
```mermaid
|
|
35
|
+
flowchart TD
|
|
36
|
+
subgraph T1["1 · À l'IMPORT du fichier"]
|
|
37
|
+
D["@Get / @Body / @IsGranted…<br/>posent des métadonnées Reflect"]
|
|
38
|
+
end
|
|
39
|
+
subgraph T2["2 · Au MONTAGE (une seule fois)"]
|
|
40
|
+
C["@controller lit routes:definitions<br/>→ Router.createRoute()"]
|
|
41
|
+
CS["@controllers → hook onBoot<br/>→ Router.setController()"]
|
|
42
|
+
end
|
|
43
|
+
subgraph T3["3 · À la 1ʳᵉ REQUÊTE de la route"]
|
|
44
|
+
RM["resolveActionMeta()<br/>fige RouteActionMeta sur la route"]
|
|
45
|
+
RQ["requêtes suivantes : 0 Reflect, O(1)"]
|
|
46
|
+
end
|
|
47
|
+
D --> C --> CS --> RM --> RQ
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
1. **À l'import**, chaque décorateur appelle `Reflect.defineMetadata` et rend la main. Zéro route
|
|
51
|
+
créée, zéro service résolu.
|
|
52
|
+
2. **Au montage**, `controller()` (`routerDecorators.ts:75`) relit ces métadonnées et fabrique les
|
|
53
|
+
objets `Route` ; `controllers()` (`routerDecorators.ts:18`) accroche le contrôleur au module sur
|
|
54
|
+
le hook `onBoot` du kernel.
|
|
55
|
+
3. **À la première requête** de chaque route, `resolveActionMeta()` (`routerDecorators.ts:1624`)
|
|
56
|
+
consolide toutes les étiquettes de l'action en **un objet figé** posé sur la route. Les requêtes
|
|
57
|
+
suivantes ne lisent plus aucune métadonnée.
|
|
58
|
+
|
|
59
|
+
> [!IMPORTANT]
|
|
60
|
+
> Conséquence directe : **une route n'existe que si son fichier a été importé**. Un contrôleur oublié
|
|
61
|
+
> dans le tableau `@controllers([...])` ne produit aucune erreur — il produit un `404`.
|
|
62
|
+
|
|
63
|
+
## 📖 Lexique
|
|
64
|
+
|
|
65
|
+
| Terme | Sens |
|
|
66
|
+
| ---------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
67
|
+
| Décorateur | Annotation TS (`@Get(…)`) exécutée à l'import, qui attache une information à une classe, une méthode ou un argument. |
|
|
68
|
+
| Décorateur legacy | Le format historique TypeScript (`experimentalDecorators`), le seul utilisé ici — voir « Le contrat TypeScript ». |
|
|
69
|
+
| Métadonnée (`Reflect`) | Étiquette clé→valeur rangée sur une classe par `reflect-metadata`, relisible plus tard sans toucher au code. |
|
|
70
|
+
| Cible | Ce que le décorateur annote : **classe**, **méthode**, ou **paramètre** d'une méthode. |
|
|
71
|
+
| Décorateur **dual** | Utilisable en classe (vaut pour toutes les actions) **et** en méthode (une seule action). |
|
|
72
|
+
| Action | La méthode du contrôleur qui traite la requête. |
|
|
73
|
+
| Montage | Le moment où `@controller` transforme les métadonnées en routes réelles dans le `Router`. |
|
|
74
|
+
| `RouteActionMeta` | Le résumé figé (par route) de tous les décorateurs de l'action — lu par le `Resolver`. |
|
|
75
|
+
| Clause (autorisation) | Un `@IsGranted`/`@RequireScope` : plusieurs attributs en **OU**, plusieurs clauses en **ET**. |
|
|
76
|
+
| Scope (`api:action`) | Droit porté par un **jeton machine** (clé API, JWT) ; ne bride jamais un humain. |
|
|
77
|
+
| ALS | _AsyncLocalStorage_ : la bulle Node qui transporte la requête courante sans la passer en argument. |
|
|
78
|
+
| Mutation | Méthode non sûre : `POST`/`PUT`/`PATCH`/`DELETE` (RFC 9110 §9.2.1). |
|
|
79
|
+
| Hot path / cold path | Chemin parcouru à **chaque** requête / chemin parcouru rarement (montage, 1ʳᵉ requête). |
|
|
80
|
+
|
|
81
|
+
## Qu'est-ce qu'un décorateur, concrètement ?
|
|
82
|
+
|
|
83
|
+
Imagine des **étiquettes collées sur une machine** avant sa mise en service. Aucune ne fait tourner
|
|
84
|
+
la machine ; elles disent au monteur quoi brancher : « alimentation 220 V », « ne pas ouvrir sans
|
|
85
|
+
habilitation », « sortie : 3 bars ». Le monteur passe une fois, lit toutes les étiquettes, et câble
|
|
86
|
+
en conséquence.
|
|
87
|
+
|
|
88
|
+
Un décorateur Nodefony, c'est exactement ça :
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
@Get("/{id}") // étiquette : réponds à GET /prefix/{id}
|
|
92
|
+
@HttpCode(200) // étiquette : statut par défaut 200
|
|
93
|
+
@IsGranted("ROLE_USER") // étiquette : réservé aux porteurs du rôle
|
|
94
|
+
async read(@Param("id") id: string) // étiquette d'argument : passe-moi la variable d'URL `id`
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Sans décorateurs, il faudrait écrire à la main un fichier de routes (le chemin, la méthode, le nom du
|
|
98
|
+
contrôleur, l'action, les droits), le maintenir en parallèle du code, et le voir diverger. **Le
|
|
99
|
+
décorateur supprime la double vérité** : la déclaration vit sur l'action qu'elle décrit.
|
|
100
|
+
|
|
101
|
+
### Le contrat TypeScript — décorateurs _legacy_, et pourquoi ça compte
|
|
102
|
+
|
|
103
|
+
Nodefony utilise le format **legacy** de TypeScript : `experimentalDecorators: true` **et**
|
|
104
|
+
`emitDecoratorMetadata: true` (`tsconfig.json:5-6`, repris par le module —
|
|
105
|
+
`framework/tsconfig.json:5-6`). Ce n'est pas un détail historique, c'est ce qui rend possible :
|
|
106
|
+
|
|
107
|
+
- les **décorateurs de paramètre** (`@Param`, `@Body`…) — le format standard ES ne les propose pas ;
|
|
108
|
+
- l'**injection par type** du conteneur : `emitDecoratorMetadata` fait émettre au compilateur la
|
|
109
|
+
liste des types du constructeur sous la clé `design:paramtypes`, que l'injecteur relit pour
|
|
110
|
+
résoudre les dépendances sans les nommer (cf `injectable()`, `kernelDecorator.ts:82`).
|
|
111
|
+
|
|
112
|
+
Concrètement, dans une app générée par `nodefony create app`, ces deux options sont **déjà** dans le
|
|
113
|
+
`tsconfig.json`. Tu n'as rien à faire — sauf si tu pars d'un `tsconfig` à toi : sans elles, les
|
|
114
|
+
décorateurs ne compilent pas.
|
|
115
|
+
|
|
116
|
+
> [!WARNING]
|
|
117
|
+
> `reflect-metadata` doit être chargé **avant** tout décorateur. `routerDecorators.ts:1` l'importe
|
|
118
|
+
> pour toi dès que tu importes un décorateur du framework — mais si tu écris ton propre décorateur
|
|
119
|
+
> dans un fichier chargé plus tôt, mets-y `import "reflect-metadata";` en tête.
|
|
120
|
+
|
|
121
|
+
## La vision Nodefony
|
|
122
|
+
|
|
123
|
+
Trois partis pris expliquent la forme de cette surface, et un développeur qui les connaît ne se fait
|
|
124
|
+
jamais surprendre.
|
|
125
|
+
|
|
126
|
+
**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:839`) pose une
|
|
128
|
+
clause ; c'est le `Resolver` qui appellera le moteur d'autorisation, **résolu par son nom** dans le
|
|
129
|
+
conteneur (`Resolver._enforceSecurity()`, `Resolver.ts:576`). Pourquoi ce détour : `@nodefony/framework`
|
|
130
|
+
ne dépend **pas** de `@nodefony/security` — sans ça, les deux modules formeraient un cycle. Le prix à
|
|
131
|
+
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:582`).
|
|
133
|
+
|
|
134
|
+
**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:1580`) puis gelées sur la route.
|
|
136
|
+
L'objet `RouteActionMeta` (`routerDecorators.ts:1392`) est **partagé par toutes les requêtes** — le
|
|
137
|
+
framework ne le mute jamais, et ton code non plus. Une action non décorée obtient des champs à `null`,
|
|
138
|
+
ce qui vaut **zéro branche** dans le chemin chaud.
|
|
139
|
+
|
|
140
|
+
**3 — Les mêmes décorateurs pour HTTP et WebSocket.** C'est le différenciateur du framework : un
|
|
141
|
+
contrôleur ne change pas de forme selon le transport. Une action WS se déclare avec `@route` et le
|
|
142
|
+
transport `WEBSOCKET` dans ses `requirements` ; ses paramètres s'injectent avec les mêmes `@Body`,
|
|
143
|
+
`@Query`, `@CurrentUser`.
|
|
144
|
+
|
|
145
|
+
## 🚀 Démarrage rapide
|
|
146
|
+
|
|
147
|
+
Vu depuis une app créée par `nodefony create app`. Rien à configurer : **les décorateurs ne se
|
|
148
|
+
règlent pas, ils se déclarent**.
|
|
149
|
+
|
|
150
|
+
### Le contrôleur
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
// nodefony/controller/BookController.ts — complet, compile tel quel
|
|
154
|
+
import {
|
|
155
|
+
Controller,
|
|
156
|
+
controller,
|
|
157
|
+
Get,
|
|
158
|
+
Post,
|
|
159
|
+
Delete,
|
|
160
|
+
Param,
|
|
161
|
+
Query,
|
|
162
|
+
Body,
|
|
163
|
+
HttpCode,
|
|
164
|
+
Header,
|
|
165
|
+
IsGranted,
|
|
166
|
+
CurrentUser,
|
|
167
|
+
} from "@nodefony/framework";
|
|
168
|
+
import type { IUser } from "@nodefony/user";
|
|
169
|
+
|
|
170
|
+
interface BookInput {
|
|
171
|
+
title: string;
|
|
172
|
+
author: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Le préfixe s'applique à TOUTES les routes de la classe.
|
|
176
|
+
@controller("/api/books")
|
|
177
|
+
class BookController extends Controller {
|
|
178
|
+
// GET /api/books?q=… — `@Query` sans valeur present → undefined, jamais throw.
|
|
179
|
+
@Get("")
|
|
180
|
+
async list(@Query("q") q?: string) {
|
|
181
|
+
return this.renderJson({ items: [], q: q ?? null });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// GET /api/books/{id} — `{id}` est capturé et injecté par son NOM.
|
|
185
|
+
@Get("/{id}")
|
|
186
|
+
async read(@Param("id") id: string) {
|
|
187
|
+
return this.renderJson({ id, title: "Le Horla" });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// POST /api/books — 201 + en-tête posés AVANT l'exécution de l'action.
|
|
191
|
+
// @IsGranted est évalué encore avant : un 403 n'instancie même pas ce contrôleur.
|
|
192
|
+
@Post("")
|
|
193
|
+
@HttpCode(201)
|
|
194
|
+
@Header("Cache-Control", "no-store")
|
|
195
|
+
@IsGranted(["ROLE_USER"])
|
|
196
|
+
async create(@Body() dto: BookInput, @CurrentUser() user: IUser) {
|
|
197
|
+
return this.renderJson({ id: "b_42", ...dto, owner: user.identifier });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// DELETE /api/books/{id} — `subject: "id"` passe la variable d'URL au voter
|
|
201
|
+
// métier (« cet utilisateur est-il propriétaire de CE livre ? »).
|
|
202
|
+
@Delete("/{id}")
|
|
203
|
+
@HttpCode(204)
|
|
204
|
+
@IsGranted("book.delete", { subject: "id" })
|
|
205
|
+
// ⚠️ PAS `remove` : `Controller` hérite de `Service.remove()` — voir les Pièges.
|
|
206
|
+
async destroy(@Param("id") id: string) {
|
|
207
|
+
void id;
|
|
208
|
+
return null; // 204 : le Resolver envoie une réponse vide (RFC 9110)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export default BookController;
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Le branchement (une ligne, dans le module de l'app)
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
// index.ts du module — `nodefony create controller` fait ce câblage pour toi
|
|
219
|
+
import { Kernel, Module } from "nodefony";
|
|
220
|
+
import { Controller, controller, controllers, Get } from "@nodefony/framework";
|
|
221
|
+
|
|
222
|
+
@controller("/hello")
|
|
223
|
+
class HelloController extends Controller {
|
|
224
|
+
@Get("")
|
|
225
|
+
async index() {
|
|
226
|
+
return this.renderJson({ hello: "nodefony" });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Sans cette ligne, les routes existent mais aucun module ne les porte → 404.
|
|
231
|
+
@controllers([HelloController])
|
|
232
|
+
class AppModule extends Module {
|
|
233
|
+
constructor(kernel: Kernel) {
|
|
234
|
+
super("app", kernel, import.meta.url, {});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export default AppModule;
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Ce qu'on observe
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
# 1) Lecture publique
|
|
245
|
+
curl -s http://localhost:5151/api/books/42
|
|
246
|
+
# {"id":"42","title":"Le Horla"}
|
|
247
|
+
|
|
248
|
+
# 2) Création sans rôle → 403 rendu AVANT l'instanciation du contrôleur
|
|
249
|
+
curl -si -X POST http://localhost:5151/api/books \
|
|
250
|
+
-H 'Content-Type: application/json' -d '{"title":"X","author":"Y"}' | head -1
|
|
251
|
+
# HTTP/1.1 403 Forbidden
|
|
252
|
+
|
|
253
|
+
# 3) Créée avec le rôle : le 201 et l'en-tête viennent des décorateurs
|
|
254
|
+
curl -si -b /tmp/jar -X POST http://localhost:5151/api/books \
|
|
255
|
+
-H 'Content-Type: application/json' -d '{"title":"X","author":"Y"}' | head -3
|
|
256
|
+
# HTTP/1.1 201 Created
|
|
257
|
+
# Cache-Control: no-store
|
|
258
|
+
|
|
259
|
+
# 4) Méthode non déclarée pour ce chemin → 405 avec l'agrégat des méthodes
|
|
260
|
+
curl -si -X PUT http://localhost:5151/api/books/42 | head -2
|
|
261
|
+
# HTTP/1.1 405 Method Not Allowed
|
|
262
|
+
# Allow: GET, DELETE
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## 🧰 La table de référence — toute la surface décorateur
|
|
266
|
+
|
|
267
|
+
Six familles, **36 décorateurs**, un seul fichier source. Le tableau de synthèse sert à choisir en
|
|
268
|
+
5 secondes ; les tables détaillées qui suivent donnent l'effet exact et un exemple.
|
|
269
|
+
|
|
270
|
+
<!-- prettier-ignore -->
|
|
271
|
+
| Famille | Ce qu'elle décide | Décorateurs |
|
|
272
|
+
| --- | --- | --- |
|
|
273
|
+
| **Déclaration** | Où vit le contrôleur, quelles routes il porte | `@controllers` `@controller` `@route` `@Domain` `@Scope` |
|
|
274
|
+
| **Méthodes HTTP** | Quand l'action répond | `@Get` `@Post` `@Put` `@Patch` `@Delete` `@Options` `@Head` `@All` |
|
|
275
|
+
| **Paramètres** | Ce que l'action reçoit en arguments | `@Param` `@Query` `@Body` `@Headers` `@Cookie` `@Session` `@CurrentUser` `@Req` `@Res` `@UploadedFile` `@UploadedFiles` |
|
|
276
|
+
| **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` |
|
|
278
|
+
| **Cycle de la requête** | Session, anti-rejeu | `@UseSession` `@Idempotent` |
|
|
279
|
+
|
|
280
|
+
> Tous s'importent depuis `"@nodefony/framework"` — jamais par un chemin relatif interne.
|
|
281
|
+
|
|
282
|
+
### Déclaration — classe, module, route
|
|
283
|
+
|
|
284
|
+
<!-- prettier-ignore -->
|
|
285
|
+
| Décorateur | Cible | Effet | Exemple |
|
|
286
|
+
| --- | --- | --- | --- |
|
|
287
|
+
| `@controllers([…])` | **module** | Rattache des contrôleurs au module sur le hook `onBoot` ; sans lui, aucune route n'est servie (`controllers()`, `routerDecorators.ts:18`) | `@controllers([BookController])` |
|
|
288
|
+
| `@controller("/prefix")` | **classe** | Pose le préfixe d'URL **et déclenche la création des routes** de la classe (`controller()`, `routerDecorators.ts:75`) | `@controller("/api/books")` |
|
|
289
|
+
| `@route(nom, options)` | méthode | Forme complète : nom explicite, chemin, `requirements`, `defaults`, hôte (`route()`, `routerDecorators.ts:157`) | `@route("ws-echo", { path: "/echo", requirements: { methods: ["WEBSOCKET"] } })` |
|
|
290
|
+
| `@Domain(motif \| motifs)` | **dual** | Restreint la route (ou la classe) à un ou plusieurs vhosts ; hors domaine → **403** (`Domain()`, `routerDecorators.ts:625`) | `@Domain("*.cdn.example.com")` |
|
|
291
|
+
| `@Scope("singleton")` | **classe** | Une seule instance de contrôleur partagée par toutes les requêtes (`Scope()`, `routerDecorators.ts:729`) | `@Scope("singleton")` |
|
|
292
|
+
|
|
293
|
+
**`@controller` est le déclencheur.** Il relit les métadonnées posées par `@route`/`@Get`/… puis les
|
|
294
|
+
**efface** (`Reflect.deleteMetadata`, `routerDecorators.ts:135`) : une classe ne se monte qu'une
|
|
295
|
+
fois. Il traite au passage la route « magique » `path: "*"` en **dernier**, quel que soit son ordre
|
|
296
|
+
d'écriture (`routerDecorators.ts:281`) — sinon un attrape-tout masquerait les routes précises.
|
|
297
|
+
|
|
298
|
+
**`@Scope("singleton")` est un contrat, pas une optimisation.** L'instance étant partagée, l'action
|
|
299
|
+
ne doit lire ni écrire **aucun** état de requête sur `this` : tout passe par les arguments décorés et
|
|
300
|
+
les accesseurs, qui retrouvent la requête courante via l'ALS. Le défaut reste `"request"` — une
|
|
301
|
+
instance par requête (`ControllerScope`, `Controller.ts:110`).
|
|
302
|
+
|
|
303
|
+
> [!NOTE]
|
|
304
|
+
> Le core `nodefony` exporte lui aussi un `Scope` (les portées du conteneur d'injection). Celui des
|
|
305
|
+
> contrôleurs s'importe **depuis `@nodefony/framework`** — l'homonymie est signalée dans le code
|
|
306
|
+
> (`routerDecorators.ts:747`).
|
|
307
|
+
|
|
308
|
+
### Méthodes HTTP
|
|
309
|
+
|
|
310
|
+
Toutes les fabriques sortent du même moule, `httpMethodDecorator()` (`routerDecorators.ts:455`) :
|
|
311
|
+
elles nomment la route automatiquement `ClasseName::methode` et posent `requirements.methods`.
|
|
312
|
+
|
|
313
|
+
| Décorateur | Méthode filtrée | Ancre | Exemple |
|
|
314
|
+
| ------------------------ | --------------- | ------------------------------------- | ------------------- |
|
|
315
|
+
| `@Get(path?, opts?)` | `GET` | `Get` (`routerDecorators.ts:476`) | `@Get("/{id}")` |
|
|
316
|
+
| `@Post(path?, opts?)` | `POST` | `Post` (`routerDecorators.ts:477`) | `@Post("")` |
|
|
317
|
+
| `@Put(path?, opts?)` | `PUT` | `Put` (`routerDecorators.ts:478`) | `@Put("/{id}")` |
|
|
318
|
+
| `@Delete(path?, opts?)` | `DELETE` | `Delete` (`routerDecorators.ts:479`) | `@Delete("/{id}")` |
|
|
319
|
+
| `@Patch(path?, opts?)` | `PATCH` | `Patch` (`routerDecorators.ts:480`) | `@Patch("/{id}")` |
|
|
320
|
+
| `@Options(path?, opts?)` | `OPTIONS` | `Options` (`routerDecorators.ts:481`) | `@Options("/{id}")` |
|
|
321
|
+
| `@Head(path?, opts?)` | `HEAD` | `Head` (`routerDecorators.ts:367`) | `@Head("/{id}")` |
|
|
322
|
+
| `@All(path?, opts?)` | **aucune** | `All()` (`routerDecorators.ts:374`) | `@All("/proxy/*")` |
|
|
323
|
+
|
|
324
|
+
Deux points qu'un dev découvre sinon à ses dépens :
|
|
325
|
+
|
|
326
|
+
- **Le nom de route est automatique et déterministe** : `BookController::read`. Utile pour les logs,
|
|
327
|
+
l'écran Routes de Studio et `forward()`. Deux actions homonymes dans deux classes ne collisionnent
|
|
328
|
+
pas ; deux méthodes de même nom dans la même classe, si (c'est impossible en TS).
|
|
329
|
+
- **`@All` n'émet aucun `requirements.methods`** — la route matche donc **toutes** les méthodes et ne
|
|
330
|
+
produit jamais de `405`. À réserver aux proxies et attrape-tout ; une API REST gagne à déclarer ses
|
|
331
|
+
méthodes, ne serait-ce que pour l'en-tête `Allow`.
|
|
332
|
+
|
|
333
|
+
Le second argument accepte les options de route non redondantes — `Omit<RouteOptions, "path" | "method">`
|
|
334
|
+
(`routerDecorators.ts:338`), soit `defaults`, `requirements`, `host`, `bypassFirewall`
|
|
335
|
+
(`RouteOptions`, `Route.ts:94`) :
|
|
336
|
+
|
|
337
|
+
```typescript
|
|
338
|
+
@Get("/{page}", { defaults: { page: "1" }, requirements: { scheme: "https" } })
|
|
339
|
+
async index(@Param("page") page: string) { /* … */ }
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
### Paramètres — ce que l'action reçoit
|
|
343
|
+
|
|
344
|
+
Onze décorateurs, tous produits par `paramDecoratorFactory()` (`routerDecorators.ts:1148`) sauf
|
|
345
|
+
`@Body`, qui accepte une option supplémentaire. Chacun pose `{ source, key, index }` ; la valeur est
|
|
346
|
+
calculée par `resolveParamArg()` (`routerDecorators.ts:1283`), une fonction **pure** — ce qui la rend
|
|
347
|
+
testable sans démarrer de serveur.
|
|
348
|
+
|
|
349
|
+
| Décorateur | Sans clé renvoie… | Avec clé renvoie… | Ancre |
|
|
350
|
+
| ------------------- | ------------------------------------ | ------------------------------------------ | -------------------------------------------- |
|
|
351
|
+
| `@Param("id")` | toutes les variables d'URL (objet) | la variable d'URL nommée | `Param` (`routerDecorators.ts:1168`) |
|
|
352
|
+
| `@Query("q")` | toute la query string | un paramètre de la query string | `Query` (`routerDecorators.ts:1169`) |
|
|
353
|
+
| `@Body("field")` | le corps parsé entier | un champ du corps parsé | `Body()` (`routerDecorators.ts:1209`) |
|
|
354
|
+
| `@Headers("x-foo")` | tous les en-têtes de requête | un en-tête (**lookup en minuscules**) | `Headers` (`routerDecorators.ts:1232`) |
|
|
355
|
+
| `@Cookie("sid")` | la map des cookies | un cookie (objet `Cookie`, champ `.value`) | `Cookie` (`routerDecorators.ts:1233`) |
|
|
356
|
+
| `@Session("user")` | l'objet `Session` vivant | `session.get(clé)` | `Session` (`routerDecorators.ts:1234`) |
|
|
357
|
+
| `@CurrentUser()` | l'utilisateur résolu par le firewall | — | `CurrentUser` (`routerDecorators.ts:1236`) |
|
|
358
|
+
| `@Req()` | la requête brute du contexte | — | `Req` (`routerDecorators.ts:1237`) |
|
|
359
|
+
| `@Res()` | la réponse du contexte | — | `Res` (`routerDecorators.ts:1238`) |
|
|
360
|
+
| `@UploadedFile()` | le **premier** fichier téléversé | — | `UploadedFile` (`routerDecorators.ts:1239`) |
|
|
361
|
+
| `@UploadedFiles()` | tous les fichiers téléversés | — | `UploadedFiles` (`routerDecorators.ts:1240`) |
|
|
362
|
+
|
|
363
|
+
La liste des sources possibles est fermée et typée : `ParamSource` (`routerDecorators.ts:365`).
|
|
364
|
+
|
|
365
|
+
#### Trois comportements à connaître
|
|
366
|
+
|
|
367
|
+
**`@CurrentUser` lit l'ALS, jamais un argument caché.** La valeur vient de `RequestContext.getUser()`
|
|
368
|
+
(`routerDecorators.ts:1236`) : l'utilisateur posé par le firewall. C'est **l'utilisateur**, jamais le
|
|
369
|
+
justificatif (mot de passe, jeton). Hors zone authentifiée, la valeur est `undefined` — le décorateur
|
|
370
|
+
n'authentifie rien, il expose ce qui a déjà été prouvé.
|
|
371
|
+
|
|
372
|
+
**`@Session` active la session à lui seul.** La simple présence d'un paramètre `@Session` vaut
|
|
373
|
+
déclaration d'intention : `resolveSessionIntent()` (`routerDecorators.ts:819`) la détecte et pose
|
|
374
|
+
l'intent, exactement comme `@UseSession()`. Une route sans l'un ni l'autre ne paie aucune session.
|
|
375
|
+
|
|
376
|
+
**`@Body({ stream: true })` court-circuite le parsing.** Pour un gros téléversement (vidéo,
|
|
377
|
+
sauvegarde), on injecte le **flux brut** de la requête au lieu du corps chargé en mémoire ; le
|
|
378
|
+
pipeline saute alors le parsing pour cette route, décision prise en amont par
|
|
379
|
+
`routeExpectsBodyStream()` (`routerDecorators.ts:1365`) :
|
|
380
|
+
|
|
381
|
+
```typescript
|
|
382
|
+
@Post("/upload")
|
|
383
|
+
async upload(@Body({ stream: true }) stream: NodeJS.ReadableStream) {
|
|
384
|
+
await pipeline(stream, createWriteStream("/data/upload.bin")); // 0 pic mémoire
|
|
385
|
+
return this.renderJson({ ok: true });
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
> [!TIP]
|
|
390
|
+
> L'ordre d'écriture des paramètres décorés n'a aucune importance : chaque valeur est placée à son
|
|
391
|
+
> **index déclaré** par `buildParamArgs()` (`routerDecorators.ts:1347`), et les trous restent
|
|
392
|
+
> `undefined`. Tu peux mélanger décorés et non décorés — les non décorés reçoivent `undefined`.
|
|
393
|
+
|
|
394
|
+
#### Le corps n'est pas validé — et c'est un choix
|
|
395
|
+
|
|
396
|
+
`@Body()` injecte le corps **tel qu'il a été parsé**. Le type écrit à côté n'est pas vérifié à
|
|
397
|
+
l'exécution : `@Body() dto: CreateOrder` compile, et un client peut très bien envoyer autre chose.
|
|
398
|
+
|
|
399
|
+
Ce n'est pas un oubli. Valider ici ne garderait que la porte **HTTP** — la même écriture arrivant
|
|
400
|
+
par WebSocket ou par une commande CLI passerait à côté — et la validation devrait rester
|
|
401
|
+
**synchrone**, puisque `resolveParamArg()` l'est ; la rendre asynchrone coûterait une microtâche à
|
|
402
|
+
toute requête à paramètres décorés, y compris celles qui ne valident rien.
|
|
403
|
+
|
|
404
|
+
La validation vit donc **plus bas**, là où tous les chemins se rejoignent.
|
|
405
|
+
|
|
406
|
+
**Une entité → les hooks du service.** `AbstractCrudService` appelle `beforeCreate` et
|
|
407
|
+
`beforeUpdate` en `await` (`orm-core/nodefony/src/AbstractCrudService.ts:150` et `:175`) : une règle
|
|
408
|
+
asynchrone — vérifier qu'un courriel est libre — y est donc possible, et le contrôle s'applique à
|
|
409
|
+
REST, à la socket et à la CLI d'un seul geste. C'est exactement ce que `nodefony create entity`
|
|
410
|
+
génère :
|
|
411
|
+
|
|
412
|
+
```typescript
|
|
413
|
+
protected override beforeCreate(data: Partial<PostRow>): Partial<PostRow> {
|
|
414
|
+
return createPostSchema.parse(data) as Partial<PostRow>;
|
|
415
|
+
}
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
**Un cas isolé → le schéma en tête d'action.** Même geste qu'`assertPageQuery()`, la garde de
|
|
419
|
+
pagination du cœur : une fonction appelée en première ligne, qui lève. Rien d'autre à écrire — une
|
|
420
|
+
`ZodError` qui remonte devient un **422** portant `error.fields` :
|
|
421
|
+
|
|
422
|
+
```typescript
|
|
423
|
+
@Post("/subscribe")
|
|
424
|
+
subscribe(@Body() body: unknown) {
|
|
425
|
+
const dto = subscribeSchema.parse(body); // lève → 422 + fields
|
|
426
|
+
return this.renderJson({ ok: true, email: dto.email });
|
|
427
|
+
}
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
Le rendu est assuré par `toValidationFields()` (`http/nodefony/service/error-renderer.ts:126`), qui
|
|
431
|
+
reconnaît l'erreur **par sa forme** (`name` + `issues`) et non par `instanceof` — une application
|
|
432
|
+
qui embarque sa propre copie de zod est donc servie pareil. Le client reçoit **422** (RFC 9110
|
|
433
|
+
§15.5.21 : le corps est lisible, c'est son contenu qui viole le contrat) et la liste des champs
|
|
434
|
+
fautifs, avec pour chacun son message et la règle qui a échoué.
|
|
435
|
+
|
|
436
|
+
**Comment typer le paramètre**, puisque le décorateur ne promet rien :
|
|
437
|
+
|
|
438
|
+
| Écriture | Ce que ça annonce | Verdict |
|
|
439
|
+
| ----------------------------- | --------------------------------------------------- | ----------------------- |
|
|
440
|
+
| `@Body() b: Partial<PostRow>` | la ligne de **table** — `id` et horodatages compris | promet trop |
|
|
441
|
+
| `@Body() b: unknown` | rien, honnêtement | juste, mais peu commode |
|
|
442
|
+
| `@Body() b: CreatePost` | le contrat d'**entrée**, `z.infer` du schéma | ✅ à préférer |
|
|
443
|
+
|
|
444
|
+
`CreatePost` et `UpdatePost` sont générés à côté du schéma (`nodefony/entity/Post.schema.ts`) : le
|
|
445
|
+
type et la validation dérivent de la même source, ils ne peuvent donc pas diverger. Un schéma
|
|
446
|
+
d'entrée ne décrit d'ailleurs pas la table — ni `id` ni horodatages n'y figurent, ils sont posés par
|
|
447
|
+
le serveur —, et zod **retire** les champs inconnus : un client qui glisserait `{ "role": "admin" }`
|
|
448
|
+
ne s'auto-promeut pas.
|
|
449
|
+
|
|
450
|
+
### Réponse — statut, en-têtes, redirection
|
|
451
|
+
|
|
452
|
+
| Décorateur | Cible | Effet | Exemple |
|
|
453
|
+
| ------------------------- | ------- | ------------------------------------------------------------------------------------------ | ------------------------------------- |
|
|
454
|
+
| `@HttpCode(201)` | méthode | Fixe le statut **avant** l'exécution de l'action (`HttpCode()`, `routerDecorators.ts:548`) | `@HttpCode(204)` |
|
|
455
|
+
| `@Header("X-Foo", "bar")` | méthode | Ajoute un en-tête ; **s'empile** (plusieurs `@Header` cumulent, `routerDecorators.ts:580`) | `@Header("Cache-Control","no-store")` |
|
|
456
|
+
| `@Redirect("/url", 302)` | méthode | Redirige **si** l'action ne renvoie rien (`Redirect()`, `routerDecorators.ts:589`) | `@Redirect("/login", 302)` |
|
|
457
|
+
|
|
458
|
+
Les deux premiers sont appliqués par `Resolver._applyResponseMeta()` (`Resolver.ts:650`) **avant**
|
|
459
|
+
l'appel de l'action : ton code peut donc les écraser ensuite (`this.renderJson(data, 202)` gagne).
|
|
460
|
+
|
|
461
|
+
`@Redirect` a une subtilité utile : si l'action **retourne un objet** portant `url` (et
|
|
462
|
+
éventuellement `statusCode`), cet objet **prend le dessus** sur les valeurs du décorateur
|
|
463
|
+
(`Resolver._handleRedirect()`, `Resolver.ts:666`) — la cible peut donc être calculée à l'exécution :
|
|
464
|
+
|
|
465
|
+
```typescript
|
|
466
|
+
@Get("/go")
|
|
467
|
+
@Redirect("/fallback", 302) // cible par défaut
|
|
468
|
+
async go(@Query("to") to?: string) {
|
|
469
|
+
return to ? { url: to, statusCode: 307 } : undefined; // undefined → /fallback
|
|
470
|
+
}
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
> [!WARNING]
|
|
474
|
+
> Redirection sans statut explicite ailleurs dans le code : `Response.redirect()` vaut **301** par
|
|
475
|
+
> défaut (permanent, mis en cache par les navigateurs). Passe toujours le code —
|
|
476
|
+
> `this.redirect(url, 302)`.
|
|
477
|
+
|
|
478
|
+
### Sécurité — qui passe, qui décide, quelles défenses
|
|
479
|
+
|
|
480
|
+
Sept décorateurs, **tous duals** (classe ou méthode) et **tous sans logique** : ils posent une
|
|
481
|
+
étiquette que le `Resolver` ou le firewall consommera.
|
|
482
|
+
|
|
483
|
+
| Décorateur | Effet | Ancre |
|
|
484
|
+
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
|
|
485
|
+
| `@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:839`) |
|
|
486
|
+
| `@RequireScope(scope \| scopes)` | Exige un scope `api:action` d'un **jeton machine** ; no-op pour une session humaine | `RequireScope()` (`routerDecorators.ts:936`) |
|
|
487
|
+
| `@Anonymous()` | Rend l'action publique : annule l'autorisation **et** l'authentification (le « permitAll ») | `Anonymous()` (`routerDecorators.ts:912`) |
|
|
488
|
+
| `@BypassFirewall` | Court-circuite le firewall (sonde de liveness, webhook signé, endpoint de login). **Sans parenthèses** | `BypassFirewall` (`routerDecorators.ts:686`) |
|
|
489
|
+
| `@Csp({ "frame-src": [...] })` | Ajoute des directives CSP **à cette réponse** ; classe + méthode fusionnent additivement | `Csp()` (`routerDecorators.ts:1001`) |
|
|
490
|
+
| `@CsrfProtect()` | Opt-**in** au jeton anti-CSRF (double-submit signé) en plus de la défense globale | `CsrfProtect` (`routerDecorators.ts:1090`) |
|
|
491
|
+
| `@CsrfExempt()` | Opt-**out** de la défense CSRF **en gardant** l'authentification (webhook, POST cross-origin légitime) | `CsrfExempt` (`routerDecorators.ts:1099`) |
|
|
492
|
+
|
|
493
|
+
#### Rôles et scopes — deux axes, un seul verdict
|
|
494
|
+
|
|
495
|
+
`@IsGranted` et `@RequireScope` écrivent dans **deux jeux de métadonnées distincts**, puis
|
|
496
|
+
`computeSecurityRequirement()` (`routerDecorators.ts:1444`) les fusionne en une exigence unique dont
|
|
497
|
+
toutes les clauses sont en **ET**. Une seule chaîne d'application côté `Resolver`, deux jurés
|
|
498
|
+
différents côté `security` (le voteur de rôles, le voteur de scopes).
|
|
499
|
+
|
|
500
|
+
```typescript
|
|
501
|
+
@controller("/api/orders")
|
|
502
|
+
@IsGranted("ROLE_USER") // vaut pour TOUTES les actions de la classe
|
|
503
|
+
class OrderController extends Controller {
|
|
504
|
+
@Get("") // hérite ROLE_USER
|
|
505
|
+
async list() {}
|
|
506
|
+
|
|
507
|
+
@Post("")
|
|
508
|
+
@RequireScope("orders:write") // + un scope si l'appelant est une clé API
|
|
509
|
+
async create() {} // ⇒ ROLE_USER ET orders:write
|
|
510
|
+
|
|
511
|
+
@Get("/health")
|
|
512
|
+
@Anonymous() // annule la garde de classe → route publique
|
|
513
|
+
async health() {}
|
|
514
|
+
}
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
Pourquoi deux axes plutôt qu'un : **les rôles disent qui tu es**, **les scopes disent ce qu'une clé a
|
|
518
|
+
le droit de faire**. Un humain connecté ne doit pas être bridé par une notion prévue pour restreindre
|
|
519
|
+
un jeton délégué — d'où le no-op côté session.
|
|
520
|
+
|
|
521
|
+
#### La différence entre `@Anonymous`, `@BypassFirewall` et `@CsrfExempt`
|
|
522
|
+
|
|
523
|
+
Trois façons d'ouvrir une porte, trois portées — les confondre coûte cher :
|
|
524
|
+
|
|
525
|
+
| Décorateur | Authentification | Autorisation | Défense CSRF | Cas d'usage typique |
|
|
526
|
+
| ----------------- | :--------------: | :--------------: | :----------: | ------------------------------------- |
|
|
527
|
+
| `@Anonymous()` | ignorée | ignorée | conservée | page publique d'un contrôleur protégé |
|
|
528
|
+
| `@BypassFirewall` | ignorée | (rien à évaluer) | conservée | sonde `/health`, endpoint de login |
|
|
529
|
+
| `@CsrfExempt()` | **conservée** | **conservée** | ignorée | webhook signé, API cross-origin |
|
|
530
|
+
|
|
531
|
+
`@Anonymous()` pose en réalité **deux** marqueurs : « pas d'autorisation » et « pas de firewall »
|
|
532
|
+
(`routerDecorators.ts:719-734`) — c'est un `@BypassFirewall` doublé d'une annulation des clauses
|
|
533
|
+
héritées de la classe.
|
|
534
|
+
|
|
535
|
+
> [!CAUTION]
|
|
536
|
+
> `@BypassFirewall` s'écrit **sans parenthèses** : c'est un drapeau, pas une fabrique. Écrire
|
|
537
|
+
> `@BypassFirewall()` appelle la fonction avec `undefined` en cible et **n'ouvre rien** — la route
|
|
538
|
+
> reste gardée. Le sens du défaut est volontaire (_fail-closed_) : un oubli laisse la route fermée,
|
|
539
|
+
> jamais ouverte par erreur.
|
|
540
|
+
|
|
541
|
+
### Cycle de la requête — session et anti-rejeu
|
|
542
|
+
|
|
543
|
+
| Décorateur | Cible | Effet | Ancre |
|
|
544
|
+
| ------------------------------------ | ----- | ---------------------------------------------------------------------------------------------------------- | --------------------------------- |
|
|
545
|
+
| `@UseSession({ readOnly?, eager? })` | dual | Déclare le besoin d'une session serveur ; **méthode > classe** (`UseSession()`, `routerDecorators.ts:761`) | `@UseSession({ readOnly: true })` |
|
|
546
|
+
| `@Idempotent({ required? })` | dual | Protège une mutation du double effet via `Idempotency-Key` (`Idempotent()`, `routerDecorators.ts:1103`) | `@Idempotent()` |
|
|
547
|
+
|
|
548
|
+
**`@UseSession` est la seule façon d'ouvrir une session** (avec un paramètre `@Session`, ou la reprise
|
|
549
|
+
d'un cookie existant). Il n'existe plus de « démarrer partout » global : une route qui ne déclare rien
|
|
550
|
+
ne coûte aucune lecture de stockage. Les deux options sont `readOnly` (lire sans jamais persister —
|
|
551
|
+
zéro écriture) et `eager` (activer tôt, pour régénérer l'identifiant juste après une authentification).
|
|
552
|
+
La forme exacte est celle de `SessionIntent` (`ISession.ts:18`).
|
|
553
|
+
|
|
554
|
+
**`@Idempotent` est strict par défaut** : une mutation sans `Idempotency-Key` reçoit **400**. Le mode
|
|
555
|
+
souple s'obtient par `@Idempotent({ required: false })` — sans effet en WebSocket, toujours strict
|
|
556
|
+
puisqu'une socket rejoue par nature. Les cinq verdicts, les statuts 409/422, la clé scopée par
|
|
557
|
+
identité et les stockages distribués sont traités dans la page dédiée →
|
|
558
|
+
[idempotence](./idempotence.md).
|
|
559
|
+
|
|
560
|
+
### Le voisinage — décorateurs des autres modules
|
|
561
|
+
|
|
562
|
+
Ils ne viennent pas de `@nodefony/framework`, mais complètent la même DX ; on les cite pour éviter les
|
|
563
|
+
recherches inutiles.
|
|
564
|
+
|
|
565
|
+
| Décorateur | Paquet | Rôle |
|
|
566
|
+
| ----------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------- |
|
|
567
|
+
| `@services([…])` | `nodefony` | Déclare les services d'un module (`services()`, `kernelDecorator.ts:24`) |
|
|
568
|
+
| `@injectable()` | `nodefony` | Rend une classe résoluble par le conteneur (`injectable()`, `kernelDecorator.ts:135`) |
|
|
569
|
+
| `@inject("nom")` | `nodefony` | Injecte un service à une position de constructeur (`inject()`, `kernelDecorator.ts:114`) |
|
|
570
|
+
| `@Inject("nom")` | `nodefony` | Idem, sur une propriété (`Inject()`, `kernelDecorator.ts:143`) |
|
|
571
|
+
| `@RealtimeAction("méthode")` | `@nodefony/realtime` | Expose une action JSON-RPC sur socket (`RealtimeAction()`, `realtimeDecorators.ts:101`) |
|
|
572
|
+
| `@RealtimeChannel("canal", policy)` | `@nodefony/realtime` | Déclare un canal temps réel et sa politique (`RealtimeChannel()`, `realtimeDecorators.ts:142`) |
|
|
573
|
+
| `@RealtimeInbound("méthode")` | `@nodefony/realtime` | Traite un message entrant typé (`RealtimeInbound()`, `realtimeDecorators.ts:182`) |
|
|
574
|
+
|
|
575
|
+
Injection et portées → [injection-portees](../../../../../docs/architecture/injection-portees.md) ·
|
|
576
|
+
socket → [realtime](../../realtime/docs/index.md).
|
|
577
|
+
|
|
578
|
+
> [!NOTE]
|
|
579
|
+
> **`@nodefony/security` n'exporte aucun décorateur.** Toutes les annotations de sécurité
|
|
580
|
+
> (`@IsGranted`, `@RequireScope`, `@Anonymous`, `@Csp`, `@Csrf*`, `@BypassFirewall`) vivent **ici**,
|
|
581
|
+
> dans le framework, précisément pour qu'aucun cycle de dépendance ne se forme. Le moteur qui les
|
|
582
|
+
> applique, lui, est dans security → [firewall](../../security/docs/firewall.md) ·
|
|
583
|
+
> [autorisation](../../security/docs/authorization.md).
|
|
584
|
+
|
|
585
|
+
## 🔌 HTTP et WebSocket — les mêmes décorateurs
|
|
586
|
+
|
|
587
|
+
Un contrôleur ne change pas de forme selon le transport : ce sont les `requirements.methods` qui
|
|
588
|
+
déclarent le canal, `WEBSOCKET` étant une pseudo-méthode du type `HTTPMethod` (`Context.ts:100`).
|
|
589
|
+
|
|
590
|
+
```typescript
|
|
591
|
+
@controller("/ws/chat")
|
|
592
|
+
class ChatController extends Controller {
|
|
593
|
+
// Handshake + chaque message arrivent dans CETTE action.
|
|
594
|
+
@route("chat-echo", {
|
|
595
|
+
path: "/echo",
|
|
596
|
+
requirements: { methods: ["WEBSOCKET"] },
|
|
597
|
+
})
|
|
598
|
+
async echo(message: string | Buffer | null) {
|
|
599
|
+
if (!message) return this.renderJson({ handshake: true }); // 1er passage
|
|
600
|
+
return this.render(message.toString());
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// DUPLEX : la même action est joignable en GET et par une frame `api.request`.
|
|
604
|
+
@route("chat-rooms", {
|
|
605
|
+
path: "/rooms",
|
|
606
|
+
requirements: { methods: ["GET", "WEBSOCKET"] },
|
|
607
|
+
})
|
|
608
|
+
async rooms(@Query("limit") limit?: string) {
|
|
609
|
+
return this.renderJson({ rooms: [], limit: limit ?? "25" });
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
Trois faits à retenir :
|
|
615
|
+
|
|
616
|
+
- **Il n'existe pas de décorateur `@Ws`.** Le transport se déclare dans les `requirements` — via
|
|
617
|
+
`@route`, ou via `@All("/x", { requirements: { methods: ["WEBSOCKET"] } })` si tu préfères la forme
|
|
618
|
+
courte (les fabriques `@Get`/`@Post` écrasent, elles, `requirements.methods` par leur propre méthode,
|
|
619
|
+
`routerDecorators.ts:476`).
|
|
620
|
+
- **Les décorateurs de paramètre fonctionnent pareil.** Pour une invocation par socket, le corps de
|
|
621
|
+
la mutation voyage dans l'ALS et **prime** sur le corps HTTP (vide dans ce cas) — c'est traité dans
|
|
622
|
+
`resolveParamArg()` (`routerDecorators.ts:1283`), et `@Query` lit la query du chemin **invoqué**,
|
|
623
|
+
pas celle du handshake (`Resolver._buildParamArgs()`, `Resolver.ts:637`).
|
|
624
|
+
- **Les gardes s'appliquent identiquement.** `@IsGranted` protège une action joignable par socket
|
|
625
|
+
exactement comme une action HTTP : la décision est prise avant l'instanciation, quel que soit le
|
|
626
|
+
transport.
|
|
627
|
+
|
|
628
|
+
Les décorateurs propres au temps réel (canaux, actions JSON-RPC) appartiennent à `@nodefony/realtime`
|
|
629
|
+
→ [socket Nodefony](../../realtime/docs/index.md).
|
|
630
|
+
|
|
631
|
+
## ⚙️ Options communes et règles de précédence
|
|
632
|
+
|
|
633
|
+
Quand la même chose est déclarée à deux endroits, qui gagne ? Les règles sont fixes, et elles ne sont
|
|
634
|
+
pas toutes identiques — c'est la source d'erreur n°1.
|
|
635
|
+
|
|
636
|
+
<!-- prettier-ignore -->
|
|
637
|
+
| Sujet | Règle | Ancre |
|
|
638
|
+
| --- | --- | --- |
|
|
639
|
+
| `@Domain` | option `host` de la route > méthode > classe | `controller()` (`routerDecorators.ts:88`) |
|
|
640
|
+
| `@BypassFirewall` | **cumulatif** : `true` de la route, de la méthode ou de la classe suffit | `routerDecorators.ts:686` |
|
|
641
|
+
| `@UseSession` | méthode > classe (fusion des champs) | `resolveSessionIntent()` (`routerDecorators.ts:819`) |
|
|
642
|
+
| `@Idempotent` | méthode > classe | `computeIdempotent()` (`routerDecorators.ts:1561`) |
|
|
643
|
+
| `@IsGranted` / `@RequireScope` | **cumul en ET** : classe **plus** méthode | `computeSecurityRequirement()` (`routerDecorators.ts:1444`) |
|
|
644
|
+
| `@Anonymous` | méthode → annule tout ce que la classe a posé | `routerDecorators.ts:912` |
|
|
645
|
+
| `@Csp` | fusion **additive** classe + méthode (sources concaténées) | `mergeCspDirectives()` (`routerDecorators.ts:1000`) |
|
|
646
|
+
| `@CsrfProtect` / `@CsrfExempt` | OU logique : classe **ou** méthode suffit | `computeActionMeta()` (`routerDecorators.ts:1580`) |
|
|
647
|
+
| `@Header` | s'empile (plusieurs en-têtes) ; même clé → dernier écrit gagne | `Header()` (`routerDecorators.ts:580`) |
|
|
648
|
+
| `@HttpCode` | un seul par action (le dernier posé écrase) | `HttpCode()` (`routerDecorators.ts:548`) |
|
|
649
|
+
|
|
650
|
+
### Où placer les décorateurs de classe
|
|
651
|
+
|
|
652
|
+
TypeScript applique les décorateurs de classe **de bas en haut** : celui écrit le plus près de la
|
|
653
|
+
classe s'exécute en premier. Deux régimes en découlent :
|
|
654
|
+
|
|
655
|
+
- **Lus AU MONTAGE** — `@Domain`, `@BypassFirewall` : ils doivent avoir posé leur métadonnée **avant**
|
|
656
|
+
que `@controller` ne construise les routes, donc **sous** `@controller`.
|
|
657
|
+
- **Lus PARESSEUSEMENT** (à la 1ʳᵉ requête) — `@IsGranted`, `@RequireScope`, `@Csp`, `@Csrf*`,
|
|
658
|
+
`@Idempotent`, `@UseSession`, `@Scope` : l'ordre est indifférent.
|
|
659
|
+
|
|
660
|
+
Une seule règle à retenir, sûre dans tous les cas : **`@controller` en haut, le reste en dessous.**
|
|
661
|
+
|
|
662
|
+
```typescript
|
|
663
|
+
@controller("/admin") // ← toujours en premier
|
|
664
|
+
@Domain("admin.example.com")
|
|
665
|
+
@IsGranted("ROLE_ADMIN")
|
|
666
|
+
class AdminController extends Controller {
|
|
667
|
+
/* … */
|
|
668
|
+
}
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
## 🏗️ Architecture interne — de l'import à la requête
|
|
672
|
+
|
|
673
|
+
```mermaid
|
|
674
|
+
sequenceDiagram
|
|
675
|
+
participant TS as Fichier contrôleur
|
|
676
|
+
participant R as Reflect metadata
|
|
677
|
+
participant CT as @controller
|
|
678
|
+
participant RTR as Router
|
|
679
|
+
participant RS as Resolver
|
|
680
|
+
|
|
681
|
+
TS->>R: @Get / @Body / @IsGranted (à l'import)
|
|
682
|
+
TS->>CT: @controller("/prefix") (dernier décorateur de classe)
|
|
683
|
+
CT->>R: getMetadata("routes:definitions")
|
|
684
|
+
CT->>RTR: createRoute(nom, options) × N
|
|
685
|
+
CT->>R: deleteMetadata (une classe = un montage)
|
|
686
|
+
Note over RTR: onBoot — @controllers → setController(classe, module)
|
|
687
|
+
RS->>R: 1ʳᵉ requête : computeActionMeta → RouteActionMeta figé
|
|
688
|
+
RS->>RS: requêtes suivantes : lecture O(1), 0 Reflect
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
Le snapshot `RouteActionMeta` (`routerDecorators.ts:1392`) regroupe **tout** ce que les décorateurs
|
|
692
|
+
ont dit de l'action :
|
|
693
|
+
|
|
694
|
+
<!-- prettier-ignore -->
|
|
695
|
+
| Champ | Vient de | `null`/`false` quand |
|
|
696
|
+
| --- | --- | --- |
|
|
697
|
+
| `paramsMeta` | `@Param`/`@Body`/… | aucun paramètre décoré |
|
|
698
|
+
| `httpCode` | `@HttpCode` | absent |
|
|
699
|
+
| `headerEntries` | `@Header` | absent (entrées pré-dépliées une fois) |
|
|
700
|
+
| `redirectMeta` | `@Redirect` | absent |
|
|
701
|
+
| `sessionIntent` | `@UseSession` / `@Session` | la route ne veut pas de session |
|
|
702
|
+
| `security` | `@IsGranted` + `@RequireScope` | action non gardée (ou `@Anonymous`) |
|
|
703
|
+
| `cspDirectives` | `@Csp` | aucune directive déclarée |
|
|
704
|
+
| `csrfProtect` / `csrfExempt` | `@CsrfProtect` / `@CsrfExempt` | non déclarés |
|
|
705
|
+
| `idempotent` | `@Idempotent` | action non protégée |
|
|
706
|
+
|
|
707
|
+
Le `Resolver` consomme ce snapshot dans un ordre qui a du sens sécurité :
|
|
708
|
+
**garde d'abord, instanciation ensuite**. `security !== null` déclenche
|
|
709
|
+
`_enforceSecurity()` (`Resolver.ts:576`) **avant** `newController()` — un `403` n'instancie pas le
|
|
710
|
+
contrôleur et n'exécute pas son `initialize()`. Puis viennent les arguments
|
|
711
|
+
(`_buildParamArgs()`, `Resolver.ts:619`), les métadonnées de réponse
|
|
712
|
+
(`_applyResponseMeta()`, `Resolver.ts:650`), l'action, et enfin la redirection éventuelle.
|
|
713
|
+
|
|
714
|
+
Un usage cold path mérite d'être connu : `extractActionScopes()` (`routerDecorators.ts:1476`) parcourt
|
|
715
|
+
les routes au démarrage pour bâtir le **catalogue des scopes déclarés** — le formulaire de création
|
|
716
|
+
de clés API dans Studio propose les scopes réellement utilisés par le code, jamais une liste
|
|
717
|
+
maintenue à part.
|
|
718
|
+
|
|
719
|
+
## ⚡ Performance & mémoire
|
|
720
|
+
|
|
721
|
+
Un décorateur non employé doit coûter **zéro**. C'est tenu par trois mécanismes vérifiables :
|
|
722
|
+
|
|
723
|
+
- **Lecture unique.** `resolveActionMeta()` (`routerDecorators.ts:1624`) mémorise le snapshot sur la
|
|
724
|
+
route au premier passage — ensuite, plus aucun appel `Reflect.getMetadata` ni `Object.entries` par
|
|
725
|
+
requête. Le même schéma vaut pour la détection du flux brut
|
|
726
|
+
(`routeExpectsBodyStream()`, `routerDecorators.ts:1365`).
|
|
727
|
+
- **`null` plutôt que structure vide.** Une action sans garde a `security: null` : le `Resolver` teste
|
|
728
|
+
un `null` et passe — ni résolution de service, ni `await`, ni allocation (`Resolver.ts:334`). Idem
|
|
729
|
+
pour `idempotent`, `cspDirectives`, `paramsMeta`.
|
|
730
|
+
- **Objets gelés et partagés.** Les exigences de sécurité et d'idempotence sont créées **une fois** et
|
|
731
|
+
`Object.freeze`-ées (`routerDecorators.ts:1496`, `:1340`) : une seule instance pour la durée de vie
|
|
732
|
+
du processus, quelle que soit la charge. Corollaire : ne les mute jamais.
|
|
733
|
+
|
|
734
|
+
Coût résiduel côté montage seulement : la reconstruction de la pile d'appels dans `route()`
|
|
735
|
+
(`stackTrace`, `routerDecorators.ts:169`) pour retrouver le fichier source. Elle a lieu **à l'import**, une fois par
|
|
736
|
+
route, jamais pendant une requête.
|
|
737
|
+
|
|
738
|
+
## 🧩 Extension — écrire son propre décorateur
|
|
739
|
+
|
|
740
|
+
Le module montre le patron à suivre : un décorateur maison **ne fait qu'écrire une métadonnée**, et
|
|
741
|
+
un point du pipeline la relit. Pour un simple drapeau dual (classe + méthode), le framework fournit
|
|
742
|
+
déjà la fabrique `booleanMarkerDecorator()` (`routerDecorators.ts:1065`), dont `@CsrfProtect` et
|
|
743
|
+
`@CsrfExempt` sont les deux usages.
|
|
744
|
+
|
|
745
|
+
Le squelette d'un drapeau maison, en dehors du framework :
|
|
746
|
+
|
|
747
|
+
```typescript
|
|
748
|
+
import "reflect-metadata";
|
|
749
|
+
|
|
750
|
+
const AUDIT_METADATA = "app:audit";
|
|
751
|
+
|
|
752
|
+
/** `@Audited()` — marque une action à tracer. Dual : classe ou méthode. */
|
|
753
|
+
export function Audited() {
|
|
754
|
+
return function (
|
|
755
|
+
target: any,
|
|
756
|
+
propertyKey?: string,
|
|
757
|
+
descriptor?: PropertyDescriptor,
|
|
758
|
+
): any {
|
|
759
|
+
if (propertyKey === undefined) {
|
|
760
|
+
Reflect.defineMetadata(AUDIT_METADATA, true, target); // classe → constructeur
|
|
761
|
+
return target;
|
|
762
|
+
}
|
|
763
|
+
Reflect.defineMetadata(AUDIT_METADATA, true, target, propertyKey); // méthode → prototype
|
|
764
|
+
return descriptor;
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
Deux invariants à respecter, tirés du code du module :
|
|
770
|
+
|
|
771
|
+
1. **Classe → constructeur, méthode → prototype keyé par nom.** C'est la convention de toutes les
|
|
772
|
+
métadonnées du fichier (`routerDecorators.ts:864` pour `@IsGranted`) ; s'en écarter rend la
|
|
773
|
+
fusion classe/méthode impossible.
|
|
774
|
+
2. **Aucune I/O, aucun service, aucun import lourd dans le décorateur.** Il s'exécute à l'import, hors
|
|
775
|
+
de tout kernel : y résoudre un service planterait le simple fait de charger le fichier.
|
|
776
|
+
|
|
777
|
+
La lecture, elle, se fait au **cold path** (montage ou première requête), jamais à chaque requête.
|
|
778
|
+
|
|
779
|
+
## 📡 Observabilité — Studio
|
|
780
|
+
|
|
781
|
+
Le **Playground** (`/nodefony/playground`, dev uniquement) construit un formulaire par action **à
|
|
782
|
+
partir des décorateurs** : transports déclarés, paramètres décorés triés par index, et badges de
|
|
783
|
+
gardes (`@IsGranted`, scopes, `@Idempotent`, CSRF, intent de session, bypass firewall). C'est le
|
|
784
|
+
miroir exact de ce que cette page décrit — si un badge manque, c'est que le décorateur n'est pas là.
|
|
785
|
+
|
|
786
|
+
L'écran **Routes** (`/nodefony/routes`) et le point d'API `/nodefony/framework/api/routes` listent les routes issues de
|
|
787
|
+
`@controller`/`@route`, avec leur nom auto-généré et leurs `requirements`.
|
|
788
|
+
|
|
789
|
+
## ⚠️ Pièges (symptôme → cause → correction)
|
|
790
|
+
|
|
791
|
+
<!-- prettier-ignore -->
|
|
792
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
793
|
+
| --- | --- | --- |
|
|
794
|
+
| `404` sur une route pourtant décorée | Contrôleur jamais importé, ou absent de `@controllers([…])` | L'ajouter au tableau `@controllers` du module |
|
|
795
|
+
| `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:452`](../../../../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. |
|
|
796
|
+
| `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) |
|
|
797
|
+
| Le vhost de `@Domain` classe est ignoré | `@Domain` placé **au-dessus** de `@controller` → posé trop tard | Placer `@Domain` sous `@controller` |
|
|
798
|
+
| `@BypassFirewall` n'ouvre rien | Écrit **avec** parenthèses — c'est un drapeau, pas une fabrique | `@BypassFirewall` (sans `()`) |
|
|
799
|
+
| Une route de classe reste publique malgré l'option | `bypassFirewall` est **cumulatif** : le `true` de la classe l'emporte | Retirer `@BypassFirewall` de la classe et le poser action par action |
|
|
800
|
+
| `403` alors que le rôle est bon | Module `security` absent, ou route hors zone firewall → aucun jeton (fail-closed) | Charger `@nodefony/security` et couvrir la route par une zone |
|
|
801
|
+
| `@CurrentUser()` vaut `undefined` | Route hors zone firewall — l'identité n'est jamais résolue hors zone | Couvrir la route par une zone (voir [firewall](../../security/docs/firewall.md)) |
|
|
802
|
+
| `@Session()` toujours `null` | Aucun intent : ni `@UseSession`, ni paramètre `@Session`, ni cookie repris | Ajouter `@UseSession()` sur l'action ou la classe |
|
|
803
|
+
| `@Headers("X-Foo")` vaut `undefined` | Node met les en-têtes en minuscules ; la recherche est normalisée mais la clé compte | Utiliser la forme minuscule (`"x-foo"`) |
|
|
804
|
+
| `@Redirect` ne redirige pas | L'action a retourné une valeur — la redirection ne joue que sur `undefined`/`null` | Ne rien retourner, ou retourner `{ url, statusCode }` |
|
|
805
|
+
| Réponse `301` inattendue sur un `redirect()` manuel | `Response.redirect()` vaut 301 par défaut | Passer le code : `this.redirect(url, 302)` |
|
|
806
|
+
| Une méthode nommée `session`/`request`/`response` est refusée | Même règle : ce sont des **accesseurs** de `Controller`. Sans le garde-fou ils ne cassaient rien au build — ils masquaient l'action en silence. | Renommer l'action (aussi : `get`, `set`, `method`, `context`, `route`) |
|
|
807
|
+
| Deux requêtes se mélangent leurs données | `@Scope("singleton")` avec un état de requête stocké sur `this` | Revenir au défaut per-request, ou n'utiliser que des arguments décorés |
|
|
808
|
+
| La route `*` avale toutes les autres | Attendu : elle est montée en dernier mais matche tout ce qui reste | Vérifier que les routes précises sont bien déclarées (elles gagnent) |
|
|
809
|
+
|
|
810
|
+
## 🧪 Tests & couverture
|
|
811
|
+
|
|
812
|
+
Quatre suites unitaires et deux bancs d'intégration couvrent la surface — les chiffres exacts vivent
|
|
813
|
+
dans la carte régénérée depuis vitest, jamais figés ici :
|
|
814
|
+
|
|
815
|
+
- **unit `routerDecorators`** : création de route par `@controller`, application du préfixe, routes
|
|
816
|
+
multiples, effacement des métadonnées après montage, route magique `*` montée en dernier, stockage
|
|
817
|
+
des métadonnées `@Param`/`@Body`/`@Query` ;
|
|
818
|
+
- **unit `httpMethodDecorators`** : nommage automatique `Classe::méthode`, `requirements.methods` par
|
|
819
|
+
verbe, `@All` sans contrainte, `405` sur méthode non déclarée, `@HttpCode`/`@Header`
|
|
820
|
+
(accumulation)/`@Redirect` et leurs combinaisons ;
|
|
821
|
+
- **unit `paramDecorators`** : pose des métadonnées, accumulation sur une même méthode, résolution de
|
|
822
|
+
chaque source, robustesse sur contexte partiel (WS), placement positionnel des arguments ;
|
|
823
|
+
- **unit `securityDecorators`** : OU interne d'une clause, ET entre clauses empilées, fusion
|
|
824
|
+
classe+méthode, `subject`, annulation par `@Anonymous`, axe scope, descripteur gelé,
|
|
825
|
+
`@CurrentUser` depuis l'ALS ;
|
|
826
|
+
- **intégration** (`@nodefony/http`, serveur réel) : `decorators` (paramètres bout en bout) et
|
|
827
|
+
`decorators-response` (statut et en-têtes réellement émis).
|
|
828
|
+
|
|
829
|
+
Ce qui **manque** aujourd'hui : aucun banc de charge ni test mémoire dédié à la surface décorateur —
|
|
830
|
+
c'est cohérent avec le fait que tout y est cold path (montage, première requête), mais un
|
|
831
|
+
`@Scope("singleton")` mal utilisé se prouverait mieux sous charge (skill `nodefony-load-test`).
|
|
832
|
+
|
|
833
|
+
Couverture : `npm run coverage` dans `@nodefony/framework`.
|
|
834
|
+
|
|
835
|
+
## 🔗 Pour aller plus loin
|
|
836
|
+
|
|
837
|
+
- ⬆️ **Retour au hub** : [@nodefony/framework — vue du module](index.md) · [Toute la documentation](../../../../../docs/index.md)
|
|
838
|
+
- 🧭 **Pages sœurs** : [routing](./routing.md) (comment une route est compilée et choisie) ·
|
|
839
|
+
[controller](./controller.md) (cycle de vie et helpers de rendu) ·
|
|
840
|
+
[idempotence](./idempotence.md) (`@Idempotent` en profondeur)
|
|
841
|
+
- 🔐 **Le moteur derrière les gardes** : [firewall](../../security/docs/firewall.md) ·
|
|
842
|
+
[autorisation](../../security/docs/authorization.md) · [CSRF](../../security/docs/csrf.md)
|
|
843
|
+
- 🔌 **Socket et décorateurs temps réel** : [realtime](../../realtime/docs/index.md)
|
|
844
|
+
- 🏗️ **Où tout ça s'insère** : [pipeline-requete](../../../../../docs/architecture/pipeline-requete.md) ·
|
|
845
|
+
[injection-portees](../../../../../docs/architecture/injection-portees.md)
|