@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 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. Guards,
1226
- interceptors and mappers are providers: they may be request-scoped (resolved per request) and have
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 no `http:request` wrapper or listener is registered (and CORS is off), routes (other than
1497
- `@All`) without guards, interceptors, `@Sse`, `@Res()` or `@Header` take a fast lane: the plugin
1498
- decides at boot what the pipeline would do for them, runs a synchronous handler without creating a
1499
- promise and builds the headers of a JSON reply from a template. The replies are the same as through
1500
- the full pipeline (a thrown error, an `HttpResponse` or anything unusual continues there, in the
1501
- same request scope; a promise is awaited first); registering a hook simply sends every request
1502
- through the full pipeline again.
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
@@ -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 = routeMeta(ctx, cls, cls.decorators, cls.displayName);
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 = routeMeta(ctx, cls, decorators, where);
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/validate.ts
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) and, when
2005
- * the compiler provides `definitionsOf`, the ones applied nowhere (invalid ones are left to the
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, controllers) {
1995
+ function routeDecoratorDefinitions(ctx, applied) {
2009
1996
  const definitions = /* @__PURE__ */ new Map();
2010
- const visit = (entries) => {
2011
- for (const entry of entries) {
2012
- const known = definitions.get(entry.identity);
2013
- if (known === void 0) definitions.set(entry.identity, {
2014
- identity: entry.identity,
2015
- name: entry.decorator,
2016
- key: entry.key,
2017
- group: entry.group,
2018
- required: entry.required,
2019
- definition: entry.definition,
2020
- flag: entry.value === true
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
- * Route decorator groups (RFC 0001 §5.1): every definition of a key uses the same options and the
2055
- * decorators of a group agree on `required` (ZNIGHT005, at the second definition); every route has
2056
- * a decision of every required group, its own or its controller's (ZNIGHT023).
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 validateDecisions(ctx, controllers) {
2059
- const definitions = routeDecoratorDefinitions(ctx, controllers);
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
- ctx.report({
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
- ctx.report({
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 = [...byGroup.values()].filter((definition) => definition.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 ${listed} to the route or to ${controller.displayName}`],
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: (inOrder[0] ?? group).definition
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
- function describeOptions(entry) {
2131
- const group = entry.group === entry.key ? "no group" : `group "${entry.group}"`;
2132
- return entry.required ? `${group}, required` : group;
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