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.
@@ -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
- public constructor(tokenName: string) {
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
- super(`Cannot resolve scoped service "${tokenToDebugName(token)}" from root provider.`);
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
- const cyclePath = [...tokenPath, registration.token].map((token) => tokenToDebugName(token)).join(" -> ");
68
- issues.add(`Cycle detected: ${cyclePath}`);
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
- * Headers and raw bodies are read through HttpContext; services are injected
36
- * into the constructor. Any DTO class parameter is treated as the request body.
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
- if (!spec.model) {
88
- return { source: "body" };
89
- }
90
- // An explicit target controller always resolves through its own
91
- // constructor-local index. Name fallback is legacy/default-only.
92
- const model = models === undefined ? findRequestModelByName(spec.model) : models.get(spec.model);
93
- if (model === undefined) {
94
- throw new HttpSetupError(
95
- `${controllerName}.${methodName}: body model "${spec.model}" is not registered. ` +
96
- `Regenerate the target metadata; for an external DTO use @RequestModel() and import its module.`,
97
- );
98
- }
99
- if (model === AMBIGUOUS_REQUEST_MODEL) {
100
- throw new HttpSetupError(
101
- `${controllerName}.${methodName}: body model name "${spec.model}" is ambiguous (several classes share it). ` +
102
- `Rename one of the classes.`,
103
- );
104
- }
105
- return { source: "body", model };
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, sanitized and validated). */
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
- /** DTO class name (resolved through the generated target; `@RequestModel()` classes are the fallback). */
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 Error {
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[] = [
@@ -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; for headers and raw
64
- bodies take `ctx: HttpContext`, and inject services into the constructor.
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 argument sources from the signature |
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
  ---
@@ -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.100.7" }, capabilities: { experimentalApi: true },
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");
@@ -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
- console.error(`[bazis] ${redactSensitiveText(error.message)} Forcing exit.`);
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
- await this.invokeHook(() => (this.hooks[index] as LifecycleHook).onInit?.(), signal);
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)) issue(key, "conflict", "conflicting environment aliases");
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
- const details = Object.fromEntries(Object.entries(current).filter(([name]) => !SHOWN.has(name)));
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 headers or raw bodies through HttpContext, and inject services in the constructor.`,
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.100.7",
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",