@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/README.md
CHANGED
|
@@ -1222,8 +1222,18 @@ export class AdminController {
|
|
|
1222
1222
|
HttpPlugin.configure({ guards: [AuthGuard] })
|
|
1223
1223
|
```
|
|
1224
1224
|
|
|
1225
|
-
`decorator.get(ctx)` reads the route metadata (`ctx.route.meta`) with its type.
|
|
1226
|
-
|
|
1225
|
+
`decorator.get(ctx)` reads the route metadata (`ctx.route.meta`) with its type. It accepts any
|
|
1226
|
+
`RouteMetaCarrier` (`{ route?: { meta } }`), not only an `HttpContext`: the contexts of other
|
|
1227
|
+
transports that reuse route decorators (a GraphQL resolver, a WebSocket gateway) expose their
|
|
1228
|
+
effective metadata the same way, so one guard helper serves every transport:
|
|
1229
|
+
|
|
1230
|
+
```ts
|
|
1231
|
+
import type { RouteMetaCarrier } from '@znight/http'
|
|
1232
|
+
|
|
1233
|
+
const isPublic = (ctx: RouteMetaCarrier): boolean => Public.get(ctx) === true
|
|
1234
|
+
```
|
|
1235
|
+
|
|
1236
|
+
Guards, interceptors and mappers are providers: they may be request-scoped (resolved per request) and have
|
|
1227
1237
|
dependencies. Plugin options also accept instances (`guards: [new IpAllowList(['10.0.0.0/8'])]`).
|
|
1228
1238
|
|
|
1229
1239
|
## Route decisions
|
|
@@ -1493,13 +1503,18 @@ app.hooks.wrap('http:request', async (event, next) => {
|
|
|
1493
1503
|
stream, `run` resolves when the stream opens (`status: 200`): durations measure the time to open.
|
|
1494
1504
|
A stream-lifetime hook is not provided.
|
|
1495
1505
|
|
|
1496
|
-
When
|
|
1497
|
-
`@
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
through the full pipeline
|
|
1506
|
+
When CORS is off, routes (other than `@All`) without guards, interceptors, `@Sse`, `@Res()` or
|
|
1507
|
+
`@Header` take a fast lane: the plugin decides at boot what the pipeline would do for them, runs a
|
|
1508
|
+
synchronous handler without creating a promise and builds the headers of a JSON reply from a
|
|
1509
|
+
template. A plain object or array result is served there, and so is an `HttpResponse` whose body is
|
|
1510
|
+
one, with no cookie and no header other than a lower-case `content-type` (`HttpResponse.ok(body)`,
|
|
1511
|
+
an explicit status, a custom JSON media type), except on `@Head` routes. The replies are the same as
|
|
1512
|
+
through the full pipeline (a thrown error, another `HttpResponse` or anything unusual continues
|
|
1513
|
+
there, in the same request scope; a promise is awaited first). The
|
|
1514
|
+
lane runs the `http:request` hooks too: listeners keep it synchronous, and wrappers (such as those of
|
|
1515
|
+
`@znight/observability`) see the same event, in the same request scope and async context, as with
|
|
1516
|
+
the full pipeline; a reply whose wrappers added response headers or cookies is completed like any
|
|
1517
|
+
other.
|
|
1503
1518
|
|
|
1504
1519
|
## Plugin options
|
|
1505
1520
|
|
|
@@ -1587,6 +1602,7 @@ export class StatusPlugin implements Plugin, PluginHttpRoutes {
|
|
|
1587
1602
|
handler: ctx => ({ up: true }), // (ctx: HttpContext) => result, serialised like a controller result
|
|
1588
1603
|
meta: [Public.meta()], // route metadata: read by guards, checked by the route decision check
|
|
1589
1604
|
guards: [], // extra guards (classes or instances)
|
|
1605
|
+
bodyLimit: '100kb', // optional: this route's body limit, instead of the HttpPlugin bodyLimit
|
|
1590
1606
|
},
|
|
1591
1607
|
]
|
|
1592
1608
|
}
|
|
@@ -1599,9 +1615,12 @@ export class StatusPlugin implements Plugin, PluginHttpRoutes {
|
|
|
1599
1615
|
then the route `guards`, the handler and the exception mappers. Global interceptors do not wrap
|
|
1600
1616
|
plugin routes (a response envelope would break a document). The body is read lazily
|
|
1601
1617
|
(`ctx.parseBody()`). `GET` routes answer `HEAD`; `ALL` works as for controllers.
|
|
1618
|
+
- `bodyLimit` (a `ByteSize`: `1048576`, `'100kb'`, `Infinity`, the grammar of the plugin option)
|
|
1619
|
+
replaces the `HttpPlugin` `bodyLimit` for that route only, lower or higher, like `@BodyLimit` on a
|
|
1620
|
+
controller route: reading a larger body answers 413. Without it, the plugin limit applies.
|
|
1602
1621
|
- `meta` entries (`decorator.meta(value)`, one per group) are the route metadata. A required group
|
|
1603
1622
|
needs a decision there too: without one the boot fails (ZNIGHT023), with a hint naming the plugin.
|
|
1604
|
-
- An invalid route (method, path grammar, name, handler, meta) or a collision with another route is
|
|
1623
|
+
- An invalid route (method, path grammar, name, handler, meta, body limit) or a collision with another route is
|
|
1605
1624
|
ZNIGHT007 naming the plugin. `HttpPlugin.routes` lists the routes with `source: '<plugin name>'`.
|
|
1606
1625
|
|
|
1607
1626
|
## Adapters
|
|
@@ -2293,6 +2312,62 @@ Controllers are stored in identity order of the classes; sort them by `token` fo
|
|
|
2293
2312
|
output. The effective metadata of a route is the controller's entries whose `group` has no entry on
|
|
2294
2313
|
the route, then the route's entries.
|
|
2295
2314
|
|
|
2315
|
+
### Route metadata in other extensions
|
|
2316
|
+
|
|
2317
|
+
Extensions whose own targets carry route decorators (WebSocket gateways, GraphQL resolvers) read
|
|
2318
|
+
them with the functions of `@znight/http/compiler`, which apply exactly the rules of controllers and
|
|
2319
|
+
routes (RFC 0001 groups). They are stable API, like the types above, and the entry stays
|
|
2320
|
+
runtime-free (it never loads the HTTP runtime):
|
|
2321
|
+
|
|
2322
|
+
| Function | What it does |
|
|
2323
|
+
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
2324
|
+
| `readRouteMeta(ctx, cls, method?)` | The `MetaEntry[]` of the route decorators applied to a class (`method` omitted) or to one of its methods, in source order. Works with the `AnalyzeContext` of any extension. Problems (uncalled decorator, two decisions of one group...) are reported as ZNIGHT005, once per call: read each target once |
|
|
2325
|
+
| `effectiveRouteMeta(classMeta, own)` | The effective metadata of a method: the class entries whose group the method does not decide, then the method entries (the rule of `ctx.route.meta`) |
|
|
2326
|
+
| `requiredRouteGroups(ctx, applied?)` | The `RequiredRouteGroup[]` of the application (in the `ValidateContext` of any extension): every group declared `required`, applied anywhere or not. `applied` are the entries the caller read. Inconsistent definitions are left out (the http extension reports them) |
|
|
2327
|
+
|
|
2328
|
+
A `RequiredRouteGroup` has `group`, `decorators` (`RouteGroupMember[]`, source order: `identity`,
|
|
2329
|
+
`name`, `key`, `group`, `required`, `definition`, `flag`) and `suggestion`
|
|
2330
|
+
(`@Public(), @Authenticated() or @Roles(...)`). A target of the extension without a decision of a
|
|
2331
|
+
required group is **ZNIGHT023**, reported by the extension itself:
|
|
2332
|
+
|
|
2333
|
+
```ts
|
|
2334
|
+
import type { CompilerExtension, Location } from '@znight/compiler'
|
|
2335
|
+
import type { MetaEntry } from '@znight/http/compiler'
|
|
2336
|
+
|
|
2337
|
+
import { readRouteMeta, requiredRouteGroups } from '@znight/http/compiler'
|
|
2338
|
+
|
|
2339
|
+
interface GatewayData {
|
|
2340
|
+
readonly name: string
|
|
2341
|
+
readonly meta: readonly MetaEntry[] // the class entries (methods: effectiveRouteMeta(classMeta, own))
|
|
2342
|
+
readonly location: Location
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
const ws: CompilerExtension<GatewayData> = {
|
|
2346
|
+
apiVersion: 1,
|
|
2347
|
+
name: 'ws',
|
|
2348
|
+
decorators: { '@acme/ws#Gateway': { targets: ['class'], params: [] } },
|
|
2349
|
+
analyzeClass(cls, ctx) {
|
|
2350
|
+
ctx.store({ name: cls.displayName, meta: readRouteMeta(ctx, cls), location: cls.location })
|
|
2351
|
+
},
|
|
2352
|
+
validate(ctx) {
|
|
2353
|
+
const required = requiredRouteGroups(
|
|
2354
|
+
ctx,
|
|
2355
|
+
ctx.stored.flatMap(gateway => gateway.meta),
|
|
2356
|
+
)
|
|
2357
|
+
for (const gateway of ctx.stored)
|
|
2358
|
+
for (const group of required)
|
|
2359
|
+
if (!gateway.meta.some(entry => entry.group === group.group))
|
|
2360
|
+
ctx.report({
|
|
2361
|
+
code: 'ZNIGHT023',
|
|
2362
|
+
severity: 'error',
|
|
2363
|
+
message: `${gateway.name} has no "${group.group}" decision`,
|
|
2364
|
+
location: gateway.location,
|
|
2365
|
+
hints: [`Add ${group.suggestion} to ${gateway.name}`],
|
|
2366
|
+
})
|
|
2367
|
+
},
|
|
2368
|
+
}
|
|
2369
|
+
```
|
|
2370
|
+
|
|
2296
2371
|
## Writing an adapter
|
|
2297
2372
|
|
|
2298
2373
|
An adapter implements `HttpAdapter` (exported types): it routes requests to the handlers given by
|
package/dist/compiler.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { _ as HttpMethod, y as HttpParamSource } from "./adapter-J0UXFnNM.mjs";
|
|
2
|
-
import { ClassRef, CompilerExtension, Location, StaticValue, ValueRef } from "@znight/compiler";
|
|
2
|
+
import { AnalyzeContext, ClassInfo, ClassRef, CompilerExtension, Location, MethodInfo, StaticValue, ValidateContext, ValueRef } from "@znight/compiler";
|
|
3
3
|
/** Primitive coercions (`coerce.<kind>`). */
|
|
4
4
|
type ScalarCoercion = "string" | "number" | "integer" | "boolean" | "bigint" | "date";
|
|
5
5
|
/**
|
|
@@ -150,7 +150,82 @@ interface HttpControllerData {
|
|
|
150
150
|
/** Location of the base path argument (the decorator when absent). */
|
|
151
151
|
readonly pathLocation: Location;
|
|
152
152
|
}
|
|
153
|
+
/** A route decorator definition of the application (`export const Roles = createRouteDecorator(...)`). */
|
|
154
|
+
interface RouteGroupMember {
|
|
155
|
+
/** Canonical identity of the decorator `const` (`src/auth/access.ts#Roles`). */
|
|
156
|
+
readonly identity: string;
|
|
157
|
+
/** Name of the `const` (`Roles`). */
|
|
158
|
+
readonly name: string;
|
|
159
|
+
readonly key: string;
|
|
160
|
+
/** Decision group (`options.group ?? key`). */
|
|
161
|
+
readonly group: string;
|
|
162
|
+
readonly required: boolean;
|
|
163
|
+
/** Location of the definition. */
|
|
164
|
+
readonly definition: Location;
|
|
165
|
+
/**
|
|
166
|
+
* A flag (`@Public()`): created without type argument, and applied without argument wherever it
|
|
167
|
+
* was seen. Decides how the decorator is suggested: `@Public()` or `@Roles(...)`.
|
|
168
|
+
*/
|
|
169
|
+
readonly flag: boolean;
|
|
170
|
+
}
|
|
171
|
+
/** A required route decorator group: every route-like target must carry a decision of it. */
|
|
172
|
+
interface RequiredRouteGroup {
|
|
173
|
+
/** Group name (`access`). */
|
|
174
|
+
readonly group: string;
|
|
175
|
+
/** The decorators of the group, in source order (file, then position). Never empty. */
|
|
176
|
+
readonly decorators: readonly RouteGroupMember[];
|
|
177
|
+
/** The decorators as a hint: `@Public(), @Authenticated() or @Roles(...)`. */
|
|
178
|
+
readonly suggestion: string;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The required route decorator groups of the application (RFC 0001): what every route-like target
|
|
182
|
+
* of another extension (a gateway, a GraphQL root field) must decide, exactly as HTTP routes must.
|
|
183
|
+
* A target without a decision of a returned group is **ZNIGHT023**, reported by the caller.
|
|
184
|
+
*
|
|
185
|
+
* `applied` are the route metadata entries the caller read on its own targets
|
|
186
|
+
* ({@link readRouteMeta}): they make decorators known even when the compiler has no
|
|
187
|
+
* `definitionsOf`, and refine how flags are suggested. Inconsistent definitions are left out and
|
|
188
|
+
* not reported here: the http extension reports them (ZNIGHT005) once.
|
|
189
|
+
*
|
|
190
|
+
* ```ts
|
|
191
|
+
* validate(ctx) {
|
|
192
|
+
* const required = requiredRouteGroups(ctx, ctx.stored.flatMap(gateway => gateway.meta))
|
|
193
|
+
* for (const gateway of ctx.stored)
|
|
194
|
+
* for (const group of required)
|
|
195
|
+
* if (!gateway.meta.some(entry => entry.group === group.group))
|
|
196
|
+
* ctx.report({ code: 'ZNIGHT023', severity: 'error', message: `${gateway.name} has no "${group.group}" decision`,
|
|
197
|
+
* location: gateway.location, hints: [`Add ${group.suggestion} to ${gateway.name}`] })
|
|
198
|
+
* }
|
|
199
|
+
* ```
|
|
200
|
+
*/
|
|
201
|
+
export declare function requiredRouteGroups(ctx: ValidateContext<unknown>, applied?: Iterable<MetaEntry>): readonly RequiredRouteGroup[];
|
|
202
|
+
/**
|
|
203
|
+
* The route metadata entries of a class (`method` omitted) or of one of its methods: the route
|
|
204
|
+
* decorators (`createRouteDecorator` consts) applied to that target, in source order, read exactly
|
|
205
|
+
* as the http extension reads controllers and routes (keys, statically evaluated values, groups,
|
|
206
|
+
* `required`). Problems are reported as ZNIGHT005 through `ctx`, once per call: read each target
|
|
207
|
+
* once. Decorators that are not route decorators are ignored.
|
|
208
|
+
*
|
|
209
|
+
* Works with the analysis context of any extension. The entries of a target are its own: combine a
|
|
210
|
+
* class and a method with {@link effectiveRouteMeta}.
|
|
211
|
+
*
|
|
212
|
+
* ```ts
|
|
213
|
+
* analyzeClass(cls, ctx) {
|
|
214
|
+
* const classMeta = readRouteMeta(ctx, cls)
|
|
215
|
+
* for (const method of cls.methods) {
|
|
216
|
+
* const meta = effectiveRouteMeta(classMeta, readRouteMeta(ctx, cls, method))
|
|
217
|
+
* }
|
|
218
|
+
* }
|
|
219
|
+
* ```
|
|
220
|
+
*/
|
|
221
|
+
export declare function readRouteMeta(ctx: AnalyzeContext<unknown>, cls: ClassInfo, method?: MethodInfo): MetaEntry[];
|
|
222
|
+
/**
|
|
223
|
+
* The effective metadata of a method from the entries of its class and its own (RFC 0001 §5.1):
|
|
224
|
+
* the class entries, except those of a group the method decides itself, then the method entries.
|
|
225
|
+
* The same rule as `ctx.route.meta` at runtime.
|
|
226
|
+
*/
|
|
227
|
+
export declare function effectiveRouteMeta(classMeta: readonly MetaEntry[], methodMeta: readonly MetaEntry[]): MetaEntry[];
|
|
153
228
|
/** The `@znight/http` compiler extension. */
|
|
154
229
|
declare const http: CompilerExtension<HttpControllerData>;
|
|
155
|
-
export { type CoercionPlan, type HttpControllerData, type HttpParamData, type HttpRouteData, type MetaEntry, type ParamCoercion, type ScalarCoercion, type SseRouteData, type UploadData, http as default };
|
|
230
|
+
export { type CoercionPlan, type HttpControllerData, type HttpParamData, type HttpRouteData, type MetaEntry, type ParamCoercion, type RequiredRouteGroup, type RouteGroupMember, type ScalarCoercion, type SseRouteData, type UploadData, http as default };
|
|
156
231
|
//# sourceMappingURL=compiler.d.mts.map
|
package/dist/compiler.mjs
CHANGED
|
@@ -514,6 +514,38 @@ function routeMeta(ctx, cls, decorators, target) {
|
|
|
514
514
|
}
|
|
515
515
|
return entries;
|
|
516
516
|
}
|
|
517
|
+
/**
|
|
518
|
+
* The route metadata entries of a class (`method` omitted) or of one of its methods: the route
|
|
519
|
+
* decorators (`createRouteDecorator` consts) applied to that target, in source order, read exactly
|
|
520
|
+
* as the http extension reads controllers and routes (keys, statically evaluated values, groups,
|
|
521
|
+
* `required`). Problems are reported as ZNIGHT005 through `ctx`, once per call: read each target
|
|
522
|
+
* once. Decorators that are not route decorators are ignored.
|
|
523
|
+
*
|
|
524
|
+
* Works with the analysis context of any extension. The entries of a target are its own: combine a
|
|
525
|
+
* class and a method with {@link effectiveRouteMeta}.
|
|
526
|
+
*
|
|
527
|
+
* ```ts
|
|
528
|
+
* analyzeClass(cls, ctx) {
|
|
529
|
+
* const classMeta = readRouteMeta(ctx, cls)
|
|
530
|
+
* for (const method of cls.methods) {
|
|
531
|
+
* const meta = effectiveRouteMeta(classMeta, readRouteMeta(ctx, cls, method))
|
|
532
|
+
* }
|
|
533
|
+
* }
|
|
534
|
+
* ```
|
|
535
|
+
*/
|
|
536
|
+
function readRouteMeta(ctx, cls, method) {
|
|
537
|
+
return method === void 0 ? routeMeta(ctx, cls, cls.decorators, cls.displayName) : routeMeta(ctx, cls, method.decorators, `${cls.displayName}.${method.name}`);
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* The effective metadata of a method from the entries of its class and its own (RFC 0001 §5.1):
|
|
541
|
+
* the class entries, except those of a group the method decides itself, then the method entries.
|
|
542
|
+
* The same rule as `ctx.route.meta` at runtime.
|
|
543
|
+
*/
|
|
544
|
+
function effectiveRouteMeta(classMeta, methodMeta) {
|
|
545
|
+
if (methodMeta.length === 0) return [...classMeta];
|
|
546
|
+
const decided = new Set(methodMeta.map((entry) => entry.group));
|
|
547
|
+
return [...classMeta.filter((entry) => !decided.has(entry.group)), ...methodMeta];
|
|
548
|
+
}
|
|
517
549
|
/** `true` when the decorator is a route metadata decorator. */
|
|
518
550
|
function isRouteMetaDecorator(ctx, decorator) {
|
|
519
551
|
return decorator.owner === void 0 && decorator.identity !== void 0 && resolveRouteDecorator(ctx, decorator.identity).kind !== "other";
|
|
@@ -661,7 +693,7 @@ function analyzeHttpClass(cls, ctx) {
|
|
|
661
693
|
const basePath = normalizePath(options.path);
|
|
662
694
|
const guards = classRefs(ctx, cls, cls.decorators, Ids.UseGuard, "guard");
|
|
663
695
|
const interceptors = classRefs(ctx, cls, cls.decorators, Ids.UseInterceptor, "interceptor");
|
|
664
|
-
const meta =
|
|
696
|
+
const meta = readRouteMeta(ctx, cls);
|
|
665
697
|
const bodyLimit = bodyLimitOf(ctx, cls.decorators);
|
|
666
698
|
const routes = [];
|
|
667
699
|
for (const method of cls.methods) routes.push(...analyzeMethod(ctx, cls, method, basePath, bodyLimit));
|
|
@@ -796,7 +828,7 @@ function analyzeMethod(ctx, cls, method, basePath, controllerBodyLimit) {
|
|
|
796
828
|
let headers = responseHeaders(ctx, decorators);
|
|
797
829
|
const guards = classRefs(ctx, cls, decorators, Ids.UseGuard, "guard");
|
|
798
830
|
const interceptors = classRefs(ctx, cls, decorators, Ids.UseInterceptor, "interceptor");
|
|
799
|
-
const meta =
|
|
831
|
+
const meta = readRouteMeta(ctx, cls, method);
|
|
800
832
|
const bodySchema = bodyValidation !== void 0 && !params.some((param) => param.source === "body") ? bodyValidation.schema : void 0;
|
|
801
833
|
let rawResponse = params.some((param) => param.source === "response");
|
|
802
834
|
const bodyLimit = bodyLimitOf(ctx, decorators) ?? controllerBodyLimit;
|
|
@@ -1954,77 +1986,26 @@ function itemCoercer(ctx, helpers, plan) {
|
|
|
1954
1986
|
default: return ctx.member(helpers.coerce(), plan.kind);
|
|
1955
1987
|
}
|
|
1956
1988
|
}
|
|
1957
|
-
// src/compiler/
|
|
1958
|
-
/** Validates the routes of every controller. */
|
|
1959
|
-
function validateHttp(ctx) {
|
|
1960
|
-
const controllers = [...ctx.stored].sort((a, b) => compareStrings(a.token, b.token));
|
|
1961
|
-
const registered = /* @__PURE__ */ new Map();
|
|
1962
|
-
for (const controller of controllers) {
|
|
1963
|
-
const baseIssue = checkRoutePath(controller.rawBasePath);
|
|
1964
|
-
if (baseIssue !== void 0) {
|
|
1965
|
-
invalidRoute(ctx, controller.pathLocation, `Invalid base path ${JSON.stringify(controller.rawBasePath)} of ${controller.displayName}: ${baseIssue.message}`, GRAMMAR_HINTS);
|
|
1966
|
-
continue;
|
|
1967
|
-
}
|
|
1968
|
-
for (const route of controller.routes) {
|
|
1969
|
-
const where = `${controller.displayName}.${route.handler}`;
|
|
1970
|
-
const ownIssue = checkRoutePath(route.rawPath);
|
|
1971
|
-
const issue = ownIssue ?? checkRoutePath(route.fullPath);
|
|
1972
|
-
if (issue !== void 0) {
|
|
1973
|
-
const path = ownIssue === void 0 ? route.fullPath : route.rawPath;
|
|
1974
|
-
invalidRoute(ctx, route.pathLocation, `Invalid route path ${JSON.stringify(path)} of ${where}: ${issue.message}`, GRAMMAR_HINTS);
|
|
1975
|
-
continue;
|
|
1976
|
-
}
|
|
1977
|
-
const parsed = parseRoutePath(route.fullPath);
|
|
1978
|
-
checkParams(ctx, route, parsed, where);
|
|
1979
|
-
const key = `${route.method} ${parsed.shape}`;
|
|
1980
|
-
const previous = registered.get(key);
|
|
1981
|
-
if (previous === void 0) {
|
|
1982
|
-
registered.set(key, {
|
|
1983
|
-
where,
|
|
1984
|
-
route
|
|
1985
|
-
});
|
|
1986
|
-
continue;
|
|
1987
|
-
}
|
|
1988
|
-
ctx.report({
|
|
1989
|
-
code: Codes.InvalidRoute,
|
|
1990
|
-
severity: "error",
|
|
1991
|
-
message: `Duplicate route ${route.method} ${route.fullPath}: ${where} conflicts with ${previous.where} (${previous.route.method} ${previous.route.fullPath})`,
|
|
1992
|
-
location: route.location,
|
|
1993
|
-
hints: ["Two routes with the same method and the same path shape (parameter names aside) cannot coexist"],
|
|
1994
|
-
related: [{
|
|
1995
|
-
message: `${previous.where} is declared here`,
|
|
1996
|
-
location: previous.route.location
|
|
1997
|
-
}]
|
|
1998
|
-
});
|
|
1999
|
-
}
|
|
2000
|
-
}
|
|
2001
|
-
validateDecisions(ctx, controllers);
|
|
2002
|
-
}
|
|
1989
|
+
// src/compiler/route-groups.ts
|
|
2003
1990
|
/**
|
|
2004
|
-
* Every route decorator definition, sorted by identity: the applied ones (first usage
|
|
2005
|
-
* the compiler provides `definitionsOf`, the ones applied nowhere (invalid ones are
|
|
2006
|
-
* usages, which report them).
|
|
1991
|
+
* Every route decorator definition, sorted by identity: the applied ones (`applied`, first usage
|
|
1992
|
+
* wins) and, when the compiler provides `definitionsOf`, the ones applied nowhere (invalid ones are
|
|
1993
|
+
* left to the usages, which report them).
|
|
2007
1994
|
*/
|
|
2008
|
-
function routeDecoratorDefinitions(ctx,
|
|
1995
|
+
function routeDecoratorDefinitions(ctx, applied) {
|
|
2009
1996
|
const definitions = /* @__PURE__ */ new Map();
|
|
2010
|
-
const
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
else if (entry.value !== true) known.flag = false;
|
|
2023
|
-
}
|
|
2024
|
-
};
|
|
2025
|
-
for (const controller of controllers) {
|
|
2026
|
-
visit(controller.meta);
|
|
2027
|
-
for (const route of controller.routes) visit(route.meta);
|
|
1997
|
+
for (const entry of applied) {
|
|
1998
|
+
const known = definitions.get(entry.identity);
|
|
1999
|
+
if (known === void 0) definitions.set(entry.identity, {
|
|
2000
|
+
identity: entry.identity,
|
|
2001
|
+
name: entry.decorator,
|
|
2002
|
+
key: entry.key,
|
|
2003
|
+
group: entry.group,
|
|
2004
|
+
required: entry.required,
|
|
2005
|
+
definition: entry.definition,
|
|
2006
|
+
flag: entry.value === true
|
|
2007
|
+
});
|
|
2008
|
+
else if (entry.value !== true) known.flag = false;
|
|
2028
2009
|
}
|
|
2029
2010
|
const all = typeof ctx.definitionsOf === "function" ? ctx.definitionsOf(Ids.createRouteDecorator) : [];
|
|
2030
2011
|
for (const found of all) {
|
|
@@ -2051,20 +2032,26 @@ function routeDecoratorDefinitions(ctx, controllers) {
|
|
|
2051
2032
|
return [...definitions.values()].sort((a, b) => compareStrings(a.identity, b.identity));
|
|
2052
2033
|
}
|
|
2053
2034
|
/**
|
|
2054
|
-
*
|
|
2055
|
-
* decorators of a group agree on `required` (
|
|
2056
|
-
*
|
|
2035
|
+
* The route decorator groups of the application (RFC 0001 §5.1): every definition of a key must use
|
|
2036
|
+
* the same options and the decorators of a group must agree on `required` (a conflict is returned
|
|
2037
|
+
* at the second definition, which is then ignored); the required groups with their decorators.
|
|
2038
|
+
* `applied` are the entries of the targets the caller analysed (their first usage of a decorator
|
|
2039
|
+
* wins over its `definitionsOf` entry).
|
|
2057
2040
|
*/
|
|
2058
|
-
function
|
|
2059
|
-
const definitions = routeDecoratorDefinitions(ctx,
|
|
2060
|
-
if (definitions.length === 0) return
|
|
2041
|
+
function analyzeRouteGroups(ctx, applied) {
|
|
2042
|
+
const definitions = routeDecoratorDefinitions(ctx, applied);
|
|
2043
|
+
if (definitions.length === 0) return {
|
|
2044
|
+
conflicts: [],
|
|
2045
|
+
required: []
|
|
2046
|
+
};
|
|
2047
|
+
const conflicts = [];
|
|
2061
2048
|
const byKey = /* @__PURE__ */ new Map();
|
|
2062
2049
|
const byGroup = /* @__PURE__ */ new Map();
|
|
2063
2050
|
const members = /* @__PURE__ */ new Map();
|
|
2064
2051
|
for (const definition of definitions) {
|
|
2065
2052
|
const sameKey = byKey.get(definition.key);
|
|
2066
2053
|
if (sameKey !== void 0 && (sameKey.group !== definition.group || sameKey.required !== definition.required)) {
|
|
2067
|
-
|
|
2054
|
+
conflicts.push({
|
|
2068
2055
|
code: Codes.InvalidDecorator,
|
|
2069
2056
|
severity: "error",
|
|
2070
2057
|
message: `The route decorator ${definition.name} defines the key "${definition.key}" with ${describeOptions(definition)}, but ${sameKey.name} defines it with ${describeOptions(sameKey)}`,
|
|
@@ -2079,7 +2066,7 @@ function validateDecisions(ctx, controllers) {
|
|
|
2079
2066
|
}
|
|
2080
2067
|
const sameGroup = byGroup.get(definition.group);
|
|
2081
2068
|
if (sameGroup !== void 0 && sameGroup.required !== definition.required) {
|
|
2082
|
-
|
|
2069
|
+
conflicts.push({
|
|
2083
2070
|
code: Codes.InvalidDecorator,
|
|
2084
2071
|
severity: "error",
|
|
2085
2072
|
message: `The "${definition.group}" group is ${definition.required ? "" : "not "}required for ${definition.name} but ${sameGroup.required ? "" : "not "}required for ${sameGroup.name}`,
|
|
@@ -2098,7 +2085,112 @@ function validateDecisions(ctx, controllers) {
|
|
|
2098
2085
|
list.push(definition);
|
|
2099
2086
|
members.set(definition.group, list);
|
|
2100
2087
|
}
|
|
2101
|
-
const required = [
|
|
2088
|
+
const required = [];
|
|
2089
|
+
for (const first of byGroup.values()) {
|
|
2090
|
+
if (!first.required) continue;
|
|
2091
|
+
const decorators = [...members.get(first.group) ?? [first]].sort((a, b) => compareStrings(a.definition.file, b.definition.file) || a.definition.start - b.definition.start).map((member) => Object.freeze({ ...member }));
|
|
2092
|
+
const usages = decorators.map((member) => `@${member.name}(${member.flag ? "" : "..."})`);
|
|
2093
|
+
const suggestion = usages.length === 1 ? usages[0] : `${usages.slice(0, -1).join(", ")} or ${usages.at(-1)}`;
|
|
2094
|
+
required.push(Object.freeze({
|
|
2095
|
+
group: first.group,
|
|
2096
|
+
decorators: Object.freeze(decorators),
|
|
2097
|
+
suggestion
|
|
2098
|
+
}));
|
|
2099
|
+
}
|
|
2100
|
+
return {
|
|
2101
|
+
conflicts,
|
|
2102
|
+
required
|
|
2103
|
+
};
|
|
2104
|
+
}
|
|
2105
|
+
/**
|
|
2106
|
+
* The required route decorator groups of the application (RFC 0001): what every route-like target
|
|
2107
|
+
* of another extension (a gateway, a GraphQL root field) must decide, exactly as HTTP routes must.
|
|
2108
|
+
* A target without a decision of a returned group is **ZNIGHT023**, reported by the caller.
|
|
2109
|
+
*
|
|
2110
|
+
* `applied` are the route metadata entries the caller read on its own targets
|
|
2111
|
+
* ({@link readRouteMeta}): they make decorators known even when the compiler has no
|
|
2112
|
+
* `definitionsOf`, and refine how flags are suggested. Inconsistent definitions are left out and
|
|
2113
|
+
* not reported here: the http extension reports them (ZNIGHT005) once.
|
|
2114
|
+
*
|
|
2115
|
+
* ```ts
|
|
2116
|
+
* validate(ctx) {
|
|
2117
|
+
* const required = requiredRouteGroups(ctx, ctx.stored.flatMap(gateway => gateway.meta))
|
|
2118
|
+
* for (const gateway of ctx.stored)
|
|
2119
|
+
* for (const group of required)
|
|
2120
|
+
* if (!gateway.meta.some(entry => entry.group === group.group))
|
|
2121
|
+
* ctx.report({ code: 'ZNIGHT023', severity: 'error', message: `${gateway.name} has no "${group.group}" decision`,
|
|
2122
|
+
* location: gateway.location, hints: [`Add ${group.suggestion} to ${gateway.name}`] })
|
|
2123
|
+
* }
|
|
2124
|
+
* ```
|
|
2125
|
+
*/
|
|
2126
|
+
function requiredRouteGroups(ctx, applied = []) {
|
|
2127
|
+
return analyzeRouteGroups(ctx, applied).required;
|
|
2128
|
+
}
|
|
2129
|
+
function describeOptions(entry) {
|
|
2130
|
+
const group = entry.group === entry.key ? "no group" : `group "${entry.group}"`;
|
|
2131
|
+
return entry.required ? `${group}, required` : group;
|
|
2132
|
+
}
|
|
2133
|
+
// src/compiler/validate.ts
|
|
2134
|
+
/** Validates the routes of every controller. */
|
|
2135
|
+
function validateHttp(ctx) {
|
|
2136
|
+
const controllers = [...ctx.stored].sort((a, b) => compareStrings(a.token, b.token));
|
|
2137
|
+
const registered = /* @__PURE__ */ new Map();
|
|
2138
|
+
for (const controller of controllers) {
|
|
2139
|
+
const baseIssue = checkRoutePath(controller.rawBasePath);
|
|
2140
|
+
if (baseIssue !== void 0) {
|
|
2141
|
+
invalidRoute(ctx, controller.pathLocation, `Invalid base path ${JSON.stringify(controller.rawBasePath)} of ${controller.displayName}: ${baseIssue.message}`, GRAMMAR_HINTS);
|
|
2142
|
+
continue;
|
|
2143
|
+
}
|
|
2144
|
+
for (const route of controller.routes) {
|
|
2145
|
+
const where = `${controller.displayName}.${route.handler}`;
|
|
2146
|
+
const ownIssue = checkRoutePath(route.rawPath);
|
|
2147
|
+
const issue = ownIssue ?? checkRoutePath(route.fullPath);
|
|
2148
|
+
if (issue !== void 0) {
|
|
2149
|
+
const path = ownIssue === void 0 ? route.fullPath : route.rawPath;
|
|
2150
|
+
invalidRoute(ctx, route.pathLocation, `Invalid route path ${JSON.stringify(path)} of ${where}: ${issue.message}`, GRAMMAR_HINTS);
|
|
2151
|
+
continue;
|
|
2152
|
+
}
|
|
2153
|
+
const parsed = parseRoutePath(route.fullPath);
|
|
2154
|
+
checkParams(ctx, route, parsed, where);
|
|
2155
|
+
const key = `${route.method} ${parsed.shape}`;
|
|
2156
|
+
const previous = registered.get(key);
|
|
2157
|
+
if (previous === void 0) {
|
|
2158
|
+
registered.set(key, {
|
|
2159
|
+
where,
|
|
2160
|
+
route
|
|
2161
|
+
});
|
|
2162
|
+
continue;
|
|
2163
|
+
}
|
|
2164
|
+
ctx.report({
|
|
2165
|
+
code: Codes.InvalidRoute,
|
|
2166
|
+
severity: "error",
|
|
2167
|
+
message: `Duplicate route ${route.method} ${route.fullPath}: ${where} conflicts with ${previous.where} (${previous.route.method} ${previous.route.fullPath})`,
|
|
2168
|
+
location: route.location,
|
|
2169
|
+
hints: ["Two routes with the same method and the same path shape (parameter names aside) cannot coexist"],
|
|
2170
|
+
related: [{
|
|
2171
|
+
message: `${previous.where} is declared here`,
|
|
2172
|
+
location: previous.route.location
|
|
2173
|
+
}]
|
|
2174
|
+
});
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
validateDecisions(ctx, controllers);
|
|
2178
|
+
}
|
|
2179
|
+
/** Every route metadata entry of the controllers: controller entries, then route entries. */
|
|
2180
|
+
function* appliedEntries(controllers) {
|
|
2181
|
+
for (const controller of controllers) {
|
|
2182
|
+
yield* controller.meta;
|
|
2183
|
+
for (const route of controller.routes) yield* route.meta;
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
/**
|
|
2187
|
+
* Route decorator groups (RFC 0001 §5.1): every definition of a key uses the same options and the
|
|
2188
|
+
* decorators of a group agree on `required` (ZNIGHT005, at the second definition); every route has
|
|
2189
|
+
* a decision of every required group, its own or its controller's (ZNIGHT023).
|
|
2190
|
+
*/
|
|
2191
|
+
function validateDecisions(ctx, controllers) {
|
|
2192
|
+
const { conflicts, required } = analyzeRouteGroups(ctx, appliedEntries(controllers));
|
|
2193
|
+
for (const conflict of conflicts) ctx.report(conflict);
|
|
2102
2194
|
if (required.length === 0) return;
|
|
2103
2195
|
for (const controller of controllers) {
|
|
2104
2196
|
const controllerGroups = new Set(controller.meta.map((entry) => entry.group));
|
|
@@ -2106,18 +2198,15 @@ function validateDecisions(ctx, controllers) {
|
|
|
2106
2198
|
const decided = /* @__PURE__ */ new Set([...controllerGroups, ...route.meta.map((entry) => entry.group)]);
|
|
2107
2199
|
for (const group of required) {
|
|
2108
2200
|
if (decided.has(group.group)) continue;
|
|
2109
|
-
const inOrder = [...members.get(group.group) ?? [group]].sort((a, b) => compareStrings(a.definition.file, b.definition.file) || a.definition.start - b.definition.start);
|
|
2110
|
-
const decorators = inOrder.map((definition) => `@${definition.name}(${definition.flag ? "" : "..."})`);
|
|
2111
|
-
const listed = decorators.length === 1 ? decorators[0] : `${decorators.slice(0, -1).join(", ")} or ${decorators.at(-1)}`;
|
|
2112
2201
|
ctx.report({
|
|
2113
2202
|
code: Codes.MissingRouteDecision,
|
|
2114
2203
|
severity: "error",
|
|
2115
2204
|
message: `Route ${route.method} ${route.fullPath} (${controller.displayName}.${route.handler}) has no "${group.group}" decision`,
|
|
2116
2205
|
location: route.location,
|
|
2117
|
-
hints: [`Add ${
|
|
2206
|
+
hints: [`Add ${group.suggestion} to the route or to ${controller.displayName}`],
|
|
2118
2207
|
related: [{
|
|
2119
2208
|
message: `The "${group.group}" group is required`,
|
|
2120
|
-
location: (
|
|
2209
|
+
location: firstDefinition(group)
|
|
2121
2210
|
}, {
|
|
2122
2211
|
message: `${controller.displayName} is declared here`,
|
|
2123
2212
|
location: controller.location
|
|
@@ -2127,9 +2216,9 @@ function validateDecisions(ctx, controllers) {
|
|
|
2127
2216
|
}
|
|
2128
2217
|
}
|
|
2129
2218
|
}
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
return
|
|
2219
|
+
/** Where the first decorator of a group (source order) is defined. */
|
|
2220
|
+
function firstDefinition(group) {
|
|
2221
|
+
return group.decorators[0].definition;
|
|
2133
2222
|
}
|
|
2134
2223
|
const GRAMMAR_HINTS = ["Paths are made of static segments (letters, digits and - . _ ~ ! $ & ' + , ; = @), :name parameters and a trailing * wildcard"];
|
|
2135
2224
|
/** `:param` of the path ↔ `@Param('name')` parameters of the handler. */
|
|
@@ -2244,6 +2333,6 @@ const http = Object.freeze({
|
|
|
2244
2333
|
validate: validateHttp,
|
|
2245
2334
|
generate: generateHttp
|
|
2246
2335
|
});
|
|
2247
|
-
export { http as default };
|
|
2336
|
+
export { http as default, effectiveRouteMeta, readRouteMeta, requiredRouteGroups };
|
|
2248
2337
|
|
|
2249
2338
|
//# sourceMappingURL=compiler.mjs.map
|