bazis 0.100.6 → 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 +12 -7
- package/core/app/index.ts +1 -0
- package/core/app/messages.ts +44 -0
- package/core/app/runApp.ts +20 -0
- package/core/http/Binding/ParameterBinder.ts +3 -3
- package/core/http/Binding/convert.ts +2 -2
- package/core/http/Binding/modelBinder.ts +17 -18
- package/core/http/Binding/modelValidator.ts +12 -4
- package/core/http/Errors/HttpError.ts +38 -13
- package/core/http/Errors/messages.ts +158 -0
- package/core/http/HttpContext/HttpContext.ts +3 -3
- package/core/http/HttpServer.ts +21 -9
- package/core/http/Middleware/errorHandler.ts +39 -13
- package/core/http/index.ts +1 -0
- package/core/http/options.ts +7 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/library/validation/MessageRegistry.ts +39 -10
- package/library/validation/README.md +1 -1
- package/library/validation/RuleEngine.ts +1 -1
- package/library/validation/SPEC.md +7 -1
- package/library/validation/ValidationError.ts +4 -0
- package/library/validation/index.ts +1 -1
- package/library/validation/modelValidatorAdapter.ts +5 -6
- package/package.json +1 -1
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
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/core/app/runApp.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
117
|
+
throw frameworkBadRequest("invalidListQuery", undefined, error.problems);
|
|
118
118
|
}
|
|
119
119
|
throw error;
|
|
120
120
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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?.
|
|
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(...
|
|
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(
|
|
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
|
-
|
|
217
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
21
|
-
|
|
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
|
|
29
|
-
|
|
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
|
|
35
|
-
|
|
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
|
|
41
|
-
|
|
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, "
|
|
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,
|
|
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, "
|
|
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, "
|
|
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 {
|
|
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
|
|
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
|
|
97
|
+
throw frameworkBadRequest("malformedForm");
|
|
98
98
|
}) as ReturnType<Request["formData"]>;
|
|
99
99
|
}
|
|
100
100
|
|
package/core/http/HttpServer.ts
CHANGED
|
@@ -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: "
|
|
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: "
|
|
449
|
+
json(413, { error: this.text("payloadTooLarge"), details: { maxBytes: this.maxBodyBytes } }));
|
|
438
450
|
}
|
|
439
|
-
return this.runShortCircuit(request, url, clientIp, () => json(400, { error: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
147
|
+
details === undefined ? { error: message } : { error: message, details },
|
|
131
148
|
extra,
|
|
132
149
|
);
|
|
133
150
|
return;
|
|
134
151
|
}
|
|
135
|
-
const [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
|
-
|
|
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, "
|
|
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, "
|
|
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,
|
|
171
|
-
return [500, "
|
|
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
|
}
|
package/core/http/index.ts
CHANGED
|
@@ -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";
|
package/core/http/options.ts
CHANGED
|
@@ -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.
|
|
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");
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
export type MessageParams = Readonly<Record<string, unknown>>;
|
|
6
6
|
|
|
7
7
|
/** Built-in default messages (English). */
|
|
8
|
-
const
|
|
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.
|
|
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}"
|
|
54
|
-
maxLength: 'Поле "{property}"
|
|
55
|
-
length: 'Поле "{property}"
|
|
56
|
-
minItems: 'Поле "{property}"
|
|
57
|
-
maxItems: 'Поле "{property}"
|
|
58
|
-
itemCount: 'Поле "{property}"
|
|
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}"
|
|
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
|
|
42
|
-
|
|
43
|
-
return MessageRegistry.format(
|
|
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.
|
|
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",
|