bazis 0.136.0 → 0.138.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.
Files changed (132) hide show
  1. package/README.md +7 -4
  2. package/cli/generateProject.ts +11 -18
  3. package/cli/main.ts +9 -1
  4. package/cli/parseCli.ts +9 -0
  5. package/cli/templates/module.ts +16 -19
  6. package/cli/templates/passport.ts +2 -2
  7. package/cli/upgrade/markColumns.ts +151 -0
  8. package/cli/upgrade/runUpgrade.ts +57 -0
  9. package/core/access/Access.module.ts +1 -1
  10. package/core/access/catalog/moduleFields.ts +41 -4
  11. package/core/access/catalog/permissions.ts +10 -0
  12. package/core/access/check/Access.ts +5 -5
  13. package/core/access/check/can.ts +2 -1
  14. package/core/access/index.ts +11 -1
  15. package/core/access/roles/roles.ts +4 -2
  16. package/core/access/testing/index.ts +26 -4
  17. package/core/agent/ToolRunner.ts +7 -1
  18. package/core/agent/defineTool.ts +109 -0
  19. package/core/agent/index.ts +8 -3
  20. package/core/agent/moduleContributions-v1.ts +3 -10
  21. package/core/agent/session/entities.ts +45 -45
  22. package/core/app/index.ts +1 -1
  23. package/core/app/messages.ts +3 -3
  24. package/core/app/runApp.ts +90 -26
  25. package/core/cache/ICache.ts +1 -1
  26. package/core/cache/README.md +4 -2
  27. package/core/cache/SPEC.md +16 -9
  28. package/core/cache/http/missingCacheModuleCheck.ts +5 -4
  29. package/core/cache/internal/buildCacheProviders.ts +4 -5
  30. package/core/cache/providers/cachedProviders.ts +1 -1
  31. package/core/di/index.ts +0 -4
  32. package/core/di/lowLevel.ts +12 -0
  33. package/core/di/module/index.ts +0 -3
  34. package/core/di/module/shortcuts.ts +31 -86
  35. package/core/generatedRuntime.ts +11 -0
  36. package/core/grpc/Grpc.module.ts +2 -1
  37. package/core/grpc/grpcClients.ts +2 -1
  38. package/core/http/Authorization/metadata.ts +1 -1
  39. package/core/http/Decorators/metadata.ts +3 -0
  40. package/core/http/Decorators/routes.ts +12 -0
  41. package/core/http/Errors/HttpError.ts +16 -2
  42. package/core/http/Http.config.ts +22 -0
  43. package/core/http/HttpContext/HttpContext.ts +1 -1
  44. package/core/http/HttpServer.ts +29 -9
  45. package/core/http/Middleware/errorHandler.ts +16 -1
  46. package/core/http/OpenApi/openApiDocument.ts +44 -5
  47. package/core/http/Results/normalize.ts +38 -1
  48. package/core/http/Results/results.ts +5 -2
  49. package/core/http/Routing/RouterBuilder.ts +27 -2
  50. package/core/http/httpModule.ts +10 -8
  51. package/core/http/index.ts +4 -0
  52. package/core/http/middlewareTokens.ts +12 -0
  53. package/core/http/options.ts +5 -1
  54. package/core/http-client/httpClients.ts +2 -1
  55. package/core/infra/Infra.ts +3 -3
  56. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  57. package/core/infra/connectors/codex/contracts.ts +3 -3
  58. package/core/infra/connectors/llm.ts +2 -1
  59. package/core/infra/connectors/postgres.ts +4 -2
  60. package/core/infra/connectors/redis.ts +2 -1
  61. package/core/infra/index.ts +1 -1
  62. package/core/kernel/KernelBuilder.ts +2 -1
  63. package/core/kernel/config/ConfigRegistry.ts +2 -1
  64. package/core/kernel/config/addConfigOptions.ts +2 -1
  65. package/core/kernel/config/defineConfig.ts +56 -20
  66. package/core/kernel/events/EventBus.ts +1 -0
  67. package/core/kernel/events/eventSubscribers.ts +1 -1
  68. package/core/kernel/events/onEvent.ts +2 -1
  69. package/core/kernel/index.ts +3 -0
  70. package/core/orm/buildOrmProviders.ts +4 -2
  71. package/core/orm/contextEntities.ts +36 -0
  72. package/core/orm/databaseConnector.ts +9 -3
  73. package/core/orm/databaseHttpError.ts +21 -0
  74. package/core/orm/encryptedWith.ts +1 -1
  75. package/core/orm/index.ts +12 -1
  76. package/core/orm/ormModule.ts +19 -8
  77. package/core/orm/ownedStoreContributions.ts +2 -1
  78. package/core/orm/registerRepositories.ts +2 -1
  79. package/core/scripts/di-generate.ts +113 -12
  80. package/core/testing/index.ts +51 -11
  81. package/core/tools/StandardTools.module.ts +1 -1
  82. package/core/tools/contracts/Tools.config.ts +13 -7
  83. package/core/tools/generated/deps.ts +3 -3
  84. package/core/tools/http/HttpRequest.tool.ts +1 -2
  85. package/core/tools/http/publicAddress.ts +2 -2
  86. package/core/tools/index.ts +1 -1
  87. package/core/tools/orm/OrmEntity.references.ts +7 -8
  88. package/core/tools/orm/ormEntityTools.ts +54 -28
  89. package/core/websocket/DESIGN.md +5 -3
  90. package/core/websocket/middleware.ts +1 -1
  91. package/core/websocket/websocketModule.ts +3 -3
  92. package/core/workflow/contracts/Workflow.config.ts +5 -5
  93. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +3 -4
  94. package/core/workflow/definitions_module/code/defineProcess.ts +213 -11
  95. package/core/workflow/definitions_module/code/inspectProcesses.ts +12 -2
  96. package/core/workflow/definitions_module/http/Processes.controller.ts +2 -2
  97. package/core/workflow/definitions_module/internal/PublishedVersions.ts +39 -0
  98. package/core/workflow/definitions_module/model/WfProcess.model.ts +24 -11
  99. package/core/workflow/definitions_module/model/WfVersion.model.ts +7 -1
  100. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +4 -1
  101. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +17 -7
  102. package/core/workflow/generated/deps.ts +2 -1
  103. package/core/workflow/index.ts +23 -1
  104. package/core/workflow/internal/WorkflowLanguages.ts +1 -1
  105. package/core/workflow/internal/http.ts +2 -2
  106. package/core/workflow/runtime_module/background/WorkflowWorker.ts +6 -2
  107. package/core/workflow/runtime_module/http/Instances.controller.ts +1 -1
  108. package/core/workflow/runtime_module/internal/WorkflowJobs.ts +43 -1
  109. package/core/workflow/runtime_module/model/WfHistory.model.ts +13 -6
  110. package/core/workflow/runtime_module/model/WfInstance.model.ts +30 -10
  111. package/core/workflow/runtime_module/model/WfJob.model.ts +22 -10
  112. package/core/workflow/runtime_module/model/WfSubscription.model.ts +8 -3
  113. package/core/workflow/runtime_module/model/WfToken.model.ts +16 -7
  114. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +10 -6
  115. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +5 -2
  116. package/core/workflow/tasks_module/model/WfTask.model.ts +25 -9
  117. package/core/workflow/testing/index.ts +46 -4
  118. package/index.ts +4 -3
  119. package/library/openapi/codegen.ts +2 -2
  120. package/library/orm/DbContext.ts +37 -1
  121. package/library/orm/Metadata/ModelBuilder.ts +23 -7
  122. package/library/orm/Metadata/decorators.ts +23 -10
  123. package/library/orm/Metadata/types.ts +2 -0
  124. package/library/orm/Transactions/TransactionScopeCoordinator.ts +3 -3
  125. package/library/orm/errors.ts +39 -1
  126. package/library/orm/index.ts +1 -0
  127. package/library/terminal/messages.ts +6 -6
  128. package/library/validation/README.md +1 -1
  129. package/library/validation/SPEC.md +1 -1
  130. package/library/workflow/index.ts +1 -0
  131. package/library/workflow/nodeConfigs.ts +130 -0
  132. package/package.json +2 -1
@@ -1,5 +1,6 @@
1
1
  import type { HttpMiddleware } from "../Middleware/types";
2
2
  import { HttpSetupError } from "../Errors/HttpError";
3
+ import type { HttpResult } from "../Results/HttpResult";
3
4
  import { ownActionMeta, ownRoutes } from "./metadata";
4
5
 
5
6
  type MethodDecorator = (value: (...args: never[]) => unknown, context: ClassMethodDecoratorContext) => void;
@@ -27,6 +28,11 @@ export interface RouteOptions {
27
28
  readonly version?: string;
28
29
  /** Route middleware (= `@Middleware` on the method). */
29
30
  readonly middleware?: readonly HttpMiddleware[];
31
+ /**
32
+ * What `null` or `undefined` from the action means, as a result helper:
33
+ * `{ nullAs: NotFound }` answers 404 with the standard body instead of 204.
34
+ */
35
+ readonly nullAs?: () => HttpResult;
30
36
  /** Maximum body size: an integer and a binary unit together, for example `4mb`. */
31
37
  readonly maxBodySize?: `${bigint}${"b" | "kb" | "mb" | "gb"}`;
32
38
  }
@@ -59,6 +65,12 @@ function route(httpMethod: string, templateOrOptions?: string | RouteOptions, ma
59
65
  if (options.middleware !== undefined) {
60
66
  action.middleware.push(...options.middleware);
61
67
  }
68
+ if (options.nullAs !== undefined) {
69
+ if (typeof options.nullAs !== "function") {
70
+ throw new HttpSetupError("nullAs must be a result helper, for example NotFound.");
71
+ }
72
+ action.nullAs = options.nullAs;
73
+ }
62
74
  if (options.maxBodySize !== undefined) {
63
75
  const maxBodyBytes = parseRouteMaxBodySize(options.maxBodySize);
64
76
  if (action.maxBodyBytes !== undefined && action.maxBodyBytes !== maxBodyBytes) {
@@ -1,6 +1,6 @@
1
1
  import { KernelError } from "../../kernel";
2
2
  import type { ModelValidationIssue } from "../Binding/modelValidator";
3
- import { englishHttpMessage, type HttpMessageKey, type LocalizedMessage, type MessageParams } from "./messages";
3
+ import { englishHttpMessage, STATUS_MESSAGE_KEYS, type HttpMessageKey, type LocalizedMessage, type MessageParams } from "./messages";
4
4
 
5
5
  /**
6
6
  * Base class for errors that map directly to an HTTP response.
@@ -14,7 +14,7 @@ export class HttpError extends Error {
14
14
  readonly details?: unknown,
15
15
  /**
16
16
  * Key and parameters of the message: the error handler renders it in the
17
- * application language (`runApp({ language, messages })`); `message` is the
17
+ * application language (`@App({ language, messages })`); `message` is the
18
18
  * English text. Errors created with their own text have no key.
19
19
  */
20
20
  readonly localized?: LocalizedMessage,
@@ -59,6 +59,14 @@ export class NotFoundError extends HttpError {
59
59
  }
60
60
  }
61
61
 
62
+ /** The request conflicts with what is stored: the name is taken, the record changed meanwhile. */
63
+ export class ConflictError extends HttpError {
64
+ constructor(message?: string, details?: unknown) {
65
+ const [text, localized] = textOf(message, "conflict");
66
+ super(409, text, details, localized);
67
+ }
68
+ }
69
+
62
70
  export class MethodNotAllowedError extends HttpError {
63
71
  constructor(readonly allow: readonly string[]) {
64
72
  super(405, englishHttpMessage("methodNotAllowed"), undefined, { key: "methodNotAllowed" });
@@ -116,3 +124,9 @@ export function frameworkBadRequest(key: HttpMessageKey, params?: MessageParams,
116
124
  * KernelError: the start reports it as a configuration error of the code.
117
125
  */
118
126
  export class HttpSetupError extends KernelError {}
127
+
128
+ /** An error of a status with the framework text for it, translated at the response: what an empty result helper stands for. */
129
+ export function httpErrorOfStatus(status: number): HttpError {
130
+ const key = STATUS_MESSAGE_KEYS[status] ?? (status >= 500 ? "serverError" : "clientError");
131
+ return new HttpError(status, englishHttpMessage(key), undefined, { key });
132
+ }
@@ -0,0 +1,22 @@
1
+ import { defineConfig } from "../kernel";
2
+
3
+ /**
4
+ * Where the HTTP server listens. `BAZIS_HTTP__HOST` and `BAZIS_HTTP__PORT` set it
5
+ * from the environment. Outside `production` the server is reachable from this
6
+ * machine only; in `production` it listens on every interface.
7
+ *
8
+ * A `port` or `hostname` written in `@App({ http })` is fixed in code and wins.
9
+ */
10
+ export const httpConfig = defineConfig("http", {
11
+ default: {
12
+ host: "127.0.0.1",
13
+ port: 3000,
14
+ },
15
+ production: {
16
+ host: "0.0.0.0",
17
+ },
18
+ validate: {
19
+ port: (port) => (Number.isInteger(port) && port >= 0 && port <= 65535 ? undefined : "must be a TCP port (0-65535)"),
20
+ host: (host) => (host.trim() === "" ? "must not be empty" : undefined),
21
+ },
22
+ });
@@ -50,7 +50,7 @@ export class HttpContext {
50
50
  /** Validator captured by the owning HTTP server. */
51
51
  readonly modelValidator?: ModelValidator,
52
52
  /**
53
- * Response language of this request: `runApp({ language })`, or the best
53
+ * Response language of this request: `@App({ language })`, or the best
54
54
  * match of `Accept-Language` when several languages are supported.
55
55
  * `undefined` without `language`.
56
56
  */
@@ -19,10 +19,11 @@ import { defaultAuthorizeChecks } from "./Authorization/metadata";
19
19
  import type { Router } from "./Routing/Router";
20
20
  import { parseRequestPath } from "./Routing/template";
21
21
  import { assertVersioningOptions, createVersionReader, type VersionReader } from "./Versioning/VersionReader";
22
- import { HTTP_ERROR_HOOK, ROUTE_MIDDLEWARE_COMPOSER, SERVER_MIDDLEWARE, sortByOrder } from "./middlewareTokens";
22
+ import { HTTP_ERROR_HOOK, HTTP_ERROR_MAPPER, ROUTE_MIDDLEWARE_COMPOSER, SERVER_MIDDLEWARE, sortByOrder } from "./middlewareTokens";
23
23
  import type { HealthEndpointOptions, RouteMiddlewareComposer, HttpModuleOptions, HttpServerOptions } from "./options";
24
24
  import { WEBSOCKET_UPGRADE, type WebSocketUpgrade } from "./WebSocketUpgrade";
25
- import { HttpSetupError } from "./Errors/HttpError";
25
+ import { HttpSetupError, type HttpError } from "./Errors/HttpError";
26
+ import { httpConfig } from "./Http.config";
26
27
  import { clientAddress, trustedProxies, type TrustedProxies } from "./HttpContext/clientAddress";
27
28
  import { EN_HTTP_MESSAGES, formatMessage, languageHeaders, type HttpMessageKey, type MessageParams, type SelectedLanguage } from "./Errors/messages";
28
29
  import { getModelValidator, type ModelValidator } from "./Binding/modelValidator";
@@ -180,6 +181,7 @@ export class HttpServer implements HostedService {
180
181
  ...(this.options.languages ? { languages: this.options.languages } : {}),
181
182
  ...this.options.errorHandler,
182
183
  onUnexpectedError: this.collectErrorHook(),
184
+ map: this.collectErrorMapper(),
183
185
  }),
184
186
  );
185
187
  this.shortCircuitChain = [...serverChain];
@@ -281,14 +283,14 @@ export class HttpServer implements HostedService {
281
283
  }
282
284
 
283
285
  private async startBunServer(create: (listen: ListenOptions) => ReturnType<typeof Bun.serve>): Promise<ReturnType<typeof Bun.serve>> {
284
- const attempts = this.options.port === 0 ? 10 : 1;
286
+ const listen = this.listenOptions();
287
+ const attempts = listen.port === 0 ? 10 : 1;
285
288
  let lastError: unknown;
286
289
  for (let attempt = 0; attempt < attempts; attempt += 1) {
287
- const listen = await this.resolveListenOptions();
288
290
  try {
289
291
  return create(listen);
290
292
  } catch (error) {
291
- if (this.options.port !== 0 || !isAddressInUse(error)) {
293
+ if (listen.port !== 0 || !isAddressInUse(error)) {
292
294
  throw error;
293
295
  }
294
296
  lastError = error;
@@ -297,12 +299,14 @@ export class HttpServer implements HostedService {
297
299
  throw lastError;
298
300
  }
299
301
 
300
- private async resolveListenOptions(): Promise<ListenOptions> {
301
- const hostname = this.options.hostname ?? (this.options.port === 0 ? "127.0.0.1" : undefined);
302
+ /** The address: what the options fix in code, otherwise the `http` settings. */
303
+ private listenOptions(): ListenOptions {
304
+ const settings = this.resolver.tryResolve(httpConfig.token);
302
305
  // Let the operating system reserve an ephemeral port atomically. Picking a
303
306
  // random port in user space creates a check/use race between concurrent
304
307
  // application or test processes and can still exhaust every retry.
305
- const port = this.options.port ?? 3000;
308
+ const port = this.options.port ?? settings?.get("port") ?? 3000;
309
+ const hostname = this.options.hostname ?? (this.options.port === 0 ? "127.0.0.1" : settings?.get("host"));
306
310
  return {
307
311
  port,
308
312
  ...(hostname !== undefined ? { hostname } : {}),
@@ -332,6 +336,22 @@ export class HttpServer implements HostedService {
332
336
  };
333
337
  }
334
338
 
339
+ /** Explicit `options.errorHandler.map` first, then DI {@link HTTP_ERROR_MAPPER} mappers; the first answer wins. */
340
+ private collectErrorMapper(): ((error: unknown) => HttpError | undefined) | undefined {
341
+ const explicit = this.options.errorHandler?.map;
342
+ const mappers = [...(explicit === undefined ? [] : [explicit]), ...this.resolver.resolveAll(HTTP_ERROR_MAPPER)];
343
+ if (mappers.length === 0) {
344
+ return undefined;
345
+ }
346
+ return (error) => {
347
+ for (const mapper of mappers) {
348
+ const mapped = mapper(error);
349
+ if (mapped !== undefined) return mapped;
350
+ }
351
+ return undefined;
352
+ };
353
+ }
354
+
335
355
  /** Explicit `options.routeMiddlewareComposer` (order 0) merged with DI {@link ROUTE_MIDDLEWARE_COMPOSER}. */
336
356
  private collectRouteComposer(): RouteMiddlewareComposer | undefined {
337
357
  const explicit = this.options.routeMiddlewareComposer
@@ -400,7 +420,7 @@ export class HttpServer implements HostedService {
400
420
  return request.method === "HEAD" ? stripBody(response) : response;
401
421
  }
402
422
 
403
- /** The response language of a request; `undefined` without `runApp({ language })`. */
423
+ /** The response language of a request; `undefined` without `@App({ language })`. */
404
424
  private selected(request: Request | undefined): SelectedLanguage | undefined {
405
425
  try {
406
426
  return this.options.languages?.select(request?.headers.get("accept-language"));
@@ -28,6 +28,12 @@ export interface ErrorHandlerOptions {
28
28
  * mistake and is not logged or sent to `onUnexpectedError`.
29
29
  */
30
30
  status?: (error: unknown) => number | undefined;
31
+ /**
32
+ * Turns a non-{@link HttpError} failure into an HttpError, with its own text
33
+ * and details; `HTTP_ERROR_MAPPER` registrations are merged in. Asked before
34
+ * {@link status}. A mapped 5xx is still logged as unexpected.
35
+ */
36
+ map?: (error: unknown) => HttpError | undefined;
31
37
  /**
32
38
  * Logger for unexpected errors when {@link logError} is absent: one `error`
33
39
  * line with the method, path, request id and the redacted error. The server
@@ -114,7 +120,16 @@ export function errorHandler(options: ErrorHandlerOptions = {}): HttpMiddleware
114
120
  return async (ctx, next) => {
115
121
  try {
116
122
  await next();
117
- } catch (error) {
123
+ } catch (thrown) {
124
+ let error = thrown;
125
+ if (!(thrown instanceof HttpError) && options.map !== undefined) {
126
+ let mapped: HttpError | undefined;
127
+ try { mapped = options.map(thrown); } catch { mapped = undefined; }
128
+ if (mapped instanceof HttpError) {
129
+ if (mapped.status >= 500) safelyReportUnexpected(options, log, ctx, thrown);
130
+ error = mapped;
131
+ }
132
+ }
118
133
  const selected = options.languages?.select(ctx.header("accept-language"));
119
134
  const text = textRenderer(selected?.localize);
120
135
  const language = languageHeaders(options.languages, selected);
@@ -23,7 +23,7 @@ export interface HttpOpenApiDocumentBuildInput {
23
23
  readonly versioning?: ApiVersioningOptions;
24
24
  readonly title: string;
25
25
  readonly version: string;
26
- /** Default checks of `runApp({ http: { authorize } })`: such routes are secured too. */
26
+ /** Default checks of `@App({ http: { authorize } })`: such routes are secured too. */
27
27
  readonly authorize?: readonly AuthorizeCheck[];
28
28
  }
29
29
 
@@ -87,7 +87,42 @@ function collectOpenApiOperations(
87
87
  }
88
88
  }
89
89
  }
90
- return operations;
90
+ return withCreatedLocation(operations);
91
+ }
92
+
93
+ /**
94
+ * Documents the automatic `Location` of a 201: the server sets it when the
95
+ * body has `id` and a GET route answers `<path>/<id>`.
96
+ */
97
+ function withCreatedLocation(operations: OpenApiCatalogOperation[]): OpenApiCatalogOperation[] {
98
+ const itemPaths = new Set<string>();
99
+ for (const operation of operations) {
100
+ const parent = /^(.*)\/\{[^{}/]+\}$/u.exec(operation.path)?.[1];
101
+ if (operation.httpMethod === "GET" && parent !== undefined) {
102
+ itemPaths.add(parent);
103
+ }
104
+ }
105
+ return operations.map((operation) => {
106
+ const created = operation.responses["201"];
107
+ if (!created || !itemPaths.has(operation.path.replace(/\/+$/u, ""))) {
108
+ return operation;
109
+ }
110
+ return {
111
+ ...operation,
112
+ responses: {
113
+ ...operation.responses,
114
+ "201": {
115
+ ...created,
116
+ headers: {
117
+ Location: {
118
+ description: "Address of the created item, when the response has `id`.",
119
+ schema: { type: "string" },
120
+ },
121
+ },
122
+ },
123
+ },
124
+ };
125
+ });
91
126
  }
92
127
 
93
128
  interface CreateOperationInput {
@@ -140,7 +175,7 @@ function createOperation(input: CreateOperationInput): OpenApiCatalogOperation {
140
175
  },
141
176
  }
142
177
  : { description: "Success" },
143
- ...errorResponses(errorStatuses(input.bindings, authorized, generatedOperation?.errors)),
178
+ ...errorResponses(errorStatuses(input.bindings, authorized, generatedOperation?.errors, input.action)),
144
179
  },
145
180
  authorized,
146
181
  ...(version !== undefined ? { versions: [version] } : {}),
@@ -380,15 +415,19 @@ function successStatus(method: string, action: ActionMeta, generated: number | u
380
415
  /**
381
416
  * Errors the operation can answer with: 400 when the request carries values
382
417
  * that are converted or validated (body, list query, query and header values
383
- * and models, typed route parameters), 401/403 under @Authorize, and the
384
- * errors the method body returns or throws itself.
418
+ * and models, typed route parameters), 401/403 under @Authorize, the status of
419
+ * `nullAs`, and the errors the method body returns or throws itself.
385
420
  */
386
421
  function errorStatuses(
387
422
  bindings: readonly ParameterBinding[],
388
423
  authorized: boolean,
389
424
  generated: readonly number[] | undefined,
425
+ action: ActionMeta,
390
426
  ): number[] {
391
427
  const statuses = new Set(generated ?? []);
428
+ // `nullAs: NotFound` is an answer of the route the method body does not show.
429
+ const instead = action.nullAs?.().status;
430
+ if (instead !== undefined && instead >= 400) statuses.add(instead);
392
431
  const validated = bindings.some((binding) =>
393
432
  binding.source === "body" || binding.source === "list" || binding.source === "query" || binding.source === "header"
394
433
  || binding.source === "queryModel" || binding.source === "headerModel"
@@ -1,4 +1,5 @@
1
1
  import type { HttpContext } from "../HttpContext/HttpContext";
2
+ import { httpErrorOfStatus } from "../Errors/HttpError";
2
3
  import { HttpResult } from "./HttpResult";
3
4
 
4
5
  /** Per-action response defaults coming from decorators. */
@@ -7,6 +8,26 @@ export interface ResponseDefaults {
7
8
  httpCode?: number;
8
9
  /** `@Produces(...)` value. */
9
10
  produces?: string;
11
+ /** `nullAs` of the route: the result that stands for `null` and `undefined`. */
12
+ nullAs?: () => HttpResult;
13
+ /**
14
+ * Address of a created item for an automatic `Location` header: given the
15
+ * `id` of a 201 body, returns the address or `undefined` when the
16
+ * application has no GET route for it.
17
+ */
18
+ locate?: (ctx: HttpContext, id: string | number) => string | undefined;
19
+ }
20
+
21
+ /** `id` of a 201 body that can name the created item. */
22
+ function createdItemId(body: unknown): string | number | undefined {
23
+ if (body === null || typeof body !== "object" || Array.isArray(body) || isBinaryBody(body)) {
24
+ return undefined;
25
+ }
26
+ const id = (body as { id?: unknown }).id;
27
+ if (typeof id === "number" && Number.isFinite(id)) {
28
+ return id;
29
+ }
30
+ return typeof id === "string" && id !== "" ? id : undefined;
10
31
  }
11
32
 
12
33
  type ResponseBody = ConstructorParameters<typeof Response>[0];
@@ -26,15 +47,24 @@ function isBinaryBody(body: unknown): body is Blob | Uint8Array | ArrayBuffer |
26
47
  * - `string` — text/plain (unless `@Produces` says otherwise);
27
48
  * - `Blob`/bytes/stream — binary passthrough;
28
49
  * - any other object/array/number/boolean — JSON;
29
- * - `undefined`/`null` — empty body, 204 (or `@HttpCode`).
50
+ * - `undefined`/`null` — empty body, 204 (or `@HttpCode`), unless the route says `nullAs`.
30
51
  *
31
52
  * Status priority: result helper > `@HttpCode` > `ctx.res.status(...)` > default.
32
53
  * Headers from `ctx.res.header(...)` are merged into every non-raw response.
54
+ *
55
+ * A 201 without `Location` whose body is an object with `id` gets
56
+ * `Location: <request path>/<id>` when a GET route answers that address.
33
57
  */
34
58
  export function normalizeResult(result: unknown, ctx: HttpContext, defaults: ResponseDefaults): Response {
35
59
  if (result instanceof Response) {
36
60
  return result;
37
61
  }
62
+ if ((result === null || result === undefined) && defaults.nullAs !== undefined) {
63
+ const instead = defaults.nullAs();
64
+ // An error goes through the error handler: the standard body, in the language of the request.
65
+ if (instead.status >= 400) throw httpErrorOfStatus(instead.status);
66
+ result = instead;
67
+ }
38
68
 
39
69
  const builderStatus = ctx.res.statusOverride;
40
70
  const builderHeaders = ctx.res.headers;
@@ -63,6 +93,13 @@ export function normalizeResult(result: unknown, ctx: HttpContext, defaults: Res
63
93
  for (const [name, value] of builderHeaders) {
64
94
  headers.append(name, value);
65
95
  }
96
+ if (status === 201 && defaults.locate && !headers.has("location")) {
97
+ const id = createdItemId(body);
98
+ const location = id === undefined ? undefined : defaults.locate(ctx, id);
99
+ if (location !== undefined) {
100
+ headers.set("location", location);
101
+ }
102
+ }
66
103
 
67
104
  let payload: ResponseBody = null;
68
105
  if (body === undefined || body === null) {
@@ -5,8 +5,11 @@ export function Ok(body?: unknown): HttpResult {
5
5
  return new HttpResult(200, body);
6
6
  }
7
7
 
8
- /** 201 Created with optional Location header. */
9
- export function Created(location?: string, body?: unknown): HttpResult {
8
+ /**
9
+ * 201 Created. Without `location` the server derives it: a body with `id`
10
+ * gets `Location: <request path>/<id>` when a GET route answers that address.
11
+ */
12
+ export function Created(body?: unknown, location?: string): HttpResult {
10
13
  return new HttpResult(201, body, location ? [["location", location]] : undefined);
11
14
  }
12
15
 
@@ -23,7 +23,7 @@ export interface RegisteredRoute {
23
23
  readonly anonymous: boolean;
24
24
  }
25
25
  import { Router, type RouteAction } from "./Router";
26
- import { joinPaths, parseTemplate } from "./template";
26
+ import { joinPaths, parseRequestPath, parseTemplate } from "./template";
27
27
 
28
28
  type ControllerInstance = Record<string | symbol, (...args: unknown[]) => unknown>;
29
29
 
@@ -43,6 +43,8 @@ export class RouterBuilder {
43
43
  private readonly authorizeComposer: RouteMiddlewareComposer;
44
44
  /** Every route of the last {@link build}, in registration order. */
45
45
  readonly routes: RegisteredRoute[] = [];
46
+ /** Router of the last {@link build}: automatic `Location` looks up GET routes in it. */
47
+ private router?: Router;
46
48
 
47
49
  constructor(
48
50
  /** Middleware prepended to every route: [logger?, cors?, errorHandler, ...global]. */
@@ -58,6 +60,7 @@ export class RouterBuilder {
58
60
 
59
61
  build(controllers: readonly Class<object>[]): Router {
60
62
  const router = new Router();
63
+ this.router = router;
61
64
  for (const controllerClass of controllers) {
62
65
  const meta = controllerMetaOf(controllerClass);
63
66
  if (!meta?.isController) {
@@ -122,6 +125,23 @@ export class RouterBuilder {
122
125
  }
123
126
  }
124
127
 
128
+ /**
129
+ * Address of an item created by a request: the request path plus `/<id>`,
130
+ * but only when a GET route of this application answers it.
131
+ */
132
+ private locateCreated(ctx: HttpContext, id: string | number): string | undefined {
133
+ const segments = parseRequestPath(ctx.url.pathname);
134
+ if (!segments || !this.router) {
135
+ return undefined;
136
+ }
137
+ const value = String(id);
138
+ const match = this.router.match("GET", [...segments, value], ctx.apiVersion);
139
+ if (match.kind !== "matched") {
140
+ return undefined;
141
+ }
142
+ return `${ctx.url.pathname.replace(/\/+$/u, "")}/${encodeURIComponent(value)}`;
143
+ }
144
+
125
145
  /**
126
146
  * Precompiles binding + ActionFilter.before as a boundary middleware and
127
147
  * controller execution + ActionFilter.after as the terminal. This lets
@@ -138,7 +158,12 @@ export class RouterBuilder {
138
158
  const catches = meta.catches;
139
159
  const bindings = resolveGeneratedBindings(controllerClass, methodName);
140
160
  assertBindingsFresh(controllerClass, methodName, bindings);
141
- const responseDefaults = { httpCode: action.httpCode, produces: action.produces };
161
+ const responseDefaults = {
162
+ httpCode: action.httpCode,
163
+ nullAs: action.nullAs,
164
+ produces: action.produces,
165
+ locate: (ctx: HttpContext, id: string | number) => this.locateCreated(ctx, id),
166
+ };
142
167
  const bindingDefaults = { consumes: action.consumes };
143
168
  const token = controllerClass as unknown as Token<object>;
144
169
  const boundArguments = new WeakMap<HttpContext, readonly unknown[]>();
@@ -1,24 +1,25 @@
1
- import { DI, HOSTED_SERVICE, Module, SERVICE_PROVIDER, collectModuleControllers, type BazisModule, type ProviderDefinition } from "../di";
1
+ import { HOSTED_SERVICE, Module, SERVICE_PROVIDER, collectModuleControllers, type BazisModule, type ProviderDefinition } from "../di";
2
+ import { DI } from "../di/lowLevel";
2
3
  import { HttpServer } from "./HttpServer";
4
+ import { httpConfig } from "./Http.config";
3
5
  import type { HttpModuleOptions, HttpServerOptions } from "./options";
4
6
 
5
7
  /**
6
8
  * Builds the HTTP module: discovers the `controllers` of the modules in
7
- * `imports` and registers the HttpServer hosted service.
9
+ * `imports` and registers the HttpServer hosted service. The address comes from
10
+ * {@link httpConfig} unless the options fix it.
8
11
  *
9
12
  * ```ts
10
13
  * @Module({
11
14
  * controllers: [UsersController],
12
- * providers: [scoped(IUserStore, UserService, [repositoryFor(User)] as const)],
15
+ * providers: [scoped(IUserStore, UserService)],
13
16
  * })
14
17
  * export class UsersModule {}
15
18
  *
16
- * export const AppModule = createApp({
17
- * imports: [UsersModule],
18
- * http: { port: 3000, prefix: "api" },
19
- * }).module;
19
+ * @App({ modules: [UsersModule], http: { prefix: "api" } })
20
+ * export class Application {}
20
21
  *
21
- * await Bazis.run(AppModule);
22
+ * await runApp(Application);
22
23
  * ```
23
24
  */
24
25
  export function httpModule(options: HttpModuleOptions): BazisModule {
@@ -37,6 +38,7 @@ export function httpModule(options: HttpModuleOptions): BazisModule {
37
38
 
38
39
  @Module({
39
40
  imports: options.imports,
41
+ config: httpConfig,
40
42
  providers,
41
43
  })
42
44
  class HttpModule {}
@@ -6,6 +6,7 @@
6
6
  * dependencies, compatible with `bun build --compile`.
7
7
  */
8
8
  export { httpModule } from "./httpModule";
9
+ export { httpConfig } from "./Http.config";
9
10
  export { HttpServer } from "./HttpServer";
10
11
  export { WEBSOCKET_UPGRADE, type WebSocketUpgrade } from "./WebSocketUpgrade";
11
12
  export type {
@@ -18,10 +19,12 @@ export type { OpenApiDocsOptions } from "../../library/openapi";
18
19
  export { composeRouteMiddlewareComposers } from "./composeRouteMiddlewareComposers";
19
20
  export {
20
21
  HTTP_ERROR_HOOK,
22
+ HTTP_ERROR_MAPPER,
21
23
  ROUTE_MIDDLEWARE_COMPOSER,
22
24
  SERVER_MIDDLEWARE,
23
25
  sortByOrder,
24
26
  type HttpErrorHook,
27
+ type HttpErrorMapper,
25
28
  type RouteMiddlewareComposerRegistration,
26
29
  type ServerMiddlewareRegistration,
27
30
  } from "./middlewareTokens";
@@ -131,6 +134,7 @@ export type { ActionFilterHooks, HttpMiddleware } from "./Middleware/types";
131
134
  // Errors
132
135
  export {
133
136
  BadRequestError,
137
+ ConflictError,
134
138
  ForbiddenError,
135
139
  HttpError,
136
140
  HttpSetupError,
@@ -1,4 +1,5 @@
1
1
  import { createToken } from "../di";
2
+ import type { HttpError } from "./Errors/HttpError";
2
3
  import type { HttpContext } from "./HttpContext/HttpContext";
3
4
  import type { HttpMiddleware } from "./Middleware/types";
4
5
  import type { RouteMiddlewareComposer } from "./options";
@@ -39,6 +40,17 @@ export type HttpErrorHook = (ctx: HttpContext, error: unknown) => void;
39
40
  /** Enumerable token: register any number of unexpected-error hooks. */
40
41
  export const HTTP_ERROR_HOOK = createToken<HttpErrorHook>("BazisHttpErrorHook");
41
42
 
43
+ /**
44
+ * Turns a failure that is not an {@link HttpError} into one: a module that owns
45
+ * a kind of errors says how they answer. `undefined` leaves the error to the
46
+ * next mapper and then to 500. The ORM registers one for database errors.
47
+ * Collected via `resolveAll`.
48
+ */
49
+ export type HttpErrorMapper = (error: unknown) => HttpError | undefined;
50
+
51
+ /** Enumerable token: register any number of error mappers. */
52
+ export const HTTP_ERROR_MAPPER = createToken<HttpErrorMapper>("BazisHttpErrorMapper");
53
+
42
54
  /** Stable order by `order ?? 0` (registration order preserved within a tier). */
43
55
  export function sortByOrder<T extends { readonly order?: number }>(items: readonly T[]): T[] {
44
56
  return [...items]
@@ -72,8 +72,12 @@ export interface HttpModuleOptions {
72
72
  * either listed here or exported by global modules.
73
73
  */
74
74
  readonly imports?: readonly import("../di").BazisModuleRef[];
75
- /** Listen port (default 3000; 0 — random free port). */
75
+ /**
76
+ * Fixes the listen port in code (0 — a random free port). Leave it out: the
77
+ * port then comes from the `http.port` setting, 3000 unless `BAZIS_HTTP__PORT` says otherwise.
78
+ */
76
79
  readonly port?: number;
80
+ /** Fixes the listen address in code. Leave it out: it comes from the `http.host` setting (`BAZIS_HTTP__HOST`). */
77
81
  readonly hostname?: string;
78
82
  /** Global route prefix, e.g. "api". */
79
83
  readonly prefix?: string;
@@ -1,4 +1,5 @@
1
- import { DI, registerModuleMetadataExpander, type BazisModuleMetadata, type BazisModuleRef, type ServiceResolver } from "../di";
1
+ import { registerModuleMetadataExpander, type BazisModuleMetadata, type BazisModuleRef, type ServiceResolver } from "../di";
2
+ import { DI } from "../di/lowLevel";
2
3
  import { getOutboundCorrelationHeaders, type ConfigDefinition } from "../kernel";
3
4
  import { HttpClientBase, HttpClientError, type RequestConfig } from "../../library/http-client";
4
5
 
@@ -1,5 +1,4 @@
1
1
  import {
2
- DI,
3
2
  HOSTED_SERVICE,
4
3
  Module,
5
4
  createToken,
@@ -12,6 +11,7 @@ import {
12
11
  type BazisModuleRef,
13
12
  type ProviderDefinition,
14
13
  } from "../di";
14
+ import { DI } from "../di/lowLevel";
15
15
  import { ConfigRegistry, HEALTH_CHECK, StartupReport } from "../kernel";
16
16
  import { InfraError, type InfraConnector } from "./InfraConnector";
17
17
  import { InfraLifecycle } from "./InfraLifecycle";
@@ -136,7 +136,7 @@ function buildInfraDefinitions(manifest: InfraManifest): {
136
136
 
137
137
  /**
138
138
  * Dynamic infrastructure module (a plain metadata object): the same as
139
- * `@Infra`, but without a class. Handy for `imports` or `runApp({ infra })`.
139
+ * `@Infra`, but without a class. Handy for `imports` or `@App({ infra })`.
140
140
  */
141
141
  export function infraModule(manifest: InfraManifest): BazisModuleRef {
142
142
  try {
@@ -153,7 +153,7 @@ export function infraModule(manifest: InfraManifest): BazisModuleRef {
153
153
  /**
154
154
  * Infrastructure decorator. Puts the metadata of a regular **global module**
155
155
  * on the class, so the class becomes a valid `BazisModuleRef`: it can be passed
156
- * to `runApp({ infra: AppInfra })` or to `imports`.
156
+ * to `@App({ infra: AppInfra })` or to `imports`.
157
157
  *
158
158
  * The class stays pure metadata (like `@Module`) and is **never instantiated**:
159
159
  * only the clients it lists live and hold state.
@@ -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.136.0" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.138.0" }, 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");
@@ -52,7 +52,7 @@ const EN_CODEX_MESSAGES: Readonly<Record<CodexErrorCode, string>> = Object.freez
52
52
  LOGIN_EXPIRED: "The sign-in window expired. Start signing in again.",
53
53
  });
54
54
 
55
- /** Russian texts for every code; `runApp({ language: "ru" })` turns them on. */
55
+ /** Russian texts for every code; `@App({ language: "ru" })` turns them on. */
56
56
  export const RU_CODEX_MESSAGES: Readonly<Record<CodexErrorCode, string>> = Object.freeze({
57
57
  NOT_CONFIGURED: "Подключение ChatGPT не настроено на сервере.",
58
58
  UNAVAILABLE: "Codex App Server недоступен. Проверьте его установку и настройки.",
@@ -73,7 +73,7 @@ let own: Partial<Record<CodexErrorCode, string>> = {};
73
73
  let messages: Readonly<Record<CodexErrorCode, string>> = EN_CODEX_MESSAGES;
74
74
 
75
75
  /**
76
- * @internal `runApp({ language })` sets the language of the texts. It is
76
+ * @internal `@App({ language })` sets the language of the texts. It is
77
77
  * process-wide, like the texts themselves: the last started application wins.
78
78
  */
79
79
  export function useCodexLanguage(name: keyof typeof CODEX_LANGUAGES): void {
@@ -81,7 +81,7 @@ export function useCodexLanguage(name: keyof typeof CODEX_LANGUAGES): void {
81
81
  messages = Object.freeze({ ...language, ...own });
82
82
  }
83
83
 
84
- /** `message` is user-facing text for `code`, in the language of `runApp({ language })`. */
84
+ /** `message` is user-facing text for `code`, in the language of `@App({ language })`. */
85
85
  export class CodexError extends Error {
86
86
  constructor(readonly code: CodexErrorCode) { super(messages[code]); this.name = "CodexError"; }
87
87
 
@@ -1,6 +1,7 @@
1
1
  import type { AppConfig, ConfigRegistry, Secret } from "../../kernel";
2
2
  import { AGENT_MODEL_PROVIDER, AGENT_RUNTIME_OPTIONS, AgentRuntime, type AgentModelProvider } from "../../agent";
3
- import { DI, DiError, HOSTED_SERVICE, SERVICE_PROVIDER, type ProviderDefinition } from "../../di";
3
+ import { DiError, HOSTED_SERVICE, SERVICE_PROVIDER, type ProviderDefinition } from "../../di";
4
+ import { DI } from "../../di/lowLevel";
4
5
  import { LOGGER } from "../../kernel/logging/Logger";
5
6
  import { reader, requireValue, settingNames } from "../connectorConfig";
6
7
  import { InfraError, type InfraConnector } from "../InfraConnector";