bazis 0.96.4 → 0.96.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/di/module/encapsulation.ts +15 -3
- package/core/http/Binding/ParameterBinder.ts +2 -2
- package/core/http/Binding/modelBinder.ts +71 -26
- package/core/http/Binding/modelValidator.ts +12 -1
- package/core/http/Errors/HttpError.ts +2 -2
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/kernel/config/README.md +4 -2
- package/core/scripts/di-generate.ts +59 -1
- package/library/validation/MessageRegistry.ts +11 -1
- package/library/validation/RuleEngine.ts +27 -3
- package/library/validation/SPEC.md +7 -1
- package/library/validation/modelValidatorAdapter.ts +28 -7
- package/library/validation/types/ValidationCode.ts +7 -1
- package/library/validation/types/ValidatorOptions.ts +4 -4
- package/package.json +1 -1
|
@@ -170,6 +170,16 @@ export function validateModuleEncapsulation(
|
|
|
170
170
|
}
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
// Why a provided token is not visible: the owner does not export it, or the
|
|
174
|
+
// consumer module does not import the owner. Both used to read "not exported".
|
|
175
|
+
const explainHidden = (token: Token<unknown>, consumer: ModuleGraphRecord): string => {
|
|
176
|
+
const owner = records.find((record) => record !== consumer && record.providedTokens.has(token));
|
|
177
|
+
if (owner === undefined) return "which is provided by another module but not exported.";
|
|
178
|
+
return exportedTokens(owner).has(token)
|
|
179
|
+
? `which module "${owner.name}" exports, but "${consumer.name}" does not list "${owner.name}" in its imports. Add "${owner.name}" to the imports of "${consumer.name}".`
|
|
180
|
+
: `which module "${owner.name}" provides but does not export. Add it to the exports of "${owner.name}".`;
|
|
181
|
+
};
|
|
182
|
+
|
|
173
183
|
const contexts = new Map<ModuleGraphRecord, DependencyCheckContext>();
|
|
174
184
|
for (let recordIndex = 0; recordIndex < records.length; recordIndex += 1) {
|
|
175
185
|
const record = records[recordIndex] as ModuleGraphRecord;
|
|
@@ -183,7 +193,7 @@ export function validateModuleEncapsulation(
|
|
|
183
193
|
}
|
|
184
194
|
|
|
185
195
|
const context: DependencyCheckContext = {
|
|
186
|
-
visible, familiesVisible, tokensByName, allProvidedTokens, allProvidedFamilies, issues,
|
|
196
|
+
visible, familiesVisible, tokensByName, allProvidedTokens, allProvidedFamilies, issues, explainHidden,
|
|
187
197
|
};
|
|
188
198
|
contexts.set(record, context);
|
|
189
199
|
for (const definition of record.definitions) {
|
|
@@ -214,6 +224,7 @@ interface DependencyCheckContext {
|
|
|
214
224
|
readonly allProvidedTokens: ReadonlySet<Token<unknown>>;
|
|
215
225
|
readonly allProvidedFamilies: ReadonlySet<symbol>;
|
|
216
226
|
readonly issues: string[];
|
|
227
|
+
readonly explainHidden: (token: Token<unknown>, consumer: ModuleGraphRecord) => string;
|
|
217
228
|
}
|
|
218
229
|
|
|
219
230
|
function checkDefinition(
|
|
@@ -265,8 +276,9 @@ function checkDependency(
|
|
|
265
276
|
return;
|
|
266
277
|
}
|
|
267
278
|
}
|
|
279
|
+
const provided = candidates.find((candidate) => context.allProvidedTokens.has(candidate as Token<unknown>)) as Token<unknown> | undefined;
|
|
268
280
|
context.issues.push(
|
|
269
|
-
`Module "${record.name}": "${consumer}" depends on "${dep.name}", which is provided by another module but not exported
|
|
281
|
+
`Module "${record.name}": "${consumer}" depends on "${dep.name}", ${provided ? context.explainHidden(provided, record) : "which is provided by another module but not exported."}`,
|
|
270
282
|
);
|
|
271
283
|
return;
|
|
272
284
|
}
|
|
@@ -293,7 +305,7 @@ function checkDependency(
|
|
|
293
305
|
return;
|
|
294
306
|
}
|
|
295
307
|
context.issues.push(
|
|
296
|
-
`Module "${record.name}": "${consumer}" depends on "${tokenToDebugName(token)}",
|
|
308
|
+
`Module "${record.name}": "${consumer}" depends on "${tokenToDebugName(token)}", ${context.explainHidden(token, record)}`,
|
|
297
309
|
);
|
|
298
310
|
}
|
|
299
311
|
|
|
@@ -3,7 +3,7 @@ import { BadRequestError, UnsupportedMediaTypeError } from "../Errors/HttpError"
|
|
|
3
3
|
import type { HttpContext } from "../HttpContext/HttpContext";
|
|
4
4
|
import type { ParameterBinding } from "./bindings";
|
|
5
5
|
import { convertOr400 } from "./convert";
|
|
6
|
-
import {
|
|
6
|
+
import { bindModelAsync } from "./modelBinder";
|
|
7
7
|
|
|
8
8
|
/** Per-action constraints affecting binding (from decorators). */
|
|
9
9
|
export interface BindingDefaults {
|
|
@@ -67,7 +67,7 @@ export async function bindArguments(
|
|
|
67
67
|
throw new UnsupportedMediaTypeError("application/json");
|
|
68
68
|
}
|
|
69
69
|
const data = await ctx.json();
|
|
70
|
-
args[index] = binding.model ?
|
|
70
|
+
args[index] = binding.model ? await bindModelAsync(binding.model, data, ctx.modelValidator, { primitiveTypes: true }) : data;
|
|
71
71
|
break;
|
|
72
72
|
}
|
|
73
73
|
case "request":
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Class } from "../../di";
|
|
2
2
|
import { BadRequestError, ModelValidationError } from "../Errors/HttpError";
|
|
3
|
-
import { getModelValidator, type ModelValidator } from "./modelValidator";
|
|
3
|
+
import { getModelValidator, type ModelValidationIssue, type ModelValidationResult, type ModelValidator } from "./modelValidator";
|
|
4
4
|
import {
|
|
5
5
|
findRequestModelShape,
|
|
6
6
|
type RequestModelClass,
|
|
@@ -17,6 +17,9 @@ const MAX_JSON_NODES = 100_000;
|
|
|
17
17
|
interface BindingTraversal {
|
|
18
18
|
readonly stack: WeakSet<object>;
|
|
19
19
|
readonly options: ModelBindingOptions;
|
|
20
|
+
/** Per-field binding problems; a bad field does not stop the other fields. */
|
|
21
|
+
readonly issues: ModelValidationIssue[];
|
|
22
|
+
readonly typeMessage?: (property: string, expected: string, actual: string) => string;
|
|
20
23
|
nodes: number;
|
|
21
24
|
}
|
|
22
25
|
|
|
@@ -49,19 +52,48 @@ export interface ModelBindingOptions {
|
|
|
49
52
|
* runtime fallback.
|
|
50
53
|
*/
|
|
51
54
|
export function bindModel<T extends object>(model: Class<T>, data: unknown, validator?: ModelValidator, options: ModelBindingOptions = {}): T {
|
|
55
|
+
const activeValidator = validator ?? getModelValidator();
|
|
56
|
+
const { instance, traversal } = hydrate(model, data, activeValidator, options);
|
|
57
|
+
finish(traversal, activeValidator, activeValidator?.validate(instance));
|
|
58
|
+
return instance;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `bindModel` for callers that can wait: runs async `custom` rules through
|
|
63
|
+
* `ModelValidator.validateAsync` when the validator provides it.
|
|
64
|
+
*/
|
|
65
|
+
export async function bindModelAsync<T extends object>(model: Class<T>, data: unknown, validator?: ModelValidator, options: ModelBindingOptions = {}): Promise<T> {
|
|
66
|
+
const activeValidator = validator ?? getModelValidator();
|
|
67
|
+
const { instance, traversal } = hydrate(model, data, activeValidator, options);
|
|
68
|
+
const result = activeValidator?.validateAsync !== undefined
|
|
69
|
+
? await activeValidator.validateAsync(instance)
|
|
70
|
+
: activeValidator?.validate(instance);
|
|
71
|
+
finish(traversal, activeValidator, result);
|
|
72
|
+
return instance;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function hydrate<T extends object>(model: Class<T>, data: unknown, validator: ModelValidator | undefined, options: ModelBindingOptions): { instance: T; traversal: BindingTraversal } {
|
|
52
76
|
if (!isPlainJsonObject(data)) {
|
|
53
77
|
throw new BadRequestError("Request body must be a JSON object");
|
|
54
78
|
}
|
|
55
|
-
const traversal: BindingTraversal = {
|
|
79
|
+
const traversal: BindingTraversal = {
|
|
80
|
+
stack: new WeakSet(), nodes: 0, options, issues: [],
|
|
81
|
+
typeMessage: validator?.typeMismatchMessage?.bind(validator),
|
|
82
|
+
};
|
|
56
83
|
const instance = hydrateModel(model as RequestModelClass, data, "", traversal, 0) as T;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
84
|
+
return { instance, traversal };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* One 400 with every problem: binding issues first, then `@Validator` errors of
|
|
89
|
+
* the other fields (a field with a wrong JSON type gets only its type error).
|
|
90
|
+
*/
|
|
91
|
+
function finish(traversal: BindingTraversal, validator: ModelValidator | undefined, result: ModelValidationResult | undefined): void {
|
|
92
|
+
const bound = new Set(traversal.issues.map((issue) => issue.property));
|
|
93
|
+
const errors = [...traversal.issues, ...(result?.errors ?? []).filter((error) => !bound.has(error.property))];
|
|
94
|
+
if (errors.length > 0) {
|
|
95
|
+
throw new ModelValidationError(errors, validator?.failureTitle?.() ?? "Validation failed");
|
|
63
96
|
}
|
|
64
|
-
return instance;
|
|
65
97
|
}
|
|
66
98
|
|
|
67
99
|
function hydrateModel(
|
|
@@ -84,7 +116,12 @@ function hydrateModel(
|
|
|
84
116
|
}
|
|
85
117
|
const target = instance as Record<string, unknown>;
|
|
86
118
|
const fieldPath = path === "" ? key : `${path}.${key}`;
|
|
87
|
-
|
|
119
|
+
try {
|
|
120
|
+
target[key] = bindField(model, key, target[key], source[key], fieldPath, traversal, depth + 1);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
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));
|
|
124
|
+
}
|
|
88
125
|
}
|
|
89
126
|
return instance;
|
|
90
127
|
} finally {
|
|
@@ -117,13 +154,13 @@ function checkPrimitive(shape: RequestModelPrimitiveFieldShape, incoming: unknow
|
|
|
117
154
|
return;
|
|
118
155
|
}
|
|
119
156
|
if (shape.array !== true) {
|
|
120
|
-
if (typeof incoming !== shape.primitive) throw bindingTypeError(path, shape.primitive);
|
|
157
|
+
if (typeof incoming !== shape.primitive) throw bindingTypeError(path, shape.primitive, incoming);
|
|
121
158
|
return;
|
|
122
159
|
}
|
|
123
|
-
if (!Array.isArray(incoming)) throw bindingTypeError(path, "array");
|
|
160
|
+
if (!Array.isArray(incoming)) throw bindingTypeError(path, "array", incoming);
|
|
124
161
|
incoming.forEach((item, index) => {
|
|
125
162
|
if (!(item === null && shape.elementNullable === true) && typeof item !== shape.primitive) {
|
|
126
|
-
throw bindingTypeError(`${path}[${index}]`, shape.primitive);
|
|
163
|
+
throw bindingTypeError(`${path}[${index}]`, shape.primitive, item);
|
|
127
164
|
}
|
|
128
165
|
});
|
|
129
166
|
}
|
|
@@ -142,11 +179,11 @@ function hydrateField(
|
|
|
142
179
|
if (shape.nullable === true) {
|
|
143
180
|
return null;
|
|
144
181
|
}
|
|
145
|
-
throw bindingTypeError(path, shape.array === true ? "array" : "object");
|
|
182
|
+
throw bindingTypeError(path, shape.array === true ? "array" : "object", incoming);
|
|
146
183
|
}
|
|
147
184
|
if (shape.array === true) {
|
|
148
185
|
if (!Array.isArray(incoming)) {
|
|
149
|
-
throw bindingTypeError(path, "array");
|
|
186
|
+
throw bindingTypeError(path, "array", incoming);
|
|
150
187
|
}
|
|
151
188
|
enterContainer(incoming, path, traversal, depth);
|
|
152
189
|
try {
|
|
@@ -156,7 +193,7 @@ function hydrateField(
|
|
|
156
193
|
return null;
|
|
157
194
|
}
|
|
158
195
|
if (!isPlainJsonObject(item)) {
|
|
159
|
-
throw bindingTypeError(itemPath, "object");
|
|
196
|
+
throw bindingTypeError(itemPath, "object", item);
|
|
160
197
|
}
|
|
161
198
|
return hydrateModel(shape.model, item, itemPath, traversal, depth + 1);
|
|
162
199
|
});
|
|
@@ -165,17 +202,25 @@ function hydrateField(
|
|
|
165
202
|
}
|
|
166
203
|
}
|
|
167
204
|
if (!isPlainJsonObject(incoming)) {
|
|
168
|
-
throw bindingTypeError(path, "object");
|
|
205
|
+
throw bindingTypeError(path, "object", incoming);
|
|
169
206
|
}
|
|
170
207
|
return hydrateModel(shape.model, incoming, path, traversal, depth);
|
|
171
208
|
}
|
|
172
209
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
message: `Field "${property
|
|
177
|
-
|
|
178
|
-
|
|
210
|
+
/** A JSON value of the wrong type; the field is skipped and the binding goes on. */
|
|
211
|
+
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" }]);
|
|
214
|
+
}
|
|
215
|
+
|
|
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
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function bindingTypeError(property: string, expected: string, value: unknown): BindingTypeError {
|
|
222
|
+
const actual = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
|
|
223
|
+
return new BindingTypeError(property || "$", expected, actual);
|
|
179
224
|
}
|
|
180
225
|
|
|
181
226
|
function isPlainJsonObject(value: unknown): value is Record<string, unknown> {
|
|
@@ -215,7 +260,7 @@ function sanitizeBoundValue(
|
|
|
215
260
|
|
|
216
261
|
if (Array.isArray(incoming)) {
|
|
217
262
|
if (isNestedModel(template) || isPlainJsonObject(template)) {
|
|
218
|
-
throw bindingTypeError(path, "object");
|
|
263
|
+
throw bindingTypeError(path, "object", incoming);
|
|
219
264
|
}
|
|
220
265
|
enterContainer(incoming, path, traversal, depth);
|
|
221
266
|
try {
|
|
@@ -228,10 +273,10 @@ function sanitizeBoundValue(
|
|
|
228
273
|
}
|
|
229
274
|
|
|
230
275
|
if (!isPlainJsonObject(incoming)) {
|
|
231
|
-
throw bindingTypeError(path, "JSON object or array");
|
|
276
|
+
throw bindingTypeError(path, "JSON object or array", incoming);
|
|
232
277
|
}
|
|
233
278
|
if (Array.isArray(template)) {
|
|
234
|
-
throw bindingTypeError(path, "array");
|
|
279
|
+
throw bindingTypeError(path, "array", incoming);
|
|
235
280
|
}
|
|
236
281
|
|
|
237
282
|
enterContainer(incoming, path, traversal, depth);
|
|
@@ -13,8 +13,19 @@ export interface ModelValidationIssue {
|
|
|
13
13
|
readonly code?: string;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export interface ModelValidationResult {
|
|
17
|
+
readonly isValid: boolean;
|
|
18
|
+
readonly errors: readonly ModelValidationIssue[];
|
|
19
|
+
}
|
|
20
|
+
|
|
16
21
|
export interface ModelValidator {
|
|
17
|
-
validate(instance: object):
|
|
22
|
+
validate(instance: object): ModelValidationResult;
|
|
23
|
+
/** Async rules (custom checks returning a Promise). HTTP binding prefers it when present. */
|
|
24
|
+
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;
|
|
18
29
|
}
|
|
19
30
|
|
|
20
31
|
let activeValidator: ModelValidator | undefined;
|
|
@@ -72,9 +72,9 @@ export class TooManyRequestsError extends HttpError {
|
|
|
72
72
|
* of validation problems.
|
|
73
73
|
*/
|
|
74
74
|
export class ModelValidationError extends BadRequestError {
|
|
75
|
-
constructor(readonly errors: readonly ModelValidationIssue[]) {
|
|
75
|
+
constructor(readonly errors: readonly ModelValidationIssue[], title = "Validation failed") {
|
|
76
76
|
super(
|
|
77
|
-
|
|
77
|
+
title,
|
|
78
78
|
errors.map((error) => ({ property: error.property, message: error.message, code: error.code })),
|
|
79
79
|
);
|
|
80
80
|
}
|
|
@@ -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.96.
|
|
235
|
+
clientInfo: { name: "bazis", title: "Bazis", version: "0.96.6" }, capabilities: { experimentalApi: true },
|
|
236
236
|
}, 15_000, signal);
|
|
237
237
|
if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
|
|
238
238
|
client.notify("initialized");
|
|
@@ -29,8 +29,10 @@ empty or whitespace value. TypeScript forbids unsupported object types.
|
|
|
29
29
|
|
|
30
30
|
A declaration stores no chosen environment, values or cache. `get/has/ensureValid`
|
|
31
31
|
stay for standalone reading of the process env, but `ensureValid` no longer
|
|
32
|
-
switches later reads. Host code gets `resolve`, a service
|
|
33
|
-
`
|
|
32
|
+
switches later reads. Host code gets `resolve`, a service declares
|
|
33
|
+
`ConfigView<T>` in its constructor (codegen binds it to the token of the one
|
|
34
|
+
`defineConfig<T>(...)` declaration; the type argument is required) or injects
|
|
35
|
+
`definition.token` explicitly, a connector gets `ConfigRegistry` as the create argument.
|
|
34
36
|
All built-in connectors use this argument; a custom connector reads
|
|
35
37
|
`configs?.get(config) ?? config`. A direct global get inside a service does not
|
|
36
38
|
read the settings of its kernel.
|
|
@@ -162,6 +162,9 @@ let inferredDiClasses = new Set<ts.ClassDeclaration>();
|
|
|
162
162
|
let explicitlyBoundDiClasses = new Set<ts.ClassDeclaration>();
|
|
163
163
|
// Known runtime token names: createToken descriptions + class names usable as class-tokens.
|
|
164
164
|
let tokenDescriptions = new Set<string>();
|
|
165
|
+
// defineConfig<T>("prefix") declarations by the name of T: a ConfigView<T>
|
|
166
|
+
// constructor parameter binds to the token of that declaration.
|
|
167
|
+
let configPrefixesByType = new Map<string, Set<string>>();
|
|
165
168
|
let knownClassNames = new Set<string>();
|
|
166
169
|
let warnings: string[] = [];
|
|
167
170
|
let fatalErrors: string[] = [];
|
|
@@ -446,7 +449,7 @@ function collectDynamicImportDiagnostics(source: ts.SourceFile, target: string,
|
|
|
446
449
|
function resetTargetAnalysis(files: Set<string>, generatedDir: string): void {
|
|
447
450
|
activeGeneratedDir = generatedDir;
|
|
448
451
|
classDeps = new Map(); inferredDiClasses = new Set(); explicitlyBoundDiClasses = new Set();
|
|
449
|
-
tokenDescriptions = new Set(); knownClassNames = new Set(); warnings = []; fatalErrors = [];
|
|
452
|
+
tokenDescriptions = new Set(); configPrefixesByType = new Map(); knownClassNames = new Set(); warnings = []; fatalErrors = [];
|
|
450
453
|
httpBindings = {}; httpControllerFiles = {}; openApiSchemas = {}; openApiOperations = {};
|
|
451
454
|
grpcBindings = new Map();
|
|
452
455
|
uiProfileResponseModels = new Map(); openApiSchemaModels = new Map(); requestModelClassNames = new Set(); listRequestClassNames = new Set();
|
|
@@ -476,6 +479,7 @@ async function generateTarget(name: string, reachable: Set<string>, production:
|
|
|
476
479
|
}
|
|
477
480
|
collectHttpBindings(source); collectOpenApiSchemas(source); collectUiProfileResponseModels(source);
|
|
478
481
|
}
|
|
482
|
+
resolveConfigViewDeps();
|
|
479
483
|
validateDepsAgainstKnownTokens();
|
|
480
484
|
const requestModelImports = resolveRequestModelImports();
|
|
481
485
|
const requestModelHydration = resolveRequestModelHydration(requestModelImports);
|
|
@@ -711,6 +715,13 @@ function collectTokenDescriptions(source: ts.SourceFile): void {
|
|
|
711
715
|
if (node.expression.text === "createToken" || node.expression.text === "createOpenGenericTokenFamily") {
|
|
712
716
|
tokenDescriptions.add(firstArg.text);
|
|
713
717
|
}
|
|
718
|
+
if (node.expression.text === "defineConfig") {
|
|
719
|
+
// The same description defineConfig gives its token.
|
|
720
|
+
const prefix = firstArg.text || "default";
|
|
721
|
+
tokenDescriptions.add(`Config:${prefix}`);
|
|
722
|
+
const typeName = node.typeArguments?.[0] && getDependencyTypeName(node.typeArguments[0]);
|
|
723
|
+
if (typeName) configPrefixesByType.set(typeName, (configPrefixesByType.get(typeName) ?? new Set()).add(prefix));
|
|
724
|
+
}
|
|
714
725
|
}
|
|
715
726
|
}
|
|
716
727
|
if (ts.isClassDeclaration(node) && node.name) {
|
|
@@ -722,6 +733,39 @@ function collectTokenDescriptions(source: ts.SourceFile): void {
|
|
|
722
733
|
ts.forEachChild(source, visit);
|
|
723
734
|
}
|
|
724
735
|
|
|
736
|
+
/**
|
|
737
|
+
* Replaces each `ConfigView<T>` placeholder with the token of the one
|
|
738
|
+
* `defineConfig<T>(...)` declaration. Runs after every source was read, because
|
|
739
|
+
* a service may come before the file that declares its configuration.
|
|
740
|
+
*/
|
|
741
|
+
function resolveConfigViewDeps(): void {
|
|
742
|
+
for (const [declaration, deps] of classDeps) {
|
|
743
|
+
let changed = false;
|
|
744
|
+
const resolved: CollectedDependency[] = [];
|
|
745
|
+
for (const [index, dep] of deps.entries()) {
|
|
746
|
+
const typeName = /^ConfigView<(.+)>$/.exec(dep.name)?.[1];
|
|
747
|
+
if (typeName === undefined) {
|
|
748
|
+
resolved.push(dep);
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
const prefixes = [...(configPrefixesByType.get(typeName) ?? [])];
|
|
752
|
+
if (prefixes.length !== 1) {
|
|
753
|
+
classDeps.delete(declaration);
|
|
754
|
+
if (needsInferredDiDeps(declaration)) {
|
|
755
|
+
fatalErrors.push(prefixes.length === 0
|
|
756
|
+
? `BAZIS_DI_CONFIG_UNKNOWN: ${sourceLocation(declaration)}: constructor parameter ${index + 1} of "${declaration.name?.text}" has type "ConfigView<${typeName}>", but no defineConfig<${typeName}>(...) declaration was found. Declare the configuration with an explicit type argument, for example defineConfig<${typeName}>("name", { default: {...} }).`
|
|
757
|
+
: `BAZIS_DI_CONFIG_AMBIGUOUS: ${sourceLocation(declaration)}: constructor parameter ${index + 1} of "${declaration.name?.text}" has type "ConfigView<${typeName}>", which matches several declarations: ${prefixes.map((prefix) => `"${prefix}"`).join(", ")}. Give each declaration its own type, or pass the token explicitly: scoped(Service, Service, [config.token]).`);
|
|
758
|
+
}
|
|
759
|
+
changed = false;
|
|
760
|
+
break;
|
|
761
|
+
}
|
|
762
|
+
resolved.push({ name: `Config:${prefixes[0]}` });
|
|
763
|
+
changed = true;
|
|
764
|
+
}
|
|
765
|
+
if (changed && classDeps.has(declaration)) classDeps.set(declaration, resolved);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
725
769
|
function validateDepsAgainstKnownTokens(): void {
|
|
726
770
|
for (const [declaration, deps] of classDeps) {
|
|
727
771
|
for (let index = 0; index < deps.length; index += 1) {
|
|
@@ -910,6 +954,11 @@ function dependencyFromType(type: ts.Type): CollectedDependency | undefined {
|
|
|
910
954
|
const name = declaration?.name?.text ?? type.aliasSymbol?.name ?? symbol?.name;
|
|
911
955
|
if (!name || BUILTIN_DEPENDENCY_NAMES.has(name)) return undefined;
|
|
912
956
|
const arguments_ = type.flags & ts.TypeFlags.Object ? checker.getTypeArguments(type as ts.TypeReference) : [];
|
|
957
|
+
if (name === "ConfigView") {
|
|
958
|
+
const configType = arguments_[0];
|
|
959
|
+
const configTypeName = configType && (configType.aliasSymbol?.name ?? configType.getSymbol()?.name);
|
|
960
|
+
return configTypeName ? { name: `ConfigView<${configTypeName}>` } : undefined;
|
|
961
|
+
}
|
|
913
962
|
if (name === "Lazy" || name === "IRepository") {
|
|
914
963
|
const inner = arguments_[0] && dependencyFromType(arguments_[0]);
|
|
915
964
|
if (!inner) return undefined;
|
|
@@ -950,6 +999,15 @@ function readConstructorTypeNames(node: ts.ClassDeclaration): ConstructorDepende
|
|
|
950
999
|
continue;
|
|
951
1000
|
}
|
|
952
1001
|
|
|
1002
|
+
if (refName === "ConfigView" && paramType && ts.isTypeReferenceNode(paramType)) {
|
|
1003
|
+
const configType = paramType.typeArguments?.[0];
|
|
1004
|
+
const configTypeName = configType ? getDependencyTypeName(configType) : undefined;
|
|
1005
|
+
if (configTypeName) {
|
|
1006
|
+
deps.push({ name: `ConfigView<${configTypeName}>` });
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
953
1011
|
if (refName === "IRepository" && paramType && ts.isTypeReferenceNode(paramType)) {
|
|
954
1012
|
const entityType = paramType.typeArguments?.[0];
|
|
955
1013
|
const entityName = entityType ? getDependencyTypeName(entityType) : undefined;
|
|
@@ -6,11 +6,16 @@ export type MessageParams = Readonly<Record<string, unknown>>;
|
|
|
6
6
|
|
|
7
7
|
/** Built-in default messages (English). */
|
|
8
8
|
const BUILT_IN: Readonly<Record<string, string>> = {
|
|
9
|
+
validationFailed: "Validation failed",
|
|
10
|
+
invalid: 'Field "{property}" is invalid ({code})',
|
|
9
11
|
required: 'Field "{property}" is required',
|
|
10
12
|
notEmpty: 'Field "{property}" must not be empty',
|
|
11
13
|
minLength: 'Field "{property}" must be at least {min} characters long',
|
|
12
14
|
maxLength: 'Field "{property}" must be at most {max} characters long',
|
|
13
15
|
length: 'Field "{property}" must be {min} to {max} characters long',
|
|
16
|
+
minItems: 'Field "{property}" must contain at least {min} items',
|
|
17
|
+
maxItems: 'Field "{property}" must contain at most {max} items',
|
|
18
|
+
itemCount: 'Field "{property}" must contain {min} to {max} items',
|
|
14
19
|
contains: 'Field "{property}" must contain "{contains}"',
|
|
15
20
|
notContains: 'Field "{property}" must not contain "{contains}"',
|
|
16
21
|
pattern: 'Field "{property}" does not match the format {pattern}',
|
|
@@ -41,11 +46,16 @@ const BUILT_IN: Readonly<Record<string, string>> = {
|
|
|
41
46
|
* `MessageRegistry.setDefaults(RU_VALIDATION_MESSAGES)`.
|
|
42
47
|
*/
|
|
43
48
|
export const RU_VALIDATION_MESSAGES: Readonly<Record<string, string>> = Object.freeze({
|
|
49
|
+
validationFailed: "Проверка данных не пройдена",
|
|
50
|
+
invalid: 'Поле "{property}" не прошло проверку ({code})',
|
|
44
51
|
required: 'Поле "{property}" обязательно для заполнения',
|
|
45
52
|
notEmpty: 'Поле "{property}" не должно быть пустым',
|
|
46
53
|
minLength: 'Поле "{property}" должно содержать не менее {min} символов',
|
|
47
54
|
maxLength: 'Поле "{property}" должно содержать не более {max} символов',
|
|
48
55
|
length: 'Поле "{property}" должно содержать от {min} до {max} символов',
|
|
56
|
+
minItems: 'Поле "{property}" должно содержать не менее {min} элементов',
|
|
57
|
+
maxItems: 'Поле "{property}" должно содержать не более {max} элементов',
|
|
58
|
+
itemCount: 'Поле "{property}" должно содержать от {min} до {max} элементов',
|
|
49
59
|
contains: 'Поле "{property}" должно содержать "{contains}"',
|
|
50
60
|
notContains: 'Поле "{property}" не должно содержать "{contains}"',
|
|
51
61
|
pattern: 'Поле "{property}" не соответствует формату {pattern}',
|
|
@@ -118,7 +128,7 @@ export class MessageRegistry {
|
|
|
118
128
|
localMessage ??
|
|
119
129
|
(Object.prototype.hasOwnProperty.call(MessageRegistry.defaults, code)
|
|
120
130
|
? MessageRegistry.defaults[code]!
|
|
121
|
-
:
|
|
131
|
+
: MessageRegistry.defaults.invalid ?? BUILT_IN.invalid!);
|
|
122
132
|
return MessageRegistry.interpolate(template, params);
|
|
123
133
|
}
|
|
124
134
|
|
|
@@ -115,11 +115,14 @@ export class RuleEngine {
|
|
|
115
115
|
|
|
116
116
|
// 3. String rules: first one type check, then the rules themselves.
|
|
117
117
|
if (rule.needsString) {
|
|
118
|
-
if (
|
|
118
|
+
if (Array.isArray(value) && RuleEngine.onlyLengthRules(o)) {
|
|
119
|
+
RuleEngine.applyArrayLengthRules(o, value, path, errors);
|
|
120
|
+
} else if (typeof value !== "string") {
|
|
119
121
|
RuleEngine.failType(errors, path, value, o, "string");
|
|
120
122
|
return;
|
|
123
|
+
} else {
|
|
124
|
+
RuleEngine.applyStringRules(rule, value, path, errors);
|
|
121
125
|
}
|
|
122
|
-
RuleEngine.applyStringRules(rule, value, path, errors);
|
|
123
126
|
}
|
|
124
127
|
|
|
125
128
|
// 4. Number rules.
|
|
@@ -167,6 +170,27 @@ export class RuleEngine {
|
|
|
167
170
|
}
|
|
168
171
|
}
|
|
169
172
|
|
|
173
|
+
/** notEmpty/minLength/maxLength/length also apply to arrays; any other string rule needs a string. */
|
|
174
|
+
private static onlyLengthRules(o: ValidatorOptions): boolean {
|
|
175
|
+
return o.type === undefined && o.contains === undefined && o.notContains === undefined && o.pattern === undefined
|
|
176
|
+
&& o.email !== true && o.url !== true && o.uuid !== true && o.json !== true && o.phone !== true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private static applyArrayLengthRules(o: ValidatorOptions, value: readonly unknown[], path: string, errors: ValidationError[]): void {
|
|
180
|
+
if (o.notEmpty === true && value.length === 0) {
|
|
181
|
+
RuleEngine.fail(errors, ValidationCodes.notEmpty, path, value, o, undefined);
|
|
182
|
+
}
|
|
183
|
+
if (o.minLength !== undefined && value.length < o.minLength) {
|
|
184
|
+
RuleEngine.fail(errors, ValidationCodes.minItems, path, value, o, { min: o.minLength });
|
|
185
|
+
}
|
|
186
|
+
if (o.maxLength !== undefined && value.length > o.maxLength) {
|
|
187
|
+
RuleEngine.fail(errors, ValidationCodes.maxItems, path, value, o, { max: o.maxLength });
|
|
188
|
+
}
|
|
189
|
+
if (o.length !== undefined && (value.length < o.length[0] || value.length > o.length[1])) {
|
|
190
|
+
RuleEngine.fail(errors, ValidationCodes.itemCount, path, value, o, { min: o.length[0], max: o.length[1] });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
170
194
|
private static applyStringRules(rule: CompiledRule, value: string, path: string, errors: ValidationError[]): void {
|
|
171
195
|
const o = rule.options;
|
|
172
196
|
if (o.notEmpty === true && value.length === 0) {
|
|
@@ -361,7 +385,7 @@ export class RuleEngine {
|
|
|
361
385
|
}
|
|
362
386
|
|
|
363
387
|
private static failType(errors: ValidationError[], path: string, value: unknown, o: ValidatorOptions, expected: string): void {
|
|
364
|
-
const actual = typeof value === "number" && Number.isNaN(value) ? "NaN" : value === null ? "null" : typeof value;
|
|
388
|
+
const actual = typeof value === "number" && Number.isNaN(value) ? "NaN" : value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
|
|
365
389
|
RuleEngine.fail(errors, ValidationCodes.type, path, value, o, { expected, actual });
|
|
366
390
|
}
|
|
367
391
|
|
|
@@ -114,6 +114,11 @@ Any of these rules requires the value to be a string (otherwise one `type` error
|
|
|
114
114
|
`maxLength` and the upper bound of `length` limit the input of the later content checks
|
|
115
115
|
**of the same decorator**: `contains`, `notContains`, `pattern`, `email`, `url`, `uuid`,
|
|
116
116
|
`json`, `phone`. When exceeded, the length errors stay and the listed checks do not run.
|
|
117
|
+
|
|
118
|
+
On an **array** value, `notEmpty`, `minLength`, `maxLength` and `length` count its
|
|
119
|
+
items (codes `notEmpty`, `minItems`, `maxItems`, `itemCount`), as long as the decorator
|
|
120
|
+
has no other string rule and no `type` hint. Any other string rule on an array is a
|
|
121
|
+
`type` error (`got: array`).
|
|
117
122
|
The lower length bound, the other fields/decorators and `custom` keep their behavior.
|
|
118
123
|
For an untrusted `pattern`, set the upper bound in the same decorator; a separate
|
|
119
124
|
limiting decorator does not change the neighboring rules.
|
|
@@ -480,7 +485,8 @@ from your own `ValidationError`.
|
|
|
480
485
|
| Code | When |
|
|
481
486
|
|---|---|
|
|
482
487
|
| `required` | the value is `undefined`/`null` with `required: true` |
|
|
483
|
-
| `notEmpty`, `minLength`, `maxLength`, `length` | the string length |
|
|
488
|
+
| `notEmpty`, `minLength`, `maxLength`, `length` | the string length (`notEmpty` also an empty array) |
|
|
489
|
+
| `minItems`, `maxItems`, `itemCount` | the array item count (`minLength`, `maxLength`, `length` on an array) |
|
|
484
490
|
| `contains`, `notContains`, `pattern` | the string content |
|
|
485
491
|
| `email`, `url`, `uuid`, `json`, `phone`, `date` | the format |
|
|
486
492
|
| `type` | the value has the wrong type (including `NaN` for number rules) |
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Validator } from "./Validator";
|
|
2
2
|
import { rulesOf } from "./metadata";
|
|
3
|
+
import { MessageRegistry } from "./MessageRegistry";
|
|
4
|
+
import type { ValidationResult } from "./ValidationResult";
|
|
3
5
|
|
|
4
6
|
interface AdapterValidationIssue {
|
|
5
7
|
readonly property: string;
|
|
@@ -27,11 +29,31 @@ interface AdapterValidationIssue {
|
|
|
27
29
|
* kernel unaware of the concrete validation engine (dependency inversion).
|
|
28
30
|
*/
|
|
29
31
|
export const modelValidatorAdapter = {
|
|
30
|
-
validate(instance: object): {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
validate(instance: object): AdapterValidationResult {
|
|
33
|
+
return toAdapterResult(instance, Validator.validate(instance));
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
/** Runs async `custom` rules too; HTTP request binding uses this. */
|
|
37
|
+
async validateAsync(instance: object): Promise<AdapterValidationResult> {
|
|
38
|
+
return toAdapterResult(instance, await Validator.validateAsync(instance));
|
|
39
|
+
},
|
|
40
|
+
|
|
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 });
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
failureTitle(): string {
|
|
47
|
+
return MessageRegistry.format("validationFailed", undefined, {});
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
interface AdapterValidationResult {
|
|
52
|
+
readonly isValid: boolean;
|
|
53
|
+
readonly errors: readonly AdapterValidationIssue[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function toAdapterResult(instance: object, result: ValidationResult): AdapterValidationResult {
|
|
35
57
|
const nestedInstanceErrors = validateExplicitNestedInstances(instance);
|
|
36
58
|
const errors: AdapterValidationIssue[] = result.errors.map((error) => ({
|
|
37
59
|
property: error.property,
|
|
@@ -43,8 +65,7 @@ export const modelValidatorAdapter = {
|
|
|
43
65
|
isValid: errors.length === 0,
|
|
44
66
|
errors,
|
|
45
67
|
};
|
|
46
|
-
|
|
47
|
-
};
|
|
68
|
+
}
|
|
48
69
|
|
|
49
70
|
/**
|
|
50
71
|
* `nested: true` must never silently validate a plain object with no class
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
export const ValidationCodes = {
|
|
9
9
|
/** The value is missing (`undefined` or `null`) but the field is required. */
|
|
10
10
|
required: "required",
|
|
11
|
-
/** The string is empty but the rule requires a non-empty one. */
|
|
11
|
+
/** The string or array is empty but the rule requires a non-empty one. */
|
|
12
12
|
notEmpty: "notEmpty",
|
|
13
13
|
/** The string is shorter than the minimum. */
|
|
14
14
|
minLength: "minLength",
|
|
@@ -16,6 +16,12 @@ export const ValidationCodes = {
|
|
|
16
16
|
maxLength: "maxLength",
|
|
17
17
|
/** The string length is outside `length: [min, max]`. */
|
|
18
18
|
length: "length",
|
|
19
|
+
/** The array has fewer items than `minLength`. */
|
|
20
|
+
minItems: "minItems",
|
|
21
|
+
/** The array has more items than `maxLength`. */
|
|
22
|
+
maxItems: "maxItems",
|
|
23
|
+
/** The array item count is outside `length: [min, max]`. */
|
|
24
|
+
itemCount: "itemCount",
|
|
19
25
|
/** The string does not contain the required substring. */
|
|
20
26
|
contains: "contains",
|
|
21
27
|
/** The string contains a forbidden substring. */
|
|
@@ -88,13 +88,13 @@ export interface ValidatorOptions {
|
|
|
88
88
|
|
|
89
89
|
// ── Strings ──────────────────────────────────────────────────────────────
|
|
90
90
|
|
|
91
|
-
/** The string must not be empty
|
|
91
|
+
/** The string (`""`) or array (`[]`) must not be empty. */
|
|
92
92
|
notEmpty?: boolean;
|
|
93
|
-
/** Minimum string length (inclusive). */
|
|
93
|
+
/** Minimum string length or array item count (inclusive). */
|
|
94
94
|
minLength?: number;
|
|
95
|
-
/** Maximum string length (inclusive); exceeding it skips this decorator's content checks. */
|
|
95
|
+
/** Maximum string length or array item count (inclusive); for a string, exceeding it skips this decorator's content checks. */
|
|
96
96
|
maxLength?: number;
|
|
97
|
-
/** String length range `[min, max]` (inclusive); exceeding max skips this decorator's content checks. */
|
|
97
|
+
/** String length or array item count range `[min, max]` (inclusive); for a string, exceeding max skips this decorator's content checks. */
|
|
98
98
|
length?: readonly [number, number];
|
|
99
99
|
/** The string must contain the substring. */
|
|
100
100
|
contains?: string;
|
package/package.json
CHANGED