@znight/http 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -10
- package/dist/compiler.d.mts +77 -2
- package/dist/compiler.mjs +177 -88
- package/dist/compiler.mjs.map +1 -1
- package/dist/index.d.mts +27 -3
- package/dist/index.mjs +264 -46
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/compiler/analyze.ts +3 -3
- package/src/compiler/index.ts +13 -0
- package/src/compiler/route-decorators.ts +45 -2
- package/src/compiler/route-groups.ts +216 -0
- package/src/compiler/validate.ts +15 -126
- package/src/decorators.ts +19 -3
- package/src/index.ts +1 -0
- package/src/pipeline.ts +327 -63
- package/src/plugin-routes.ts +19 -0
- package/src/plugin.ts +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@znight/http",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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.
|
|
82
|
+
"@znight/core": "0.2.0"
|
|
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.
|
|
90
|
+
"@znight/compiler": "^0.2.0"
|
|
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.
|
|
121
|
+
"@znight/compiler": "0.2.0"
|
|
122
122
|
},
|
|
123
123
|
"znight": {
|
|
124
124
|
"apiVersion": 1,
|
package/src/compiler/analyze.ts
CHANGED
|
@@ -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,
|
|
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 =
|
|
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 =
|
|
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')
|
package/src/compiler/index.ts
CHANGED
|
@@ -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 {
|
|
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<
|
|
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
|
+
}
|
package/src/compiler/validate.ts
CHANGED
|
@@ -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
|
|
13
|
-
import {
|
|
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
|
-
/**
|
|
73
|
-
|
|
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
|
-
|
|
111
|
-
for (const route of controller.routes)
|
|
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
|
|
155
|
-
|
|
156
|
-
|
|
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 ${
|
|
107
|
+
hints: [`Add ${group.suggestion} to the route or to ${controller.displayName}`],
|
|
219
108
|
related: [
|
|
220
|
-
{ message: `The "${group.group}" group is required`, location: (
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
return
|
|
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:
|
|
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:
|
|
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
|
})
|