bazis 0.96.5 → 0.97.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/templates/module.ts +13 -12
- package/cli/templates/passport.ts +1 -1
- package/core/di/README.md +1 -1
- package/core/di/index.ts +1 -0
- package/core/di/module/encapsulation.ts +15 -3
- package/core/di/token.ts +9 -1
- package/core/http/Authorization/Authorize.ts +3 -2
- package/core/http/Authorization/metadata.ts +17 -11
- 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/http/SPEC.md +7 -2
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -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
package/cli/templates/module.ts
CHANGED
|
@@ -194,23 +194,24 @@ export class ${n.module}DbContext extends DbContext {
|
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
function serviceInterfaceFile(n: ModuleNaming): string {
|
|
197
|
-
return `import {
|
|
198
|
-
import type { PageResult } from "bazis/core/orm";
|
|
197
|
+
return `import type { PageResult } from "bazis/core/orm";
|
|
199
198
|
import type { ListQuery } from "bazis/library/jsonapi";
|
|
200
199
|
import type { Create${n.entity}Request, Update${n.entity}Request } from "../http/contracts/${n.module}.requests";
|
|
201
200
|
import type { ${n.entity}Response, ${n.module}Summary } from "../http/contracts/${n.module}.responses";
|
|
202
201
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
202
|
+
/**
|
|
203
|
+
* Service contract. An abstract class, not an interface: it exists at runtime,
|
|
204
|
+
* so it is the DI token itself: \`scoped(I${n.module}Service, ${n.module}Service)\`.
|
|
205
|
+
*/
|
|
206
|
+
export abstract class I${n.module}Service {
|
|
207
|
+
abstract getAll(query: ListQuery): Promise<PageResult<${n.entity}Response>>;
|
|
208
|
+
abstract summary(): Promise<${n.module}Summary>;
|
|
209
|
+
abstract count(): Promise<number>;
|
|
210
|
+
abstract getById(id: string): Promise<${n.entity}Response | null>;
|
|
211
|
+
abstract create(body: Create${n.entity}Request): Promise<${n.entity}Response>;
|
|
212
|
+
abstract update(id: string, body: Update${n.entity}Request): Promise<${n.entity}Response | null>;
|
|
213
|
+
abstract delete(id: string): Promise<boolean>;
|
|
211
214
|
}
|
|
212
|
-
|
|
213
|
-
export const I${n.module}Service = createToken<I${n.module}Service>("I${n.module}Service");
|
|
214
215
|
`;
|
|
215
216
|
}
|
|
216
217
|
|
|
@@ -51,7 +51,7 @@ adapters that access it.
|
|
|
51
51
|
| ${n.moduleClass} | [${n.module}.module.ts](${n.module}.module.ts) | host imports | ORM, DI, HTTP${profile === "full" ? ", background, AI" : ""} |
|
|
52
52
|
| ${e} | [model/${m}.model.ts](model/${m}.model.ts) | Fields below | Table ${n.route} |
|
|
53
53
|
| ${m}DbContext | [model/${m}.dbContext.ts](model/${m}.dbContext.ts) | Shared host ORM provider | DbSet ${n.collection} |
|
|
54
|
-
| I${m}Service / ${m}Service | [services/${m}.service.ts](services/${m}.service.ts), [
|
|
54
|
+
| I${m}Service / ${m}Service | [services/${m}.service.ts](services/${m}.service.ts), [contract](services/I${m}.service.ts) | ${m}DbContext${profile === "full" ? ", ICache" : ""} | CRUD, count, summary |
|
|
55
55
|
| ${m}Controller | [http/${m}.controller.ts](http/${m}.controller.ts) | I${m}Service, HTTP request | HTTP operations below |
|
|
56
56
|
| Create${e}Request / Update${e}Request | [requests](http/contracts/${m}.requests.ts) | JSON body | RequestModel + Validator |
|
|
57
57
|
| ${e}Response / ${m}Summary / to${e}Response | [responses](http/contracts/${m}.responses.ts) | ORM entity | Public data projection |
|
package/core/di/README.md
CHANGED
|
@@ -14,7 +14,7 @@ contracts are described in [MODULE.md](MODULE.md).
|
|
|
14
14
|
- `ServiceCollection.ts`: service registration.
|
|
15
15
|
- `ServiceScope.ts`: scoped resolve and scoped dispose.
|
|
16
16
|
- `container.ts`: a compatible alias wrapper over `ServiceProvider`.
|
|
17
|
-
- `token.ts`: DI tokens
|
|
17
|
+
- `token.ts`: DI tokens: classes, abstract classes as service contracts, `createToken`, open generic family.
|
|
18
18
|
|
|
19
19
|
## Internals (do not use directly)
|
|
20
20
|
|
package/core/di/index.ts
CHANGED
|
@@ -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
|
|
package/core/di/token.ts
CHANGED
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
// constructor deps is still validated by the container.
|
|
4
4
|
export type Class<T> = new (...args: any[]) => T;
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* A class usable only as a key: an abstract class declared as a contract
|
|
8
|
+
* (`export abstract class IClock { abstract now(): Date }`) and registered with
|
|
9
|
+
* `scoped(IClock, SystemClock)`. Unlike an interface it exists at runtime, so it
|
|
10
|
+
* needs no `createToken`. The container never constructs it.
|
|
11
|
+
*/
|
|
12
|
+
export type AbstractClass<T> = abstract new (...args: any[]) => T;
|
|
13
|
+
|
|
6
14
|
export interface InjectionToken<T> {
|
|
7
15
|
readonly id: symbol;
|
|
8
16
|
readonly description: string;
|
|
@@ -18,7 +26,7 @@ export function createToken<T>(description: string): InjectionToken<T> {
|
|
|
18
26
|
};
|
|
19
27
|
}
|
|
20
28
|
|
|
21
|
-
export type Token<T> = Class<T> | InjectionToken<T>;
|
|
29
|
+
export type Token<T> = Class<T> | AbstractClass<T> | InjectionToken<T>;
|
|
22
30
|
|
|
23
31
|
export interface OpenGenericTokenFamily<TArg, TResult> {
|
|
24
32
|
readonly id: symbol;
|
|
@@ -17,7 +17,8 @@ type ClassOrMethodDecorator = (
|
|
|
17
17
|
* Protects a controller or a single action: the route gets the
|
|
18
18
|
* {@link AuthorizeCheck} function. The HTTP kernel does not know *what* it
|
|
19
19
|
* checks: JWT, login/password, Telegram sign-in and so on. Several checks
|
|
20
|
-
* can be passed; all of them must pass.
|
|
20
|
+
* can be passed; all of them must pass. On a method of a protected controller
|
|
21
|
+
* the checks are added to the controller's ones: the class checks run first.
|
|
21
22
|
*
|
|
22
23
|
* ```ts
|
|
23
24
|
* @Authorize(requireTokenKind(TokenKind.Admin)) // access token kind
|
|
@@ -40,7 +41,7 @@ export function Authorize(check: AuthorizeCheck, ...rest: AuthorizeCheck[]): Cla
|
|
|
40
41
|
};
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
/** Public route:
|
|
44
|
+
/** Public route: on a method, removes every check, including the controller's `@Authorize`. */
|
|
44
45
|
export function AllowAnonymous(): ClassOrMethodDecorator {
|
|
45
46
|
return (_value, context) => {
|
|
46
47
|
if (context.kind === "class") {
|
|
@@ -122,15 +122,22 @@ export function setActionAllowAnonymous(metadata: object, methodName: string | s
|
|
|
122
122
|
delete action.authorize;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
/** Effective route requirement
|
|
125
|
+
/** Effective route requirement: the class and method checks combined. */
|
|
126
126
|
export interface ResolvedAuthorizeMeta {
|
|
127
127
|
readonly allowAnonymous: boolean;
|
|
128
128
|
readonly authorize?: AuthorizeOptions;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
/**
|
|
132
|
-
* Resolves the authorization requirement of a specific action
|
|
133
|
-
*
|
|
132
|
+
* Resolves the authorization requirement of a specific action:
|
|
133
|
+
*
|
|
134
|
+
* - `@AllowAnonymous` on the method opens it, whatever the class declares;
|
|
135
|
+
* - `@Authorize` on the class and on the method combine: the class checks run
|
|
136
|
+
* first, then the method checks (a check repeated on both runs once);
|
|
137
|
+
* - `@AllowAnonymous` on the class opens the methods without their own `@Authorize`.
|
|
138
|
+
*
|
|
139
|
+
* Inheritance is different: a subclass's own class-level declaration replaces
|
|
140
|
+
* the base class's, and an overriding method's declaration replaces the base method's.
|
|
134
141
|
*/
|
|
135
142
|
export function resolveAuthorizeMeta(ctor: object, methodName: string | symbol): ResolvedAuthorizeMeta {
|
|
136
143
|
const metadata = (ctor as { [key: symbol]: unknown })[Symbol.metadata as unknown as symbol] as
|
|
@@ -145,14 +152,13 @@ export function resolveAuthorizeMeta(ctor: object, methodName: string | symbol):
|
|
|
145
152
|
if (action?.allowAnonymous) {
|
|
146
153
|
return { allowAnonymous: true };
|
|
147
154
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (meta.allowAnonymous) {
|
|
155
|
+
const classChecks = meta.authorize?.checks ?? [];
|
|
156
|
+
const actionChecks = action?.authorize?.checks ?? [];
|
|
157
|
+
if (actionChecks.length === 0 && meta.allowAnonymous) {
|
|
152
158
|
return { allowAnonymous: true };
|
|
153
159
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
160
|
+
const checks = [...new Set([...classChecks, ...actionChecks])];
|
|
161
|
+
return checks.length === 0
|
|
162
|
+
? { allowAnonymous: false }
|
|
163
|
+
: { allowAnonymous: false, authorize: { checks } };
|
|
158
164
|
}
|
|
@@ -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
|
}
|
package/core/http/SPEC.md
CHANGED
|
@@ -804,8 +804,13 @@ Do not create a **new** middleware function per request, only at load/startup.
|
|
|
804
804
|
`@Authorize(check, ...checks)` on a controller or a method compiles into the built-in
|
|
805
805
|
authorization link [6]. A check is `(ctx) => boolean | Promise<boolean>`: `true` lets the
|
|
806
806
|
request through, `false` gives `403`, and a thrown `HttpError` defines the response
|
|
807
|
-
itself (for example `UnauthorizedError` → `401`).
|
|
808
|
-
|
|
807
|
+
itself (for example `UnauthorizedError` → `401`). Checks of the controller and of the
|
|
808
|
+
method combine: the controller's run first, then the method's; a check repeated on both
|
|
809
|
+
runs once. A method-level `@AllowAnonymous` removes every check, including the
|
|
810
|
+
controller's. On an `@AllowAnonymous` controller only methods with their own
|
|
811
|
+
`@Authorize` are protected. Inheritance replaces instead: a subclass's own class-level
|
|
812
|
+
declaration replaces the base class's (since 0.97.0; before it a method-level
|
|
813
|
+
`@Authorize` replaced the controller's checks).
|
|
809
814
|
|
|
810
815
|
```ts
|
|
811
816
|
const isSignedIn: AuthorizeCheck = (ctx) => {
|
|
@@ -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.
|
|
235
|
+
clientInfo: { name: "bazis", title: "Bazis", version: "0.97.0" }, capabilities: { experimentalApi: true },
|
|
236
236
|
}, 15_000, signal);
|
|
237
237
|
if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
|
|
238
238
|
client.notify("initialized");
|
|
@@ -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