bazis 0.100.6 → 0.100.8
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/di/errors/InvalidProviderError.ts +3 -2
- package/core/di/errors/ScopedServiceFromRootError.ts +2 -1
- package/core/di/internal/GraphValidator.ts +6 -2
- package/core/di/module/createContainer.ts +18 -1
- 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 +42 -20
- 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/core/kernel/Bazis.ts +9 -1
- package/core/kernel/LifecycleCoordinator.ts +10 -1
- package/core/kernel/config/defineConfig.ts +5 -1
- package/core/kernel/startupFailure.ts +4 -2
- package/library/http-client/HttpClient.ts +7 -0
- package/library/http-client/configKeys.ts +36 -0
- 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,9 +1,10 @@
|
|
|
1
1
|
import { DiError } from "./DiError";
|
|
2
2
|
|
|
3
3
|
export class InvalidProviderError extends DiError {
|
|
4
|
-
|
|
4
|
+
/** `message` replaces the default text when the caller knows more (the module and position). */
|
|
5
|
+
public constructor(tokenName: string, message?: string) {
|
|
5
6
|
super(
|
|
6
|
-
`Provider for "${tokenName}" has unknown shape: expected one of useClass/useFactory/useValue.`,
|
|
7
|
+
message ?? `Provider for "${tokenName}" has unknown shape: expected one of useClass/useFactory/useValue.`,
|
|
7
8
|
);
|
|
8
9
|
}
|
|
9
10
|
}
|
|
@@ -4,6 +4,7 @@ import { DiError } from "./DiError";
|
|
|
4
4
|
|
|
5
5
|
export class ScopedServiceFromRootError extends DiError {
|
|
6
6
|
public constructor(token: Token<unknown>) {
|
|
7
|
-
|
|
7
|
+
const name = tokenToDebugName(token);
|
|
8
|
+
super(`Cannot resolve scoped service "${name}" from root provider. A scoped service lives in a request or a scope: create one with provider.createScope(), resolve "${name}" from it and dispose the scope when done.`);
|
|
8
9
|
}
|
|
9
10
|
}
|
|
@@ -64,8 +64,12 @@ export class GraphValidator {
|
|
|
64
64
|
issues: Set<string>,
|
|
65
65
|
): void {
|
|
66
66
|
if (registrationPath.has(registration.id)) {
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
// One cycle is reached from each of its members: report it once, as the cycle itself.
|
|
68
|
+
const start = tokenPath.indexOf(registration.token);
|
|
69
|
+
const members = (start >= 0 ? tokenPath.slice(start) : tokenPath).map((token) => tokenToDebugName(token));
|
|
70
|
+
const first = members.indexOf([...members].sort()[0] as string);
|
|
71
|
+
const canonical = [...members.slice(first), ...members.slice(0, first)];
|
|
72
|
+
issues.add(`Cycle detected: ${[...canonical, canonical[0]].join(" -> ")}`);
|
|
69
73
|
return;
|
|
70
74
|
}
|
|
71
75
|
|
|
@@ -4,7 +4,7 @@ import { HOSTED_SERVICE, HOSTED_SERVICE_HEALTH, type HostedService } from "../ex
|
|
|
4
4
|
import { SERVICE_PROVIDER, SERVICE_PROVIDER_BY_TYPE } from "../extensions/service-provider-token";
|
|
5
5
|
import { isClassProvider, ProviderDefinition, type Provider } from "../provider";
|
|
6
6
|
import { getClassDeps } from "../internal/classDeps";
|
|
7
|
-
import { DiError, ModuleEncapsulationError } from "../errors";
|
|
7
|
+
import { DiError, InvalidProviderError, ModuleEncapsulationError } from "../errors";
|
|
8
8
|
import { OpenGenericRegistration } from "../internal/OpenGenericRegistration";
|
|
9
9
|
import { tokenToDebugName, type Class } from "../token";
|
|
10
10
|
import type { BuildServiceProviderOptions } from "../types";
|
|
@@ -133,6 +133,7 @@ export function createContainer(rootModule: BazisModuleRef, options?: CreateCont
|
|
|
133
133
|
const providers = moduleRef.providers;
|
|
134
134
|
if (providers) {
|
|
135
135
|
for (let index = 0; index < providers.length; index += 1) {
|
|
136
|
+
assertProviderDefinition(providers[index], record.name, index);
|
|
136
137
|
collection.add(providers[index] as ProviderDefinition);
|
|
137
138
|
}
|
|
138
139
|
}
|
|
@@ -311,3 +312,19 @@ function normalizeLateGeneratedClassDeps(definition: ProviderDefinition): Provid
|
|
|
311
312
|
const normalizedProvider = { ...provider, deps: generatedDeps } as Provider<unknown>;
|
|
312
313
|
return new ProviderDefinition(normalizedProvider, definition.lifetime, definition.key);
|
|
313
314
|
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* A module lists registrations made by singleton/scoped/transient/…; a raw
|
|
318
|
+
* `{ provide: X }` object used to crash later with a bare TypeError.
|
|
319
|
+
*/
|
|
320
|
+
function assertProviderDefinition(value: unknown, moduleName: string, index: number): void {
|
|
321
|
+
const provider = (value as { provider?: unknown } | null | undefined)?.provider;
|
|
322
|
+
if (typeof provider === "object" && provider !== null && "provide" in provider) return;
|
|
323
|
+
const provide = (value as { provide?: unknown } | null | undefined)?.provide;
|
|
324
|
+
const name = typeof provide === "function" ? provide.name : typeof provide === "string" ? provide : String(provide ?? value);
|
|
325
|
+
throw new InvalidProviderError(
|
|
326
|
+
name,
|
|
327
|
+
`Module "${moduleName}": providers[${index}] is not a provider registration${provide === undefined ? "" : ` ({ provide: ${name} })`}. `
|
|
328
|
+
+ `Register it with singleton(${name}), scoped(${name}), transient(${name}), singletonValue(TOKEN, value) or singletonFactory(TOKEN, [deps], factory).`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
@@ -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,6 @@
|
|
|
1
|
+
import { KernelError } from "../../kernel";
|
|
1
2
|
import type { ModelValidationIssue } from "../Binding/modelValidator";
|
|
3
|
+
import { englishHttpMessage, type HttpMessageKey, type LocalizedMessage, type MessageParams } from "./messages";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Base class for errors that map directly to an HTTP response.
|
|
@@ -10,53 +12,68 @@ export class HttpError extends Error {
|
|
|
10
12
|
readonly status: number,
|
|
11
13
|
message: string,
|
|
12
14
|
readonly details?: unknown,
|
|
15
|
+
/**
|
|
16
|
+
* Key and parameters of the message: the error handler renders it in the
|
|
17
|
+
* application language (`runApp({ language, messages })`); `message` is the
|
|
18
|
+
* English text. Errors created with their own text have no key.
|
|
19
|
+
*/
|
|
20
|
+
readonly localized?: LocalizedMessage,
|
|
13
21
|
) {
|
|
14
22
|
super(message);
|
|
15
23
|
this.name = new.target.name;
|
|
16
24
|
}
|
|
17
25
|
}
|
|
18
26
|
|
|
27
|
+
/** Own text as is, or the framework text by key (translated at the response). */
|
|
28
|
+
function textOf(message: string | undefined, key: HttpMessageKey, params?: MessageParams): readonly [string, LocalizedMessage | undefined] {
|
|
29
|
+
return message !== undefined ? [message, undefined] : [englishHttpMessage(key, params), params === undefined ? { key } : { key, params }];
|
|
30
|
+
}
|
|
31
|
+
|
|
19
32
|
export class BadRequestError extends HttpError {
|
|
20
|
-
constructor(message
|
|
21
|
-
|
|
33
|
+
constructor(message?: string, details?: unknown) {
|
|
34
|
+
const [text, localized] = textOf(message, "badRequest");
|
|
35
|
+
super(400, text, details, localized);
|
|
22
36
|
}
|
|
23
37
|
}
|
|
24
38
|
|
|
25
39
|
export class UnauthorizedError extends HttpError {
|
|
26
40
|
readonly challenge = "Bearer";
|
|
27
41
|
|
|
28
|
-
constructor(message
|
|
29
|
-
|
|
42
|
+
constructor(message?: string, details?: unknown) {
|
|
43
|
+
const [text, localized] = textOf(message, "unauthorized");
|
|
44
|
+
super(401, text, details, localized);
|
|
30
45
|
}
|
|
31
46
|
}
|
|
32
47
|
|
|
33
48
|
export class ForbiddenError extends HttpError {
|
|
34
|
-
constructor(message
|
|
35
|
-
|
|
49
|
+
constructor(message?: string, details?: unknown) {
|
|
50
|
+
const [text, localized] = textOf(message, "forbidden");
|
|
51
|
+
super(403, text, details, localized);
|
|
36
52
|
}
|
|
37
53
|
}
|
|
38
54
|
|
|
39
55
|
export class NotFoundError extends HttpError {
|
|
40
|
-
constructor(message
|
|
41
|
-
|
|
56
|
+
constructor(message?: string, details?: unknown) {
|
|
57
|
+
const [text, localized] = textOf(message, "notFound");
|
|
58
|
+
super(404, text, details, localized);
|
|
42
59
|
}
|
|
43
60
|
}
|
|
44
61
|
|
|
45
62
|
export class MethodNotAllowedError extends HttpError {
|
|
46
63
|
constructor(readonly allow: readonly string[]) {
|
|
47
|
-
super(405, "
|
|
64
|
+
super(405, englishHttpMessage("methodNotAllowed"), undefined, { key: "methodNotAllowed" });
|
|
48
65
|
}
|
|
49
66
|
}
|
|
50
67
|
|
|
51
68
|
export class UnsupportedMediaTypeError extends HttpError {
|
|
52
69
|
constructor(expected: string) {
|
|
53
|
-
super(415,
|
|
70
|
+
super(415, englishHttpMessage("unsupportedMediaType", { expected }), undefined, { key: "unsupportedMediaType", params: { expected } });
|
|
54
71
|
}
|
|
55
72
|
}
|
|
56
73
|
|
|
57
74
|
export class PayloadTooLargeError extends HttpError {
|
|
58
75
|
constructor(maxBytes: number) {
|
|
59
|
-
super(413, "
|
|
76
|
+
super(413, englishHttpMessage("payloadTooLarge"), { maxBytes }, { key: "payloadTooLarge" });
|
|
60
77
|
}
|
|
61
78
|
}
|
|
62
79
|
|
|
@@ -66,14 +83,14 @@ export class TooManyRequestsError extends HttpError {
|
|
|
66
83
|
/** Extra response headers, for example `RateLimit-*` from {@link rateLimit}. */
|
|
67
84
|
readonly headers?: Readonly<Record<string, string>>,
|
|
68
85
|
) {
|
|
69
|
-
super(429, "
|
|
86
|
+
super(429, englishHttpMessage("tooManyRequests"), undefined, { key: "tooManyRequests" });
|
|
70
87
|
}
|
|
71
88
|
}
|
|
72
89
|
|
|
73
90
|
/**
|
|
74
91
|
* Request body failed model validation (generated DTO binding + `@Validator`
|
|
75
92
|
* rules on the DTO). The error handler renders it as 400 with the full list
|
|
76
|
-
* of validation problems.
|
|
93
|
+
* of validation problems, each in the application language.
|
|
77
94
|
*/
|
|
78
95
|
export class ModelValidationError extends BadRequestError {
|
|
79
96
|
constructor(readonly errors: readonly ModelValidationIssue[], title = "Validation failed") {
|
|
@@ -82,15 +99,20 @@ export class ModelValidationError extends BadRequestError {
|
|
|
82
99
|
errors.map((error) => ({ property: error.property, message: error.message, code: error.code })),
|
|
83
100
|
);
|
|
84
101
|
}
|
|
102
|
+
|
|
103
|
+
override readonly localized: LocalizedMessage = { key: "validationFailed" };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A 400 the framework writes itself: English text plus the key to translate it. */
|
|
107
|
+
export function frameworkBadRequest(key: HttpMessageKey, params?: MessageParams, details?: unknown): BadRequestError {
|
|
108
|
+
return Object.assign(new BadRequestError(englishHttpMessage(key, params), details), {
|
|
109
|
+
localized: params === undefined ? { key } : { key, params },
|
|
110
|
+
}) as BadRequestError;
|
|
85
111
|
}
|
|
86
112
|
|
|
87
113
|
/**
|
|
88
114
|
* Misconfigured routes/controllers (duplicate route, bad template, missing
|
|
89
|
-
* @Controller). Thrown at startup, never at request time — fail fast.
|
|
115
|
+
* @Controller). Thrown at startup, never at request time — fail fast. A
|
|
116
|
+
* KernelError: the start reports it as a configuration error of the code.
|
|
90
117
|
*/
|
|
91
|
-
export class HttpSetupError extends
|
|
92
|
-
constructor(message: string) {
|
|
93
|
-
super(message);
|
|
94
|
-
this.name = "HttpSetupError";
|
|
95
|
-
}
|
|
96
|
-
}
|
|
118
|
+
export class HttpSetupError extends KernelError {}
|