@znight/http 0.1.3 → 0.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@znight/http",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Znight HTTP plugin: controllers, routing, guards, interceptors, validation with Fastify, Express or Hono",
5
5
  "license": "MIT",
6
6
  "author": "Ugho STEPHAN",
@@ -79,7 +79,7 @@
79
79
  "access": "public"
80
80
  },
81
81
  "dependencies": {
82
- "@znight/core": "0.1.3"
82
+ "@znight/core": "0.1.4"
83
83
  },
84
84
  "peerDependencies": {
85
85
  "@hono/node-server": "^2.1.5",
@@ -87,7 +87,7 @@
87
87
  "fastify": "^5.12.7",
88
88
  "hono": "^4.13.14",
89
89
  "light-my-request": "^6.8.0",
90
- "@znight/compiler": "^0.1.3"
90
+ "@znight/compiler": "^0.1.4"
91
91
  },
92
92
  "peerDependenciesMeta": {
93
93
  "@hono/node-server": {
@@ -118,7 +118,7 @@
118
118
  "valibot": "1.5.0",
119
119
  "zod": "4.6.7",
120
120
  "light-my-request": "6.8.0",
121
- "@znight/compiler": "0.1.3"
121
+ "@znight/compiler": "0.1.4"
122
122
  },
123
123
  "znight": {
124
124
  "apiVersion": 1,
@@ -31,7 +31,7 @@ import { joinPaths, normalizePath } from '../path.ts'
31
31
  import { parseByteSize } from '../size.ts'
32
32
  import { acceptsUndefined, isRaw, parameterType, planCoercion } from './coercion.ts'
33
33
  import { Codes, HTTP_PACKAGE, Ids, PARAM_SOURCES, ROUTE_METHODS } from './model.ts'
34
- import { isRouteMetaDecorator, isValueRef, routeMeta } from './route-decorators.ts'
34
+ import { isRouteMetaDecorator, isValueRef, readRouteMeta } from './route-decorators.ts'
35
35
 
36
36
  /** Name of the extension (owner of the decorators in `DecoratorInfo.owner`). */
37
37
  export const EXTENSION_NAME = 'http'
@@ -96,7 +96,7 @@ export function analyzeHttpClass(cls: ClassInfo, ctx: Context): void {
96
96
  const basePath = normalizePath(options.path)
97
97
  const guards = classRefs(ctx, cls, cls.decorators, Ids.UseGuard, 'guard')
98
98
  const interceptors = classRefs(ctx, cls, cls.decorators, Ids.UseInterceptor, 'interceptor')
99
- const meta = routeMeta(ctx, cls, cls.decorators, cls.displayName)
99
+ const meta = readRouteMeta(ctx, cls)
100
100
  const bodyLimit = bodyLimitOf(ctx, cls.decorators)
101
101
  const routes: HttpRouteData[] = []
102
102
  for (const method of cls.methods) {
@@ -328,7 +328,7 @@ function analyzeMethod(
328
328
  let headers = responseHeaders(ctx, decorators)
329
329
  const guards = classRefs(ctx, cls, decorators, Ids.UseGuard, 'guard')
330
330
  const interceptors = classRefs(ctx, cls, decorators, Ids.UseInterceptor, 'interceptor')
331
- const meta = routeMeta(ctx, cls, decorators, where)
331
+ const meta = readRouteMeta(ctx, cls, method)
332
332
  const bodySchema =
333
333
  bodyValidation !== undefined && !params.some(param => param.source === 'body') ? bodyValidation.schema : undefined
334
334
  let rawResponse = params.some(param => param.source === 'response')
@@ -10,6 +10,9 @@
10
10
  * - `generate`: `metadata.extensions.http` with one `invoke` function per route (README,
11
11
  * "Compiler contract").
12
12
  *
13
+ * It also exports the route decorator readers of other extensions: `readRouteMeta`,
14
+ * `effectiveRouteMeta` and `requiredRouteGroups`.
15
+ *
13
16
  * This entry only `import type`s from `@znight/compiler` and never loads the HTTP runtime.
14
17
  */
15
18
  import type { CompilerExtension, DecoratorSpec } from '@znight/compiler'
@@ -31,6 +34,16 @@ export type {
31
34
  SseRouteData,
32
35
  UploadData,
33
36
  } from './model.ts'
37
+ export type { RequiredRouteGroup, RouteGroupMember } from './route-groups.ts'
38
+
39
+ /**
40
+ * Route decorators for other extensions (RFC 0003 §6.4, RFC 0005 H3): read `createRouteDecorator`
41
+ * metadata on any class or method, and the required groups of the application, with the exact
42
+ * rules of controllers and routes. Stable API, see the README ("Route metadata in other
43
+ * extensions").
44
+ */
45
+ export { effectiveRouteMeta, readRouteMeta } from './route-decorators.ts'
46
+ export { requiredRouteGroups } from './route-groups.ts'
34
47
 
35
48
  const ROUTE: DecoratorSpec = { targets: ['method'], params: ['static'] }
36
49
  const NAME_OR_SCHEMA: DecoratorSpec = { targets: ['parameter'], params: ['value', 'value'] }
@@ -6,6 +6,10 @@
6
6
  * (the options) are statically evaluable. Its argument is evaluated like a `value` decorator
7
7
  * argument (literals, `const` references, references to exported bindings); without argument the
8
8
  * value is `true`. Decorators of one group are alternatives: one per controller or route.
9
+ *
10
+ * Every helper takes the analysis context of **any** extension: other extensions read route
11
+ * decorators on their own targets (gateways, resolvers) with {@link readRouteMeta}, exported by
12
+ * `@znight/http/compiler`.
9
13
  */
10
14
  import type {
11
15
  AnalyzeContext,
@@ -13,15 +17,16 @@ import type {
13
17
  DecoratorInfo,
14
18
  Diagnostic,
15
19
  Location,
20
+ MethodInfo,
16
21
  StaticValue,
17
22
  ValueRef,
18
23
  } from '@znight/compiler'
19
- import type { HttpControllerData, MetaEntry } from './model.ts'
24
+ import type { MetaEntry } from './model.ts'
20
25
 
21
26
  import { type AstNode, child, children, isNode, unwrap } from './ast.ts'
22
27
  import { Codes, GROUP_NAME, Ids } from './model.ts'
23
28
 
24
- type Context = AnalyzeContext<HttpControllerData>
29
+ type Context = AnalyzeContext<unknown>
25
30
  type Project = Context['project']
26
31
  type ScopeInput = Parameters<Project['evaluate']>[0]
27
32
  type ExpressionInput = Parameters<Project['evaluate']>[1]
@@ -295,6 +300,44 @@ export function routeMeta(
295
300
  return entries
296
301
  }
297
302
 
303
+ /**
304
+ * The route metadata entries of a class (`method` omitted) or of one of its methods: the route
305
+ * decorators (`createRouteDecorator` consts) applied to that target, in source order, read exactly
306
+ * as the http extension reads controllers and routes (keys, statically evaluated values, groups,
307
+ * `required`). Problems are reported as ZNIGHT005 through `ctx`, once per call: read each target
308
+ * once. Decorators that are not route decorators are ignored.
309
+ *
310
+ * Works with the analysis context of any extension. The entries of a target are its own: combine a
311
+ * class and a method with {@link effectiveRouteMeta}.
312
+ *
313
+ * ```ts
314
+ * analyzeClass(cls, ctx) {
315
+ * const classMeta = readRouteMeta(ctx, cls)
316
+ * for (const method of cls.methods) {
317
+ * const meta = effectiveRouteMeta(classMeta, readRouteMeta(ctx, cls, method))
318
+ * }
319
+ * }
320
+ * ```
321
+ */
322
+ export function readRouteMeta(ctx: AnalyzeContext<unknown>, cls: ClassInfo, method?: MethodInfo): MetaEntry[] {
323
+ return method === undefined
324
+ ? routeMeta(ctx, cls, cls.decorators, cls.displayName)
325
+ : routeMeta(ctx, cls, method.decorators, `${cls.displayName}.${method.name}`)
326
+ }
327
+
328
+ /**
329
+ * The effective metadata of a method from the entries of its class and its own (RFC 0001 §5.1):
330
+ * the class entries, except those of a group the method decides itself, then the method entries.
331
+ * The same rule as `ctx.route.meta` at runtime.
332
+ */
333
+ export function effectiveRouteMeta(classMeta: readonly MetaEntry[], methodMeta: readonly MetaEntry[]): MetaEntry[] {
334
+ if (methodMeta.length === 0) {
335
+ return [...classMeta]
336
+ }
337
+ const decided = new Set(methodMeta.map(entry => entry.group))
338
+ return [...classMeta.filter(entry => !decided.has(entry.group)), ...methodMeta]
339
+ }
340
+
298
341
  /** `true` when the decorator is a route metadata decorator. */
299
342
  export function isRouteMetaDecorator(ctx: Context, decorator: DecoratorInfo): boolean {
300
343
  return (
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Route decorator groups of the application (RFC 0001 §5.1): every `createRouteDecorator`
3
+ * definition, applied or not, the consistency of their options and the **required** groups.
4
+ *
5
+ * The http extension reports the inconsistencies (ZNIGHT005) and checks every route against the
6
+ * required groups (ZNIGHT023). Other extensions whose targets carry route decorators (WebSocket
7
+ * gateways, GraphQL root fields) call {@link requiredRouteGroups} to check their own targets with
8
+ * exactly the same groups, without reporting the inconsistencies a second time.
9
+ */
10
+ import type { Diagnostic, Location, ValidateContext } from '@znight/compiler'
11
+ import type { MetaEntry } from './model.ts'
12
+
13
+ import { compareStrings } from '../path.ts'
14
+ import { Codes, Ids } from './model.ts'
15
+ import { parseRouteDecoratorOptions } from './route-decorators.ts'
16
+
17
+ /** A route decorator definition of the application (`export const Roles = createRouteDecorator(...)`). */
18
+ export interface RouteGroupMember {
19
+ /** Canonical identity of the decorator `const` (`src/auth/access.ts#Roles`). */
20
+ readonly identity: string
21
+ /** Name of the `const` (`Roles`). */
22
+ readonly name: string
23
+ readonly key: string
24
+ /** Decision group (`options.group ?? key`). */
25
+ readonly group: string
26
+ readonly required: boolean
27
+ /** Location of the definition. */
28
+ readonly definition: Location
29
+ /**
30
+ * A flag (`@Public()`): created without type argument, and applied without argument wherever it
31
+ * was seen. Decides how the decorator is suggested: `@Public()` or `@Roles(...)`.
32
+ */
33
+ readonly flag: boolean
34
+ }
35
+
36
+ /** A required route decorator group: every route-like target must carry a decision of it. */
37
+ export interface RequiredRouteGroup {
38
+ /** Group name (`access`). */
39
+ readonly group: string
40
+ /** The decorators of the group, in source order (file, then position). Never empty. */
41
+ readonly decorators: readonly RouteGroupMember[]
42
+ /** The decorators as a hint: `@Public(), @Authenticated() or @Roles(...)`. */
43
+ readonly suggestion: string
44
+ }
45
+
46
+ /** Result of {@link analyzeRouteGroups}. */
47
+ export interface RouteGroupsAnalysis {
48
+ /** Inconsistent definitions (ZNIGHT005), at the second definition; not reported. */
49
+ readonly conflicts: readonly Diagnostic[]
50
+ /** The required groups, in the order of their first definition by identity. */
51
+ readonly required: readonly RequiredRouteGroup[]
52
+ }
53
+
54
+ /** A definition while flags are computed. */
55
+ type MutableMember = { -readonly [K in keyof RouteGroupMember]: RouteGroupMember[K] }
56
+
57
+ /**
58
+ * Every route decorator definition, sorted by identity: the applied ones (`applied`, first usage
59
+ * wins) and, when the compiler provides `definitionsOf`, the ones applied nowhere (invalid ones are
60
+ * left to the usages, which report them).
61
+ */
62
+ function routeDecoratorDefinitions(
63
+ ctx: Pick<ValidateContext<unknown>, 'definitionsOf'>,
64
+ applied: Iterable<MetaEntry>,
65
+ ): MutableMember[] {
66
+ const definitions = new Map<string, MutableMember>()
67
+ for (const entry of applied) {
68
+ const known = definitions.get(entry.identity)
69
+ if (known === undefined) {
70
+ definitions.set(entry.identity, {
71
+ identity: entry.identity,
72
+ name: entry.decorator,
73
+ key: entry.key,
74
+ group: entry.group,
75
+ required: entry.required,
76
+ definition: entry.definition,
77
+ flag: entry.value === true,
78
+ })
79
+ } else if (entry.value !== true) {
80
+ known.flag = false
81
+ }
82
+ }
83
+ // compilers before RFC 0001 have no definitionsOf: only applied decorators are known then
84
+ const all = typeof ctx.definitionsOf === 'function' ? ctx.definitionsOf(Ids.createRouteDecorator) : []
85
+ for (const found of all) {
86
+ const [keyArg, optionsArg, ...extra] = found.args
87
+ const key = keyArg?.evaluated === true ? keyArg.value : undefined
88
+ if (
89
+ typeof key !== 'string' ||
90
+ key === '' ||
91
+ extra.length > 0 ||
92
+ (optionsArg !== undefined && !optionsArg.evaluated)
93
+ ) {
94
+ continue
95
+ }
96
+ const parsed = parseRouteDecoratorOptions(key, optionsArg?.value)
97
+ if ('error' in parsed) {
98
+ continue
99
+ }
100
+ const flag = found.typeArgs.length === 0
101
+ const known = definitions.get(found.identity)
102
+ if (known !== undefined) {
103
+ known.flag &&= flag
104
+ continue
105
+ }
106
+ definitions.set(found.identity, {
107
+ identity: found.identity,
108
+ name: found.name,
109
+ key,
110
+ ...parsed,
111
+ definition: found.location,
112
+ flag,
113
+ })
114
+ }
115
+ return [...definitions.values()].sort((a, b) => compareStrings(a.identity, b.identity))
116
+ }
117
+
118
+ /**
119
+ * The route decorator groups of the application (RFC 0001 §5.1): every definition of a key must use
120
+ * the same options and the decorators of a group must agree on `required` (a conflict is returned
121
+ * at the second definition, which is then ignored); the required groups with their decorators.
122
+ * `applied` are the entries of the targets the caller analysed (their first usage of a decorator
123
+ * wins over its `definitionsOf` entry).
124
+ */
125
+ export function analyzeRouteGroups(
126
+ ctx: Pick<ValidateContext<unknown>, 'definitionsOf'>,
127
+ applied: Iterable<MetaEntry>,
128
+ ): RouteGroupsAnalysis {
129
+ const definitions = routeDecoratorDefinitions(ctx, applied)
130
+ if (definitions.length === 0) {
131
+ return { conflicts: [], required: [] }
132
+ }
133
+ const conflicts: Diagnostic[] = []
134
+ const byKey = new Map<string, RouteGroupMember>()
135
+ const byGroup = new Map<string, RouteGroupMember>()
136
+ const members = new Map<string, RouteGroupMember[]>()
137
+ for (const definition of definitions) {
138
+ const sameKey = byKey.get(definition.key)
139
+ if (sameKey !== undefined && (sameKey.group !== definition.group || sameKey.required !== definition.required)) {
140
+ conflicts.push({
141
+ code: Codes.InvalidDecorator,
142
+ severity: 'error',
143
+ message: `The route decorator ${definition.name} defines the key "${definition.key}" with ${describeOptions(definition)}, but ${sameKey.name} defines it with ${describeOptions(sameKey)}`,
144
+ location: definition.definition,
145
+ hints: ['Every definition of a key must use the same options: share one definition'],
146
+ related: [{ message: `${sameKey.name} is defined here`, location: sameKey.definition }],
147
+ })
148
+ continue
149
+ }
150
+ const sameGroup = byGroup.get(definition.group)
151
+ if (sameGroup !== undefined && sameGroup.required !== definition.required) {
152
+ conflicts.push({
153
+ code: Codes.InvalidDecorator,
154
+ severity: 'error',
155
+ message: `The "${definition.group}" group is ${definition.required ? '' : 'not '}required for ${definition.name} but ${sameGroup.required ? '' : 'not '}required for ${sameGroup.name}`,
156
+ location: definition.definition,
157
+ hints: [`All the decorators of the "${definition.group}" group must agree on required`],
158
+ related: [{ message: `${sameGroup.name} is defined here`, location: sameGroup.definition }],
159
+ })
160
+ continue
161
+ }
162
+ byKey.set(definition.key, sameKey ?? definition)
163
+ byGroup.set(definition.group, sameGroup ?? definition)
164
+ const list = members.get(definition.group) ?? []
165
+ list.push(definition)
166
+ members.set(definition.group, list)
167
+ }
168
+ const required: RequiredRouteGroup[] = []
169
+ for (const first of byGroup.values()) {
170
+ if (!first.required) {
171
+ continue
172
+ }
173
+ // the decorators of the group in source order: `@Public(), @Authenticated() or @Roles(...)`
174
+ const decorators = [...(members.get(first.group) ?? [first])]
175
+ .sort((a, b) => compareStrings(a.definition.file, b.definition.file) || a.definition.start - b.definition.start)
176
+ .map(member => Object.freeze({ ...member }))
177
+ const usages = decorators.map(member => `@${member.name}(${member.flag ? '' : '...'})`)
178
+ const suggestion =
179
+ usages.length === 1 ? (usages[0] as string) : `${usages.slice(0, -1).join(', ')} or ${usages.at(-1) as string}`
180
+ required.push(Object.freeze({ group: first.group, decorators: Object.freeze(decorators), suggestion }))
181
+ }
182
+ return { conflicts, required }
183
+ }
184
+
185
+ /**
186
+ * The required route decorator groups of the application (RFC 0001): what every route-like target
187
+ * of another extension (a gateway, a GraphQL root field) must decide, exactly as HTTP routes must.
188
+ * A target without a decision of a returned group is **ZNIGHT023**, reported by the caller.
189
+ *
190
+ * `applied` are the route metadata entries the caller read on its own targets
191
+ * ({@link readRouteMeta}): they make decorators known even when the compiler has no
192
+ * `definitionsOf`, and refine how flags are suggested. Inconsistent definitions are left out and
193
+ * not reported here: the http extension reports them (ZNIGHT005) once.
194
+ *
195
+ * ```ts
196
+ * validate(ctx) {
197
+ * const required = requiredRouteGroups(ctx, ctx.stored.flatMap(gateway => gateway.meta))
198
+ * for (const gateway of ctx.stored)
199
+ * for (const group of required)
200
+ * if (!gateway.meta.some(entry => entry.group === group.group))
201
+ * ctx.report({ code: 'ZNIGHT023', severity: 'error', message: `${gateway.name} has no "${group.group}" decision`,
202
+ * location: gateway.location, hints: [`Add ${group.suggestion} to ${gateway.name}`] })
203
+ * }
204
+ * ```
205
+ */
206
+ export function requiredRouteGroups(
207
+ ctx: ValidateContext<unknown>,
208
+ applied: Iterable<MetaEntry> = [],
209
+ ): readonly RequiredRouteGroup[] {
210
+ return analyzeRouteGroups(ctx, applied).required
211
+ }
212
+
213
+ function describeOptions(entry: RouteGroupMember): string {
214
+ const group = entry.group === entry.key ? 'no group' : `group "${entry.group}"`
215
+ return entry.required ? `${group}, required` : group
216
+ }
@@ -7,10 +7,11 @@
7
7
  import type { Location, ValidateContext } from '@znight/compiler'
8
8
  import type { RoutePath } from '../path.ts'
9
9
  import type { HttpControllerData, HttpRouteData, MetaEntry } from './model.ts'
10
+ import type { RequiredRouteGroup, RouteGroupMember } from './route-groups.ts'
10
11
 
11
12
  import { checkRoutePath, compareStrings, parseRoutePath, WILDCARD_PARAM } from '../path.ts'
12
- import { Codes, Ids } from './model.ts'
13
- import { parseRouteDecoratorOptions } from './route-decorators.ts'
13
+ import { Codes } from './model.ts'
14
+ import { analyzeRouteGroups } from './route-groups.ts'
14
15
 
15
16
  type Context = ValidateContext<HttpControllerData>
16
17
 
@@ -69,80 +70,12 @@ export function validateHttp(ctx: Context): void {
69
70
  validateDecisions(ctx, controllers)
70
71
  }
71
72
 
72
- /** A route decorator definition seen during the compile (applied, or found by `definitionsOf`). */
73
- interface Definition {
74
- readonly identity: string
75
- readonly name: string
76
- readonly key: string
77
- readonly group: string
78
- readonly required: boolean
79
- readonly definition: Location
80
- /** A flag (`@Public()`): no type argument, or every usage without argument. */
81
- flag: boolean
82
- }
83
-
84
- /**
85
- * Every route decorator definition, sorted by identity: the applied ones (first usage) and, when
86
- * the compiler provides `definitionsOf`, the ones applied nowhere (invalid ones are left to the
87
- * usages, which report them).
88
- */
89
- function routeDecoratorDefinitions(ctx: Context, controllers: readonly HttpControllerData[]): Definition[] {
90
- const definitions = new Map<string, Definition>()
91
- const visit = (entries: readonly MetaEntry[]): void => {
92
- for (const entry of entries) {
93
- const known = definitions.get(entry.identity)
94
- if (known === undefined) {
95
- definitions.set(entry.identity, {
96
- identity: entry.identity,
97
- name: entry.decorator,
98
- key: entry.key,
99
- group: entry.group,
100
- required: entry.required,
101
- definition: entry.definition,
102
- flag: entry.value === true,
103
- })
104
- } else if (entry.value !== true) {
105
- known.flag = false
106
- }
107
- }
108
- }
73
+ /** Every route metadata entry of the controllers: controller entries, then route entries. */
74
+ function* appliedEntries(controllers: readonly HttpControllerData[]): Generator<MetaEntry> {
109
75
  for (const controller of controllers) {
110
- visit(controller.meta)
111
- for (const route of controller.routes) visit(route.meta)
112
- }
113
- // compilers before RFC 0001 have no definitionsOf: only applied decorators are known then
114
- const all = typeof ctx.definitionsOf === 'function' ? ctx.definitionsOf(Ids.createRouteDecorator) : []
115
- for (const found of all) {
116
- const [keyArg, optionsArg, ...extra] = found.args
117
- const key = keyArg?.evaluated === true ? keyArg.value : undefined
118
- if (
119
- typeof key !== 'string' ||
120
- key === '' ||
121
- extra.length > 0 ||
122
- (optionsArg !== undefined && !optionsArg.evaluated)
123
- ) {
124
- continue
125
- }
126
- const parsed = parseRouteDecoratorOptions(key, optionsArg?.value)
127
- if ('error' in parsed) {
128
- continue
129
- }
130
- const flag = found.typeArgs.length === 0
131
- const known = definitions.get(found.identity)
132
- if (known !== undefined) {
133
- known.flag &&= flag
134
- continue
135
- }
136
- definitions.set(found.identity, {
137
- identity: found.identity,
138
- name: found.name,
139
- key,
140
- ...parsed,
141
- definition: found.location,
142
- flag,
143
- })
76
+ yield* controller.meta
77
+ for (const route of controller.routes) yield* route.meta
144
78
  }
145
- return [...definitions.values()].sort((a, b) => compareStrings(a.identity, b.identity))
146
79
  }
147
80
 
148
81
  /**
@@ -151,45 +84,10 @@ function routeDecoratorDefinitions(ctx: Context, controllers: readonly HttpContr
151
84
  * a decision of every required group, its own or its controller's (ZNIGHT023).
152
85
  */
153
86
  function validateDecisions(ctx: Context, controllers: readonly HttpControllerData[]): void {
154
- const definitions = routeDecoratorDefinitions(ctx, controllers)
155
- if (definitions.length === 0) {
156
- return
157
- }
158
- const byKey = new Map<string, Definition>()
159
- const byGroup = new Map<string, Definition>()
160
- const members = new Map<string, Definition[]>()
161
- for (const definition of definitions) {
162
- const sameKey = byKey.get(definition.key)
163
- if (sameKey !== undefined && (sameKey.group !== definition.group || sameKey.required !== definition.required)) {
164
- ctx.report({
165
- code: Codes.InvalidDecorator,
166
- severity: 'error',
167
- message: `The route decorator ${definition.name} defines the key "${definition.key}" with ${describeOptions(definition)}, but ${sameKey.name} defines it with ${describeOptions(sameKey)}`,
168
- location: definition.definition,
169
- hints: ['Every definition of a key must use the same options: share one definition'],
170
- related: [{ message: `${sameKey.name} is defined here`, location: sameKey.definition }],
171
- })
172
- continue
173
- }
174
- const sameGroup = byGroup.get(definition.group)
175
- if (sameGroup !== undefined && sameGroup.required !== definition.required) {
176
- ctx.report({
177
- code: Codes.InvalidDecorator,
178
- severity: 'error',
179
- message: `The "${definition.group}" group is ${definition.required ? '' : 'not '}required for ${definition.name} but ${sameGroup.required ? '' : 'not '}required for ${sameGroup.name}`,
180
- location: definition.definition,
181
- hints: [`All the decorators of the "${definition.group}" group must agree on required`],
182
- related: [{ message: `${sameGroup.name} is defined here`, location: sameGroup.definition }],
183
- })
184
- continue
185
- }
186
- byKey.set(definition.key, sameKey ?? definition)
187
- byGroup.set(definition.group, sameGroup ?? definition)
188
- const list = members.get(definition.group) ?? []
189
- list.push(definition)
190
- members.set(definition.group, list)
87
+ const { conflicts, required } = analyzeRouteGroups(ctx, appliedEntries(controllers))
88
+ for (const conflict of conflicts) {
89
+ ctx.report(conflict)
191
90
  }
192
- const required = [...byGroup.values()].filter(definition => definition.required)
193
91
  if (required.length === 0) {
194
92
  return
195
93
  }
@@ -201,23 +99,14 @@ function validateDecisions(ctx: Context, controllers: readonly HttpControllerDat
201
99
  if (decided.has(group.group)) {
202
100
  continue
203
101
  }
204
- // the decorators of the group in source order: `@Public(), @Authenticated() or @Roles(...)`
205
- const inOrder = [...(members.get(group.group) ?? [group])].sort(
206
- (a, b) => compareStrings(a.definition.file, b.definition.file) || a.definition.start - b.definition.start,
207
- )
208
- const decorators = inOrder.map(definition => `@${definition.name}(${definition.flag ? '' : '...'})`)
209
- const listed =
210
- decorators.length === 1
211
- ? (decorators[0] as string)
212
- : `${decorators.slice(0, -1).join(', ')} or ${decorators.at(-1) as string}`
213
102
  ctx.report({
214
103
  code: Codes.MissingRouteDecision,
215
104
  severity: 'error',
216
105
  message: `Route ${route.method} ${route.fullPath} (${controller.displayName}.${route.handler}) has no "${group.group}" decision`,
217
106
  location: route.location,
218
- hints: [`Add ${listed} to the route or to ${controller.displayName}`],
107
+ hints: [`Add ${group.suggestion} to the route or to ${controller.displayName}`],
219
108
  related: [
220
- { message: `The "${group.group}" group is required`, location: (inOrder[0] ?? group).definition },
109
+ { message: `The "${group.group}" group is required`, location: firstDefinition(group) },
221
110
  { message: `${controller.displayName} is declared here`, location: controller.location },
222
111
  ],
223
112
  })
@@ -226,9 +115,9 @@ function validateDecisions(ctx: Context, controllers: readonly HttpControllerDat
226
115
  }
227
116
  }
228
117
 
229
- function describeOptions(entry: Definition): string {
230
- const group = entry.group === entry.key ? 'no group' : `group "${entry.group}"`
231
- return entry.required ? `${group}, required` : group
118
+ /** Where the first decorator of a group (source order) is defined. */
119
+ function firstDefinition(group: RequiredRouteGroup): Location {
120
+ return (group.decorators[0] as RouteGroupMember).definition
232
121
  }
233
122
 
234
123
  const GRAMMAR_HINTS: readonly string[] = [
package/src/decorators.ts CHANGED
@@ -398,14 +398,30 @@ export interface RouteMetaEntry<T = unknown> {
398
398
  readonly required?: boolean
399
399
  }
400
400
 
401
+ /**
402
+ * Anything that carries effective route metadata: the {@link HttpContext} of a request, and the
403
+ * contexts of other transports that reuse route decorators (a GraphQL resolver context, a WebSocket
404
+ * gateway context). {@link RouteDecorator.get} reads `route.meta[key]`; nothing else is required.
405
+ *
406
+ * ```ts
407
+ * const carrier: RouteMetaCarrier = { route: { meta: { roles: ['admin'] } } }
408
+ * Roles.get(carrier) // ['admin']
409
+ * ```
410
+ */
411
+ export interface RouteMetaCarrier {
412
+ /** The matched route (or field, or handler), `undefined` when there is none. */
413
+ readonly route?: { readonly meta: Readonly<Record<string, unknown>> } | undefined
414
+ }
415
+
401
416
  /** A user-defined route metadata decorator created with {@link createRouteDecorator}. */
402
417
  export interface RouteDecorator<T> extends RouteDecoratorInfo {
403
418
  (...args: RouteDecoratorArgs<T>): ClassMarker & MethodMarker
404
419
  /**
405
420
  * The value of the current route, if set. Reads the effective metadata: the route's decision of
406
- * a group wins over the controller's.
421
+ * a group wins over the controller's. Accepts any {@link RouteMetaCarrier}: an
422
+ * {@link HttpContext}, or the context of another transport exposing `route.meta`.
407
423
  */
408
- get(ctx: HttpContext): T | undefined
424
+ get(ctx: RouteMetaCarrier): T | undefined
409
425
  /**
410
426
  * The same metadata as data (`{ key, value, group, required }`), for routes that are not compiled
411
427
  * controllers (plugin routes: `OpenApiPlugin.configure({ meta: [Public.meta()] })`). The value is
@@ -479,7 +495,7 @@ export function createRouteDecorator<T = true>(key: string, options?: RouteDecor
479
495
  key,
480
496
  group,
481
497
  required,
482
- get: (ctx: HttpContext): T | undefined => ctx.route?.meta[key] as T | undefined,
498
+ get: (ctx: RouteMetaCarrier): T | undefined => ctx.route?.meta[key] as T | undefined,
483
499
  meta: (...args: RouteDecoratorArgs<T>): RouteMetaEntry<T> =>
484
500
  Object.freeze({ key, value: (args.length === 0 ? true : args[0]) as T, group, required }),
485
501
  })
package/src/index.ts CHANGED
@@ -92,6 +92,7 @@ export {
92
92
  type RouteDecoratorArgs,
93
93
  type RouteDecoratorInfo,
94
94
  type RouteDecoratorOptions,
95
+ type RouteMetaCarrier,
95
96
  type RouteMetaEntry,
96
97
  SignedCookie,
97
98
  Sse,