bazis 0.100.5 → 0.100.7

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
@@ -173,20 +173,25 @@ Import from the subpaths listed in `exports` of `package.json`:
173
173
 
174
174
  ## Language of built-in texts
175
175
 
176
- Built-in texts are English. Russian sets ship with the package; apply them
177
- once at startup:
176
+ Built-in texts are English. Russian sets ship with the package:
178
177
 
179
178
  ```ts
180
- import { MessageRegistry, RU_VALIDATION_MESSAGES } from "bazis/library/validation";
181
179
  import { CodexError, RU_CODEX_MESSAGES } from "bazis/core/infra";
182
180
  import { RU_UI_LABELS } from "bazis/core/app";
183
181
 
184
- MessageRegistry.setDefaults(RU_VALIDATION_MESSAGES); // validation errors
185
- CodexError.useMessages(RU_CODEX_MESSAGES); // Codex connector errors
186
- await runApp(AppModule, { ui: { labels: RU_UI_LABELS, surfaces: [/* ... */] } }); // generated UI texts
182
+ CodexError.useMessages(RU_CODEX_MESSAGES); // Codex connector errors
183
+ await runApp(AppModule, {
184
+ language: "ru", // response texts: validation, 404/405, 401/403, binding errors
185
+ messages: { notFound: "Нет такой страницы" }, // optional: own texts for single keys
186
+ ui: { labels: RU_UI_LABELS, surfaces: [/* ... */] }, // generated UI texts
187
+ });
187
188
  ```
188
189
 
189
- Either call also accepts your own texts for some codes.
190
+ `language` is per application: it is applied when the response is written, so
191
+ two apps in one process keep their own languages. Texts an application passes
192
+ to an error itself (`new NotFoundError("…")`) are never changed. Outside an
193
+ application, `MessageRegistry.setDefaults(RU_VALIDATION_MESSAGES)` switches the
194
+ standalone validation library.
190
195
 
191
196
  ## Status
192
197
 
package/core/app/index.ts CHANGED
@@ -2,4 +2,5 @@
2
2
  // the validation library (it is the composition root, so it may use everything), hiding
3
3
  // infrastructure wiring from application code.
4
4
  export { runApp, type RunAppOptions } from "./runApp";
5
+ export type { AppLanguage, AppMessages } from "./messages";
5
6
  export * from "./ui";
@@ -0,0 +1,44 @@
1
+ import { EN_HTTP_MESSAGES, RU_HTTP_MESSAGES, formatMessage, type HttpMessageKey, type Localize } from "../http/Errors/messages";
2
+ import { KernelError } from "../kernel";
3
+ import { EN_VALIDATION_MESSAGES, MessageRegistry, RU_VALIDATION_MESSAGES, type ValidationCode } from "../../library/validation";
4
+
5
+ /** Language of the texts the framework writes into responses. */
6
+ export type AppLanguage = "en" | "ru";
7
+
8
+ /**
9
+ * Own texts for framework messages, by key: HTTP texts (`notFound`,
10
+ * `missingParameter`, …), validation codes (`required`, `minLength`, …),
11
+ * `validationFailed`, `unknownField` and type names (`typeName.number`).
12
+ * Placeholders: `{property}`, `{min}`, `{name}`, … as in the built-in texts.
13
+ */
14
+ export type AppMessages = Partial<Record<HttpMessageKey | ValidationCode | "validationFailed" | "unknownField" | `typeName.${string}`, string>>;
15
+
16
+ const CATALOGS: Readonly<Record<AppLanguage, Readonly<Record<string, string>>>> = {
17
+ en: { ...EN_HTTP_MESSAGES, ...EN_VALIDATION_MESSAGES },
18
+ ru: { ...RU_HTTP_MESSAGES, ...RU_VALIDATION_MESSAGES },
19
+ };
20
+
21
+ /**
22
+ * The text of a framework message in the application language. Priority:
23
+ * `messages` > `Validator.setDefaultMessages` > the language catalog >
24
+ * English. Rendered per server at the response, so two apps in one process
25
+ * keep their own languages.
26
+ */
27
+ export function createLocalize(language: AppLanguage = "en", messages: AppMessages = {}): Localize {
28
+ if (!Object.prototype.hasOwnProperty.call(CATALOGS, language)) {
29
+ throw new KernelError(`runApp: language "${String(language)}" is not supported. Use one of: ${Object.keys(CATALOGS).join(", ")}.`);
30
+ }
31
+ const catalog = CATALOGS[language];
32
+ const own = messages as Readonly<Record<string, string | undefined>>;
33
+ const template = (key: string): string | undefined => {
34
+ if (Object.prototype.hasOwnProperty.call(own, key) && typeof own[key] === "string") return own[key];
35
+ const override = MessageRegistry.override(key);
36
+ if (override !== undefined) return override;
37
+ if (Object.prototype.hasOwnProperty.call(catalog, key)) return catalog[key];
38
+ return Object.prototype.hasOwnProperty.call(CATALOGS.en, key) ? CATALOGS.en[key] : undefined;
39
+ };
40
+ return (key, params) => {
41
+ const text = template(key);
42
+ return text === undefined ? undefined : formatMessage(text, params, template);
43
+ };
44
+ }
@@ -20,6 +20,7 @@ import {
20
20
  type UiProfileV1,
21
21
  } from "../../library/ui";
22
22
  import { modelValidatorAdapter } from "../../library/validation";
23
+ import { createLocalize, type AppLanguage, type AppMessages } from "./messages";
23
24
  import {
24
25
  type RunAppUiOptions,
25
26
  type RunAppUiSurfaceOptions,
@@ -54,6 +55,22 @@ export interface RunAppOptions {
54
55
  * default. Pass your own to replace it.
55
56
  */
56
57
  readonly validator?: ModelValidator;
58
+ /**
59
+ * Language of the texts the framework writes into responses: validation
60
+ * messages, `Not Found`, `Unauthorized`, a missing parameter, malformed
61
+ * JSON, … Default `"en"`. Texts the application passes to an error itself
62
+ * (`new NotFoundError("Книга не найдена")`) are never changed.
63
+ *
64
+ * ```ts
65
+ * await runApp(AppModule, { language: "ru", http: { port: 3000 } });
66
+ * ```
67
+ */
68
+ readonly language?: AppLanguage;
69
+ /**
70
+ * Own texts for single framework messages, on top of `language`:
71
+ * `{ notFound: "Нет такой страницы", required: "Заполните {property}" }`.
72
+ */
73
+ readonly messages?: AppMessages;
57
74
  /** Fine-tuning of the kernel builder (logger, config sources, ...). */
58
75
  readonly configure?: (builder: KernelBuilder) => void;
59
76
  /**
@@ -271,6 +288,8 @@ export async function runApp(root: BazisModuleRef, options: RunAppOptions = {}):
271
288
  * options ask for. Shared with `startTestApp`, so tests run the same graph.
272
289
  */
273
290
  export function composeApp(root: BazisModuleRef, options: RunAppOptions = {}): BazisModuleRef {
291
+ // Checked even without HTTP: an unknown language is a startup error.
292
+ const localize = createLocalize(options.language, options.messages);
274
293
  // Infrastructure and cache are global modules; add them to the graph next to the feature root.
275
294
  const globals: BazisModuleRef[] = [];
276
295
  if (options.infra !== undefined) {
@@ -308,6 +327,7 @@ export function composeApp(root: BazisModuleRef, options: RunAppOptions = {}): B
308
327
  options.http,
309
328
  );
310
329
  const httpRoot = httpModule({
330
+ localize,
311
331
  ...options.http,
312
332
  validator,
313
333
  imports: [httpAppRoot],
@@ -1,5 +1,5 @@
1
1
  import { ListQueryError, parseListQuery } from "../../../library/jsonapi";
2
- import { BadRequestError, UnsupportedMediaTypeError } from "../Errors/HttpError";
2
+ import { UnsupportedMediaTypeError, frameworkBadRequest } from "../Errors/HttpError";
3
3
  import type { HttpContext } from "../HttpContext/HttpContext";
4
4
  import type { ParameterBinding } from "./bindings";
5
5
  import { convertOr400 } from "./convert";
@@ -33,7 +33,7 @@ function bindValue(binding: ParameterBinding, raw: string | undefined, kind: str
33
33
  if (binding.optional) {
34
34
  return undefined;
35
35
  }
36
- throw new BadRequestError(`Missing required ${kind} parameter "${binding.name}"`);
36
+ throw frameworkBadRequest("missingParameter", { kind, name: binding.name });
37
37
  }
38
38
  return binding.type && binding.type !== "string" ? convertOr400(raw, binding.type, binding.name ?? "?") : raw;
39
39
  }
@@ -114,7 +114,7 @@ export async function bindArguments(
114
114
  parsed = parseListQuery(ctx.url.searchParams, binding.listOptions);
115
115
  } catch (error) {
116
116
  if (error instanceof ListQueryError) {
117
- throw new BadRequestError("Invalid list query", error.problems);
117
+ throw frameworkBadRequest("invalidListQuery", undefined, error.problems);
118
118
  }
119
119
  throw error;
120
120
  }
@@ -1,4 +1,4 @@
1
- import { BadRequestError } from "../Errors/HttpError";
1
+ import { frameworkBadRequest } from "../Errors/HttpError";
2
2
 
3
3
  /** Primitive conversion targets for query/header/route values. */
4
4
  export type ValueType = "string" | "int" | "number" | "bool";
@@ -42,7 +42,7 @@ export function tryConvert(raw: string, type: ValueType): string | number | bool
42
42
  export function convertOr400(raw: string, type: ValueType, parameterName: string): string | number | boolean {
43
43
  const value = tryConvert(raw, type);
44
44
  if (value === INVALID) {
45
- throw new BadRequestError(`Parameter "${parameterName}" must be of type ${type}, got: "${raw}"`);
45
+ throw frameworkBadRequest("invalidParameter", { name: parameterName, type, value: raw });
46
46
  }
47
47
  return value;
48
48
  }
@@ -1,5 +1,5 @@
1
1
  import type { Class } from "../../di";
2
- import { BadRequestError, ModelValidationError } from "../Errors/HttpError";
2
+ import { ModelValidationError, frameworkBadRequest } from "../Errors/HttpError";
3
3
  import { getModelValidator, type ModelValidationIssue, type ModelValidationResult, type ModelValidator } from "./modelValidator";
4
4
  import {
5
5
  findRequestModelShape,
@@ -19,7 +19,7 @@ interface BindingTraversal {
19
19
  readonly options: ModelBindingOptions;
20
20
  /** Per-field binding problems; a bad field does not stop the other fields. */
21
21
  readonly issues: ModelValidationIssue[];
22
- readonly typeMessage?: (property: string, expected: string, actual: string) => string;
22
+ readonly validator?: ModelValidator;
23
23
  nodes: number;
24
24
  }
25
25
 
@@ -74,12 +74,9 @@ export async function bindModelAsync<T extends object>(model: Class<T>, data: un
74
74
 
75
75
  function hydrate<T extends object>(model: Class<T>, data: unknown, validator: ModelValidator | undefined, options: ModelBindingOptions): { instance: T; traversal: BindingTraversal } {
76
76
  if (!isPlainJsonObject(data)) {
77
- throw new BadRequestError("Request body must be a JSON object");
77
+ throw frameworkBadRequest("bodyNotObject");
78
78
  }
79
- const traversal: BindingTraversal = {
80
- stack: new WeakSet(), nodes: 0, options, issues: [],
81
- typeMessage: validator?.typeMismatchMessage?.bind(validator),
82
- };
79
+ const traversal: BindingTraversal = { stack: new WeakSet(), nodes: 0, options, issues: [], validator };
83
80
  const instance = hydrateModel(model as RequestModelClass, data, "", traversal, 0) as T;
84
81
  return { instance, traversal };
85
82
  }
@@ -92,7 +89,7 @@ function finish(traversal: BindingTraversal, validator: ModelValidator | undefin
92
89
  const bound = new Set(traversal.issues.map((issue) => issue.property));
93
90
  const errors = [...traversal.issues, ...(result?.errors ?? []).filter((error) => !bound.has(error.property))];
94
91
  if (errors.length > 0) {
95
- throw new ModelValidationError(errors, validator?.failureTitle?.() ?? "Validation failed");
92
+ throw new ModelValidationError(errors, validator?.message?.("validationFailed", {}) ?? "Validation failed");
96
93
  }
97
94
  }
98
95
 
@@ -120,7 +117,7 @@ function hydrateModel(
120
117
  target[key] = bindField(model, key, target[key], source[key], fieldPath, traversal, depth + 1);
121
118
  } catch (error) {
122
119
  if (!(error instanceof BindingTypeError) && !(error instanceof ModelValidationError && error.errors.every((issue) => issue.code === "unknownField"))) throw error;
123
- traversal.issues.push(...(error instanceof BindingTypeError ? [error.issue(traversal.typeMessage)] : error.errors));
120
+ traversal.issues.push(...error.errors.map((issue) => inValidatorLanguage(issue, traversal.validator)));
124
121
  }
125
122
  }
126
123
  return instance;
@@ -209,13 +206,15 @@ function hydrateField(
209
206
 
210
207
  /** A JSON value of the wrong type; the field is skipped and the binding goes on. */
211
208
  class BindingTypeError extends ModelValidationError {
212
- constructor(readonly property: string, readonly expected: string, readonly actual: string) {
213
- super([{ property, message: `Field "${property}" must be a ${expected}`, code: "type" }]);
209
+ constructor(property: string, expected: string, actual: string) {
210
+ super([{ property, message: `Field "${property}" must be a ${expected}`, code: "type", params: { property, expected, actual } }]);
214
211
  }
212
+ }
215
213
 
216
- issue(format?: (property: string, expected: string, actual: string) => string): ModelValidationIssue {
217
- return { property: this.property, message: format?.(this.property, this.expected, this.actual) ?? this.errors[0]!.message, code: "type" };
218
- }
214
+ /** A binder issue in the validator's current language; the key stays for the response. */
215
+ function inValidatorLanguage(issue: ModelValidationIssue, validator: ModelValidator | undefined): ModelValidationIssue {
216
+ if (validator?.message === undefined || issue.code === undefined || issue.params === undefined) return issue;
217
+ return { ...issue, message: validator.message(issue.code, issue.params) };
219
218
  }
220
219
 
221
220
  function bindingTypeError(property: string, expected: string, value: unknown): BindingTypeError {
@@ -319,7 +318,7 @@ function enterContainer(
319
318
  ): void {
320
319
  const property = path || "$";
321
320
  if (depth > MAX_JSON_DEPTH) {
322
- throw new BadRequestError("Request body is too deeply nested", {
321
+ throw frameworkBadRequest("bodyTooDeep", undefined, {
323
322
  property,
324
323
  maxDepth: MAX_JSON_DEPTH,
325
324
  });
@@ -327,13 +326,13 @@ function enterContainer(
327
326
  const childCount = Array.isArray(value) ? value.length : Object.keys(value).length;
328
327
  traversal.nodes += 1 + childCount;
329
328
  if (traversal.nodes > MAX_JSON_NODES) {
330
- throw new BadRequestError("Request body is too complex", {
329
+ throw frameworkBadRequest("bodyTooComplex", undefined, {
331
330
  property,
332
331
  maxNodes: MAX_JSON_NODES,
333
332
  });
334
333
  }
335
334
  if (traversal.stack.has(value)) {
336
- throw new BadRequestError("Request body must be acyclic JSON", { property });
335
+ throw frameworkBadRequest("bodyCyclic", undefined, { property });
337
336
  }
338
337
  traversal.stack.add(value);
339
338
  }
@@ -352,7 +351,7 @@ function acceptKey(source: object, key: string, allowed: ReadonlySet<string> | u
352
351
  if (!FORBIDDEN_KEYS.has(key) && (allowed === undefined || allowed.has(key))) return true;
353
352
  if (options.unknownFields === "reject") {
354
353
  const property = path === "" ? key : `${path}.${key}`;
355
- throw new ModelValidationError([{ property, message: `Field "${property}" is forbidden or not declared by the model.`, code: "unknownField" }]);
354
+ throw new ModelValidationError([{ property, message: `Field "${property}" is forbidden or not declared by the model.`, code: "unknownField", params: { property } }]);
356
355
  }
357
356
  return false;
358
357
  }
@@ -11,6 +11,12 @@ export interface ModelValidationIssue {
11
11
  readonly property: string;
12
12
  readonly message: string;
13
13
  readonly code?: string;
14
+ /**
15
+ * Placeholder values of a message built from the engine template for `code`:
16
+ * the error handler re-renders it in the application language. Absent when
17
+ * the message is the rule's own text (`@Validator({ message })`).
18
+ */
19
+ readonly params?: Readonly<Record<string, unknown>>;
14
20
  }
15
21
 
16
22
  export interface ModelValidationResult {
@@ -22,10 +28,12 @@ export interface ModelValidator {
22
28
  validate(instance: object): ModelValidationResult;
23
29
  /** Async rules (custom checks returning a Promise). HTTP binding prefers it when present. */
24
30
  validateAsync?(instance: object): Promise<ModelValidationResult>;
25
- /** Text for a JSON value of the wrong type; without it the binder uses its English default. */
26
- typeMismatchMessage?(property: string, expected: string, actual: string): string;
27
- /** Title of a 400 validation response; without it "Validation failed". */
28
- failureTitle?(): string;
31
+ /**
32
+ * Text of a message the binder writes itself (`type`, `unknownField`,
33
+ * `validationFailed`) in the engine's current language; without it the
34
+ * binder uses English.
35
+ */
36
+ message?(code: string, params: Readonly<Record<string, unknown>>): string;
29
37
  }
30
38
 
31
39
  let activeValidator: ModelValidator | undefined;
@@ -1,4 +1,5 @@
1
1
  import type { ModelValidationIssue } from "../Binding/modelValidator";
2
+ import { englishHttpMessage, type HttpMessageKey, type LocalizedMessage, type MessageParams } from "./messages";
2
3
 
3
4
  /**
4
5
  * Base class for errors that map directly to an HTTP response.
@@ -10,53 +11,68 @@ export class HttpError extends Error {
10
11
  readonly status: number,
11
12
  message: string,
12
13
  readonly details?: unknown,
14
+ /**
15
+ * Key and parameters of the message: the error handler renders it in the
16
+ * application language (`runApp({ language, messages })`); `message` is the
17
+ * English text. Errors created with their own text have no key.
18
+ */
19
+ readonly localized?: LocalizedMessage,
13
20
  ) {
14
21
  super(message);
15
22
  this.name = new.target.name;
16
23
  }
17
24
  }
18
25
 
26
+ /** Own text as is, or the framework text by key (translated at the response). */
27
+ function textOf(message: string | undefined, key: HttpMessageKey, params?: MessageParams): readonly [string, LocalizedMessage | undefined] {
28
+ return message !== undefined ? [message, undefined] : [englishHttpMessage(key, params), params === undefined ? { key } : { key, params }];
29
+ }
30
+
19
31
  export class BadRequestError extends HttpError {
20
- constructor(message = "Bad Request", details?: unknown) {
21
- super(400, message, details);
32
+ constructor(message?: string, details?: unknown) {
33
+ const [text, localized] = textOf(message, "badRequest");
34
+ super(400, text, details, localized);
22
35
  }
23
36
  }
24
37
 
25
38
  export class UnauthorizedError extends HttpError {
26
39
  readonly challenge = "Bearer";
27
40
 
28
- constructor(message = "Unauthorized", details?: unknown) {
29
- super(401, message, details);
41
+ constructor(message?: string, details?: unknown) {
42
+ const [text, localized] = textOf(message, "unauthorized");
43
+ super(401, text, details, localized);
30
44
  }
31
45
  }
32
46
 
33
47
  export class ForbiddenError extends HttpError {
34
- constructor(message = "Forbidden", details?: unknown) {
35
- super(403, message, details);
48
+ constructor(message?: string, details?: unknown) {
49
+ const [text, localized] = textOf(message, "forbidden");
50
+ super(403, text, details, localized);
36
51
  }
37
52
  }
38
53
 
39
54
  export class NotFoundError extends HttpError {
40
- constructor(message = "Not Found", details?: unknown) {
41
- super(404, message, details);
55
+ constructor(message?: string, details?: unknown) {
56
+ const [text, localized] = textOf(message, "notFound");
57
+ super(404, text, details, localized);
42
58
  }
43
59
  }
44
60
 
45
61
  export class MethodNotAllowedError extends HttpError {
46
62
  constructor(readonly allow: readonly string[]) {
47
- super(405, "Method Not Allowed");
63
+ super(405, englishHttpMessage("methodNotAllowed"), undefined, { key: "methodNotAllowed" });
48
64
  }
49
65
  }
50
66
 
51
67
  export class UnsupportedMediaTypeError extends HttpError {
52
68
  constructor(expected: string) {
53
- super(415, `Unsupported Media Type: expected ${expected}`);
69
+ super(415, englishHttpMessage("unsupportedMediaType", { expected }), undefined, { key: "unsupportedMediaType", params: { expected } });
54
70
  }
55
71
  }
56
72
 
57
73
  export class PayloadTooLargeError extends HttpError {
58
74
  constructor(maxBytes: number) {
59
- super(413, "Payload Too Large", { maxBytes });
75
+ super(413, englishHttpMessage("payloadTooLarge"), { maxBytes }, { key: "payloadTooLarge" });
60
76
  }
61
77
  }
62
78
 
@@ -66,14 +82,14 @@ export class TooManyRequestsError extends HttpError {
66
82
  /** Extra response headers, for example `RateLimit-*` from {@link rateLimit}. */
67
83
  readonly headers?: Readonly<Record<string, string>>,
68
84
  ) {
69
- super(429, "Too Many Requests");
85
+ super(429, englishHttpMessage("tooManyRequests"), undefined, { key: "tooManyRequests" });
70
86
  }
71
87
  }
72
88
 
73
89
  /**
74
90
  * Request body failed model validation (generated DTO binding + `@Validator`
75
91
  * rules on the DTO). The error handler renders it as 400 with the full list
76
- * of validation problems.
92
+ * of validation problems, each in the application language.
77
93
  */
78
94
  export class ModelValidationError extends BadRequestError {
79
95
  constructor(readonly errors: readonly ModelValidationIssue[], title = "Validation failed") {
@@ -82,6 +98,15 @@ export class ModelValidationError extends BadRequestError {
82
98
  errors.map((error) => ({ property: error.property, message: error.message, code: error.code })),
83
99
  );
84
100
  }
101
+
102
+ override readonly localized: LocalizedMessage = { key: "validationFailed" };
103
+ }
104
+
105
+ /** A 400 the framework writes itself: English text plus the key to translate it. */
106
+ export function frameworkBadRequest(key: HttpMessageKey, params?: MessageParams, details?: unknown): BadRequestError {
107
+ return Object.assign(new BadRequestError(englishHttpMessage(key, params), details), {
108
+ localized: params === undefined ? { key } : { key, params },
109
+ }) as BadRequestError;
85
110
  }
86
111
 
87
112
  /**
@@ -0,0 +1,158 @@
1
+ /** Placeholder values of a message template: `{name}` -> `params.name`. */
2
+ export type MessageParams = Readonly<Record<string, unknown>>;
3
+
4
+ /**
5
+ * Turns a message key and its parameters into text in the application
6
+ * language; `undefined` keeps the English text the error was created with.
7
+ * `runApp` builds it from its `language` and `messages` options.
8
+ */
9
+ export type Localize = (key: string, params: MessageParams) => string | undefined;
10
+
11
+ /** A message key and parameters carried by an error, rendered at the response. */
12
+ export interface LocalizedMessage {
13
+ readonly key: string;
14
+ readonly params?: MessageParams;
15
+ }
16
+
17
+ /** Keys of the texts the HTTP layer writes itself. */
18
+ export type HttpMessageKey =
19
+ | "badRequest"
20
+ | "unauthorized"
21
+ | "forbidden"
22
+ | "notFound"
23
+ | "methodNotAllowed"
24
+ | "conflict"
25
+ | "gone"
26
+ | "preconditionFailed"
27
+ | "payloadTooLarge"
28
+ | "unsupportedMediaType"
29
+ | "unprocessableContent"
30
+ | "locked"
31
+ | "tooManyRequests"
32
+ | "clientError"
33
+ | "internalServerError"
34
+ | "badGateway"
35
+ | "serviceUnavailable"
36
+ | "gatewayTimeout"
37
+ | "serverError"
38
+ | "malformedPath"
39
+ | "unsupportedApiVersion"
40
+ | "malformedJson"
41
+ | "malformedForm"
42
+ | "bodyNotObject"
43
+ | "bodyTooDeep"
44
+ | "bodyTooComplex"
45
+ | "bodyCyclic"
46
+ | "missingParameter"
47
+ | "invalidParameter"
48
+ | "invalidListQuery";
49
+
50
+ export const EN_HTTP_MESSAGES: Readonly<Record<HttpMessageKey, string>> = Object.freeze({
51
+ badRequest: "Bad Request",
52
+ unauthorized: "Unauthorized",
53
+ forbidden: "Forbidden",
54
+ notFound: "Not Found",
55
+ methodNotAllowed: "Method Not Allowed",
56
+ conflict: "Conflict",
57
+ gone: "Gone",
58
+ preconditionFailed: "Precondition Failed",
59
+ payloadTooLarge: "Payload Too Large",
60
+ unsupportedMediaType: "Unsupported Media Type: expected {expected}",
61
+ unprocessableContent: "Unprocessable Content",
62
+ locked: "Locked",
63
+ tooManyRequests: "Too Many Requests",
64
+ clientError: "Client Error",
65
+ internalServerError: "Internal Server Error",
66
+ badGateway: "Bad Gateway",
67
+ serviceUnavailable: "Service Unavailable",
68
+ gatewayTimeout: "Gateway Timeout",
69
+ serverError: "Server Error",
70
+ malformedPath: "Malformed request path",
71
+ unsupportedApiVersion: "Unsupported API version",
72
+ malformedJson: "Malformed JSON in request body",
73
+ malformedForm: "Malformed form data in request body",
74
+ bodyNotObject: "Request body must be a JSON object",
75
+ bodyTooDeep: "Request body is too deeply nested",
76
+ bodyTooComplex: "Request body is too complex",
77
+ bodyCyclic: "Request body must be acyclic JSON",
78
+ missingParameter: 'Missing required {kind} parameter "{name}"',
79
+ invalidParameter: 'Parameter "{name}" must be of type {type}, got: "{value}"',
80
+ invalidListQuery: "Invalid list query",
81
+ });
82
+
83
+ export const RU_HTTP_MESSAGES: Readonly<Record<HttpMessageKey, string>> = Object.freeze({
84
+ badRequest: "Некорректный запрос",
85
+ unauthorized: "Требуется аутентификация",
86
+ forbidden: "Доступ запрещён",
87
+ notFound: "Не найдено",
88
+ methodNotAllowed: "Метод не поддерживается",
89
+ conflict: "Конфликт",
90
+ gone: "Ресурс удалён",
91
+ preconditionFailed: "Предусловие не выполнено",
92
+ payloadTooLarge: "Слишком большой запрос",
93
+ unsupportedMediaType: "Неподдерживаемый тип содержимого: ожидается {expected}",
94
+ unprocessableContent: "Невозможно обработать данные",
95
+ locked: "Ресурс заблокирован",
96
+ tooManyRequests: "Слишком много запросов",
97
+ clientError: "Ошибка в запросе",
98
+ internalServerError: "Внутренняя ошибка сервера",
99
+ badGateway: "Ошибка внешнего сервиса",
100
+ serviceUnavailable: "Сервис недоступен",
101
+ gatewayTimeout: "Внешний сервис не ответил вовремя",
102
+ serverError: "Ошибка сервера",
103
+ malformedPath: "Некорректный путь запроса",
104
+ unsupportedApiVersion: "Неподдерживаемая версия API",
105
+ malformedJson: "Тело запроса — некорректный JSON",
106
+ malformedForm: "Некорректные данные формы в теле запроса",
107
+ bodyNotObject: "Тело запроса должно быть JSON-объектом",
108
+ bodyTooDeep: "Слишком глубокая вложенность в теле запроса",
109
+ bodyTooComplex: "Тело запроса слишком сложное",
110
+ bodyCyclic: "Тело запроса не должно содержать циклов",
111
+ missingParameter: 'Не передан обязательный параметр "{name}"',
112
+ invalidParameter: 'Параметр "{name}": ожидается {type}, получено: "{value}"',
113
+ invalidListQuery: "Некорректные параметры списка",
114
+ });
115
+
116
+ /** Reason-phrase keys of the statuses the error handler writes for unexpected errors. */
117
+ export const STATUS_MESSAGE_KEYS: Readonly<Record<number, HttpMessageKey>> = Object.freeze({
118
+ 400: "badRequest", 401: "unauthorized", 403: "forbidden", 404: "notFound", 405: "methodNotAllowed",
119
+ 409: "conflict", 410: "gone", 412: "preconditionFailed", 413: "payloadTooLarge", 415: "unsupportedMediaType",
120
+ 422: "unprocessableContent", 423: "locked", 429: "tooManyRequests", 500: "internalServerError",
121
+ 502: "badGateway", 503: "serviceUnavailable", 504: "gatewayTimeout",
122
+ });
123
+
124
+ /** Parameters whose value is a type name, translated through `typeName.<name>` keys. */
125
+ const TYPE_PARAMS = new Set(["expected", "actual", "type"]);
126
+
127
+ /**
128
+ * Substitutes `{name}` placeholders. Unknown placeholders stay as they are.
129
+ * With `lookup`, a type name in `expected`/`actual`/`type` is replaced by the
130
+ * `typeName.<name>` text when the catalog has one ("number" -> "число").
131
+ */
132
+ export function formatMessage(template: string, params: MessageParams, lookup?: (key: string) => string | undefined): string {
133
+ return template.replace(/\{(\w+)\}/g, (placeholder, name: string) => {
134
+ if (!Object.prototype.hasOwnProperty.call(params, name) || params[name] === undefined) return placeholder;
135
+ const value = params[name];
136
+ if (lookup !== undefined && TYPE_PARAMS.has(name) && typeof value === "string") {
137
+ const typeName = lookup(`typeName.${value}`);
138
+ if (typeName !== undefined) return typeName;
139
+ }
140
+ return stringifyParam(value);
141
+ });
142
+ }
143
+
144
+ function stringifyParam(value: unknown): string {
145
+ if (typeof value === "object" && value !== null && !(value instanceof RegExp)) {
146
+ try {
147
+ return JSON.stringify(value);
148
+ } catch {
149
+ return String(value);
150
+ }
151
+ }
152
+ return String(value);
153
+ }
154
+
155
+ /** English text of a framework message, used when the app sets no language. */
156
+ export function englishHttpMessage(key: HttpMessageKey, params: MessageParams = {}): string {
157
+ return formatMessage(EN_HTTP_MESSAGES[key], params);
158
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ServiceScope } from "../../di";
2
- import { BadRequestError, PayloadTooLargeError } from "../Errors/HttpError";
2
+ import { PayloadTooLargeError, frameworkBadRequest } from "../Errors/HttpError";
3
3
  import { ResponseBuilder } from "./ResponseBuilder";
4
4
  import type { ModelValidator } from "../Binding/modelValidator";
5
5
 
@@ -75,7 +75,7 @@ export class HttpContext {
75
75
  try {
76
76
  return JSON.parse(text) as T;
77
77
  } catch {
78
- throw new BadRequestError("Malformed JSON in request body");
78
+ throw frameworkBadRequest("malformedJson");
79
79
  }
80
80
  });
81
81
  return this.bodyPromise as Promise<T>;
@@ -94,7 +94,7 @@ export class HttpContext {
94
94
  if (error instanceof PayloadTooLargeError) {
95
95
  throw error;
96
96
  }
97
- throw new BadRequestError("Malformed form data in request body");
97
+ throw frameworkBadRequest("malformedForm");
98
98
  }) as ReturnType<Request["formData"]>;
99
99
  }
100
100
 
@@ -22,6 +22,7 @@ import { HTTP_ERROR_HOOK, ROUTE_MIDDLEWARE_COMPOSER, SERVER_MIDDLEWARE, sortByOr
22
22
  import type { HealthEndpointOptions, RouteMiddlewareComposer, HttpModuleOptions, HttpServerOptions } from "./options";
23
23
  import { WEBSOCKET_UPGRADE, type WebSocketUpgrade } from "./WebSocketUpgrade";
24
24
  import { HttpSetupError } from "./Errors/HttpError";
25
+ import { EN_HTTP_MESSAGES, formatMessage, type HttpMessageKey, type MessageParams } from "./Errors/messages";
25
26
  import { getModelValidator, type ModelValidator } from "./Binding/modelValidator";
26
27
 
27
28
  const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
@@ -166,6 +167,7 @@ export class HttpServer implements HostedService {
166
167
  errorHandler({
167
168
  exposeDetails,
168
169
  ...(errorLogger ? { logger: errorLogger } : {}),
170
+ ...(this.options.localize ? { localize: this.options.localize } : {}),
169
171
  ...this.options.errorHandler,
170
172
  onUnexpectedError: this.collectErrorHook(),
171
173
  }),
@@ -384,13 +386,23 @@ export class HttpServer implements HostedService {
384
386
  return request.method === "HEAD" ? stripBody(response) : response;
385
387
  }
386
388
 
389
+ /** A response text the server writes itself, in the application language. */
390
+ private text(key: HttpMessageKey, params: MessageParams = {}): string {
391
+ const english = formatMessage(EN_HTTP_MESSAGES[key], params);
392
+ try {
393
+ return this.options.localize?.(key, params) ?? english;
394
+ } catch {
395
+ return english;
396
+ }
397
+ }
398
+
387
399
  private transportError(error: unknown): Response {
388
400
  try {
389
401
  console.error("[http] transport failed:", redactSensitive(error));
390
402
  } catch {
391
403
  // Best-effort diagnostics only.
392
404
  }
393
- const response = json(500, { error: "Internal Server Error" });
405
+ const response = json(500, { error: this.text("internalServerError") });
394
406
  if (this.options.securityHeaders !== false) {
395
407
  applySecurityHeaders(response, this.options.securityHeaders ?? {});
396
408
  }
@@ -434,9 +446,9 @@ export class HttpServer implements HostedService {
434
446
  if (!segments) {
435
447
  if (this.maxBodyBytes !== undefined && exceedsBodyLimit(request, this.maxBodyBytes)) {
436
448
  return this.runShortCircuit(request, url, clientIp, () =>
437
- json(413, { error: "Payload Too Large", details: { maxBytes: this.maxBodyBytes } }));
449
+ json(413, { error: this.text("payloadTooLarge"), details: { maxBytes: this.maxBodyBytes } }));
438
450
  }
439
- return this.runShortCircuit(request, url, clientIp, () => json(400, { error: "Malformed request path" }));
451
+ return this.runShortCircuit(request, url, clientIp, () => json(400, { error: this.text("malformedPath") }));
440
452
  }
441
453
 
442
454
  const match = this.router!.match(request.method, segments, this.readVersion(request, url));
@@ -445,18 +457,18 @@ export class HttpServer implements HostedService {
445
457
  : this.maxBodyBytes;
446
458
  if (effectiveMaxBodyBytes !== undefined && exceedsBodyLimit(request, effectiveMaxBodyBytes)) {
447
459
  return this.runShortCircuit(request, url, clientIp, () =>
448
- json(413, { error: "Payload Too Large", details: { maxBytes: effectiveMaxBodyBytes } }));
460
+ json(413, { error: this.text("payloadTooLarge"), details: { maxBytes: effectiveMaxBodyBytes } }));
449
461
  }
450
462
  if (match.kind === "not-found") {
451
- return this.runShortCircuit(request, url, clientIp, () => json(404, { error: "Not Found" }));
463
+ return this.runShortCircuit(request, url, clientIp, () => json(404, { error: this.text("notFound") }));
452
464
  }
453
465
  if (match.kind === "method-not-allowed") {
454
466
  return this.runShortCircuit(request, url, clientIp, () =>
455
- json(405, { error: "Method Not Allowed" }, { allow: match.allow.join(", ") }));
467
+ json(405, { error: this.text("methodNotAllowed") }, { allow: match.allow.join(", ") }));
456
468
  }
457
469
  if (match.kind === "unsupported-version") {
458
470
  return this.runShortCircuit(request, url, clientIp, () =>
459
- json(400, { error: "Unsupported API version", details: { supported: match.supported } }));
471
+ json(400, { error: this.text("unsupportedApiVersion"), details: { supported: match.supported } }));
460
472
  }
461
473
 
462
474
  const scope = this.provider.createScope();
@@ -475,7 +487,7 @@ export class HttpServer implements HostedService {
475
487
  await runPipeline(match.action.chain, ctx);
476
488
  // The error boundary always sets a response on failure; an empty
477
489
  // response here means no middleware produced one.
478
- const response = this.prepareClientResponse(ctx.response ?? json(404, { error: "Not Found" }), request);
490
+ const response = this.prepareClientResponse(ctx.response ?? json(404, { error: this.text("notFound") }), request);
479
491
  if (request.method === "HEAD") return stripBody(response);
480
492
  if (!response.body) return response;
481
493
  this.trackResponse(holdResponseScope(response, request.signal, () =>
@@ -520,7 +532,7 @@ export class HttpServer implements HostedService {
520
532
  let scopeTransferred = false;
521
533
  try {
522
534
  await runPipeline([...this.shortCircuitChain, terminal], ctx);
523
- const response = this.prepareClientResponse(ctx.response ?? json(500, { error: "Internal Server Error" }), request);
535
+ const response = this.prepareClientResponse(ctx.response ?? json(500, { error: this.text("internalServerError") }), request);
524
536
  if (request.method === "HEAD") return stripBody(response);
525
537
  if (!response.body) return response;
526
538
  this.trackResponse(holdResponseScope(response, request.signal, () =>
@@ -2,7 +2,8 @@ import { REQUEST_ID_STATE_KEY, type Logger } from "../../kernel";
2
2
  import { redactSensitive, redactSensitiveText } from "../../../library/redaction";
3
3
  import { HttpClientError, HttpErrorCode } from "../../../library/http-client/errors";
4
4
  import type { HttpContext } from "../HttpContext/HttpContext";
5
- import { HttpError } from "../Errors/HttpError";
5
+ import { HttpError, ModelValidationError } from "../Errors/HttpError";
6
+ import { EN_HTTP_MESSAGES, STATUS_MESSAGE_KEYS, type HttpMessageKey, type Localize } from "../Errors/messages";
6
7
  import { upstreamFailure } from "../Errors/upstreamFailure";
7
8
  import type { HttpMiddleware } from "./types";
8
9
 
@@ -33,6 +34,13 @@ export interface ErrorHandlerOptions {
33
34
  * passes the application `LOGGER`; without either, `console.error` is used.
34
35
  */
35
36
  logger?: Logger;
37
+ /**
38
+ * Renders framework texts (`Not Found`, validation messages, …) in the
39
+ * application language; `runApp` builds it from `language` and `messages`.
40
+ * Without it the texts stay English. Messages an application passes to an
41
+ * error itself are never changed.
42
+ */
43
+ localize?: Localize;
36
44
  }
37
45
 
38
46
  function jsonResponse(status: number, body: unknown, extraHeaders?: readonly [string, string][]): Response {
@@ -103,6 +111,7 @@ function safelyReportUnexpected(
103
111
  */
104
112
  export function errorHandler(options: ErrorHandlerOptions = {}): HttpMiddleware {
105
113
  const log = resolveErrorLog(options);
114
+ const text = textRenderer(options.localize);
106
115
  return async (ctx, next) => {
107
116
  try {
108
117
  await next();
@@ -125,14 +134,23 @@ export function errorHandler(options: ErrorHandlerOptions = {}): HttpMiddleware
125
134
  if (headers !== undefined) {
126
135
  extra.push(...Object.entries(headers));
127
136
  }
137
+ const message = error.localized === undefined ? error.message : text(error.localized.key, error.localized.params, error.message);
138
+ const details = error instanceof ModelValidationError
139
+ ? error.errors.map((issue) => ({
140
+ property: issue.property,
141
+ message: issue.params === undefined || issue.code === undefined ? issue.message : text(issue.code, issue.params, issue.message),
142
+ code: issue.code,
143
+ }))
144
+ : error.details;
128
145
  ctx.response = safeJsonResponse(
129
146
  error.status,
130
- error.details === undefined ? { error: error.message } : { error: error.message, details: error.details },
147
+ details === undefined ? { error: message } : { error: message, details },
131
148
  extra,
132
149
  );
133
150
  return;
134
151
  }
135
- const [status, title] = unexpectedStatus(error, options.status);
152
+ const [status, key] = unexpectedStatus(error, options.status);
153
+ const title = text(key, {}, EN_HTTP_MESSAGES[key]);
136
154
  if (status >= 500) safelyReportUnexpected(options, log, ctx, error);
137
155
  ctx.response = options.exposeDetails
138
156
  ? safeJsonResponse(status, {
@@ -154,19 +172,27 @@ export function errorHandler(options: ErrorHandlerOptions = {}): HttpMiddleware
154
172
  * and stays 500, like everything else. Upstream details never reach the
155
173
  * client; the log keeps the full error.
156
174
  */
157
- const REASONS: Readonly<Record<number, string>> = {
158
- 400: "Bad Request", 404: "Not Found", 409: "Conflict", 410: "Gone", 412: "Precondition Failed", 422: "Unprocessable Content",
159
- 423: "Locked", 429: "Too Many Requests", 500: "Internal Server Error", 502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout",
160
- };
161
-
162
- function unexpectedStatus(error: unknown, mapper?: (error: unknown) => number | undefined): readonly [number, string] {
175
+ function unexpectedStatus(error: unknown, mapper?: (error: unknown) => number | undefined): readonly [number, HttpMessageKey] {
163
176
  if (error instanceof HttpClientError && error.code !== undefined) {
164
- return error.code === HttpErrorCode.Timeout ? [504, "Gateway Timeout"] : [502, "Bad Gateway"];
177
+ return error.code === HttpErrorCode.Timeout ? [504, "gatewayTimeout"] : [502, "badGateway"];
165
178
  }
166
179
  const upstream = upstreamFailure(error);
167
- if (upstream !== undefined) return upstream === "timeout" ? [504, "Gateway Timeout"] : [502, "Bad Gateway"];
180
+ if (upstream !== undefined) return upstream === "timeout" ? [504, "gatewayTimeout"] : [502, "badGateway"];
168
181
  let mapped: number | undefined;
169
182
  try { mapped = mapper?.(error); } catch { mapped = undefined; }
170
- if (mapped !== undefined && Number.isInteger(mapped) && mapped >= 400 && mapped <= 599) return [mapped, REASONS[mapped] ?? (mapped >= 500 ? "Server Error" : "Client Error")];
171
- return [500, "Internal Server Error"];
183
+ if (mapped !== undefined && Number.isInteger(mapped) && mapped >= 400 && mapped <= 599) return [mapped, STATUS_MESSAGE_KEYS[mapped] ?? (mapped >= 500 ? "serverError" : "clientError")];
184
+ return [500, "internalServerError"];
185
+ }
186
+
187
+ /** Text of a message key in the application language, or the English fallback. */
188
+ function textRenderer(localize: Localize | undefined): (key: string, params: Readonly<Record<string, unknown>> | undefined, fallback: string) => string {
189
+ if (localize === undefined) return (_key, _params, fallback) => fallback;
190
+ return (key, params, fallback) => {
191
+ try {
192
+ return localize(key, params ?? {}) ?? fallback;
193
+ } catch {
194
+ // A broken catalog must not turn an error response into a 500.
195
+ return fallback;
196
+ }
197
+ };
172
198
  }
@@ -141,4 +141,5 @@ export {
141
141
  UnauthorizedError,
142
142
  UnsupportedMediaTypeError,
143
143
  } from "./Errors/HttpError";
144
+ export { EN_HTTP_MESSAGES, RU_HTTP_MESSAGES, type HttpMessageKey, type Localize, type LocalizedMessage } from "./Errors/messages";
144
145
  export { markUpstreamFailure, upstreamFailure, type UpstreamFailure } from "./Errors/upstreamFailure";
@@ -2,6 +2,7 @@ import type { Class } from "../di";
2
2
  import type { AccessLogOptions } from "./Middleware/accessLog";
3
3
  import type { CorsOptions } from "./Middleware/cors";
4
4
  import type { ErrorHandlerOptions } from "./Middleware/errorHandler";
5
+ import type { Localize } from "./Errors/messages";
5
6
  import type { SecurityHeadersOptions } from "./Middleware/securityHeaders";
6
7
  import type { HttpMiddleware } from "./Middleware/types";
7
8
  import type { ActionMeta, ControllerMeta } from "./Decorators/metadata";
@@ -58,6 +59,12 @@ export interface HttpModuleOptions {
58
59
  readonly inspectableRedirects?: boolean;
59
60
  /** Request-model validator captured by this HTTP server instance. */
60
61
  readonly validator?: ModelValidator;
62
+ /**
63
+ * Renders framework response texts in the application language. `runApp`
64
+ * sets it from its `language` and `messages` options; without it the
65
+ * texts are English.
66
+ */
67
+ readonly localize?: Localize;
61
68
  /**
62
69
  * Modules providing the controllers' dependencies. Controllers live inside
63
70
  * the returned HttpModule, so their dependencies must be visible to it:
@@ -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.100.5" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.100.7" }, 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");
@@ -34,6 +34,8 @@ export interface LlmConnectorOptions {
34
34
  * start instead of the first agent request. By default it runs in
35
35
  * development and production and not in the `test` environment, where a
36
36
  * test replaces the model; `true` checks there too, `false` never checks.
37
+ * The same choice applies to `/health`: in tests `infra:llm` does not call
38
+ * the provider and reports healthy.
37
39
  */
38
40
  readonly checkOnStart?: boolean;
39
41
  }
@@ -94,7 +96,7 @@ export function llmConnect<T extends LlmConfigShape>(
94
96
  adapter: LlmProviderAdapter,
95
97
  options: LlmConnectorOptions = {},
96
98
  ): InfraConnector<AgentModelProvider> {
97
- const described = new WeakMap<AgentModelProvider, { connection: LlmConnectionOptions; settings: string[]; check: boolean }>();
99
+ const described = new WeakMap<AgentModelProvider, { connection: LlmConnectionOptions; settings: string[]; check: boolean; offline: boolean }>();
98
100
  return identifyConnector({
99
101
  token: AGENT_MODEL_PROVIDER,
100
102
  config,
@@ -106,7 +108,9 @@ export function llmConnect<T extends LlmConfigShape>(
106
108
  // The environment of this kernel's configuration: tests do not call the real provider.
107
109
  const environment = (configs?.get(config) as { environment?: string } | undefined)?.environment;
108
110
  const check = options.checkOnStart ?? environment !== "test";
109
- described.set(provider, { connection, settings: settingNames(config, configs, ["baseUrl", "apiKey"]), check });
111
+ // In tests the provider is not reached at all, unless checkOnStart: true asks for it.
112
+ const offline = environment === "test" && options.checkOnStart !== true;
113
+ described.set(provider, { connection, settings: settingNames(config, configs, ["baseUrl", "apiKey"]), check, offline });
110
114
  return provider;
111
115
  },
112
116
  connect(provider, signal) {
@@ -126,8 +130,9 @@ export function llmConnect<T extends LlmConfigShape>(
126
130
  dispose(provider) {
127
131
  return adapter.dispose?.(provider);
128
132
  },
133
+ // Like the start check: a test does not reach the real provider.
129
134
  healthCheck: adapter.healthCheck
130
- ? (provider, signal) => adapter.healthCheck?.(provider, signal) ?? false
135
+ ? (provider, signal) => described.get(provider)?.offline === true || (adapter.healthCheck?.(provider, signal) ?? false)
131
136
  : undefined,
132
137
  }, "llm");
133
138
  }
@@ -5,7 +5,7 @@
5
5
  export type MessageParams = Readonly<Record<string, unknown>>;
6
6
 
7
7
  /** Built-in default messages (English). */
8
- const BUILT_IN: Readonly<Record<string, string>> = {
8
+ export const EN_VALIDATION_MESSAGES: Readonly<Record<string, string>> = Object.freeze({
9
9
  validationFailed: "Validation failed",
10
10
  invalid: 'Field "{property}" is invalid ({code})',
11
11
  required: 'Field "{property}" is required',
@@ -39,30 +39,36 @@ const BUILT_IN: Readonly<Record<string, string>> = {
39
39
  customError: 'Check of field "{property}" failed with an error: {error}',
40
40
  asyncCustomInSyncCall:
41
41
  'Field "{property}": an async custom check is not supported by synchronous validate(); use validateAsync()',
42
- };
42
+ unknownField: 'Field "{property}" is forbidden or not declared by the model.',
43
+ });
44
+ const BUILT_IN = EN_VALIDATION_MESSAGES;
43
45
 
44
46
  /**
45
- * Russian texts for every built-in code. Apply once at startup:
47
+ * Russian texts for every built-in code. An application gets them with
48
+ * `runApp(AppModule, { language: "ru" })`; outside an application:
46
49
  * `MessageRegistry.setDefaults(RU_VALIDATION_MESSAGES)`.
50
+ *
51
+ * Numbers stand after a dash ("минимальная длина — 5"), so no template needs
52
+ * a plural form. `typeName.*` keys translate the type names in `type`.
47
53
  */
48
54
  export const RU_VALIDATION_MESSAGES: Readonly<Record<string, string>> = Object.freeze({
49
55
  validationFailed: "Проверка данных не пройдена",
50
56
  invalid: 'Поле "{property}" не прошло проверку ({code})',
51
57
  required: 'Поле "{property}" обязательно для заполнения',
52
58
  notEmpty: 'Поле "{property}" не должно быть пустым',
53
- minLength: 'Поле "{property}" должно содержать не менее {min} символов',
54
- maxLength: 'Поле "{property}" должно содержать не более {max} символов',
55
- length: 'Поле "{property}" должно содержать от {min} до {max} символов',
56
- minItems: 'Поле "{property}" должно содержать не менее {min} элементов',
57
- maxItems: 'Поле "{property}" должно содержать не более {max} элементов',
58
- itemCount: 'Поле "{property}" должно содержать от {min} до {max} элементов',
59
+ minLength: 'Поле "{property}": минимальная длина — {min}',
60
+ maxLength: 'Поле "{property}": максимальная длина — {max}',
61
+ length: 'Поле "{property}": длина — от {min} до {max}',
62
+ minItems: 'Поле "{property}": минимум элементов — {min}',
63
+ maxItems: 'Поле "{property}": максимум элементов — {max}',
64
+ itemCount: 'Поле "{property}": количество элементов — от {min} до {max}',
59
65
  contains: 'Поле "{property}" должно содержать "{contains}"',
60
66
  notContains: 'Поле "{property}" не должно содержать "{contains}"',
61
67
  pattern: 'Поле "{property}" не соответствует формату {pattern}',
62
68
  email: 'Поле "{property}" должно быть корректным email-адресом',
63
69
  url: 'Поле "{property}" должно быть корректным URL',
64
70
  uuid: 'Поле "{property}" должно быть корректным UUID',
65
- type: 'Поле "{property}" должно иметь тип {expected}, получено: {actual}',
71
+ type: 'Поле "{property}": ожидается {expected}, получено: {actual}',
66
72
  min: 'Поле "{property}" должно быть не меньше {min}',
67
73
  max: 'Поле "{property}" должно быть не больше {max}',
68
74
  range: 'Поле "{property}" должно быть в диапазоне от {min} до {max}',
@@ -79,6 +85,15 @@ export const RU_VALIDATION_MESSAGES: Readonly<Record<string, string>> = Object.f
79
85
  customError: 'Проверка поля "{property}" завершилась ошибкой: {error}',
80
86
  asyncCustomInSyncCall:
81
87
  'Поле "{property}": асинхронная custom-проверка не поддерживается синхронным validate(), используйте validateAsync()',
88
+ unknownField: 'Поле "{property}" запрещено или не объявлено в модели',
89
+ "typeName.string": "строка",
90
+ "typeName.number": "число",
91
+ "typeName.int": "целое число",
92
+ "typeName.boolean": "логическое значение",
93
+ "typeName.bool": "логическое значение",
94
+ "typeName.array": "массив",
95
+ "typeName.object": "объект",
96
+ "typeName.JSON object or array": "объект или массив",
82
97
  });
83
98
 
84
99
  /**
@@ -92,6 +107,8 @@ export const RU_VALIDATION_MESSAGES: Readonly<Record<string, string>> = Object.f
92
107
  export class MessageRegistry {
93
108
  /** Current global templates: a copy of the built-in ones + overrides. */
94
109
  private static defaults: Record<string, string> = { ...BUILT_IN };
110
+ /** Only the templates set through {@link setDefaults}. */
111
+ private static overrides: Record<string, string> = {};
95
112
 
96
113
  /**
97
114
  * Overrides global messages for error codes.
@@ -107,6 +124,7 @@ export class MessageRegistry {
107
124
  const template = messages[code];
108
125
  if (typeof template === "string") {
109
126
  MessageRegistry.defaults[code] = template;
127
+ MessageRegistry.overrides[code] = template;
110
128
  }
111
129
  }
112
130
  }
@@ -114,6 +132,12 @@ export class MessageRegistry {
114
132
  /** Resets all global overrides to the built-in messages. */
115
133
  static reset(): void {
116
134
  MessageRegistry.defaults = { ...BUILT_IN };
135
+ MessageRegistry.overrides = {};
136
+ }
137
+
138
+ /** A template set through {@link setDefaults}, or `undefined`. */
139
+ static override(code: string): string | undefined {
140
+ return Object.prototype.hasOwnProperty.call(MessageRegistry.overrides, code) ? MessageRegistry.overrides[code] : undefined;
117
141
  }
118
142
 
119
143
  /**
@@ -137,6 +161,11 @@ export class MessageRegistry {
137
161
  if (!Object.prototype.hasOwnProperty.call(params, key) || params[key] === undefined) {
138
162
  return placeholder;
139
163
  }
164
+ const value = params[key];
165
+ if ((key === "expected" || key === "actual") && typeof value === "string") {
166
+ const typeName = MessageRegistry.defaults[`typeName.${value}`];
167
+ if (typeName !== undefined && Object.prototype.hasOwnProperty.call(MessageRegistry.defaults, `typeName.${value}`)) return typeName;
168
+ }
140
169
  return MessageRegistry.stringify(params[key]);
141
170
  });
142
171
  }
@@ -47,7 +47,7 @@ More scenarios (complex conditions, async, nested objects) are in
47
47
  | `ValidationResult.ts` | the outcome: `isValid`, `errors`, `getErrorsFor`, `hasErrorsFor` |
48
48
  | `RuleEngine.ts` | the engine: one pass over the plan, sync/async, nested, cycle protection |
49
49
  | `RuleCompiler.ts` | compiles class rules into a plan + WeakMap cache (RegExp, enum Set) |
50
- | `MessageRegistry.ts` | message templates, placeholders, priority local > global > built-in; built-in texts are English, `RU_VALIDATION_MESSAGES` is a Russian set for `setDefaults` |
50
+ | `MessageRegistry.ts` | message templates, placeholders, priority local > global > built-in; built-in texts are English, `RU_VALIDATION_MESSAGES` is a Russian set for `setDefaults` and `runApp({ language: "ru" })` |
51
51
  | `metadata.ts` | stores rules through `context.metadata` / `Symbol.metadata` |
52
52
  | `types/ValidatorOptions.ts` | all decorator options |
53
53
  | `types/ValidationCode.ts` | error codes |
@@ -381,7 +381,7 @@ export class RuleEngine {
381
381
  params[key] = extra[key];
382
382
  }
383
383
  }
384
- errors.push(new ValidationError(path, value, MessageRegistry.format(code, o.message, params), code));
384
+ errors.push(new ValidationError(path, value, MessageRegistry.format(code, o.message, params), code, o.message === undefined ? params : undefined));
385
385
  }
386
386
 
387
387
  private static failType(errors: ValidationError[], path: string, value: unknown, o: ValidatorOptions, expected: string): void {
@@ -444,7 +444,13 @@ so a valid object pays no allocations for it.
444
444
 
445
445
  Template priority: **the decorator's local `message` > global (`setDefaultMessages`) >
446
446
  built-in**. A string returned from `custom` wins over all of them.
447
- Built-in messages are English; `RU_VALIDATION_MESSAGES` is a Russian set for `setDefaultMessages`.
447
+ Built-in messages are English (`EN_VALIDATION_MESSAGES`); `RU_VALIDATION_MESSAGES` is a Russian set for `setDefaultMessages`.
448
+ An HTTP application picks the language with `runApp({ language: "ru", messages })`: each
449
+ `ValidationError` built from a template carries `params`, and the HTTP error handler
450
+ re-renders it in the application language (a rule's own `message` has no `params` and
451
+ stays as is). Priority there: `messages` > `setDefaultMessages` > the language set > English.
452
+ Russian templates put numbers after a dash ("минимальная длина — 5"), so no plural forms are
453
+ needed; `typeName.*` keys translate the type names in the `type` message.
448
454
 
449
455
  ```ts
450
456
  // Globally (once at application start):
@@ -22,12 +22,16 @@ export class ValidationError {
22
22
  * @param value Actual field value at check time.
23
23
  * @param message Human-readable message (placeholders already substituted).
24
24
  * @param code Machine-readable rule code, for example `"required"` or `"minLength"`.
25
+ * @param params Placeholder values when `message` comes from the template of
26
+ * `code`: an application re-renders it in its language. The engine leaves
27
+ * it out for a rule's own `message`.
25
28
  */
26
29
  constructor(
27
30
  readonly property: string,
28
31
  readonly value: unknown,
29
32
  readonly message: string,
30
33
  readonly code?: string,
34
+ readonly params?: Readonly<Record<string, unknown>>,
31
35
  ) {}
32
36
 
33
37
  /** Short `property: message` form, handy for logs. */
@@ -11,6 +11,6 @@ export { Validator } from "./Validator";
11
11
  export { modelValidatorAdapter } from "./modelValidatorAdapter";
12
12
  export { ValidationError } from "./ValidationError";
13
13
  export { ValidationResult } from "./ValidationResult";
14
- export { MessageRegistry, RU_VALIDATION_MESSAGES, type MessageParams } from "./MessageRegistry";
14
+ export { EN_VALIDATION_MESSAGES, MessageRegistry, RU_VALIDATION_MESSAGES, type MessageParams } from "./MessageRegistry";
15
15
  export { ValidationCodes, type ValidationCode } from "./types/ValidationCode";
16
16
  export type { CustomOutcome, CustomValidator, ValidationTypeHint, ValidatorOptions } from "./types/ValidatorOptions";
@@ -7,6 +7,7 @@ interface AdapterValidationIssue {
7
7
  readonly property: string;
8
8
  readonly message: string;
9
9
  readonly code?: string;
10
+ readonly params?: Readonly<Record<string, unknown>>;
10
11
  }
11
12
 
12
13
  /**
@@ -38,14 +39,11 @@ export const modelValidatorAdapter = {
38
39
  return toAdapterResult(instance, await Validator.validateAsync(instance));
39
40
  },
40
41
 
41
- /** A JSON value of the wrong type, in the current `MessageRegistry` language. */
42
- typeMismatchMessage(property: string, expected: string, actual: string): string {
43
- return MessageRegistry.format("type", undefined, { property, expected, actual });
42
+ /** A binder message in the current `MessageRegistry` language. */
43
+ message(code: string, params: Readonly<Record<string, unknown>>): string {
44
+ return MessageRegistry.format(code, undefined, params);
44
45
  },
45
46
 
46
- failureTitle(): string {
47
- return MessageRegistry.format("validationFailed", undefined, {});
48
- },
49
47
  };
50
48
 
51
49
  interface AdapterValidationResult {
@@ -59,6 +57,7 @@ function toAdapterResult(instance: object, result: ValidationResult): AdapterVal
59
57
  property: error.property,
60
58
  message: error.message,
61
59
  code: error.code,
60
+ ...(error.params === undefined ? {} : { params: error.params }),
62
61
  }));
63
62
  errors.push(...nestedInstanceErrors);
64
63
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.100.5",
3
+ "version": "0.100.7",
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",