bazis 0.98.4 → 0.98.6

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
@@ -25,12 +25,28 @@ export interface HttpOpenApiDocumentBuildInput {
25
25
  readonly version: string;
26
26
  }
27
27
 
28
+ /** Body of every error response: `{ "error": "...", "details": ... }` (see Middleware/errorHandler). */
29
+ const ERROR_SCHEMA_NAME = "HttpErrorResponse";
30
+ const ERROR_SCHEMA: OpenApiSchema = {
31
+ type: "object",
32
+ description: "Error response of the bazis HTTP server.",
33
+ properties: {
34
+ error: { type: "string", description: "Human-readable error message." },
35
+ details: { description: "Error details, for example the list of validation errors." },
36
+ },
37
+ required: ["error"],
38
+ };
39
+ const ERROR_DESCRIPTIONS: Readonly<Record<number, string>> = {
40
+ 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", 409: "Conflict",
41
+ 413: "Payload Too Large", 415: "Unsupported Media Type", 429: "Too Many Requests",
42
+ };
43
+
28
44
  export function buildHttpOpenApiDocument(input: HttpOpenApiDocumentBuildInput): OpenApiSchema {
29
45
  const generated = getGeneratedOpenApiMetadata(input.controllers);
30
46
  const documentInput: LibraryOpenApiDocumentBuildInput = {
31
47
  title: input.title,
32
48
  version: input.version,
33
- schemas: generated.schemas,
49
+ schemas: { [ERROR_SCHEMA_NAME]: ERROR_SCHEMA, ...generated.schemas },
34
50
  operations: collectOpenApiOperations(input, generated),
35
51
  generatedBy: "bazis:di-generate",
36
52
  };
@@ -89,16 +105,18 @@ function createOperation(input: CreateOperationInput): OpenApiCatalogOperation {
89
105
  const version = input.action.version ?? input.meta.version;
90
106
  const auth = resolveAuthorizeMeta(input.controllerClass, input.methodName);
91
107
  const generatedOperation = input.generated.operations[input.controllerClass.name]?.[methodName];
92
- const status = String(successStatus(input.httpMethod, input.action));
108
+ const status = String(successStatus(input.httpMethod, input.action, generatedOperation?.status));
93
109
  const responseSchema = generatedOperation?.response;
94
110
  const contentType = input.action.produces ?? "application/json";
111
+ const authorized = !auth.allowAnonymous && auth.authorize !== undefined;
95
112
 
96
113
  return {
97
114
  path: input.path,
98
115
  httpMethod: input.httpMethod,
99
116
  tag: tagName(input.meta, input.controllerClass),
100
117
  operationId: stableHttpOperationId(input.httpMethod, input.path),
101
- summary: `${normalizedMethod(input.httpMethod)} ${input.path}`,
118
+ summary: generatedOperation?.summary ?? `${normalizedMethod(input.httpMethod)} ${input.path}`,
119
+ ...(generatedOperation?.description !== undefined ? { description: generatedOperation.description } : {}),
102
120
  parameters: collectParameters(
103
121
  input.bindings,
104
122
  input.versioning,
@@ -117,8 +135,9 @@ function createOperation(input: CreateOperationInput): OpenApiCatalogOperation {
117
135
  },
118
136
  }
119
137
  : { description: "Success" },
138
+ ...errorResponses(errorStatuses(input.bindings, authorized, generatedOperation?.errors)),
120
139
  },
121
- authorized: !auth.allowAnonymous && auth.authorize !== undefined,
140
+ authorized,
122
141
  ...(version !== undefined ? { versions: [version] } : {}),
123
142
  };
124
143
  }
@@ -305,11 +324,42 @@ function schemaForConstraint(constraint: string | undefined): OpenApiSchema {
305
324
  }
306
325
  }
307
326
 
308
- function successStatus(method: string, action: ActionMeta): number {
309
- if (action.httpCode !== undefined) {
310
- return action.httpCode;
327
+ function successStatus(method: string, action: ActionMeta, generated: number | undefined): number {
328
+ return action.httpCode ?? generated ?? (method === "DELETE" ? 204 : 200);
329
+ }
330
+
331
+ /**
332
+ * Errors the operation can answer with: 400 when the request carries values
333
+ * that are converted or validated (body, list query, query, typed route
334
+ * parameters), 401/403 under @Authorize, and the errors the method body
335
+ * returns or throws itself.
336
+ */
337
+ function errorStatuses(
338
+ bindings: readonly ParameterBinding[],
339
+ authorized: boolean,
340
+ generated: readonly number[] | undefined,
341
+ ): number[] {
342
+ const statuses = new Set(generated ?? []);
343
+ const validated = bindings.some((binding) =>
344
+ binding.source === "body" || binding.source === "list" || binding.source === "query"
345
+ || (binding.source === "route" && binding.type !== undefined && binding.type !== "string"));
346
+ if (validated) statuses.add(400);
347
+ if (authorized) {
348
+ statuses.add(401);
349
+ statuses.add(403);
350
+ }
351
+ return [...statuses].sort((a, b) => a - b);
352
+ }
353
+
354
+ function errorResponses(statuses: readonly number[]): Record<string, OpenApiSchema> {
355
+ const responses: Record<string, OpenApiSchema> = {};
356
+ for (const status of statuses) {
357
+ responses[String(status)] = {
358
+ description: ERROR_DESCRIPTIONS[status] ?? "Error",
359
+ content: { "application/json": { schema: { $ref: `#/components/schemas/${ERROR_SCHEMA_NAME}` } } },
360
+ };
311
361
  }
312
- return method === "DELETE" ? 204 : 200;
362
+ return responses;
313
363
  }
314
364
 
315
365
  function tagName(meta: ControllerMeta, controllerClass: Class<object>): string {
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.6" }, 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) {
@@ -1361,8 +1360,7 @@ function collectOpenApiOperation(controllerName: string, method: ts.MethodDeclar
1361
1360
  if (!ts.isIdentifier(method.name)) {
1362
1361
  return;
1363
1362
  }
1364
- const response = openApiCodegen.responseSchemaFromMethod(method);
1365
- (openApiOperations[controllerName] ??= {})[method.name.text] = response === undefined ? {} : { response };
1363
+ (openApiOperations[controllerName] ??= {})[method.name.text] = openApiCodegen.operationFromMethod(method);
1366
1364
  }
1367
1365
 
1368
1366
  /** True if the class has an `extends ListRequest` (with or without type args). */
@@ -1394,23 +1392,9 @@ function collectControllerBindings(node: ts.ClassDeclaration, controllerName: st
1394
1392
  }
1395
1393
  httpControllerFiles[controllerName] = filePath;
1396
1394
 
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);
1395
+ for (const { name, routes, member } of controllerActions(node)) {
1396
+ collectOpenApiOperation(controllerName, member);
1397
+ const routeParams = routeParamNames(node, routes);
1414
1398
  const specs: HttpBindingSpec[] = [];
1415
1399
  let bodyCount = 0;
1416
1400
  let failed: string | undefined;
@@ -1469,15 +1453,59 @@ function collectControllerBindings(node: ts.ClassDeclaration, controllerName: st
1469
1453
 
1470
1454
  if (failed) {
1471
1455
  fatalErrors.push(
1472
- `BAZIS_HTTP_BINDING_UNRESOLVED: controller "${controllerName}.${member.name.text}" (${filePath}): cannot infer bindings (${failed}). ` +
1456
+ `BAZIS_HTTP_BINDING_UNRESOLVED: controller "${controllerName}.${name}" (${filePath}): cannot infer bindings (${failed}). ` +
1473
1457
  `Use supported parameter types; read headers or raw bodies through HttpContext, and inject services in the constructor.`,
1474
1458
  );
1475
1459
  continue;
1476
1460
  }
1477
- (httpBindings[controllerName] ??= {})[member.name.text] = specs;
1461
+ (httpBindings[controllerName] ??= {})[name] = specs;
1478
1462
  }
1479
1463
  }
1480
1464
 
1465
+ interface ControllerAction {
1466
+ readonly name: string;
1467
+ /** Declaration whose route decorators give the routes. */
1468
+ readonly routes: ts.MethodDeclaration;
1469
+ /** Declaration that runs: its parameters are bound. */
1470
+ readonly member: ts.MethodDeclaration;
1471
+ }
1472
+
1473
+ /**
1474
+ * Route methods of a controller, including those inherited from base classes,
1475
+ * as the runtime sees them: the nearest declaration with route decorators
1476
+ * gives the routes (a subclass's own decorators replace the base's), the
1477
+ * nearest implementation gives the parameters (an override without
1478
+ * decorators keeps the base routes with its own signature).
1479
+ */
1480
+ function controllerActions(node: ts.ClassDeclaration): ControllerAction[] {
1481
+ const routes = new Map<string, ts.MethodDeclaration>();
1482
+ const members = new Map<string, ts.MethodDeclaration>();
1483
+ const seen = new Set<ts.ClassDeclaration>();
1484
+ for (let current: ts.ClassDeclaration | undefined = node; current && !seen.has(current); current = baseClassDeclaration(current)) {
1485
+ seen.add(current);
1486
+ for (const member of current.members) {
1487
+ if (!ts.isMethodDeclaration(member) || !ts.isIdentifier(member.name)) continue;
1488
+ const name = member.name.text;
1489
+ if (!members.has(name) && member.body !== undefined) members.set(name, member);
1490
+ if (!routes.has(name) && hasRouteDecorator(member)) routes.set(name, member);
1491
+ }
1492
+ }
1493
+ return [...routes].map(([name, declaration]) => ({ name, routes: declaration, member: members.get(name) ?? declaration }));
1494
+ }
1495
+
1496
+ function hasRouteDecorator(member: ts.MethodDeclaration): boolean {
1497
+ return (ts.getDecorators(member) ?? []).some((decorator) => {
1498
+ const info = decoratorCall(decorator);
1499
+ return info !== undefined && ROUTE_DECORATORS.has(info.name);
1500
+ });
1501
+ }
1502
+
1503
+ function baseClassDeclaration(node: ts.ClassDeclaration): ts.ClassDeclaration | undefined {
1504
+ const clause = node.heritageClauses?.find((item) => item.token === ts.SyntaxKind.ExtendsKeyword);
1505
+ const base = clause?.types[0]?.expression;
1506
+ return base === undefined ? undefined : classDeclarationForExpression(base);
1507
+ }
1508
+
1481
1509
  /** `limit = 100` has no type annotation — infer the primitive from the default value. */
1482
1510
  function inferTypeFromInitializer(initializer: ts.Expression | undefined): HttpBindingSpec["type"] | undefined {
1483
1511
  if (!initializer) {
@@ -5,6 +5,14 @@ export type OpenApiCodegenSchema = Record<string, unknown>;
5
5
 
6
6
  export interface OpenApiCodegenOperationSpec {
7
7
  readonly response?: OpenApiCodegenSchema;
8
+ /** Success status when every success return uses the same result helper (`Created` -> 201). */
9
+ readonly status?: number;
10
+ /** Error statuses returned (`NotFound(...)`) or thrown (`throw new NotFoundError()`) in the method body. */
11
+ readonly errors?: readonly number[];
12
+ /** First line of the method's JSDoc. */
13
+ readonly summary?: string;
14
+ /** The rest of the method's JSDoc. */
15
+ readonly description?: string;
8
16
  }
9
17
 
10
18
  export interface OpenApiCodegenAnalyzer {
@@ -15,6 +23,8 @@ export interface OpenApiCodegenAnalyzer {
15
23
  schemaFromDeclaration(declaration: ts.ClassDeclaration | ts.InterfaceDeclaration): OpenApiCodegenSchema;
16
24
  schemaNameForDeclaration(name: string, declaration: ts.Declaration | undefined): string;
17
25
  responseSchemaFromMethod(method: ts.MethodDeclaration): OpenApiCodegenSchema | undefined;
26
+ /** Response schema, statuses and JSDoc of a controller method. */
27
+ operationFromMethod(method: ts.MethodDeclaration): OpenApiCodegenOperationSpec;
18
28
  }
19
29
 
20
30
  export interface OpenApiCodegenAnalyzerInput {
@@ -32,6 +42,14 @@ const SUCCESS_RESULT_HELPERS = new Set(["Ok", "Accepted"]);
32
42
  const CREATED_RESULT_HELPERS = new Set(["Created"]);
33
43
  const EMPTY_SUCCESS_RESULT_HELPERS = new Set(["NoContent", "Redirect", "File"]);
34
44
  const ERROR_RESULT_HELPERS = new Set(["BadRequest", "Unauthorized", "Forbidden", "NotFound", "Conflict", "InternalServerError"]);
45
+ const SUCCESS_HELPER_STATUS: Readonly<Record<string, number>> = { Ok: 200, Accepted: 202, Created: 201, NoContent: 204 };
46
+ const ERROR_HELPER_STATUS: Readonly<Record<string, number>> = {
47
+ BadRequest: 400, Unauthorized: 401, Forbidden: 403, NotFound: 404, Conflict: 409,
48
+ };
49
+ const ERROR_CLASS_STATUS: Readonly<Record<string, number>> = {
50
+ BadRequestError: 400, UnauthorizedError: 401, ForbiddenError: 403, NotFoundError: 404,
51
+ PayloadTooLargeError: 413, UnsupportedMediaTypeError: 415, TooManyRequestsError: 429,
52
+ };
35
53
  const OPENAPI_EMPTY_TYPE_NAMES = new Set(["HttpResult", "Response", "Blob", "ReadableStream", "Uint8Array"]);
36
54
  const OPENAPI_BUILTIN_TYPE_NAMES = new Set([
37
55
  "Array",
@@ -77,7 +95,8 @@ export function createOpenApiCodegenAnalyzer(input: OpenApiCodegenAnalyzerInput)
77
95
  const baseSchema = typeNode ? schemaFromTypeNode(typeNode) : schemaFromInitializer(schemaInitializer(member));
78
96
  const validators = validatorOptionsOf(member);
79
97
  const schema = validators.reduce((current, options) => applyValidatorOptions(current, options), baseSchema);
80
- properties[name] = schema;
98
+ const description = documentationOf(member.name);
99
+ properties[name] = description === undefined ? schema : { ...schema, description };
81
100
  if (isRequiredProperty(member, ownerKind, validators)) {
82
101
  required.push(name);
83
102
  }
@@ -89,12 +108,95 @@ export function createOpenApiCodegenAnalyzer(input: OpenApiCodegenAnalyzerInput)
89
108
  return schema;
90
109
  };
91
110
 
92
- const responseSchemaFromMethod = (method: ts.MethodDeclaration): OpenApiCodegenSchema | undefined => {
93
- const inferred = responseSchemaFromReturnExpressions(method);
94
- if (inferred !== undefined) {
95
- return inferred;
111
+ /** JSDoc text of a declaration name, without tags; undefined when there is none. */
112
+ const documentationOf = (name: ts.Node): string | undefined => {
113
+ const symbol = checker.getSymbolAtLocation(name);
114
+ const text = symbol === undefined ? "" : ts.displayPartsToString(symbol.getDocumentationComment(checker)).trim();
115
+ return text.length > 0 ? text : undefined;
116
+ };
117
+
118
+ const operationFromMethod = (method: ts.MethodDeclaration): OpenApiCodegenOperationSpec => {
119
+ const response = responseSchemaFromMethod(method);
120
+ const { status, errors } = statusesFromMethod(method);
121
+ const documentation = documentationOf(method.name);
122
+ const [summary, ...rest] = documentation?.split(/\r?\n/) ?? [];
123
+ const description = rest.join("\n").trim();
124
+ return {
125
+ ...(response !== undefined ? { response } : {}),
126
+ ...(status !== undefined ? { status } : {}),
127
+ ...(errors.length > 0 ? { errors } : {}),
128
+ ...(summary !== undefined && summary.trim().length > 0 ? { summary: summary.trim() } : {}),
129
+ ...(description.length > 0 ? { description } : {}),
130
+ };
131
+ };
132
+
133
+ /**
134
+ * Success and error statuses visible in the method body: result helpers in
135
+ * `return` statements and `throw new <HttpError subclass>`. Errors thrown
136
+ * by called services are not visible here.
137
+ */
138
+ const statusesFromMethod = (method: ts.MethodDeclaration): { status?: number; errors: number[] } => {
139
+ const success = new Set<number>();
140
+ const errors = new Set<number>();
141
+ const addReturn = (expression: ts.Expression): void => {
142
+ const unwrapped = unwrapExpression(expression);
143
+ if (ts.isConditionalExpression(unwrapped)) {
144
+ addReturn(unwrapped.whenTrue);
145
+ addReturn(unwrapped.whenFalse);
146
+ return;
147
+ }
148
+ const name = ts.isCallExpression(unwrapped) ? callExpressionName(unwrapped) : undefined;
149
+ if (name !== undefined && SUCCESS_HELPER_STATUS[name] !== undefined) {
150
+ success.add(SUCCESS_HELPER_STATUS[name]);
151
+ } else if (name !== undefined && ERROR_HELPER_STATUS[name] !== undefined) {
152
+ errors.add(ERROR_HELPER_STATUS[name]);
153
+ } else if (name === "StatusCode" && ts.isCallExpression(unwrapped)) {
154
+ const status = numericLiteralValue(unwrapped.arguments[0]);
155
+ if (status === undefined) success.add(-1);
156
+ else if (status >= 400 && status < 500) errors.add(status);
157
+ else if (status >= 200 && status < 300) success.add(status);
158
+ } else if (name === undefined || !EMPTY_SUCCESS_RESULT_HELPERS.has(name) && !ERROR_RESULT_HELPERS.has(name)) {
159
+ success.add(200);
160
+ } else {
161
+ success.add(-1);
162
+ }
163
+ };
164
+ const visit = (node: ts.Node): void => {
165
+ if (node !== method.body && isNestedFunctionLike(node)) return;
166
+ if (ts.isReturnStatement(node)) {
167
+ if (node.expression !== undefined) addReturn(node.expression);
168
+ return;
169
+ }
170
+ if (ts.isThrowStatement(node) && ts.isNewExpression(unwrapExpression(node.expression))) {
171
+ const status = httpErrorStatus(checker.getTypeAtLocation(node.expression));
172
+ if (status !== undefined) errors.add(status);
173
+ }
174
+ ts.forEachChild(node, visit);
175
+ };
176
+ if (method.body !== undefined) ts.forEachChild(method.body, visit);
177
+ const [only] = success;
178
+ return {
179
+ ...(success.size === 1 && only !== undefined && only !== 200 && only !== -1 ? { status: only } : {}),
180
+ errors: [...errors].sort((a, b) => a - b),
181
+ };
182
+ };
183
+
184
+ /** Status of an HttpError subclass by its class chain (`class TaskNotFound extends NotFoundError`). */
185
+ const httpErrorStatus = (type: ts.Type): number | undefined => {
186
+ let current: ts.Type | undefined = type;
187
+ for (let depth = 0; current !== undefined && depth < 16; depth += 1) {
188
+ const name = current.getSymbol()?.getName();
189
+ if (name !== undefined && ERROR_CLASS_STATUS[name] !== undefined) return ERROR_CLASS_STATUS[name];
190
+ current = current.isClass() ? checker.getBaseTypes(current)[0] : undefined;
96
191
  }
97
- return method.type ? responseSchemaFromType(method.type) : undefined;
192
+ return undefined;
193
+ };
194
+
195
+ const responseSchemaFromMethod = (method: ts.MethodDeclaration): OpenApiCodegenSchema | undefined => {
196
+ // A declared return type is the contract; result-helper types
197
+ // (HttpResult, Response) carry no schema and fall back to the returns.
198
+ const declared = method.type ? responseSchemaFromType(method.type) : undefined;
199
+ return declared ?? responseSchemaFromReturnExpressions(method);
98
200
  };
99
201
 
100
202
  const schemaFromTypeNode = (node: ts.TypeNode): OpenApiCodegenSchema => {
@@ -418,21 +520,28 @@ export function createOpenApiCodegenAnalyzer(input: OpenApiCodegenAnalyzerInput)
418
520
  return {
419
521
  schemaFromMembers,
420
522
  schemaFromDeclaration(declaration) {
421
- if (!declaration.heritageClauses?.some(clause => clause.token === ts.SyntaxKind.ExtendsKeyword)) {
422
- return schemaFromMembers(declaration.members, ts.isClassDeclaration(declaration) ? "class" : "interface");
423
- }
424
- // Effective instance properties include imported and indirect base classes.
425
- // Keep original property declarations so their Validator rules remain visible.
426
- const properties = checker.getPropertiesOfType(checker.getTypeAtLocation(declaration));
427
- const members = properties.flatMap(property => {
428
- const member = property.valueDeclaration ?? property.declarations?.[0];
429
- return member !== undefined && (ts.isPropertyDeclaration(member) || ts.isPropertySignature(member)) ? [member] : [];
430
- });
431
- return schemaFromMembers(members, ts.isClassDeclaration(declaration) ? "class" : "interface");
523
+ const schema = schemaFromDeclarationMembers(declaration);
524
+ const description = declaration.name === undefined ? undefined : documentationOf(declaration.name);
525
+ return description === undefined ? schema : { description, ...schema };
432
526
  },
433
527
  schemaNameForDeclaration,
434
528
  responseSchemaFromMethod,
529
+ operationFromMethod,
435
530
  };
531
+
532
+ function schemaFromDeclarationMembers(declaration: ts.ClassDeclaration | ts.InterfaceDeclaration): OpenApiCodegenSchema {
533
+ if (!declaration.heritageClauses?.some(clause => clause.token === ts.SyntaxKind.ExtendsKeyword)) {
534
+ return schemaFromMembers(declaration.members, ts.isClassDeclaration(declaration) ? "class" : "interface");
535
+ }
536
+ // Effective instance properties include imported and indirect base classes.
537
+ // Keep original property declarations so their Validator rules remain visible.
538
+ const properties = checker.getPropertiesOfType(checker.getTypeAtLocation(declaration));
539
+ const members = properties.flatMap(property => {
540
+ const member = property.valueDeclaration ?? property.declarations?.[0];
541
+ return member !== undefined && (ts.isPropertyDeclaration(member) || ts.isPropertySignature(member)) ? [member] : [];
542
+ });
543
+ return schemaFromMembers(members, ts.isClassDeclaration(declaration) ? "class" : "interface");
544
+ }
436
545
  }
437
546
 
438
547
  function collectSchemaNameIndex(sourceFiles: readonly ts.SourceFile[]): Map<string, Set<string>> {
@@ -4,6 +4,7 @@ type OpenApiOperation = {
4
4
  tags: string[];
5
5
  operationId: string;
6
6
  summary: string;
7
+ description?: string;
7
8
  parameters?: OpenApiParameter[];
8
9
  requestBody?: OpenApiSchema;
9
10
  responses: Record<string, OpenApiSchema>;
@@ -152,6 +153,9 @@ function operationFromCatalog(item: OpenApiCatalogOperation): OpenApiOperation {
152
153
  summary: item.summary ?? `${normalizedMethod(item.httpMethod)} ${item.path}`,
153
154
  responses: { ...item.responses },
154
155
  };
156
+ if (item.description !== undefined) {
157
+ operation.description = item.description;
158
+ }
155
159
  const parameters = mergeParameters(item.parameters ?? []);
156
160
  if (parameters.length > 0) {
157
161
  operation.parameters = parameters;
@@ -2,6 +2,14 @@ export type OpenApiSchema = Readonly<Record<string, unknown>>;
2
2
 
3
3
  export interface GeneratedOpenApiOperationMetadata {
4
4
  readonly response?: OpenApiSchema;
5
+ /** Success status from the method's result helpers (`Created` -> 201). */
6
+ readonly status?: number;
7
+ /** Error statuses returned or thrown in the method body. */
8
+ readonly errors?: readonly number[];
9
+ /** First line of the method's JSDoc. */
10
+ readonly summary?: string;
11
+ /** The rest of the method's JSDoc. */
12
+ readonly description?: string;
5
13
  }
6
14
 
7
15
  export interface GeneratedOpenApiMetadata {
@@ -53,6 +61,7 @@ export interface OpenApiCatalogOperation {
53
61
  readonly tag: string;
54
62
  readonly operationId: string;
55
63
  readonly summary?: string;
64
+ readonly description?: string;
56
65
  readonly parameters?: readonly OpenApiParameter[];
57
66
  readonly requestBody?: OpenApiSchema;
58
67
  readonly responses: Readonly<Record<string, OpenApiSchema>>;
@@ -148,7 +148,7 @@ export function renderOpenApiHtml(title: string, specPath: string): string {
148
148
  method.textContent = row.method;
149
149
  const summary = document.createElement("span");
150
150
  summary.className = "summary";
151
- summary.textContent = row.operation.operationId || row.operation.summary || "";
151
+ summary.textContent = row.operation.summary || row.operation.operationId || "";
152
152
  line.append(method, summary);
153
153
  node.appendChild(line);
154
154
  const meta = document.createElement("div");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.98.4",
3
+ "version": "0.98.6",
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",