bazis 0.100.7 → 0.101.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/core/app/messages.ts +1 -1
- 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 +8 -0
- package/core/http/Binding/autoBindings.ts +76 -24
- package/core/http/Binding/bindings.ts +15 -2
- package/core/http/Binding/generatedSpec.ts +3 -3
- package/core/http/Binding/sourceModelBinder.ts +87 -0
- package/core/http/Binding/sources.ts +76 -0
- package/core/http/Errors/HttpError.ts +4 -7
- package/core/http/OpenApi/openApiDocument.ts +45 -0
- package/core/http/README.md +39 -2
- package/core/http/SPEC.md +1 -1
- package/core/http/index.ts +1 -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/core/scripts/di-generate.ts +97 -4
- package/library/http-client/HttpClient.ts +7 -0
- package/library/http-client/configKeys.ts +36 -0
- package/library/validation/MessageRegistry.ts +4 -0
- package/package.json +1 -1
package/core/app/messages.ts
CHANGED
|
@@ -11,7 +11,7 @@ export type AppLanguage = "en" | "ru";
|
|
|
11
11
|
* `validationFailed`, `unknownField` and type names (`typeName.number`).
|
|
12
12
|
* Placeholders: `{property}`, `{min}`, `{name}`, … as in the built-in texts.
|
|
13
13
|
*/
|
|
14
|
-
export type AppMessages = Partial<Record<HttpMessageKey | ValidationCode | "validationFailed" | "unknownField" | `typeName.${string}`, string>>;
|
|
14
|
+
export type AppMessages = Partial<Record<HttpMessageKey | ValidationCode | "validationFailed" | "unknownField" | "duplicate" | "tooManyValues" | `typeName.${string}`, string>>;
|
|
15
15
|
|
|
16
16
|
const CATALOGS: Readonly<Record<AppLanguage, Readonly<Record<string, string>>>> = {
|
|
17
17
|
en: { ...EN_HTTP_MESSAGES, ...EN_VALIDATION_MESSAGES },
|
|
@@ -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,4 +1,5 @@
|
|
|
1
1
|
import { ListQueryError, parseListQuery } from "../../../library/jsonapi";
|
|
2
|
+
import { bindSourceModel } from "./sourceModelBinder";
|
|
2
3
|
import { UnsupportedMediaTypeError, frameworkBadRequest } from "../Errors/HttpError";
|
|
3
4
|
import type { HttpContext } from "../HttpContext/HttpContext";
|
|
4
5
|
import type { ParameterBinding } from "./bindings";
|
|
@@ -83,6 +84,13 @@ export async function bindArguments(
|
|
|
83
84
|
case "query":
|
|
84
85
|
args[index] = binding.array ? bindArray(binding, ctx) : bindValue(binding, ctx.query(binding.name ?? ""), "query");
|
|
85
86
|
break;
|
|
87
|
+
case "header":
|
|
88
|
+
args[index] = bindValue(binding, ctx.header(binding.name ?? ""), "header");
|
|
89
|
+
break;
|
|
90
|
+
case "queryModel":
|
|
91
|
+
case "headerModel":
|
|
92
|
+
args[index] = await bindSourceModel(binding, ctx);
|
|
93
|
+
break;
|
|
86
94
|
case "body": {
|
|
87
95
|
const contentType = ctx.header("content-type") ?? "";
|
|
88
96
|
if (defaults.consumes) {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { optionsFromSchema } from "../../../library/jsonapi";
|
|
2
|
-
import { AMBIGUOUS_REQUEST_MODEL, findRequestModelByName } from "./requestModelRegistry";
|
|
2
|
+
import { AMBIGUOUS_REQUEST_MODEL, findRequestModelByName, findRequestModelShape } from "./requestModelRegistry";
|
|
3
|
+
import { wireNamesOf } from "./sources";
|
|
3
4
|
import { AMBIGUOUS_LIST_MODEL, findListModelByName } from "./listModelRegistry";
|
|
4
5
|
import { HttpSetupError } from "../Errors/HttpError";
|
|
5
6
|
import type { Class } from "../../di";
|
|
6
|
-
import type { ParameterBinding } from "./bindings";
|
|
7
|
+
import type { ParameterBinding, SourceModelField } from "./bindings";
|
|
7
8
|
import type { GeneratedBindingSpec } from "./generatedSpec";
|
|
8
9
|
|
|
9
10
|
type GeneratedControllerClass = abstract new (...args: never[]) => object;
|
|
@@ -32,8 +33,9 @@ export function registerGeneratedBindings(
|
|
|
32
33
|
* - `HttpContext` / `Request` / `ResponseBuilder` -> context/raw request/builder;
|
|
33
34
|
* - other primitives -> query (converted by type; `?`/default -> optional).
|
|
34
35
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
36
|
+
* An explicit source comes from the type markers FromQuery/FromHeader/FromBody/
|
|
37
|
+
* FromRoute, including query and header models; raw bodies are read through
|
|
38
|
+
* HttpContext; services are injected into the constructor. Any DTO class parameter is treated as the request body.
|
|
37
39
|
* Model names are resolved once at startup; configuration errors are
|
|
38
40
|
* `HttpSetupError` (fail fast).
|
|
39
41
|
*/
|
|
@@ -56,6 +58,14 @@ function toBinding(spec: GeneratedBindingSpec, controllerName: string, methodNam
|
|
|
56
58
|
return { source: "route", name: spec.name, type: spec.type, optional: spec.optional };
|
|
57
59
|
case "query":
|
|
58
60
|
return { source: "query", name: spec.name, type: spec.type, optional: spec.optional, array: spec.array };
|
|
61
|
+
case "header":
|
|
62
|
+
return { source: "header", name: spec.name?.toLowerCase(), type: spec.type, optional: spec.optional };
|
|
63
|
+
case "queryModel":
|
|
64
|
+
case "headerModel": {
|
|
65
|
+
const source = spec.source === "queryModel" ? "query" : "header";
|
|
66
|
+
const model = resolveModel(spec, `${source} model`, controllerName, methodName, models);
|
|
67
|
+
return { source: spec.source, model, optional: spec.optional, fields: sourceModelFields(model, source, `${controllerName}.${methodName}`) };
|
|
68
|
+
}
|
|
59
69
|
case "context":
|
|
60
70
|
return { source: "context" };
|
|
61
71
|
case "request":
|
|
@@ -83,28 +93,70 @@ function toBinding(spec: GeneratedBindingSpec, controllerName: string, methodNam
|
|
|
83
93
|
}
|
|
84
94
|
return { source: "list", model: ctor as Class<object>, listOptions: optionsFromSchema(ctor) };
|
|
85
95
|
}
|
|
86
|
-
case "body":
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
96
|
+
case "body":
|
|
97
|
+
return spec.model ? { source: "body", model: resolveModel(spec, "body model", controllerName, methodName, models) } : { source: "body" };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function resolveModel(spec: GeneratedBindingSpec, label: string, controllerName: string, methodName: string, models?: ReadonlyMap<string, Class<object>>): Class<object> {
|
|
102
|
+
const name = spec.model ?? "";
|
|
103
|
+
// An explicit target controller always resolves through its own
|
|
104
|
+
// constructor-local index. Name fallback is legacy/default-only.
|
|
105
|
+
const model = models === undefined ? findRequestModelByName(name) : models.get(name);
|
|
106
|
+
if (model === undefined) {
|
|
107
|
+
throw new HttpSetupError(
|
|
108
|
+
`${controllerName}.${methodName}: ${label} "${name}" is not registered. ` +
|
|
109
|
+
`Regenerate the target metadata; for an external DTO use @RequestModel() and import its module.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
if (model === AMBIGUOUS_REQUEST_MODEL) {
|
|
113
|
+
throw new HttpSetupError(
|
|
114
|
+
`${controllerName}.${methodName}: ${label} name "${name}" is ambiguous (several classes share it). ` +
|
|
115
|
+
`Rename one of the classes.`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return model;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const FORBIDDEN_FIELDS = new Set(["__proto__", "constructor", "prototype"]);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The fields of a query or header model, resolved once at startup: the
|
|
125
|
+
* instance's own fields plus the generated shape, each with its wire name and
|
|
126
|
+
* type. Query strings and headers are flat strings, so a nested model field
|
|
127
|
+
* (or a header array) is a setup error instead of a silent `undefined`.
|
|
128
|
+
*/
|
|
129
|
+
function sourceModelFields(model: Class<object>, source: "query" | "header", action: string): readonly SourceModelField[] {
|
|
130
|
+
let instance: object;
|
|
131
|
+
try {
|
|
132
|
+
instance = new (model as unknown as new () => object)();
|
|
133
|
+
} catch (error) {
|
|
134
|
+
throw new HttpSetupError(`${action}: ${source} model "${model.name}" must be constructible without arguments: ${(error as Error).message}`);
|
|
135
|
+
}
|
|
136
|
+
const shape = findRequestModelShape(model as unknown as new () => object) ?? {};
|
|
137
|
+
const names = wireNamesOf(model, source);
|
|
138
|
+
const properties = new Set([...Object.keys(instance), ...Object.keys(shape), ...names.keys()]);
|
|
139
|
+
const fields: SourceModelField[] = [];
|
|
140
|
+
const wires = new Map<string, string>();
|
|
141
|
+
for (const property of properties) {
|
|
142
|
+
if (FORBIDDEN_FIELDS.has(property)) continue;
|
|
143
|
+
const field = shape[property];
|
|
144
|
+
if (field !== undefined && "model" in field) {
|
|
145
|
+
throw new HttpSetupError(`${action}: ${source} model "${model.name}" field "${property}" is an object; ${source} models take string, number, boolean${source === "query" ? " and arrays of them" : ""}.`);
|
|
146
|
+
}
|
|
147
|
+
const array = field?.array === true;
|
|
148
|
+
if (array && source === "header") {
|
|
149
|
+
throw new HttpSetupError(`${action}: header model "${model.name}" field "${property}" is an array; a header model field takes one value.`);
|
|
150
|
+
}
|
|
151
|
+
const wire = names.get(property) ?? (source === "header" ? property.toLowerCase() : property);
|
|
152
|
+
const other = wires.get(wire);
|
|
153
|
+
if (other !== undefined) {
|
|
154
|
+
throw new HttpSetupError(`${action}: ${source} model "${model.name}" binds "${wire}" to both "${other}" and "${property}".`);
|
|
106
155
|
}
|
|
156
|
+
wires.set(wire, property);
|
|
157
|
+
fields.push({ property, wire, type: field !== undefined && "primitive" in field ? field.primitive : "string", array });
|
|
107
158
|
}
|
|
159
|
+
return Object.freeze(fields);
|
|
108
160
|
}
|
|
109
161
|
|
|
110
162
|
/** Internal generated-runtime transaction support. */
|
|
@@ -3,7 +3,18 @@ import type { ListQueryOptions } from "../../../library/jsonapi";
|
|
|
3
3
|
import type { ValueType } from "./convert";
|
|
4
4
|
|
|
5
5
|
/** Internal descriptors resolved from generated action signatures. */
|
|
6
|
-
export type BindingSource = "route" | "query" | "body" | "request" | "response" | "context" | "list";
|
|
6
|
+
export type BindingSource = "route" | "query" | "header" | "body" | "queryModel" | "headerModel" | "request" | "response" | "context" | "list";
|
|
7
|
+
|
|
8
|
+
/** One field of a query or header model, resolved once at startup. */
|
|
9
|
+
export interface SourceModelField {
|
|
10
|
+
/** Property on the model instance. */
|
|
11
|
+
readonly property: string;
|
|
12
|
+
/** Name in the request: the query parameter, or the lower-case header name. */
|
|
13
|
+
readonly wire: string;
|
|
14
|
+
readonly type: "string" | "number" | "boolean";
|
|
15
|
+
/** Every value of a repeated query parameter (`?tag=a&tag=b`). */
|
|
16
|
+
readonly array: boolean;
|
|
17
|
+
}
|
|
7
18
|
|
|
8
19
|
export interface ParameterBinding {
|
|
9
20
|
readonly source: BindingSource;
|
|
@@ -13,8 +24,10 @@ export interface ParameterBinding {
|
|
|
13
24
|
readonly optional?: boolean;
|
|
14
25
|
/** Query array: all values of the name (`?tag=a&tag=b`), each converted by `type`. */
|
|
15
26
|
readonly array?: boolean;
|
|
16
|
-
/** Body model class (instantiated
|
|
27
|
+
/** Body, query or header model class (instantiated and validated). */
|
|
17
28
|
readonly model?: Class<object>;
|
|
29
|
+
/** Fields of a query or header model with their wire names and types. */
|
|
30
|
+
readonly fields?: readonly SourceModelField[];
|
|
18
31
|
/** Whitelist/limits for the class-based list binding (JSON:API list query). */
|
|
19
32
|
readonly listOptions?: ListQueryOptions;
|
|
20
33
|
}
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* The spec stores the name; a separate generated target supplies the constructors.
|
|
7
7
|
*/
|
|
8
8
|
export interface GeneratedBindingSpec {
|
|
9
|
-
readonly source: "route" | "query" | "body" | "context" | "request" | "response" | "list";
|
|
10
|
-
/** Name of the route/query parameter. */
|
|
9
|
+
readonly source: "route" | "query" | "header" | "body" | "queryModel" | "headerModel" | "context" | "request" | "response" | "list";
|
|
10
|
+
/** Name of the route/query parameter, or the header (`FromHeader<string, "x-request-id">`). */
|
|
11
11
|
readonly name?: string;
|
|
12
12
|
/** Primitive conversion (for query, and route params without a constraint in the template). */
|
|
13
13
|
readonly type?: "int" | "number" | "bool" | "string";
|
|
@@ -15,6 +15,6 @@ export interface GeneratedBindingSpec {
|
|
|
15
15
|
readonly optional?: boolean;
|
|
16
16
|
/** A query array (`tag: string[]`): every `?tag=` value, each converted by `type`. */
|
|
17
17
|
readonly array?: boolean;
|
|
18
|
-
/**
|
|
18
|
+
/** Body, query or header model class name (resolved through the generated target; `@RequestModel()` classes are the fallback). */
|
|
19
19
|
readonly model?: string;
|
|
20
20
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { ModelValidationError } from "../Errors/HttpError";
|
|
2
|
+
import type { HttpContext } from "../HttpContext/HttpContext";
|
|
3
|
+
import type { ParameterBinding, SourceModelField } from "./bindings";
|
|
4
|
+
import { tryConvert, INVALID, type ValueType } from "./convert";
|
|
5
|
+
import type { ModelValidationIssue, ModelValidator } from "./modelValidator";
|
|
6
|
+
|
|
7
|
+
/** Values one query parameter may carry: bounds the work for `?tag=…` repeated thousands of times. */
|
|
8
|
+
export const MAX_QUERY_VALUES = 100;
|
|
9
|
+
|
|
10
|
+
const VALUE_TYPES: Readonly<Record<SourceModelField["type"], ValueType>> = { string: "string", number: "number", boolean: "bool" };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A query or header model: every declared field is read from the request by
|
|
14
|
+
* its wire name, converted to the field type and then checked by the model's
|
|
15
|
+
* `@Validator` rules — before the action runs. All problems come in one 400,
|
|
16
|
+
* named as the client sent them (`page_size`, `x-tenant-id`). Undeclared
|
|
17
|
+
* parameters are ignored; a field that is absent keeps its initializer.
|
|
18
|
+
*/
|
|
19
|
+
export async function bindSourceModel(binding: ParameterBinding, ctx: HttpContext): Promise<object> {
|
|
20
|
+
const model = binding.model as new () => object;
|
|
21
|
+
const instance = new model() as Record<string, unknown>;
|
|
22
|
+
const source = binding.source === "headerModel" ? "header" : "query";
|
|
23
|
+
const issues: ModelValidationIssue[] = [];
|
|
24
|
+
const failed = new Set<string>();
|
|
25
|
+
const validator = ctx.modelValidator;
|
|
26
|
+
const say = (code: string, params: Readonly<Record<string, unknown>>, english: string): ModelValidationIssue => ({
|
|
27
|
+
property: params.property as string,
|
|
28
|
+
message: validator?.message?.(code, params) ?? english,
|
|
29
|
+
code,
|
|
30
|
+
params,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
for (const field of binding.fields ?? []) {
|
|
34
|
+
const raw = source === "query" ? ctx.url.searchParams.getAll(field.wire) : headerValues(ctx, field.wire);
|
|
35
|
+
if (raw.length === 0) continue;
|
|
36
|
+
if (!field.array && raw.length > 1) {
|
|
37
|
+
// `?page_size=10&page_size=1000`: the proxy and the app could read
|
|
38
|
+
// different values, so neither is taken.
|
|
39
|
+
issues.push(say("duplicate", { property: field.wire }, `Parameter "${field.wire}" must be given once`));
|
|
40
|
+
failed.add(field.property);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (raw.length > MAX_QUERY_VALUES) {
|
|
44
|
+
issues.push(say("tooManyValues", { property: field.wire, max: MAX_QUERY_VALUES }, `Parameter "${field.wire}" accepts at most ${MAX_QUERY_VALUES} values`));
|
|
45
|
+
failed.add(field.property);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const values: unknown[] = [];
|
|
49
|
+
for (const text of raw) {
|
|
50
|
+
const value = tryConvert(text, VALUE_TYPES[field.type]);
|
|
51
|
+
if (value === INVALID) {
|
|
52
|
+
const params = { property: field.wire, expected: field.type, actual: "string" };
|
|
53
|
+
issues.push(say("type", params, `Field "${field.wire}" must be of type ${field.type}, got: string`));
|
|
54
|
+
failed.add(field.property);
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
values.push(value);
|
|
58
|
+
}
|
|
59
|
+
if (!failed.has(field.property)) instance[field.property] = field.array ? values : values[0];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const result = validator === undefined ? undefined : await (validator.validateAsync?.(instance) ?? validator.validate(instance));
|
|
63
|
+
const wires = new Map((binding.fields ?? []).map((field) => [field.property, field.wire]));
|
|
64
|
+
for (const error of result?.errors ?? []) {
|
|
65
|
+
if (failed.has(error.property)) continue;
|
|
66
|
+
issues.push(onTheWire(error, wires, validator));
|
|
67
|
+
}
|
|
68
|
+
if (issues.length > 0) {
|
|
69
|
+
throw new ModelValidationError(issues, validator?.message?.("validationFailed", {}) ?? "Validation failed");
|
|
70
|
+
}
|
|
71
|
+
return instance;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A header field takes one value; `fetch` already joins repeated headers. */
|
|
75
|
+
function headerValues(ctx: HttpContext, name: string): string[] {
|
|
76
|
+
const value = ctx.header(name);
|
|
77
|
+
return value === undefined ? [] : [value];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A rule error renamed to the wire name, the message rendered again with it. */
|
|
81
|
+
function onTheWire(error: ModelValidationIssue, wires: ReadonlyMap<string, string>, validator: ModelValidator | undefined): ModelValidationIssue {
|
|
82
|
+
const wire = wires.get(error.property);
|
|
83
|
+
if (wire === undefined || wire === error.property) return error;
|
|
84
|
+
if (error.params === undefined || error.code === undefined) return { ...error, property: wire };
|
|
85
|
+
const params = { ...error.params, property: wire };
|
|
86
|
+
return { property: wire, message: validator?.message?.(error.code, params) ?? error.message, code: error.code, params };
|
|
87
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Standard TC39 decorators have no parameter decorators, so the source of a
|
|
2
|
+
// controller argument is part of its type: `size: FromQuery<number, "page_size">`.
|
|
3
|
+
// The types are erased at runtime; `bazis codegen` reads them from the method
|
|
4
|
+
// signature. On a field of a query or header model the same names are field
|
|
5
|
+
// decorators that set the name on the wire: `@FromQuery("page_size") pageSize = 20`.
|
|
6
|
+
|
|
7
|
+
// Bun runs standard decorators natively, but Symbol.metadata may be missing.
|
|
8
|
+
(Symbol as { metadata?: symbol }).metadata ??= Symbol.for("Symbol.metadata");
|
|
9
|
+
|
|
10
|
+
const QUERY_NAMES = Symbol.for("bazis:http:query-names");
|
|
11
|
+
const HEADER_NAMES = Symbol.for("bazis:http:header-names");
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* From the query string. A primitive (`FromQuery<number>`, optionally with the
|
|
15
|
+
* name on the wire: `FromQuery<number, "page_size">`) or a class whose fields
|
|
16
|
+
* are bound and validated like a request body: `FromQuery<SearchQuery>`.
|
|
17
|
+
*/
|
|
18
|
+
export type FromQuery<T, Name extends string = never> = [Name] extends [never] ? T : T;
|
|
19
|
+
/** From a request header: `FromHeader<string, "x-request-id">` or a header model `FromHeader<TenantHeaders>`. */
|
|
20
|
+
export type FromHeader<T, Name extends string = never> = [Name] extends [never] ? T : T;
|
|
21
|
+
/** From the JSON body: a request-model class, bound and validated before the action runs. */
|
|
22
|
+
export type FromBody<T> = T;
|
|
23
|
+
/** From a route parameter: `FromRoute<number>` or `FromRoute<string, "slug">`. */
|
|
24
|
+
export type FromRoute<T, Name extends string = never> = [Name] extends [never] ? T : T;
|
|
25
|
+
|
|
26
|
+
/** Query parameter name of a query-model field: `@FromQuery("page_size") pageSize = 20`. */
|
|
27
|
+
export function FromQuery(name: string): (value: undefined, context: ClassFieldDecoratorContext) => void {
|
|
28
|
+
return (_value, context) => setWireName(context, QUERY_NAMES, "FromQuery", checkedName(name, "FromQuery", QUERY_NAME));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Header name of a header-model field: `@FromHeader("x-tenant-id") tenantId!: string`. */
|
|
32
|
+
export function FromHeader(name: string): (value: undefined, context: ClassFieldDecoratorContext) => void {
|
|
33
|
+
return (_value, context) => setWireName(context, HEADER_NAMES, "FromHeader", checkedName(name, "FromHeader", HEADER_NAME).toLowerCase());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Wire names of a query or header model: field name → name in the request. */
|
|
37
|
+
export function wireNamesOf(model: object, source: "query" | "header"): ReadonlyMap<string, string> {
|
|
38
|
+
const metadata = (model as { [key: symbol]: unknown })[Symbol.metadata as unknown as symbol] as WireNameMetadata | undefined;
|
|
39
|
+
return metadata?.[source === "query" ? QUERY_NAMES : HEADER_NAMES] ?? EMPTY;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface WireNameMetadata {
|
|
43
|
+
[QUERY_NAMES]?: Map<string, string>;
|
|
44
|
+
[HEADER_NAMES]?: Map<string, string>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const EMPTY: ReadonlyMap<string, string> = new Map();
|
|
48
|
+
/** Printable ASCII without spaces; `[]` stay allowed for names like `ids[]`. */
|
|
49
|
+
const QUERY_NAME = /^[\x21-\x7e]{1,256}$/;
|
|
50
|
+
/** RFC 9110 token: the characters a header name may contain. */
|
|
51
|
+
const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,256}$/;
|
|
52
|
+
|
|
53
|
+
function checkedName(name: string, decorator: string, pattern: RegExp): string {
|
|
54
|
+
if (typeof name !== "string" || !pattern.test(name)) {
|
|
55
|
+
throw new TypeError(`@${decorator}: invalid name ${JSON.stringify(name)}.`);
|
|
56
|
+
}
|
|
57
|
+
return name;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function setWireName(context: ClassFieldDecoratorContext, key: typeof QUERY_NAMES | typeof HEADER_NAMES, decorator: string, name: string): void {
|
|
61
|
+
if (context.static || context.private || typeof context.name !== "string") {
|
|
62
|
+
throw new TypeError(`@${decorator}: "${String(context.name)}" must be a public instance field.`);
|
|
63
|
+
}
|
|
64
|
+
const metadata = context.metadata as WireNameMetadata;
|
|
65
|
+
// Metadata is inherited prototypically: copy on the first write to this class.
|
|
66
|
+
if (!Object.prototype.hasOwnProperty.call(metadata, key)) {
|
|
67
|
+
metadata[key] = new Map(metadata[key] ?? []);
|
|
68
|
+
}
|
|
69
|
+
const names = metadata[key]!;
|
|
70
|
+
for (const [field, existing] of names) {
|
|
71
|
+
if (existing === name && field !== context.name) {
|
|
72
|
+
throw new TypeError(`@${decorator}: "${name}" is used by both "${field}" and "${context.name}".`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
names.set(context.name, name);
|
|
76
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { KernelError } from "../../kernel";
|
|
1
2
|
import type { ModelValidationIssue } from "../Binding/modelValidator";
|
|
2
3
|
import { englishHttpMessage, type HttpMessageKey, type LocalizedMessage, type MessageParams } from "./messages";
|
|
3
4
|
|
|
@@ -111,11 +112,7 @@ export function frameworkBadRequest(key: HttpMessageKey, params?: MessageParams,
|
|
|
111
112
|
|
|
112
113
|
/**
|
|
113
114
|
* Misconfigured routes/controllers (duplicate route, bad template, missing
|
|
114
|
-
* @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.
|
|
115
117
|
*/
|
|
116
|
-
export class HttpSetupError extends
|
|
117
|
-
constructor(message: string) {
|
|
118
|
-
super(message);
|
|
119
|
-
this.name = "HttpSetupError";
|
|
120
|
-
}
|
|
121
|
-
}
|
|
118
|
+
export class HttpSetupError extends KernelError {}
|
|
@@ -122,6 +122,7 @@ function createOperation(input: CreateOperationInput): OpenApiCatalogOperation {
|
|
|
122
122
|
input.versioning,
|
|
123
123
|
version,
|
|
124
124
|
routeParamSchemas(input.routeTemplate),
|
|
125
|
+
input.generated,
|
|
125
126
|
),
|
|
126
127
|
requestBody: createRequestBody(input.bindings, input.action, input.generated),
|
|
127
128
|
responses: {
|
|
@@ -173,6 +174,7 @@ function collectParameters(
|
|
|
173
174
|
versioning: ApiVersioningOptions | undefined,
|
|
174
175
|
version: string | undefined,
|
|
175
176
|
routeSchemas: Readonly<Record<string, OpenApiSchema>>,
|
|
177
|
+
generated: GeneratedOpenApiMetadata,
|
|
176
178
|
): OpenApiParameter[] {
|
|
177
179
|
const parameters: OpenApiParameter[] = [];
|
|
178
180
|
for (const binding of bindings) {
|
|
@@ -197,6 +199,20 @@ function collectParameters(
|
|
|
197
199
|
});
|
|
198
200
|
}
|
|
199
201
|
break;
|
|
202
|
+
case "header":
|
|
203
|
+
if (binding.name) {
|
|
204
|
+
parameters.push({
|
|
205
|
+
name: binding.name,
|
|
206
|
+
in: "header",
|
|
207
|
+
required: binding.optional !== true && binding.defaultValue === undefined,
|
|
208
|
+
schema: schemaForValueType(binding.type),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
break;
|
|
212
|
+
case "queryModel":
|
|
213
|
+
case "headerModel":
|
|
214
|
+
parameters.push(...sourceModelParameters(binding, generated));
|
|
215
|
+
break;
|
|
200
216
|
case "list":
|
|
201
217
|
parameters.push(...listParameters(binding));
|
|
202
218
|
break;
|
|
@@ -216,6 +232,35 @@ function collectParameters(
|
|
|
216
232
|
return parameters;
|
|
217
233
|
}
|
|
218
234
|
|
|
235
|
+
/**
|
|
236
|
+
* One parameter per field of a query or header model, under its wire name.
|
|
237
|
+
* Types, rules and descriptions come from the generated schema of the model
|
|
238
|
+
* (the same schema a body model gets), `required` from its `@Validator` rules.
|
|
239
|
+
*/
|
|
240
|
+
function sourceModelParameters(binding: ParameterBinding, generated: GeneratedOpenApiMetadata): OpenApiParameter[] {
|
|
241
|
+
const schema = binding.model ? generated.schemas[binding.model.name] : undefined;
|
|
242
|
+
const properties = (schema?.properties ?? {}) as Readonly<Record<string, OpenApiSchema>>;
|
|
243
|
+
const required = new Set(Array.isArray(schema?.required) ? (schema.required as string[]) : []);
|
|
244
|
+
const location = binding.source === "headerModel" ? "header" : "query";
|
|
245
|
+
// In the order the class declares its fields, like the body schema.
|
|
246
|
+
const order = Object.keys(properties);
|
|
247
|
+
const position = (property: string) => { const index = order.indexOf(property); return index < 0 ? order.length : index; };
|
|
248
|
+
const fields = [...(binding.fields ?? [])].sort((left, right) => position(left.property) - position(right.property));
|
|
249
|
+
return fields.map((field) => {
|
|
250
|
+
const property = properties[field.property];
|
|
251
|
+
const fallback: OpenApiSchema = field.array ? { type: "array", items: { type: field.type } } : { type: field.type };
|
|
252
|
+
const description = typeof property?.description === "string" ? property.description : undefined;
|
|
253
|
+
return {
|
|
254
|
+
name: field.wire,
|
|
255
|
+
in: location,
|
|
256
|
+
required: required.has(field.property),
|
|
257
|
+
schema: property ?? fallback,
|
|
258
|
+
...(description !== undefined ? { description } : {}),
|
|
259
|
+
...(field.array ? { style: "form", explode: true } : {}),
|
|
260
|
+
};
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
219
264
|
function listParameters(binding: ParameterBinding): OpenApiParameter[] {
|
|
220
265
|
const options = binding.listOptions ?? (binding.model ? optionsFromSchema(binding.model) : {});
|
|
221
266
|
const parameters: OpenApiParameter[] = [
|
package/core/http/README.md
CHANGED
|
@@ -60,8 +60,45 @@ Instead there are ASP.NET-style conventions that codegen infers from the signatu
|
|
|
60
60
|
If no convention can be inferred (for example a union of string literals, no
|
|
61
61
|
annotation or two body classes), `di:generate` fails with
|
|
62
62
|
`BAZIS_HTTP_BINDING_UNRESOLVED`, the method name and the reason. The previous
|
|
63
|
-
generated files are not replaced. Use a supported type
|
|
64
|
-
bodies take `ctx: HttpContext`, and inject services into the
|
|
63
|
+
generated files are not replaced. Use a supported type or an explicit source
|
|
64
|
+
(below); for raw bodies take `ctx: HttpContext`, and inject services into the
|
|
65
|
+
constructor.
|
|
66
|
+
|
|
67
|
+
### Explicit sources: `FromQuery`, `FromHeader`, `FromBody`, `FromRoute`
|
|
68
|
+
|
|
69
|
+
When the convention is not what you want, the parameter type says where the
|
|
70
|
+
value comes from. The markers are type aliases (erased at runtime) that codegen
|
|
71
|
+
reads through the type checker; on a field of a query or header model the same
|
|
72
|
+
names are field decorators that set the name on the wire:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
export class SearchQuery {
|
|
76
|
+
@Validator({ required: true, minLength: 2 }) q!: string;
|
|
77
|
+
@FromQuery("page_size") @Validator({ integer: true, range: [1, 100] }) pageSize = 20;
|
|
78
|
+
tags: string[] = [];
|
|
79
|
+
}
|
|
80
|
+
export class TenantHeaders {
|
|
81
|
+
@FromHeader("x-tenant-id") @Validator({ required: true, uuid: true }) tenantId!: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@Get() search(query: FromQuery<SearchQuery>, headers: FromHeader<TenantHeaders>) {}
|
|
85
|
+
@Get(":id") item(id: FromRoute<number>, size: FromQuery<number, "page_size"> = 10, requestId?: FromHeader<string, "x-request-id">) {}
|
|
86
|
+
@Post() create(body: FromBody<CreateItem>) {}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
- A query or header model is bound like a body and validated by its
|
|
90
|
+
`@Validator` rules before the action runs: every problem in one 400, under
|
|
91
|
+
the name the client sent (`page_size`, `x-tenant-id`).
|
|
92
|
+
- Only declared fields are read; undeclared parameters are ignored. A repeated
|
|
93
|
+
single parameter is a 400 (`duplicate`), an array takes at most 100 values
|
|
94
|
+
(`tooManyValues`). Header names match case-insensitively.
|
|
95
|
+
- Query models take `string`, `number`, `boolean` and arrays of them; header
|
|
96
|
+
models one value per field. A nested object is a startup `HttpSetupError`.
|
|
97
|
+
- OpenAPI lists the model fields as `query`/`header` parameters with the
|
|
98
|
+
schema, rules and JSDoc of the model.
|
|
99
|
+
- Misuse (`FromHeader<string[]>`, a name on a model, `FromRoute` outside the
|
|
100
|
+
template, `FromBody<string>`) is `BAZIS_HTTP_BINDING_UNRESOLVED`.
|
|
101
|
+
|
|
65
102
|
Checking allowed values belongs to the Validator or the service.
|
|
66
103
|
|
|
67
104
|
A `ctx: HttpContext` parameter stays a regular supported input. For older manual
|
package/core/http/SPEC.md
CHANGED
|
@@ -943,7 +943,7 @@ The guard class is registered in DI as a regular `scoped`/`singleton` provider.
|
|
|
943
943
|
| `rateLimit` is in-memory | not shared between processes |
|
|
944
944
|
| Preflight without the global `cors` | a per-route `cors()` registers no OPTIONS handler |
|
|
945
945
|
| Several `@Controller` on a class | not supported: one controller class |
|
|
946
|
-
| Parameter decorators | codegen infers
|
|
946
|
+
| Parameter decorators | not in TC39: codegen infers the source from the signature or from the type markers `FromQuery`/`FromHeader`/`FromBody`/`FromRoute` |
|
|
947
947
|
| Middleware on abstract/private methods | route decorators apply only to public instance methods |
|
|
948
948
|
|
|
949
949
|
---
|
package/core/http/index.ts
CHANGED
|
@@ -59,6 +59,7 @@ export {
|
|
|
59
59
|
|
|
60
60
|
// Binding
|
|
61
61
|
export type { BindingSource, ParameterBinding } from "./Binding/bindings";
|
|
62
|
+
export { FromHeader, FromQuery, type FromBody, type FromRoute } from "./Binding/sources";
|
|
62
63
|
export type { ValueType } from "./Binding/convert";
|
|
63
64
|
export { RequestModel } from "./Binding/RequestModel";
|
|
64
65
|
|
|
@@ -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.101.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");
|
package/core/kernel/Bazis.ts
CHANGED
|
@@ -69,9 +69,17 @@ export class Bazis {
|
|
|
69
69
|
} catch (error) {
|
|
70
70
|
stopWaiting?.();
|
|
71
71
|
if (error instanceof ShutdownTimeoutError) {
|
|
72
|
+
// A failed start whose cleanup is stuck: report why the start failed
|
|
73
|
+
// first, otherwise only the cleanup timeout would be visible.
|
|
74
|
+
const startFailure = error.cause;
|
|
75
|
+
if (startFailure !== undefined) {
|
|
76
|
+
if (screen !== undefined) screen.failed(startFailure, kernel);
|
|
77
|
+
else console.error(`[bazis] application failed: ${formatStartupFailure(startFailure)}`);
|
|
78
|
+
}
|
|
72
79
|
// Graceful shutdown is stuck: hanging handles would keep the process
|
|
73
80
|
// alive forever, so this is the one place a hard exit is correct.
|
|
74
|
-
|
|
81
|
+
const text = startFailure === undefined ? error.message : `Cleanup after the failed start did not finish within ${error.timeoutMs}ms.`;
|
|
82
|
+
console.error(`[bazis] ${redactSensitiveText(text)} Forcing exit.`);
|
|
75
83
|
process.exit(1);
|
|
76
84
|
}
|
|
77
85
|
// Configuration problems surfacing at start (e.g. options fail-fast)
|
|
@@ -97,7 +97,16 @@ export class LifecycleCoordinator {
|
|
|
97
97
|
|
|
98
98
|
try {
|
|
99
99
|
for (let index = 0; index < this.hooks.length; index += 1) {
|
|
100
|
-
|
|
100
|
+
const hook = this.hooks[index] as LifecycleHook;
|
|
101
|
+
if (hook.onInit === undefined) continue;
|
|
102
|
+
// A hung onInit is named by the startup timeout, like a hosted service.
|
|
103
|
+
const name = `hook ${hook.constructor?.name ?? "LifecycleHook"}.onInit`;
|
|
104
|
+
this.pending.add(name);
|
|
105
|
+
try {
|
|
106
|
+
await this.invokeHook(() => hook.onInit?.(), signal);
|
|
107
|
+
} finally {
|
|
108
|
+
this.pending.delete(name);
|
|
109
|
+
}
|
|
101
110
|
}
|
|
102
111
|
|
|
103
112
|
const phases = groupByPhase(hosted);
|
|
@@ -184,7 +184,11 @@ export function defineConfig(prefixOrSchema: string | ConfigSchema<Defaults>, ma
|
|
|
184
184
|
const priority = Math.max(...candidates.map(candidate => candidate.priority));
|
|
185
185
|
const winners = candidates.filter(candidate => candidate.priority === priority);
|
|
186
186
|
const selected = winners[0];
|
|
187
|
-
if (winners.some(candidate => candidate.raw !== selected?.raw))
|
|
187
|
+
if (winners.some(candidate => candidate.raw !== selected?.raw)) {
|
|
188
|
+
// Names only: the values may be secrets.
|
|
189
|
+
const variables = [...new Set(winners.map(candidate => candidate.name))];
|
|
190
|
+
issue(key, "conflict", `conflicting environment aliases: ${variables.join(" and ")} are set to different values; set only one`);
|
|
191
|
+
}
|
|
188
192
|
const raw = selected?.raw ?? (isSecret(cell) || isEnum(cell) ? cell.default : cell);
|
|
189
193
|
let value: ConfigValueType | undefined;
|
|
190
194
|
if (type === "secret") {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { inspect } from "node:util";
|
|
2
2
|
import { redactSensitive, redactSensitiveText } from "../../library/redaction";
|
|
3
|
-
import { StartupFailuresError } from "./errors";
|
|
3
|
+
import { ShutdownTimeoutError, StartupFailuresError, StartupTimeoutError } from "./errors";
|
|
4
4
|
|
|
5
5
|
const MAX_CAUSES = 5;
|
|
6
6
|
/** Properties already printed in the header, the frames or the cause chain. */
|
|
@@ -32,7 +32,9 @@ export function formatStartupFailure(error: unknown): string {
|
|
|
32
32
|
// Services that failed together: the list above is the report; the
|
|
33
33
|
// aggregate's own frames are the kernel's, not the application's.
|
|
34
34
|
if (!(current instanceof StartupFailuresError)) lines.push(...(current.stack ?? "").split("\n").filter((line) => /^\s+at /.test(line)));
|
|
35
|
-
|
|
35
|
+
// Timeouts already say their limit and what was pending in the message.
|
|
36
|
+
const restated = current instanceof StartupTimeoutError || current instanceof ShutdownTimeoutError;
|
|
37
|
+
const details = restated ? {} : Object.fromEntries(Object.entries(current).filter(([name]) => !SHOWN.has(name)));
|
|
36
38
|
if (Object.keys(details).length > 0) lines.push(inspect(redactSensitive(details), INSPECT));
|
|
37
39
|
current = current.cause;
|
|
38
40
|
}
|
|
@@ -194,7 +194,7 @@ let fatalErrors: string[] = [];
|
|
|
194
194
|
|
|
195
195
|
// HTTP binding conventions inferred from controller method signatures.
|
|
196
196
|
interface HttpBindingSpec {
|
|
197
|
-
source: "route" | "query" | "body" | "context" | "request" | "response" | "list";
|
|
197
|
+
source: "route" | "query" | "header" | "body" | "queryModel" | "headerModel" | "context" | "request" | "response" | "list";
|
|
198
198
|
name?: string;
|
|
199
199
|
type?: "int" | "number" | "bool" | "string";
|
|
200
200
|
optional?: boolean;
|
|
@@ -244,6 +244,9 @@ const CONTEXT_TYPES: Record<string, HttpBindingSpec["source"]> = {
|
|
|
244
244
|
Request: "request",
|
|
245
245
|
ResponseBuilder: "response",
|
|
246
246
|
};
|
|
247
|
+
// Explicit argument sources of bazis/core/http: `size: FromQuery<number, "page_size">`.
|
|
248
|
+
type SourceMarkerKind = "FromQuery" | "FromHeader" | "FromBody" | "FromRoute";
|
|
249
|
+
const SOURCE_MARKERS: ReadonlySet<string> = new Set<SourceMarkerKind>(["FromQuery", "FromHeader", "FromBody", "FromRoute"]);
|
|
247
250
|
let program: ts.Program;
|
|
248
251
|
let programFiles = new Map<string, ts.SourceFile>();
|
|
249
252
|
// Framework sources outside the scanned project (node_modules/bazis, vendor/bazis).
|
|
@@ -1464,6 +1467,25 @@ function collectControllerBindings(node: ts.ClassDeclaration, controllerName: st
|
|
|
1464
1467
|
const paramName = param.name.text;
|
|
1465
1468
|
const optional = param.questionToken !== undefined || param.initializer !== undefined;
|
|
1466
1469
|
const typeNode = param.type;
|
|
1470
|
+
|
|
1471
|
+
// An explicit source: FromQuery<…>, FromHeader<…>, FromBody<…>, FromRoute<…>.
|
|
1472
|
+
const marker = typeNode ? sourceMarker(typeNode) : undefined;
|
|
1473
|
+
if (marker !== undefined) {
|
|
1474
|
+
const spec = markerBindingSpec(marker, paramName, optional, routeParams);
|
|
1475
|
+
if (typeof spec === "string") {
|
|
1476
|
+
failed = spec;
|
|
1477
|
+
break;
|
|
1478
|
+
}
|
|
1479
|
+
if (spec.source === "body") {
|
|
1480
|
+
bodyCount += 1;
|
|
1481
|
+
if (bodyCount > 1) {
|
|
1482
|
+
failed = "more than one body (class-typed) parameter";
|
|
1483
|
+
break;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
specs.push(spec);
|
|
1487
|
+
continue;
|
|
1488
|
+
}
|
|
1467
1489
|
const keywordType = typeNode ? typeNode.getText().trim() : undefined;
|
|
1468
1490
|
const refName = typeNode ? getTypeReferenceName(typeNode) : undefined;
|
|
1469
1491
|
|
|
@@ -1511,7 +1533,7 @@ function collectControllerBindings(node: ts.ClassDeclaration, controllerName: st
|
|
|
1511
1533
|
if (failed) {
|
|
1512
1534
|
fatalErrors.push(
|
|
1513
1535
|
`BAZIS_HTTP_BINDING_UNRESOLVED: controller "${controllerName}.${name}" (${filePath}): cannot infer bindings (${failed}). ` +
|
|
1514
|
-
`Use supported parameter types; read
|
|
1536
|
+
`Use supported parameter types or mark the source with FromQuery/FromHeader/FromBody/FromRoute; read raw bodies through HttpContext, and inject services in the constructor.`,
|
|
1515
1537
|
);
|
|
1516
1538
|
continue;
|
|
1517
1539
|
}
|
|
@@ -1519,6 +1541,77 @@ function collectControllerBindings(node: ts.ClassDeclaration, controllerName: st
|
|
|
1519
1541
|
}
|
|
1520
1542
|
}
|
|
1521
1543
|
|
|
1544
|
+
interface SourceMarker {
|
|
1545
|
+
readonly kind: SourceMarkerKind;
|
|
1546
|
+
readonly inner?: ts.TypeNode;
|
|
1547
|
+
/** The name on the wire from the second type argument. */
|
|
1548
|
+
readonly name?: string;
|
|
1549
|
+
readonly problem?: string;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
/**
|
|
1553
|
+
* A parameter type that is one of the source markers of bazis/core/http,
|
|
1554
|
+
* resolved through the type checker: an aliased import (`FromQuery as Q`)
|
|
1555
|
+
* works, an application type that happens to be called FromQuery does not.
|
|
1556
|
+
*/
|
|
1557
|
+
function sourceMarker(node: ts.TypeNode): SourceMarker | undefined {
|
|
1558
|
+
if (!ts.isTypeReferenceNode(node)) return undefined;
|
|
1559
|
+
const typeName = ts.isQualifiedName(node.typeName) ? node.typeName.right : node.typeName;
|
|
1560
|
+
const symbol = checker.getSymbolAtLocation(typeName);
|
|
1561
|
+
if (symbol === undefined) return undefined;
|
|
1562
|
+
const target = (symbol.flags & ts.SymbolFlags.Alias) !== 0 ? checker.getAliasedSymbol(symbol) : symbol;
|
|
1563
|
+
const kind = target.getName();
|
|
1564
|
+
if (!SOURCE_MARKERS.has(kind)) return undefined;
|
|
1565
|
+
const fromFramework = target.declarations?.some((declaration) =>
|
|
1566
|
+
/\/core\/http\/Binding\/sources(\.d)?\.ts$/.test(declaration.getSourceFile().fileName.replaceAll("\\", "/")));
|
|
1567
|
+
if (!fromFramework) return undefined;
|
|
1568
|
+
const [inner, name] = node.typeArguments ?? [];
|
|
1569
|
+
const marker = kind as SourceMarkerKind;
|
|
1570
|
+
if (inner === undefined) return { kind: marker, problem: `${kind} needs a type argument` };
|
|
1571
|
+
if (name === undefined) return { kind: marker, inner };
|
|
1572
|
+
if (ts.isLiteralTypeNode(name) && ts.isStringLiteral(name.literal) && name.literal.text.length > 0) {
|
|
1573
|
+
return { kind: marker, inner, name: name.literal.text };
|
|
1574
|
+
}
|
|
1575
|
+
return { kind: marker, problem: `the name in ${kind}<…, "name"> must be a non-empty string literal` };
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
/** The binding of an explicitly sourced parameter, or why it cannot be bound. */
|
|
1579
|
+
function markerBindingSpec(marker: SourceMarker, paramName: string, optional: boolean, routeParams: ReadonlySet<string>): HttpBindingSpec | string {
|
|
1580
|
+
if (marker.problem !== undefined || marker.inner === undefined) return marker.problem ?? `${marker.kind} needs a type argument`;
|
|
1581
|
+
const inner = marker.inner;
|
|
1582
|
+
const primitive = PRIMITIVE_QUERY_TYPES[inner.getText().trim()];
|
|
1583
|
+
const elementKeyword = arrayElementKeyword(inner);
|
|
1584
|
+
const element = elementKeyword !== undefined ? PRIMITIVE_QUERY_TYPES[elementKeyword] : undefined;
|
|
1585
|
+
const model = getTypeReferenceName(inner);
|
|
1586
|
+
const name = marker.name ?? paramName;
|
|
1587
|
+
const type = (value: HttpBindingSpec["type"]) => (value === "string" ? undefined : value);
|
|
1588
|
+
switch (marker.kind) {
|
|
1589
|
+
case "FromRoute":
|
|
1590
|
+
if (!routeParams.has(name)) return `FromRoute parameter "${name}" is not in the route template`;
|
|
1591
|
+
if (primitive === undefined) return `FromRoute<${inner.getText()}> takes string, number or boolean`;
|
|
1592
|
+
return { source: "route", name, type: type(primitive), optional };
|
|
1593
|
+
case "FromQuery":
|
|
1594
|
+
if (element !== undefined) return { source: "query", name, type: type(element), optional, array: true };
|
|
1595
|
+
if (primitive !== undefined) return { source: "query", name, type: type(primitive), optional };
|
|
1596
|
+
if (model !== undefined && marker.name === undefined) {
|
|
1597
|
+
return listRequestClassNames.has(model) ? { source: "list", model, optional } : { source: "queryModel", model, optional };
|
|
1598
|
+
}
|
|
1599
|
+
return model !== undefined
|
|
1600
|
+
? `FromQuery<${model}, "…">: a query model takes no name; name its fields with @FromQuery("…")`
|
|
1601
|
+
: `FromQuery<${inner.getText()}> takes string, number, boolean, an array of them or a query model class`;
|
|
1602
|
+
case "FromHeader":
|
|
1603
|
+
if (element !== undefined) return `FromHeader<${inner.getText()}>: a header takes one value`;
|
|
1604
|
+
if (primitive !== undefined) return { source: "header", name, type: type(primitive), optional };
|
|
1605
|
+
if (model !== undefined && marker.name === undefined) return { source: "headerModel", model, optional };
|
|
1606
|
+
return model !== undefined
|
|
1607
|
+
? `FromHeader<${model}, "…">: a header model takes no name; name its fields with @FromHeader("…")`
|
|
1608
|
+
: `FromHeader<${inner.getText()}> takes string, number, boolean or a header model class`;
|
|
1609
|
+
case "FromBody":
|
|
1610
|
+
if (model === undefined || primitive !== undefined || element !== undefined) return `FromBody<${inner.getText()}> takes a request-model class; read a raw body through HttpContext`;
|
|
1611
|
+
return { source: "body", model, optional };
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1522
1615
|
interface ControllerAction {
|
|
1523
1616
|
readonly name: string;
|
|
1524
1617
|
/** Declaration whose route decorators give the routes. */
|
|
@@ -1619,7 +1712,7 @@ function resolveRequestModelImports(): RequestModelImport[] {
|
|
|
1619
1712
|
for (const methods of Object.values(httpBindings)) {
|
|
1620
1713
|
for (const specs of Object.values(methods)) {
|
|
1621
1714
|
for (const spec of specs) {
|
|
1622
|
-
if (spec.source === "body" && spec.model !== undefined) {
|
|
1715
|
+
if ((spec.source === "body" || spec.source === "queryModel" || spec.source === "headerModel") && spec.model !== undefined) {
|
|
1623
1716
|
modelNames.add(spec.model);
|
|
1624
1717
|
}
|
|
1625
1718
|
}
|
|
@@ -1956,7 +2049,7 @@ function usedOpenApiSchemaNames(
|
|
|
1956
2049
|
for (const methods of Object.values(httpBindings)) {
|
|
1957
2050
|
for (const specs of Object.values(methods)) {
|
|
1958
2051
|
for (const spec of specs) {
|
|
1959
|
-
if ((spec.source === "body" || spec.source === "list") && spec.model !== undefined) {
|
|
2052
|
+
if ((spec.source === "body" || spec.source === "list" || spec.source === "queryModel" || spec.source === "headerModel") && spec.model !== undefined) {
|
|
1960
2053
|
add(spec.model);
|
|
1961
2054
|
}
|
|
1962
2055
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { HttpClientError, HttpErrorCode } from "./errors";
|
|
2
|
+
import { assertKnownOptions } from "./configKeys";
|
|
2
3
|
import { InspectableRedirectProtocol as redirectProtocol } from "./redirectProtocol";
|
|
3
4
|
import { InterceptorManager } from "./interceptors";
|
|
4
5
|
import { trackDownload } from "./progress";
|
|
@@ -85,6 +86,7 @@ export class HttpClient {
|
|
|
85
86
|
};
|
|
86
87
|
|
|
87
88
|
public constructor(defaults: RequestConfig = {}) {
|
|
89
|
+
assertKnownOptions(defaults, "the client defaults");
|
|
88
90
|
this.defaults = defaults;
|
|
89
91
|
this.interceptors = {
|
|
90
92
|
request: new InterceptorManager<ResolvedRequestConfig>(),
|
|
@@ -106,6 +108,11 @@ export class HttpClient {
|
|
|
106
108
|
public request<T = unknown>(configOrUrl: RequestConfig | string, maybeConfig: RequestConfig = {}): Promise<HttpResponse<T>> {
|
|
107
109
|
const config: RequestConfig =
|
|
108
110
|
typeof configOrUrl === "string" ? { ...maybeConfig, url: configOrUrl } : configOrUrl;
|
|
111
|
+
try {
|
|
112
|
+
assertKnownOptions(config, "the request config");
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return Promise.reject(error);
|
|
115
|
+
}
|
|
109
116
|
const merged = this.mergeConfig(this.defaults, config);
|
|
110
117
|
|
|
111
118
|
// axios ordering: request interceptors run last-registered-first.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { HttpClientError } from "./errors";
|
|
2
|
+
import type { RequestConfig } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Every option of {@link RequestConfig}, checked so a misspelled one is not
|
|
6
|
+
* silently ignored. A record, not a list: a new option fails typecheck until
|
|
7
|
+
* it is added here.
|
|
8
|
+
*/
|
|
9
|
+
const OPTIONS: Readonly<Record<keyof RequestConfig, true>> = {
|
|
10
|
+
url: true, method: true, baseUrl: true, headers: true, params: true, paramsSerializer: true, data: true,
|
|
11
|
+
transformRequest: true, transformResponse: true, onDownloadProgress: true, timeoutMs: true, maxResponseBytes: true,
|
|
12
|
+
signal: true, responseType: true, withCredentials: true, credentials: true, auth: true,
|
|
13
|
+
allowCrossOriginCredentials: true, validateStatus: true, retry: true, propagateCorrelation: true,
|
|
14
|
+
correlationHeaders: true, fetch: true, inspectableRedirects: true, redirect: true, maxRedirects: true, cache: true,
|
|
15
|
+
mode: true, keepalive: true, meta: true,
|
|
16
|
+
};
|
|
17
|
+
const KNOWN: ReadonlySet<string> = new Set(Object.keys(OPTIONS));
|
|
18
|
+
|
|
19
|
+
/** axios names with a different spelling here. */
|
|
20
|
+
const AXIOS_NAMES: Readonly<Record<string, keyof RequestConfig>> = {
|
|
21
|
+
timeout: "timeoutMs",
|
|
22
|
+
maxContentLength: "maxResponseBytes",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Rejects an option the client does not know. Without it `baseURL` (the axios
|
|
27
|
+
* spelling) was dropped, and every call failed with ERR_NETWORK
|
|
28
|
+
* "fetch() URL is invalid".
|
|
29
|
+
*/
|
|
30
|
+
export function assertKnownOptions(config: object, where: string): void {
|
|
31
|
+
for (const key of Object.keys(config)) {
|
|
32
|
+
if (KNOWN.has(key)) continue;
|
|
33
|
+
const suggestion = AXIOS_NAMES[key] ?? [...KNOWN].find((known) => known.toLowerCase() === key.toLowerCase());
|
|
34
|
+
throw new HttpClientError(`Unknown HttpClient option "${key}" in ${where}${suggestion === undefined ? "" : `; did you mean "${suggestion}"?`}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -40,6 +40,8 @@ export const EN_VALIDATION_MESSAGES: Readonly<Record<string, string>> = Object.f
|
|
|
40
40
|
asyncCustomInSyncCall:
|
|
41
41
|
'Field "{property}": an async custom check is not supported by synchronous validate(); use validateAsync()',
|
|
42
42
|
unknownField: 'Field "{property}" is forbidden or not declared by the model.',
|
|
43
|
+
duplicate: 'Parameter "{property}" must be given once',
|
|
44
|
+
tooManyValues: 'Parameter "{property}" accepts at most {max} values',
|
|
43
45
|
});
|
|
44
46
|
const BUILT_IN = EN_VALIDATION_MESSAGES;
|
|
45
47
|
|
|
@@ -86,6 +88,8 @@ export const RU_VALIDATION_MESSAGES: Readonly<Record<string, string>> = Object.f
|
|
|
86
88
|
asyncCustomInSyncCall:
|
|
87
89
|
'Поле "{property}": асинхронная custom-проверка не поддерживается синхронным validate(), используйте validateAsync()',
|
|
88
90
|
unknownField: 'Поле "{property}" запрещено или не объявлено в модели',
|
|
91
|
+
duplicate: 'Параметр "{property}" передан несколько раз',
|
|
92
|
+
tooManyValues: 'Параметр "{property}": максимум значений — {max}',
|
|
89
93
|
"typeName.string": "строка",
|
|
90
94
|
"typeName.number": "число",
|
|
91
95
|
"typeName.int": "целое число",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bazis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.101.0",
|
|
4
4
|
"description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alexander Alenin",
|