bazis 0.100.8 → 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,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
+ }
@@ -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.8" }, 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");
@@ -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
  }
@@ -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.8",
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",