@webpieces/core-util 0.4.806 → 0.4.808

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.
@@ -1,84 +1,52 @@
1
1
  "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DtoSchemaBuilder = exports.DtoValidationFailure = exports.ApiJsonSchema = exports.WpDtoFieldMetadata = exports.WpDtoMapFieldOptions = exports.WpMcpHeader = exports.WpDtoFieldOptions = void 0;
4
- exports.WpDto = WpDto;
5
- exports.WpDtoField = WpDtoField;
6
- exports.WpResponseDto = WpResponseDto;
7
- require("reflect-metadata");
8
- const DTO_MARKER = 'webpieces:dto-schema';
9
- const DTO_FIELDS = 'webpieces:dto-fields';
10
- const RESPONSE_DTOS = 'webpieces:response-dtos';
11
- /** Additive field metadata; the scalar/object type itself comes from `design:type`. */
12
- class WpDtoFieldOptions {
13
- description;
14
- required;
15
- arrayItems;
16
- integer;
17
- minimum;
18
- maximum;
19
- enumValues;
20
- mcpHeader;
21
- constructor(description,
22
- /** Required/optional is explicit because TypeScript erases `?` at runtime. */
23
- required,
24
- /** Required for arrays because TypeScript erases their element type. */
25
- arrayItems,
26
- /** A reflected Number is otherwise emitted as JSON Schema `number`. */
27
- integer = false, minimum, maximum, enumValues,
28
- /** MCP 2026 SEP-2243 header mirrored as `Mcp-Param-{name}`. */
29
- mcpHeader) {
30
- this.description = description;
31
- this.required = required;
32
- this.arrayItems = arrayItems;
33
- this.integer = integer;
34
- this.minimum = minimum;
35
- this.maximum = maximum;
36
- this.enumValues = enumValues;
37
- this.mcpHeader = mcpHeader;
38
- }
39
- }
40
- exports.WpDtoFieldOptions = WpDtoFieldOptions;
41
2
  /**
42
- * Names the `x-mcp-header` a primitive tool parameter is mirrored into (`Mcp-Param-{name}`). A class,
43
- * not a bare string, so the 8th `WpDtoFieldOptions` argument can never be mistaken for a map value
44
- * type (maps use `WpDtoMapFieldOptions`).
3
+ * The JSON Schema subset webpieces publishes on its API boundaries, and the ONE runtime validator of
4
+ * it.
5
+ *
6
+ * ## Nothing here reads reflect-metadata any more
7
+ *
8
+ * Until #984 this file also carried `@WpDto`, `@WpDtoField`, `WpDtoFieldOptions`,
9
+ * `WpDtoMapFieldOptions`, `WpMcpHeader`, `@WpResponseDto` and `DtoSchemaBuilder` — a second, runtime
10
+ * spelling of facts the TypeScript compiler already holds. #983 measured the two against each other
11
+ * and found the compiler reproduces every one of those arguments byte for byte, so they are deleted
12
+ * rather than deprecated (`.claude/rules/no-backwards-compat.md`): a schema is now BUILT at build
13
+ * time from the source by `@webpieces/api-doc-model`, and the runtime is HANDED the result.
14
+ *
15
+ * What survives is the schema itself, a builder for the boundaries that construct one by hand, and a
16
+ * validator that reads the schema instead of a decorator.
45
17
  */
46
- class WpMcpHeader {
47
- name;
48
- constructor(name) {
49
- this.name = name;
50
- }
51
- }
52
- exports.WpMcpHeader = WpMcpHeader;
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.ApiJsonSchemaValidator = exports.DtoValidationFailure = exports.ObjectSchemaBuilder = exports.ApiJsonSchema = exports.ApiJsonSchemaDiscriminator = void 0;
53
20
  /**
54
- * Field metadata for a typed map such as `Record<string, string>` or `Record<string, SomeDto>`.
55
- * Keys are open strings; every VALUE must match `mapValues`. The value type is explicit for the same
56
- * reason `arrayItems` is: TypeScript erases it, and a `Record` reflects as plain `Object`.
21
+ * The DISCRIMINATOR of a {@link ApiJsonSchema.oneOf} — which property narrows the union, and what
22
+ * each of its values names.
23
+ *
24
+ * The shape MIRRORS what `OpenApiGenerator`'s `SchemaRenderer` already emits for the same union
25
+ * (`propertyName` + `mapping`), because a union has exactly ONE published spelling in this repo and
26
+ * the OpenAPI renderer is the reference implementation of it. The one difference is what a mapping
27
+ * VALUE holds: OpenAPI maps a value to `#/components/schemas/ScheduledWindow`, and an MCP tool
28
+ * schema is INLINE and has no `$ref` to point at, so it maps to the branch's type NAME.
29
+ *
30
+ * `mapping` is DOCUMENTATION for the reader. {@link ApiJsonSchemaValidator} never narrows with it:
31
+ * it selects the branch whose own `enum` carries the value, so the accepted request and the
32
+ * published document cannot disagree about which branch a value means.
57
33
  */
58
- class WpDtoMapFieldOptions {
59
- description;
60
- required;
61
- mapValues;
62
- constructor(description,
63
- /** Required/optional is explicit because TypeScript erases `?` at runtime. */
64
- required, mapValues) {
65
- this.description = description;
66
- this.required = required;
67
- this.mapValues = mapValues;
68
- }
69
- }
70
- exports.WpDtoMapFieldOptions = WpDtoMapFieldOptions;
71
- class WpDtoFieldMetadata {
72
- propertyKey;
73
- options;
74
- constructor(propertyKey, options) {
75
- this.propertyKey = propertyKey;
76
- this.options = options;
34
+ class ApiJsonSchemaDiscriminator {
35
+ propertyName;
36
+ mapping;
37
+ constructor(
38
+ /** The property every branch declares as exactly one string literal, e.g. `kind`. */
39
+ propertyName,
40
+ /** discriminator value -> the branch's TYPE NAME (MCP is inline: there is no `$ref`). */
41
+ mapping) {
42
+ this.propertyName = propertyName;
43
+ this.mapping = mapping;
77
44
  }
78
45
  }
79
- exports.WpDtoFieldMetadata = WpDtoFieldMetadata;
46
+ exports.ApiJsonSchemaDiscriminator = ApiJsonSchemaDiscriminator;
80
47
  /** The JSON Schema subset emitted for Webpieces API DTOs. */
81
48
  class ApiJsonSchema {
49
+ /** One type, or `[T, "null"]` for a nullable field. */
82
50
  type;
83
51
  description;
84
52
  properties;
@@ -89,11 +57,90 @@ class ApiJsonSchema {
89
57
  enum;
90
58
  minimum;
91
59
  maximum;
60
+ /**
61
+ * The branches of a union, INLINE (MCP tool schemas have no `$ref`), in declaration order.
62
+ *
63
+ * A union is legal NESTED, inside a property. It is NOT legal at the ROOT of a tool's parameter
64
+ * schema — both the OpenAI and the Anthropic function-calling APIs reject a top-level
65
+ * `oneOf`/`anyOf`/`allOf`, and because a server sends its whole tool list on every request, one
66
+ * such tool makes EVERY request 400. That is refused at build time by the `no-root-union-api-type`
67
+ * rule, before a contract can be published at all.
68
+ */
69
+ oneOf;
70
+ /**
71
+ * Set ONLY when every branch carries the same property typed as one string literal — DERIVED,
72
+ * never invented. A union TypeScript itself cannot narrow is published as a bare {@link oneOf},
73
+ * because claiming a narrowing the source does not have is worse than admitting there is none.
74
+ */
75
+ discriminator;
92
76
  constructor(type) {
93
77
  this.type = type;
94
78
  }
79
+ /**
80
+ * The non-`null` member of the type, which is the one that decides how a value is checked.
81
+ *
82
+ * These three are STATIC and not instance methods on purpose: an instance method would put a
83
+ * function on the structural type, and this class is handed straight to the MCP SDK, whose
84
+ * parameter types are index-signature JSON objects that a class carrying methods no longer
85
+ * satisfies. It stays a data-only structure (`CLAUDE.md` §1) and the helpers live beside it.
86
+ */
87
+ // webpieces-disable no-function-outside-class -- static reader of this class
88
+ static baseTypeOf(schema) {
89
+ if (schema.type === undefined)
90
+ return undefined;
91
+ if (typeof schema.type === 'string')
92
+ return schema.type;
93
+ return schema.type.find((one) => one !== 'null');
94
+ }
95
+ /** True when the schema was widened with `"null"`, i.e. the property may hold `null`. */
96
+ // webpieces-disable no-function-outside-class -- static reader of this class
97
+ static allowsNull(schema) {
98
+ return typeof schema.type !== 'string' && (schema.type?.includes('null') ?? false);
99
+ }
100
+ /**
101
+ * The one closure gate for an object schema. Closed means no key can carry an unspecified value:
102
+ * either extra keys are forbidden (`additionalProperties: false`) or every extra key's value is
103
+ * typed (`additionalProperties: <schema>`, a typed map). A missing or `true` value is open.
104
+ * Use this instead of `schema.additionalProperties === false`, which rejects typed maps.
105
+ */
106
+ // webpieces-disable no-function-outside-class -- static reader of this class
107
+ static isClosed(schema) {
108
+ const extra = schema.additionalProperties;
109
+ return extra === false || (typeof extra === 'object' && extra !== null);
110
+ }
95
111
  }
96
112
  exports.ApiJsonSchema = ApiJsonSchema;
113
+ /**
114
+ * Builds the CLOSED object schema a hand-written runtime boundary validates against — `@WpStream`'s
115
+ * two event schemas are the whole of its client list today.
116
+ *
117
+ * It exists because the alternative spellings are both worse: a decorator that re-states the field
118
+ * types the compiler already knows is exactly what #984 deleted, and a bare object literal is what
119
+ * `CLAUDE.md` §3 forbids. A generated schema never comes through here — it is read from the build's
120
+ * own artifact.
121
+ */
122
+ class ObjectSchemaBuilder {
123
+ schema = new ApiJsonSchema('object');
124
+ requiredNames = [];
125
+ constructor() {
126
+ this.schema.properties = {};
127
+ this.schema.additionalProperties = false;
128
+ }
129
+ required(name, type) {
130
+ this.requiredNames.push(name);
131
+ return this.optional(name, type);
132
+ }
133
+ optional(name, type) {
134
+ this.schema.properties[name] = type;
135
+ return this;
136
+ }
137
+ build() {
138
+ if (this.requiredNames.length > 0)
139
+ this.schema.required = [...this.requiredNames];
140
+ return this.schema;
141
+ }
142
+ }
143
+ exports.ObjectSchemaBuilder = ObjectSchemaBuilder;
97
144
  /** A validation result safe to show to an API caller/model. */
98
145
  class DtoValidationFailure {
99
146
  field;
@@ -106,305 +153,166 @@ class DtoValidationFailure {
106
153
  }
107
154
  }
108
155
  exports.DtoValidationFailure = DtoValidationFailure;
109
- /** Marks a request/response class as a schema-bearing DTO, including intentionally empty DTOs. */
110
- // webpieces-disable no-function-outside-class -- decorator factories are inherently module-scope
111
- function WpDto() {
112
- return (target) => Reflect.defineMetadata(DTO_MARKER, true, target);
113
- }
114
- /** Adds documentation and runtime-only shape facts to a DTO property. */
115
- // webpieces-disable no-function-outside-class -- decorator factories are inherently module-scope
116
- function WpDtoField(options) {
117
- if (options.description.trim() === '') {
118
- throw new Error('@WpDtoField requires non-empty field documentation.');
156
+ /**
157
+ * Validates a value against an {@link ApiJsonSchema} — the ONE runtime narrowing boundary for MCP
158
+ * tool arguments, MCP structured output and typed stream events.
159
+ *
160
+ * It reads the SCHEMA, which is the same object the build published to agents, so a document that
161
+ * says a field is required and a server that accepts it missing cannot disagree. The predecessor
162
+ * walked reflect-metadata instead, which meant the published document and the accepted request were
163
+ * derived from two different declarations.
164
+ */
165
+ class ApiJsonSchemaValidator {
166
+ validate(schema, value) {
167
+ return this.validateAt(schema, value, '$');
119
168
  }
120
- return (target, propertyKey) => {
121
- const dtoClass = target.constructor;
122
- const fields = Reflect.getMetadata(DTO_FIELDS, dtoClass) ?? {};
123
- const name = String(propertyKey);
124
- fields[name] = new WpDtoFieldMetadata(name, options);
125
- Reflect.defineMetadata(DTO_FIELDS, fields, dtoClass);
126
- };
127
- }
128
- /** Declares an async API method's response DTO because reflection sees Promise, not its generic. */
129
- // webpieces-disable no-function-outside-class -- decorator factories are inherently module-scope
130
- function WpResponseDto(responseDto) {
131
- return (target, propertyKey) => {
132
- const apiClass = target.constructor;
133
- const responses = Reflect.getMetadata(RESPONSE_DTOS, apiClass) ?? {};
134
- responses[String(propertyKey)] = responseDto;
135
- Reflect.defineMetadata(RESPONSE_DTOS, responses, apiClass);
136
- };
137
- }
138
- /** Builds and validates the same DTO schema used for MCP input and structured output. */
139
- class DtoSchemaBuilder {
140
- requestClassOf(apiClass, methodName) {
141
- // webpieces-disable no-any-unknown -- reflect-metadata returns erased runtime constructor tokens
142
- const params = Reflect.getMetadata('design:paramtypes', apiClass.prototype, methodName);
143
- if (!params || params.length !== 1) {
144
- throw new Error(`${apiClass.name}.${methodName} must take exactly one @WpDto request class to be an MCP tool.`);
169
+ validateAt(schema, value, path) {
170
+ if (value === null) {
171
+ return ApiJsonSchema.allowsNull(schema)
172
+ ? undefined
173
+ : new DtoValidationFailure(path, `${path} must not be null`);
145
174
  }
146
- return params[0];
147
- }
148
- responseClassOf(apiClass, methodName) {
149
- const responses = Reflect.getMetadata(RESPONSE_DTOS, apiClass) ?? {};
150
- const response = responses[methodName];
151
- if (!response) {
152
- throw new Error(`${apiClass.name}.${methodName} must declare @WpResponseDto(() => ResponseClass) before MCP exposure.`);
175
+ if (schema.oneOf !== undefined) {
176
+ return this.validateUnion(schema, value, path);
177
+ }
178
+ switch (ApiJsonSchema.baseTypeOf(schema)) {
179
+ case 'object':
180
+ return this.validateObject(schema, value, path);
181
+ case 'array':
182
+ return this.validateArray(schema, value, path);
183
+ case 'string':
184
+ return this.validateString(schema, value, path);
185
+ case 'number':
186
+ case 'integer':
187
+ return this.validateNumber(schema, value, path);
188
+ case 'boolean':
189
+ return typeof value === 'boolean'
190
+ ? undefined
191
+ : new DtoValidationFailure(path, `${path} must be a boolean`);
192
+ default:
193
+ return undefined;
153
194
  }
154
- return response();
155
195
  }
156
196
  /**
157
- * The one closure gate for an object schema. Closed means no key can carry an unspecified value:
158
- * either extra keys are forbidden (`additionalProperties: false`) or every extra key's value is
159
- * typed (`additionalProperties: <schema>`, a typed map). A missing or `true` value is open.
160
- * Use this instead of `schema.additionalProperties === false`, which rejects typed maps.
197
+ * A union, validated BY DISCRIMINATOR.
198
+ *
199
+ * The discriminator property is read FIRST and the branch chosen from it, so a value naming no
200
+ * branch is reported as what it is — `$.window.kind must be one of: scheduled | asap`, naming the
201
+ * field that is wrong and the values it may hold. Trying every branch and reporting "no branch
202
+ * matched" tells a caller nothing they can act on: it names neither the offending property nor
203
+ * the legal values, and it hides a branch that matched on `kind` and failed three levels down.
204
+ *
205
+ * The branch is selected by the branches' own `enum`s, never by
206
+ * {@link ApiJsonSchemaDiscriminator.mapping} — the branches ARE the schema, so selection and
207
+ * validation read one source and cannot disagree.
161
208
  */
162
- isClosedSchema(schema) {
163
- const extra = schema.additionalProperties;
164
- return extra === false || (typeof extra === 'object' && extra !== null);
165
- }
166
- build(dtoClass) {
167
- const schema = this.buildAt(dtoClass, new Set());
168
- this.validateMcpHeaders(schema, new Map());
169
- return schema;
170
- }
171
- validate(dtoClass, value) {
172
- return this.validateAt(dtoClass, value, '$');
173
- }
174
- buildAt(dtoClass, parents) {
175
- this.requireDto(dtoClass);
176
- if (parents.has(dtoClass)) {
177
- throw new Error(`Recursive DTO ${dtoClass.name} cannot use an inline MCP schema.`);
178
- }
179
- const nextParents = new Set(parents);
180
- nextParents.add(dtoClass);
181
- const schema = new ApiJsonSchema('object');
182
- schema.properties = {};
183
- schema.additionalProperties = false;
184
- const required = [];
185
- for (const field of this.fields(dtoClass)) {
186
- schema.properties[field.propertyKey] = this.fieldSchema(dtoClass, field, nextParents);
187
- if (field.options.required)
188
- required.push(field.propertyKey);
209
+ validateUnion(schema, value, path) {
210
+ const branches = schema.oneOf ?? [];
211
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
212
+ return new DtoValidationFailure(path, `${path} must be an object`);
189
213
  }
190
- if (required.length > 0)
191
- schema.required = required;
192
- return schema;
193
- }
194
- fieldSchema(dtoClass, field, parents) {
195
- // webpieces-disable no-any-unknown -- reflect-metadata returns an erased constructor token
196
- const reflected = Reflect.getMetadata('design:type', dtoClass.prototype, field.propertyKey);
197
- if (!reflected)
198
- throw new Error(`No reflected type for ${dtoClass.name}.${field.propertyKey}.`);
199
- this.validateOptions(dtoClass, field, reflected);
200
- const options = field.options;
201
- if (options instanceof WpDtoMapFieldOptions) {
202
- const mapSchema = new ApiJsonSchema('object');
203
- mapSchema.description = options.description;
204
- mapSchema.additionalProperties = this.itemSchema(options.mapValues, parents);
205
- return mapSchema;
214
+ const discriminator = schema.discriminator;
215
+ if (discriminator === undefined) {
216
+ return this.validateAnyBranch(branches, value, path);
206
217
  }
207
- let schema;
208
- switch (reflected.name) {
209
- case 'String':
210
- schema = new ApiJsonSchema('string');
211
- break;
212
- case 'Number':
213
- schema = new ApiJsonSchema(options.integer ? 'integer' : 'number');
214
- break;
215
- case 'Boolean':
216
- schema = new ApiJsonSchema('boolean');
217
- break;
218
- case 'Array':
219
- if (!options.arrayItems) {
220
- throw new Error(`${dtoClass.name}.${field.propertyKey} must declare arrayItems.`);
221
- }
222
- schema = new ApiJsonSchema('array');
223
- schema.items = this.itemSchema(options.arrayItems, parents);
224
- break;
225
- default:
226
- schema = this.buildAt(reflected, parents);
218
+ const property = discriminator.propertyName;
219
+ const held = Object.getOwnPropertyDescriptor(value, property)?.value;
220
+ const branch = branches.find((one) => ApiJsonSchemaValidator.branchValuesOf(one, property).includes(held));
221
+ if (branch === undefined) {
222
+ const legal = branches
223
+ .flatMap((one) => ApiJsonSchemaValidator.branchValuesOf(one, property))
224
+ .join(' | ');
225
+ return new DtoValidationFailure(`${path}.${property}`, `${path}.${property} must be one of: ${legal}`);
227
226
  }
228
- schema.description = options.description;
229
- if (options.minimum !== undefined)
230
- schema.minimum = options.minimum;
231
- if (options.maximum !== undefined)
232
- schema.maximum = options.maximum;
233
- if (options.enumValues)
234
- schema.enum = [...options.enumValues];
235
- if (options.mcpHeader)
236
- schema['x-mcp-header'] = options.mcpHeader.name;
237
- return schema;
227
+ return this.validateAt(branch, value, path);
238
228
  }
239
- validateOptions(dtoClass, field, reflected) {
240
- const label = `${dtoClass.name}.${field.propertyKey}`;
241
- const options = field.options;
242
- if (options instanceof WpDtoMapFieldOptions) {
243
- if (reflected !== Object) {
244
- throw new Error(`${label} declares mapValues but is not a map (Record<string, V>).`);
245
- }
246
- return;
247
- }
248
- if (reflected === Object) {
249
- throw new Error(`${label} has type Object: an interface or Record can never be a @WpDto. ` +
250
- 'For a map, use WpDtoMapFieldOptions.');
251
- }
252
- if (reflected.name !== 'Array' && options.arrayItems) {
253
- throw new Error(`${label} declares arrayItems but is not an array.`);
254
- }
255
- if (reflected.name !== 'Number' &&
256
- (options.integer || options.minimum !== undefined || options.maximum !== undefined)) {
257
- throw new Error(`${label} declares numeric constraints but is not a number.`);
258
- }
259
- if (options.minimum !== undefined &&
260
- options.maximum !== undefined &&
261
- options.minimum > options.maximum) {
262
- throw new Error(`${label} minimum cannot exceed maximum.`);
263
- }
264
- if (options.enumValues && reflected.name !== 'String') {
265
- throw new Error(`${label} enumValues require a string field.`);
266
- }
267
- if (options.mcpHeader !== undefined) {
268
- if (!/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(options.mcpHeader.name)) {
269
- throw new Error(`${label} mcpHeader must be an RFC 9110 token.`);
270
- }
271
- if (!['String', 'Number', 'Boolean'].includes(reflected.name)) {
272
- throw new Error(`${label} mcpHeader requires a primitive field.`);
273
- }
274
- if (reflected.name === 'Number' && !options.integer) {
275
- throw new Error(`${label} mcpHeader cannot mirror a non-integer number (MCP 2026-07-28).`);
229
+ /**
230
+ * A union with NO discriminator — one TypeScript itself cannot narrow. Every branch is tried and
231
+ * the value accepted when one matches. There is no better report when none does: the source does
232
+ * not narrow, so no property can honestly be named as the wrong one.
233
+ */
234
+ validateAnyBranch(branches, value, path) {
235
+ for (const branch of branches) {
236
+ if (this.validateAt(branch, value, path) === undefined) {
237
+ return undefined;
276
238
  }
277
239
  }
240
+ return new DtoValidationFailure(path, `${path} matches none of the ${branches.length} accepted shapes`);
278
241
  }
279
- itemSchema(item, parents) {
280
- if (typeof item !== 'string')
281
- return this.buildAt(item, parents);
282
- return new ApiJsonSchema(item === 'integer' ? 'integer' : item);
242
+ /** The string literals ONE branch declares for the discriminator property. */
243
+ // webpieces-disable no-function-outside-class -- private static reader of a branch schema
244
+ static branchValuesOf(branch, property) {
245
+ const declared = Object.getOwnPropertyDescriptor(branch.properties ?? {}, property)
246
+ ?.value;
247
+ return declared?.enum ?? [];
283
248
  }
284
- validateMcpHeaders(schema, seen) {
285
- const header = schema['x-mcp-header'];
286
- if (header) {
287
- const key = header.toLowerCase();
288
- const prior = seen.get(key);
289
- if (prior) {
290
- throw new Error(`mcpHeader '${header}' duplicates '${prior}' case-insensitively.`);
291
- }
292
- seen.set(key, header);
293
- }
294
- for (const child of Object.values(schema.properties ?? {})) {
295
- this.validateMcpHeaders(child, seen);
296
- }
297
- }
298
- validateAt(dtoClass, value, path) {
299
- this.requireDto(dtoClass);
249
+ validateObject(schema, value, path) {
300
250
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
301
251
  return new DtoValidationFailure(path, `${path} must be an object`);
302
252
  }
303
- const fields = this.fields(dtoClass);
304
- const known = new Set(fields.map((field) => field.propertyKey));
253
+ const properties = schema.properties ?? {};
254
+ const extra = schema.additionalProperties;
305
255
  for (const key of Object.keys(value)) {
306
- if (!known.has(key))
307
- return new DtoValidationFailure(`${path}.${key}`, `${path}.${key} is not allowed`);
308
- }
309
- for (const field of fields) {
310
- const fieldValue = Object.getOwnPropertyDescriptor(value, field.propertyKey)?.value;
311
- if (fieldValue === undefined) {
312
- if (field.options.required) {
313
- return new DtoValidationFailure(`${path}.${field.propertyKey}`, `${path}.${field.propertyKey} is required`);
256
+ const entry = Object.getOwnPropertyDescriptor(value, key)?.value;
257
+ const declared = Object.getOwnPropertyDescriptor(properties, key)?.value;
258
+ if (declared === undefined) {
259
+ if (typeof extra !== 'object' || extra === null) {
260
+ return new DtoValidationFailure(`${path}.${key}`, `${path}.${key} is not allowed`);
314
261
  }
262
+ const failure = this.validateAt(extra, entry, `${path}.${key}`);
263
+ if (failure)
264
+ return failure;
315
265
  continue;
316
266
  }
317
- const failure = this.validateField(dtoClass, field, fieldValue, `${path}.${field.propertyKey}`);
267
+ const failure = this.validateAt(declared, entry, `${path}.${key}`);
318
268
  if (failure)
319
269
  return failure;
320
270
  }
321
- return undefined;
322
- }
323
- validateField(dtoClass, field, value, path) {
324
- // webpieces-disable no-any-unknown -- reflect-metadata returns an erased constructor token
325
- const reflected = Reflect.getMetadata('design:type', dtoClass.prototype, field.propertyKey);
326
- const options = field.options;
327
- if (options instanceof WpDtoMapFieldOptions)
328
- return this.validateMap(options, value, path);
329
- const expected = reflected?.name;
330
- if (expected === 'Array')
331
- return this.validateArray(options, value, path);
332
- if (expected === 'String') {
333
- if (typeof value !== 'string')
334
- return new DtoValidationFailure(path, `${path} must be a string`);
335
- if (options.enumValues && !options.enumValues.includes(value)) {
336
- return new DtoValidationFailure(path, `${path} must be one of: ${options.enumValues.join(', ')}`);
337
- }
338
- return undefined;
339
- }
340
- if (expected === 'Number') {
341
- if (typeof value !== 'number' || !Number.isFinite(value)) {
342
- return new DtoValidationFailure(path, `${path} must be a number`);
343
- }
344
- if (options.integer && !Number.isInteger(value)) {
345
- return new DtoValidationFailure(path, `${path} must be an integer`);
271
+ for (const name of schema.required ?? []) {
272
+ if (Object.getOwnPropertyDescriptor(value, name)?.value === undefined) {
273
+ return new DtoValidationFailure(`${path}.${name}`, `${path}.${name} is required`);
346
274
  }
347
- if (options.minimum !== undefined && value < options.minimum) {
348
- return new DtoValidationFailure(path, `${path} must be >= ${options.minimum}`);
349
- }
350
- if (options.maximum !== undefined && value > options.maximum) {
351
- return new DtoValidationFailure(path, `${path} must be <= ${options.maximum}`);
352
- }
353
- return undefined;
354
275
  }
355
- if (expected === 'Boolean') {
356
- return typeof value === 'boolean'
357
- ? undefined
358
- : new DtoValidationFailure(path, `${path} must be a boolean`);
359
- }
360
- return this.validateAt(reflected, value, path);
276
+ return undefined;
361
277
  }
362
- validateArray(options, value, path) {
363
- if (!Array.isArray(value))
278
+ validateArray(schema, value, path) {
279
+ if (!Array.isArray(value)) {
364
280
  return new DtoValidationFailure(path, `${path} must be an array`);
365
- if (!options.arrayItems)
366
- return new DtoValidationFailure(path, `${path} has no item type`);
281
+ }
282
+ const items = schema.items;
283
+ if (items === undefined)
284
+ return undefined;
367
285
  for (let index = 0; index < value.length; index += 1) {
368
- const failure = this.validateElement(options.arrayItems, value[index], `${path}[${index}]`);
286
+ const failure = this.validateAt(items, value[index], `${path}[${index}]`);
369
287
  if (failure)
370
288
  return failure;
371
289
  }
372
290
  return undefined;
373
291
  }
374
- validateMap(options, value, path) {
375
- if (typeof value !== 'object' || value === null || Array.isArray(value)) {
376
- return new DtoValidationFailure(path, `${path} must be an object map`);
292
+ validateString(schema, value, path) {
293
+ if (typeof value !== 'string') {
294
+ return new DtoValidationFailure(path, `${path} must be a string`);
377
295
  }
378
- const prototype = Object.getPrototypeOf(value);
379
- if (prototype !== Object.prototype && prototype !== null) {
380
- return new DtoValidationFailure(path, `${path} must be a plain object map`);
381
- }
382
- for (const key of Object.keys(value)) {
383
- const entry = Object.getOwnPropertyDescriptor(value, key)?.value;
384
- const failure = this.validateElement(options.mapValues, entry, `${path}.${key}`);
385
- if (failure)
386
- return failure;
296
+ if (schema.enum && !schema.enum.includes(value)) {
297
+ return new DtoValidationFailure(path, `${path} must be one of: ${schema.enum.join(', ')}`);
387
298
  }
388
299
  return undefined;
389
300
  }
390
- validateElement(type, item, path) {
391
- if (typeof type !== 'string')
392
- return this.validateAt(type, item, path);
393
- const wanted = type === 'integer' ? 'number' : type;
394
- if (typeof item !== wanted || (type === 'integer' && !Number.isInteger(item))) {
395
- return new DtoValidationFailure(path, `${path} must be ${type}`);
301
+ validateNumber(schema, value, path) {
302
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
303
+ return new DtoValidationFailure(path, `${path} must be a number`);
396
304
  }
397
- return undefined;
398
- }
399
- fields(dtoClass) {
400
- const fields = Reflect.getMetadata(DTO_FIELDS, dtoClass) ?? {};
401
- return Object.values(fields);
402
- }
403
- requireDto(dtoClass) {
404
- if (!Reflect.getMetadata(DTO_MARKER, dtoClass)) {
405
- throw new Error(`${dtoClass.name} must be decorated with @WpDto() for schema generation.`);
305
+ if (ApiJsonSchema.baseTypeOf(schema) === 'integer' && !Number.isInteger(value)) {
306
+ return new DtoValidationFailure(path, `${path} must be an integer`);
406
307
  }
308
+ if (schema.minimum !== undefined && value < schema.minimum) {
309
+ return new DtoValidationFailure(path, `${path} must be >= ${schema.minimum}`);
310
+ }
311
+ if (schema.maximum !== undefined && value > schema.maximum) {
312
+ return new DtoValidationFailure(path, `${path} must be <= ${schema.maximum}`);
313
+ }
314
+ return undefined;
407
315
  }
408
316
  }
409
- exports.DtoSchemaBuilder = DtoSchemaBuilder;
317
+ exports.ApiJsonSchemaValidator = ApiJsonSchemaValidator;
410
318
  //# sourceMappingURL=DtoSchema.js.map