bazis 0.98.4 → 0.98.5

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.
@@ -54,15 +54,24 @@ function emptyMeta(): ControllerMeta {
54
54
  return { isController: false, middleware: [], filters: [], catches: [], actions: new Map() };
55
55
  }
56
56
 
57
+ /**
58
+ * Actions of a subclass whose routes are still the base class's. The first
59
+ * route decorator of the subclass on such a method replaces them instead of
60
+ * adding a second, duplicate route.
61
+ */
62
+ const inheritedRoutes = new WeakSet<ActionMeta>();
63
+
57
64
  function cloneMeta(source: ControllerMeta): ControllerMeta {
58
65
  const actions = new Map<string | symbol, ActionMeta>();
59
66
  for (const [name, action] of source.actions) {
60
- actions.set(name, {
67
+ const copy: ActionMeta = {
61
68
  ...action,
62
69
  routes: [...action.routes],
63
70
  middleware: [...action.middleware],
64
71
  filters: [...action.filters],
65
- });
72
+ };
73
+ if (copy.routes.length > 0) inheritedRoutes.add(copy);
74
+ actions.set(name, copy);
66
75
  }
67
76
  return {
68
77
  ...source,
@@ -98,6 +107,17 @@ export function ownActionMeta(metadata: object, methodName: string | symbol): Ac
98
107
  return action;
99
108
  }
100
109
 
110
+ /**
111
+ * Routes of an action for a route decorator to add to. Routes inherited from
112
+ * the base class are dropped first: a subclass that declares routes on an
113
+ * overriding method replaces the base method's routes. Other settings of the
114
+ * base method (status code, middleware, version) stay inherited.
115
+ */
116
+ export function ownRoutes(action: ActionMeta): RouteDeclaration[] {
117
+ if (inheritedRoutes.delete(action)) action.routes = [];
118
+ return action.routes;
119
+ }
120
+
101
121
  /** Reads controller metadata from a class (undefined if not decorated). */
102
122
  export function controllerMetaOf(ctor: object): ControllerMeta | undefined {
103
123
  const metadata = (ctor as { [key: symbol]: unknown })[Symbol.metadata as unknown as symbol] as
@@ -1,6 +1,6 @@
1
1
  import type { HttpMiddleware } from "../Middleware/types";
2
2
  import { HttpSetupError } from "../Errors/HttpError";
3
- import { ownActionMeta } from "./metadata";
3
+ import { ownActionMeta, ownRoutes } from "./metadata";
4
4
 
5
5
  type MethodDecorator = (value: (...args: never[]) => unknown, context: ClassMethodDecoratorContext) => void;
6
6
 
@@ -39,7 +39,7 @@ function route(httpMethod: string, templateOrOptions?: string | RouteOptions, ma
39
39
  throw new Error(`HTTP route decorators support public instance methods only ("${String(context.name)}").`);
40
40
  }
41
41
  const action = ownActionMeta(context.metadata, context.name);
42
- action.routes.push({ httpMethod, template });
42
+ ownRoutes(action).push({ httpMethod, template });
43
43
  if (options) {
44
44
  // Options apply to the whole action (like attribute decorators); with
45
45
  // several route decorators scalar fields are overwritten and
package/core/http/SPEC.md CHANGED
@@ -228,6 +228,26 @@ class DerivedController extends BaseController {}
228
228
  // DerivedController inherits sharedMiddleware
229
229
  ```
230
230
 
231
+ Routes are inherited too, which is how a new API version is built on the old one:
232
+
233
+ ```ts
234
+ @Controller("notes") @ApiVersion("2")
235
+ class NotesV2Controller extends NotesV1Controller {
236
+ @Get(":noteId") override getById(noteId: string) { ... } // replaces GET :id of v1
237
+ override text(id: string) { ... } // keeps the v1 route
238
+ }
239
+ ```
240
+
241
+ - A method the subclass does not override keeps its routes, served by the base implementation.
242
+ - An override without route decorators keeps the base method's routes; its own parameters are bound.
243
+ - Route decorators on an override replace the base method's routes (since 0.98.5; before, they
244
+ were added to them, and startup failed with a duplicate route). Other settings of the base
245
+ method — status code, middleware, version — stay inherited.
246
+
247
+ Codegen follows the `extends` chain, so inherited actions get argument bindings for the subclass
248
+ (since 0.98.5; before, a subclass without own route decorators got none, and an override received
249
+ `HttpContext` in place of its parameters).
250
+
231
251
  ### The equivalent through inline route options
232
252
 
233
253
  `RouteOptions.middleware` on `@Get`/`@Post` writes into the same `ActionMeta.middleware`:
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
232
232
  if (method === "account/updated") this.modelCache = undefined;
233
233
  }, () => { this.clearLogin(); this.modelCache = undefined; });
234
234
  const initialized = await client.request("initialize", {
235
- clientInfo: { name: "bazis", title: "Bazis", version: "0.98.4" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.98.5" }, capabilities: { experimentalApi: true },
236
236
  }, 15_000, signal);
237
237
  if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
238
238
  client.notify("initialized");
@@ -1089,12 +1089,11 @@ function hasDecorator(node: ts.HasDecorators, name: string): boolean {
1089
1089
  * template, parsed like `Routing/template.ts` (`:name`, `:name(int)`, `*name`,
1090
1090
  * and a bare `*` named `rest`).
1091
1091
  */
1092
- function routeParamNames(method: ts.MethodDeclaration): Set<string> {
1092
+ function routeParamNames(controller: ts.ClassDeclaration, method: ts.MethodDeclaration): Set<string> {
1093
1093
  const names = new Set<string>();
1094
1094
  const templates: string[] = [];
1095
- const owner = method.parent;
1096
1095
  const sources: readonly [readonly ts.Decorator[], (name: string) => boolean][] = [
1097
- [ts.isClassDeclaration(owner) ? ts.getDecorators(owner) ?? [] : [], (name) => name === "Controller"],
1096
+ [ts.getDecorators(controller) ?? [], (name) => name === "Controller"],
1098
1097
  [ts.getDecorators(method) ?? [], (name) => ROUTE_DECORATORS.has(name)],
1099
1098
  ];
1100
1099
  for (const [decorators, accepts] of sources) {
@@ -1394,23 +1393,9 @@ function collectControllerBindings(node: ts.ClassDeclaration, controllerName: st
1394
1393
  }
1395
1394
  httpControllerFiles[controllerName] = filePath;
1396
1395
 
1397
- for (const member of node.members) {
1398
- if (!ts.isMethodDeclaration(member) || !ts.isIdentifier(member.name)) {
1399
- continue;
1400
- }
1401
- const decorators = ts.getDecorators(member) ?? [];
1402
- const hasRoute = decorators.some((decorator) => {
1403
- const info = decoratorCall(decorator);
1404
- return info !== undefined && ROUTE_DECORATORS.has(info.name);
1405
- });
1406
- if (hasRoute) {
1407
- collectOpenApiOperation(controllerName, member);
1408
- }
1409
- if (!hasRoute) {
1410
- continue;
1411
- }
1412
-
1413
- const routeParams = routeParamNames(member);
1396
+ for (const { name, routes, member } of controllerActions(node)) {
1397
+ collectOpenApiOperation(controllerName, member);
1398
+ const routeParams = routeParamNames(node, routes);
1414
1399
  const specs: HttpBindingSpec[] = [];
1415
1400
  let bodyCount = 0;
1416
1401
  let failed: string | undefined;
@@ -1469,15 +1454,59 @@ function collectControllerBindings(node: ts.ClassDeclaration, controllerName: st
1469
1454
 
1470
1455
  if (failed) {
1471
1456
  fatalErrors.push(
1472
- `BAZIS_HTTP_BINDING_UNRESOLVED: controller "${controllerName}.${member.name.text}" (${filePath}): cannot infer bindings (${failed}). ` +
1457
+ `BAZIS_HTTP_BINDING_UNRESOLVED: controller "${controllerName}.${name}" (${filePath}): cannot infer bindings (${failed}). ` +
1473
1458
  `Use supported parameter types; read headers or raw bodies through HttpContext, and inject services in the constructor.`,
1474
1459
  );
1475
1460
  continue;
1476
1461
  }
1477
- (httpBindings[controllerName] ??= {})[member.name.text] = specs;
1462
+ (httpBindings[controllerName] ??= {})[name] = specs;
1478
1463
  }
1479
1464
  }
1480
1465
 
1466
+ interface ControllerAction {
1467
+ readonly name: string;
1468
+ /** Declaration whose route decorators give the routes. */
1469
+ readonly routes: ts.MethodDeclaration;
1470
+ /** Declaration that runs: its parameters are bound. */
1471
+ readonly member: ts.MethodDeclaration;
1472
+ }
1473
+
1474
+ /**
1475
+ * Route methods of a controller, including those inherited from base classes,
1476
+ * as the runtime sees them: the nearest declaration with route decorators
1477
+ * gives the routes (a subclass's own decorators replace the base's), the
1478
+ * nearest implementation gives the parameters (an override without
1479
+ * decorators keeps the base routes with its own signature).
1480
+ */
1481
+ function controllerActions(node: ts.ClassDeclaration): ControllerAction[] {
1482
+ const routes = new Map<string, ts.MethodDeclaration>();
1483
+ const members = new Map<string, ts.MethodDeclaration>();
1484
+ const seen = new Set<ts.ClassDeclaration>();
1485
+ for (let current: ts.ClassDeclaration | undefined = node; current && !seen.has(current); current = baseClassDeclaration(current)) {
1486
+ seen.add(current);
1487
+ for (const member of current.members) {
1488
+ if (!ts.isMethodDeclaration(member) || !ts.isIdentifier(member.name)) continue;
1489
+ const name = member.name.text;
1490
+ if (!members.has(name) && member.body !== undefined) members.set(name, member);
1491
+ if (!routes.has(name) && hasRouteDecorator(member)) routes.set(name, member);
1492
+ }
1493
+ }
1494
+ return [...routes].map(([name, declaration]) => ({ name, routes: declaration, member: members.get(name) ?? declaration }));
1495
+ }
1496
+
1497
+ function hasRouteDecorator(member: ts.MethodDeclaration): boolean {
1498
+ return (ts.getDecorators(member) ?? []).some((decorator) => {
1499
+ const info = decoratorCall(decorator);
1500
+ return info !== undefined && ROUTE_DECORATORS.has(info.name);
1501
+ });
1502
+ }
1503
+
1504
+ function baseClassDeclaration(node: ts.ClassDeclaration): ts.ClassDeclaration | undefined {
1505
+ const clause = node.heritageClauses?.find((item) => item.token === ts.SyntaxKind.ExtendsKeyword);
1506
+ const base = clause?.types[0]?.expression;
1507
+ return base === undefined ? undefined : classDeclarationForExpression(base);
1508
+ }
1509
+
1481
1510
  /** `limit = 100` has no type annotation — infer the primitive from the default value. */
1482
1511
  function inferTypeFromInitializer(initializer: ts.Expression | undefined): HttpBindingSpec["type"] | undefined {
1483
1512
  if (!initializer) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.98.4",
3
+ "version": "0.98.5",
4
4
  "description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Alexander Alenin",