Line data Source code
1 : // Copyright 2026 The Authors
2 : //
3 : // Licensed under the Apache License, Version 2.0 (the "License");
4 : // you may not use this file except in compliance with the License.
5 : // You may obtain a copy of the License at
6 : //
7 : // https://www.apache.org/licenses/LICENSE-2.0
8 : //
9 : // Unless required by applicable law or agreed to in writing, software
10 : // distributed under the License is distributed on an "AS IS" BASIS,
11 : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 : // See the License for the specific language governing permissions and
13 : // limitations under the License.
14 :
15 : import 'package:characters/characters.dart';
16 : import 'package:collection/collection.dart';
17 :
18 : import 'formats/formats_base.dart';
19 : import 'schema_violation.dart';
20 :
21 : /// Compiled representation of a JSON Schema keyword group.
22 : ///
23 : /// Each [SchemaRule] subtype corresponds to one keyword group from the JSON
24 : /// Schema subset supported by this package. Rules validate a runtime
25 : /// [dynamic] value and return every [SchemaViolation] found — all rules are
26 : /// always evaluated so callers receive the complete list in one pass.
27 : ///
28 : /// Rule trees are produced by [SchemaParser.parse] and can also be constructed
29 : /// directly for programmatic schema building (see the examples in `example/`).
30 : /// [JsonSchemaValidator] is a higher-level convenience that handles both parsing
31 : /// and validation in a single call.
32 : sealed class SchemaRule {
33 19 : const SchemaRule();
34 :
35 : /// Validates [value] at [path] and returns every violation found.
36 : ///
37 : /// [path] is a dot-notation string identifying the location of [value]
38 : /// within the document (e.g. `'address.city'`, `''` for the root). Violations
39 : /// use [path] as their [SchemaViolation.path].
40 : List<SchemaViolation> validate(dynamic value, String path);
41 : }
42 :
43 : // ── AlwaysInvalid ─────────────────────────────────────────────────────────────
44 :
45 : /// A sentinel rule that always fails validation with a fixed message.
46 : ///
47 : /// Used internally by [SchemaParser] to represent boolean `false` schemas
48 : /// (e.g. `items: false`, or `additionalProperties: false` when combined with
49 : /// `patternProperties`). Any value validated against this rule produces a
50 : /// violation regardless of type.
51 : final class AlwaysInvalidRule extends SchemaRule {
52 19 : const AlwaysInvalidRule();
53 :
54 2 : @override
55 2 : List<SchemaViolation> validate(dynamic value, String path) => [
56 2 : SchemaViolation(path: path, message: 'value is not allowed here'),
57 : ];
58 : }
59 :
60 : // ── Composite ────────────────────────────────────────────────────────────────
61 :
62 : /// Runs multiple rules against the same value and collects all violations.
63 : final class CompositeRule extends SchemaRule {
64 4 : const CompositeRule(this.rules);
65 :
66 : final List<SchemaRule> rules;
67 :
68 4 : @override
69 : List<SchemaViolation> validate(dynamic value, String path) {
70 4 : final violations = <SchemaViolation>[];
71 8 : for (final rule in rules) {
72 8 : violations.addAll(rule.validate(value, path));
73 : }
74 : return violations;
75 : }
76 : }
77 :
78 : // ── Type ─────────────────────────────────────────────────────────────────────
79 :
80 : /// Checks whether [value] matches a single JSON Schema type string.
81 : ///
82 : /// Returns `true` if the value satisfies [type]. Unknown type strings
83 : /// are silently accepted (spec says unknown types are ignored).
84 4 : bool _matchesType(String type, dynamic value) {
85 : return switch (type) {
86 7 : 'string' => value is String,
87 6 : 'number' => value is num,
88 : // Per JSON Schema spec §6.1.1, an integer is any number without a
89 : // fractional part — so 1.0 (a Dart double) must be accepted.
90 : // Non-finite doubles (NaN, Infinity) are excluded because their
91 : // modulo is NaN, not 0.
92 3 : 'integer' =>
93 9 : value is int || (value is double && value.isFinite && value % 1 == 0),
94 3 : 'boolean' => value is bool,
95 4 : 'array' => value is List,
96 2 : 'object' => value is Map,
97 1 : 'null' => value == null,
98 : _ => true, // unknown types are silently ignored
99 : };
100 : }
101 :
102 : /// Validates the Dart runtime type of a value against a JSON Schema `type`.
103 : ///
104 : /// Supports both the string form (`"type": "string"`) and the array form
105 : /// (`"type": ["string", "null"]`) as required by JSON Schema spec §6.1.1.
106 : /// In the array form the value is valid if it matches *any* of the listed
107 : /// types (logical OR).
108 : final class TypeRule extends SchemaRule {
109 : /// Creates a rule that accepts a single [type] string.
110 8 : TypeRule(String type) : types = [type], _single = type;
111 :
112 : /// Creates a rule that accepts any of [types] (array form).
113 : ///
114 : /// Per JSON Schema spec §6.1.1, a value is valid when its type matches
115 : /// at least one entry in the list.
116 1 : TypeRule.fromList(this.types) : _single = null;
117 :
118 : /// The JSON Schema type strings accepted by this rule.
119 : ///
120 : /// Contains exactly one entry in the single-string form, and two or more
121 : /// entries in the array form.
122 : final List<String> types;
123 :
124 : // Backing field for the single-string form; null when constructed via
125 : // [TypeRule.fromList].
126 : final String? _single;
127 :
128 4 : @override
129 : List<SchemaViolation> validate(dynamic value, String path) {
130 4 : if (_single != null) {
131 : // Single-type form: produce a targeted error message.
132 8 : if (!_matchesType(_single, value)) {
133 16 : return [SchemaViolation(path: path, message: 'expected type $_single')];
134 : }
135 3 : return [];
136 : }
137 : // Array form — value must match at least one listed type.
138 2 : for (final t in types) {
139 2 : if (_matchesType(t, value)) return [];
140 : }
141 1 : return [
142 1 : SchemaViolation(
143 : path: path,
144 3 : message: 'expected type to be one of: ${types.join(', ')}',
145 : ),
146 : ];
147 : }
148 : }
149 :
150 : // ── Required ─────────────────────────────────────────────────────────────────
151 :
152 : /// Validates that all named fields are present in a map.
153 : ///
154 : /// A field is considered present when its key exists in the map, even if
155 : /// the value is `null`.
156 : final class RequiredRule extends SchemaRule {
157 3 : const RequiredRule(this.fields);
158 :
159 : final List<String> fields;
160 :
161 3 : @override
162 : List<SchemaViolation> validate(dynamic value, String path) {
163 3 : if (value is! Map) return [];
164 3 : return [
165 3 : for (final field in fields)
166 3 : if (!value.containsKey(field))
167 3 : SchemaViolation(
168 4 : path: path.isEmpty ? field : '$path.$field',
169 : message: 'required field is missing',
170 : ),
171 : ];
172 : }
173 : }
174 :
175 : // ── Properties ───────────────────────────────────────────────────────────────
176 :
177 : /// Recursively validates named fields in a map against per-field schemas.
178 : ///
179 : /// Fields that are absent in the value are skipped — use [RequiredRule] to
180 : /// enforce presence.
181 : final class PropertiesRule extends SchemaRule {
182 3 : const PropertiesRule(this.properties);
183 :
184 : final Map<String, SchemaRule> properties;
185 :
186 3 : @override
187 : List<SchemaViolation> validate(dynamic value, String path) {
188 3 : if (value is! Map) return [];
189 3 : final violations = <SchemaViolation>[];
190 15 : for (final MapEntry(:key, value: rule) in properties.entries) {
191 3 : if (!value.containsKey(key)) continue;
192 4 : final fieldPath = path.isEmpty ? key : '$path.$key';
193 9 : violations.addAll(rule.validate(value[key], fieldPath));
194 : }
195 : return violations;
196 : }
197 : }
198 :
199 : // ── AdditionalProperties ─────────────────────────────────────────────────────
200 :
201 : /// Rejects map keys that are not in the declared set.
202 : ///
203 : /// Corresponds to `additionalProperties: false` in JSON Schema for programmatic
204 : /// schema construction where no `patternProperties` are involved.
205 : ///
206 : /// **Limitation:** this rule has no knowledge of [PatternPropertiesRule]. If
207 : /// both are included in the same [CompositeRule], keys matched by a pattern
208 : /// will still be rejected as "additional". For schemas that combine
209 : /// `additionalProperties` with `patternProperties`, use
210 : /// [AdditionalPropertiesSchemaRule] with an [AlwaysInvalidRule] payload and
211 : /// supply the relevant [AdditionalPropertiesSchemaRule.patternRegexes] —
212 : /// this is what [SchemaParser] always produces.
213 : final class AdditionalPropertiesRule extends SchemaRule {
214 0 : const AdditionalPropertiesRule(this.allowed);
215 :
216 : final Set<String> allowed;
217 :
218 0 : @override
219 : List<SchemaViolation> validate(dynamic value, String path) {
220 0 : if (value is! Map) return [];
221 0 : return [
222 0 : for (final key in value.keys.cast<String>())
223 0 : if (!allowed.contains(key))
224 0 : SchemaViolation(
225 0 : path: path.isEmpty ? key : '$path.$key',
226 : message: 'additional property not allowed',
227 : ),
228 : ];
229 : }
230 : }
231 :
232 : // ── Enum ─────────────────────────────────────────────────────────────────────
233 :
234 : /// Validates that a value is one of an enumerated set.
235 : final class EnumRule extends SchemaRule {
236 1 : const EnumRule(this.values);
237 :
238 : final List<dynamic> values;
239 :
240 1 : @override
241 : List<SchemaViolation> validate(dynamic value, String path) {
242 2 : if (!values.contains(value)) {
243 1 : return [
244 1 : SchemaViolation(
245 : path: path,
246 3 : message: 'must be one of: ${values.join(', ')}',
247 : ),
248 : ];
249 : }
250 1 : return [];
251 : }
252 : }
253 :
254 : // ── Numeric ──────────────────────────────────────────────────────────────────
255 :
256 : /// Validates numeric range constraints.
257 : final class NumericRule extends SchemaRule {
258 3 : const NumericRule({
259 : this.minimum,
260 : this.maximum,
261 : this.exclusiveMinimum,
262 : this.exclusiveMaximum,
263 : });
264 :
265 : final num? minimum;
266 : final num? maximum;
267 : final num? exclusiveMinimum;
268 : final num? exclusiveMaximum;
269 :
270 3 : @override
271 : List<SchemaViolation> validate(dynamic value, String path) {
272 5 : if (value is! num) return [];
273 3 : final violations = <SchemaViolation>[];
274 9 : if (minimum != null && value < minimum!) {
275 2 : violations.add(
276 6 : SchemaViolation(path: path, message: 'must be >= $minimum'),
277 : );
278 : }
279 5 : if (maximum != null && value > maximum!) {
280 1 : violations.add(
281 3 : SchemaViolation(path: path, message: 'must be <= $maximum'),
282 : );
283 : }
284 5 : if (exclusiveMinimum != null && value <= exclusiveMinimum!) {
285 1 : violations.add(
286 3 : SchemaViolation(path: path, message: 'must be > $exclusiveMinimum'),
287 : );
288 : }
289 5 : if (exclusiveMaximum != null && value >= exclusiveMaximum!) {
290 1 : violations.add(
291 3 : SchemaViolation(path: path, message: 'must be < $exclusiveMaximum'),
292 : );
293 : }
294 : return violations;
295 : }
296 : }
297 :
298 : // ── String ───────────────────────────────────────────────────────────────────
299 :
300 : /// Validates string length and pattern constraints.
301 : final class StringRule extends SchemaRule {
302 3 : const StringRule({this.minLength, this.maxLength, this.pattern});
303 :
304 : final int? minLength;
305 : final int? maxLength;
306 :
307 : /// Pre-compiled regex for the `pattern` keyword.
308 : final RegExp? pattern;
309 :
310 3 : @override
311 : List<SchemaViolation> validate(dynamic value, String path) {
312 4 : if (value is! String) return [];
313 3 : final violations = <SchemaViolation>[];
314 6 : final len = value.characters.length;
315 9 : if (minLength != null && len < minLength!) {
316 2 : violations.add(
317 2 : SchemaViolation(
318 : path: path,
319 4 : message: 'must have at least $minLength characters',
320 : ),
321 : );
322 : }
323 5 : if (maxLength != null && len > maxLength!) {
324 1 : violations.add(
325 1 : SchemaViolation(
326 : path: path,
327 2 : message: 'must have at most $maxLength characters',
328 : ),
329 : );
330 : }
331 3 : if (pattern != null) {
332 : // Per JSON Schema spec §6.3.3, patterns are not implicitly anchored —
333 : // the pattern only needs to match somewhere within the string.
334 2 : if (!pattern!.hasMatch(value)) {
335 1 : violations.add(
336 1 : SchemaViolation(
337 : path: path,
338 3 : message: 'must match pattern ${pattern!.pattern}',
339 : ),
340 : );
341 : }
342 : }
343 : return violations;
344 : }
345 : }
346 :
347 : // ── Format ───────────────────────────────────────────────────────────────────
348 :
349 : /// Surface-validates a string against a named format from [StringFormatValidator].
350 : ///
351 : /// Unknown format names produce no violations (the spec says implementations
352 : /// SHOULD support formats but are not required to reject unknown ones).
353 : final class FormatRule extends SchemaRule {
354 1 : FormatRule(this.format)
355 3 : : _fn = StringFormatValidator().getValidator(format)?.function;
356 :
357 : final String format;
358 : final bool Function(String)? _fn;
359 :
360 1 : @override
361 : List<SchemaViolation> validate(dynamic value, String path) {
362 3 : if (value is! String || _fn == null) return [];
363 2 : if (!_fn(value)) {
364 4 : return [SchemaViolation(path: path, message: 'must be a valid $format')];
365 : }
366 1 : return [];
367 : }
368 : }
369 :
370 : // ── Array ────────────────────────────────────────────────────────────────────
371 :
372 : /// Validates array length and per-element schema constraints.
373 : ///
374 : /// When `prefixItems` is present in a schema, [items] must only apply to
375 : /// elements beyond the prefix. The [itemsStartIndex] parameter controls this:
376 : /// the [items] schema is applied only to elements at indices ≥
377 : /// [itemsStartIndex]. When [itemsStartIndex] is 0 (the default, used when no
378 : /// `prefixItems` is present), [items] applies uniformly to all elements, which
379 : /// is the pre-2020-12 behaviour.
380 : final class ArrayRule extends SchemaRule {
381 3 : const ArrayRule({
382 : this.minItems,
383 : this.maxItems,
384 : this.items,
385 : this.itemsStartIndex = 0,
386 : });
387 :
388 : final int? minItems;
389 : final int? maxItems;
390 :
391 : /// Schema applied to elements at indices ≥ [itemsStartIndex].
392 : ///
393 : /// When `null`, no per-element constraint is applied.
394 : final SchemaRule? items;
395 :
396 : /// The first index at which [items] applies.
397 : ///
398 : /// Set to `prefixItems.length` when `prefixItems` is present in the schema,
399 : /// so that [items] only constrains elements beyond the positional prefix.
400 : /// Defaults to 0 (all elements).
401 : final int itemsStartIndex;
402 :
403 3 : @override
404 : List<SchemaViolation> validate(dynamic value, String path) {
405 4 : if (value is! List) return [];
406 3 : final violations = <SchemaViolation>[];
407 12 : if (minItems != null && value.length < minItems!) {
408 3 : violations.add(
409 3 : SchemaViolation(
410 : path: path,
411 6 : message: 'must have at least $minItems items',
412 : ),
413 : );
414 : }
415 9 : if (maxItems != null && value.length > maxItems!) {
416 1 : violations.add(
417 1 : SchemaViolation(
418 : path: path,
419 2 : message: 'must have at most $maxItems items',
420 : ),
421 : );
422 : }
423 3 : if (items != null) {
424 : // Apply the items schema only to elements at indices >= itemsStartIndex.
425 : // When prefixItems is absent, itemsStartIndex is 0 so all elements are
426 : // covered (backwards-compatible behaviour). When prefixItems is present,
427 : // itemsStartIndex equals the prefix length so only elements beyond the
428 : // prefix are validated by the items schema.
429 8 : for (var i = itemsStartIndex; i < value.length; i++) {
430 10 : violations.addAll(items!.validate(value[i], '$path[$i]'));
431 : }
432 : }
433 : return violations;
434 : }
435 : }
436 :
437 : // ── Const ─────────────────────────────────────────────────────────────────────
438 :
439 : /// Validates that a value is exactly equal to the schema-declared constant.
440 : ///
441 : /// Corresponds to `const` in JSON Schema spec §6.1.3. The comparison uses
442 : /// [DeepCollectionEquality] so that nested [List] and [Map] values are
443 : /// compared by structural value rather than by reference. Primitive values
444 : /// (numbers, strings, booleans, `null`) are also handled correctly.
445 : ///
446 : /// Unlike `required`, the `const` keyword validates the *value* of the
447 : /// instance — presence is enforced separately via `required`. A `const: null`
448 : /// schema accepts only the value `null`.
449 : final class ConstRule extends SchemaRule {
450 : /// Creates a rule that accepts only [constValue].
451 : ///
452 : /// [constValue] may be any JSON-representable type, including `null`.
453 1 : const ConstRule(this.constValue);
454 :
455 : /// The single accepted value.
456 : final dynamic constValue;
457 :
458 : // Deep equality is required for JSON value comparison — Dart's == operator
459 : // compares List and Map by identity, not by structural value.
460 : static const _deep = DeepCollectionEquality();
461 :
462 1 : @override
463 : List<SchemaViolation> validate(dynamic value, String path) {
464 2 : if (!_deep.equals(constValue, value)) {
465 1 : return [
466 3 : SchemaViolation(path: path, message: 'must be equal to $constValue'),
467 : ];
468 : }
469 1 : return [];
470 : }
471 : }
472 :
473 : // ── MultipleOf ───────────────────────────────────────────────────────────────
474 :
475 : /// Validates that a numeric value is a multiple of the schema-declared divisor.
476 : ///
477 : /// Corresponds to `multipleOf` in JSON Schema spec §6.2.1. Non-numeric
478 : /// instances are silently skipped. Uses a floating-point-safe algorithm:
479 : /// divides the value by the divisor and checks whether the quotient is within
480 : /// an epsilon of a whole number. The naive `value % divisor == 0` check fails
481 : /// for decimal divisors (e.g. `0.3 % 0.1 ≠ 0` in IEEE-754 arithmetic).
482 : final class MultipleOfRule extends SchemaRule {
483 : /// Creates a rule that requires values to be a multiple of [divisor].
484 : ///
485 : /// [divisor] must be strictly greater than zero per the JSON Schema spec.
486 : /// A [divisor] of zero acts as a schema-error guard: validation always fails
487 : /// for any numeric value (spec §6.2.1 disallows zero divisors).
488 1 : const MultipleOfRule(this.divisor);
489 :
490 : /// The required divisor.
491 : final num divisor;
492 :
493 : // Tolerance used when checking whether the quotient is a whole number.
494 : // 1e-10 is small enough to avoid false positives for common decimal values
495 : // while remaining robust to typical IEEE-754 rounding errors.
496 : static const double _epsilon = 1e-10;
497 :
498 1 : @override
499 : List<SchemaViolation> validate(dynamic value, String path) {
500 : // Applies only to numbers; other types are silently skipped.
501 2 : if (value is! num) return [];
502 2 : if (divisor == 0) {
503 1 : return [
504 1 : SchemaViolation(path: path, message: 'multipleOf divisor must be > 0'),
505 : ];
506 : }
507 : // Compute the quotient and check that its fractional part is negligibly
508 : // small, guarding against IEEE-754 rounding in decimal arithmetic.
509 2 : final quotient = value / divisor;
510 4 : if ((quotient - quotient.roundToDouble()).abs() >= _epsilon) {
511 1 : return [
512 3 : SchemaViolation(path: path, message: 'must be a multiple of $divisor'),
513 : ];
514 : }
515 1 : return [];
516 : }
517 : }
518 :
519 : // ── UniqueItems ───────────────────────────────────────────────────────────────
520 :
521 : /// Validates that all elements in an array are pairwise distinct.
522 : ///
523 : /// Corresponds to `uniqueItems: true` in JSON Schema spec §6.4.3. When
524 : /// `uniqueItems` is `false` (or absent) no validation is performed.
525 : /// Non-array instances are silently skipped.
526 : ///
527 : /// Uses an O(n²) pairwise [DeepCollectionEquality] comparison so that nested
528 : /// [List] and [Map] elements are compared by structural value rather than by
529 : /// reference. A plain `toSet()` check would fail to detect duplicate nested
530 : /// objects.
531 : final class UniqueItemsRule extends SchemaRule {
532 19 : const UniqueItemsRule();
533 :
534 : static const _deep = DeepCollectionEquality();
535 :
536 1 : @override
537 : List<SchemaViolation> validate(dynamic value, String path) {
538 : // Applies only to arrays; other types are silently skipped.
539 2 : if (value is! List) return [];
540 : // Pairwise O(n²) deep-equality check. An empty list or single-element
541 : // list is vacuously unique.
542 3 : for (var i = 0; i < value.length; i++) {
543 4 : for (var j = i + 1; j < value.length; j++) {
544 3 : if (_deep.equals(value[i], value[j])) {
545 2 : return [SchemaViolation(path: path, message: 'items must be unique')];
546 : }
547 : }
548 : }
549 1 : return [];
550 : }
551 : }
552 :
553 : // ── ObjectSize ────────────────────────────────────────────────────────────────
554 :
555 : /// Validates the number of properties in an object (map).
556 : ///
557 : /// Covers both `minProperties` (spec §6.5.2) and `maxProperties` (spec §6.5.1).
558 : /// Non-map instances are silently skipped. Either bound may be `null` to
559 : /// indicate no constraint in that direction.
560 : final class ObjectSizeRule extends SchemaRule {
561 1 : const ObjectSizeRule({this.minProperties, this.maxProperties});
562 :
563 : /// Inclusive minimum number of properties, or `null` for no lower bound.
564 : final int? minProperties;
565 :
566 : /// Inclusive maximum number of properties, or `null` for no upper bound.
567 : final int? maxProperties;
568 :
569 1 : @override
570 : List<SchemaViolation> validate(dynamic value, String path) {
571 : // Applies only to objects (maps); other types are silently skipped.
572 2 : if (value is! Map) return [];
573 1 : final violations = <SchemaViolation>[];
574 4 : if (minProperties != null && value.length < minProperties!) {
575 1 : violations.add(
576 1 : SchemaViolation(
577 : path: path,
578 2 : message: 'must have at least $minProperties properties',
579 : ),
580 : );
581 : }
582 4 : if (maxProperties != null && value.length > maxProperties!) {
583 1 : violations.add(
584 1 : SchemaViolation(
585 : path: path,
586 2 : message: 'must have at most $maxProperties properties',
587 : ),
588 : );
589 : }
590 : return violations;
591 : }
592 : }
593 :
594 : // ── Contains ──────────────────────────────────────────────────────────────────
595 :
596 : /// Validates that an array contains at least [minContains] (default 1) and at
597 : /// most [maxContains] (optional) elements that satisfy [itemSchema].
598 : ///
599 : /// Corresponds to the `contains`, `minContains`, and `maxContains` keywords in
600 : /// JSON Schema spec §6.4.5, §6.4.4, and §6.4.6. The three keywords are tightly
601 : /// coupled and implemented together in this rule.
602 : ///
603 : /// Sub-schema violations are used only as a counting signal — they are never
604 : /// emitted to the caller. The rule reports a single violation when the count
605 : /// falls outside the permitted range.
606 : ///
607 : /// Non-array instances are silently skipped (no violation).
608 : ///
609 : /// `minContains: 0` makes the rule effectively optional unless [maxContains] is
610 : /// set and exceeded.
611 : final class ContainsRule extends SchemaRule {
612 : /// Creates a rule enforcing that [minContains] to [maxContains] elements
613 : /// satisfy [itemSchema].
614 : ///
615 : /// [minContains] defaults to 1 per spec §6.4.4. [maxContains] is optional;
616 : /// when `null` there is no upper bound.
617 1 : const ContainsRule({
618 : required this.itemSchema,
619 : this.minContains = 1,
620 : this.maxContains,
621 : });
622 :
623 : /// The sub-schema that each candidate element is tested against.
624 : final SchemaRule itemSchema;
625 :
626 : /// The minimum number of elements that must satisfy [itemSchema].
627 : ///
628 : /// Defaults to 1, which means at least one element must match. A value of 0
629 : /// means the rule always passes unless [maxContains] is violated.
630 : final int minContains;
631 :
632 : /// The maximum number of elements allowed to satisfy [itemSchema].
633 : ///
634 : /// When `null` there is no upper bound.
635 : final int? maxContains;
636 :
637 1 : @override
638 : List<SchemaViolation> validate(dynamic value, String path) {
639 : // Applies only to arrays; other types are silently skipped.
640 2 : if (value is! List) return [];
641 :
642 : // Count elements that satisfy the sub-schema. Violations from the
643 : // sub-schema are used only as a non-match signal and are never forwarded
644 : // to the caller.
645 : var matchCount = 0;
646 2 : for (final element in value) {
647 3 : if (itemSchema.validate(element, path).isEmpty) {
648 1 : matchCount++;
649 : }
650 : }
651 :
652 1 : final violations = <SchemaViolation>[];
653 :
654 2 : if (matchCount < minContains) {
655 1 : violations.add(
656 1 : SchemaViolation(
657 : path: path,
658 1 : message:
659 1 : 'must contain at least $minContains matching item(s) '
660 : '(found $matchCount)',
661 : ),
662 : );
663 : }
664 :
665 3 : if (maxContains != null && matchCount > maxContains!) {
666 1 : violations.add(
667 1 : SchemaViolation(
668 : path: path,
669 1 : message:
670 1 : 'must contain at most $maxContains matching item(s) '
671 : '(found $matchCount)',
672 : ),
673 : );
674 : }
675 :
676 : return violations;
677 : }
678 : }
679 :
680 : // ── PrefixItems ───────────────────────────────────────────────────────────────
681 :
682 : /// Validates array elements positionally against a list of per-index schemas.
683 : ///
684 : /// Corresponds to `prefixItems` in JSON Schema spec §6.4.1 (2020-12). Element
685 : /// at index `i` is validated against `schemas[i]`. If the array is shorter than
686 : /// the prefix list, the extra prefix schemas simply do not apply — there is no
687 : /// violation for a short array. Elements beyond the prefix are not validated by
688 : /// this rule; use [ArrayRule] with an [ArrayRule.items] schema to constrain
689 : /// those.
690 : ///
691 : /// Non-array instances are silently skipped (no violation).
692 : final class PrefixItemsRule extends SchemaRule {
693 : /// Creates a rule that validates positional elements against [schemas].
694 1 : const PrefixItemsRule(this.schemas);
695 :
696 : /// Per-index sub-schemas. `schemas[i]` is applied to element `i`.
697 : final List<SchemaRule> schemas;
698 :
699 1 : @override
700 : List<SchemaViolation> validate(dynamic value, String path) {
701 : // Applies only to arrays; other types are silently skipped.
702 2 : if (value is! List) return [];
703 1 : final violations = <SchemaViolation>[];
704 : // Only iterate as far as the shorter of the array and the prefix list.
705 7 : final limit = schemas.length < value.length ? schemas.length : value.length;
706 2 : for (var i = 0; i < limit; i++) {
707 6 : violations.addAll(schemas[i].validate(value[i], '$path[$i]'));
708 : }
709 : return violations;
710 : }
711 : }
712 :
713 : // ── PatternProperties ─────────────────────────────────────────────────────────
714 :
715 : /// Validates object properties whose names match ECMA-262 regex patterns.
716 : ///
717 : /// Corresponds to `patternProperties` in JSON Schema spec §6.5.5. For each
718 : /// property in the instance, every pattern in [patterns] is tested against the
719 : /// property name using [RegExp.hasMatch] (unanchored, per spec). If a pattern
720 : /// matches, the associated sub-schema is applied to the property value. A
721 : /// property may be matched by zero, one, or more patterns — every matching
722 : /// sub-schema is applied and all violations are collected.
723 : ///
724 : /// Non-object instances are silently skipped (no violation).
725 : final class PatternPropertiesRule extends SchemaRule {
726 : /// Creates a rule from a list of (regex, sub-schema) pairs.
727 : ///
728 : /// [patterns] is a list of `(RegExp, SchemaRule)` records. Each regex is
729 : /// tested unanchored against property names; the associated rule is applied
730 : /// to the value of every matching property.
731 1 : const PatternPropertiesRule(this.patterns);
732 :
733 : /// The list of pattern→schema pairs.
734 : final List<(RegExp, SchemaRule)> patterns;
735 :
736 1 : @override
737 : List<SchemaViolation> validate(dynamic value, String path) {
738 : // Applies only to objects (maps); other types are silently skipped.
739 2 : if (value is! Map) return [];
740 1 : final violations = <SchemaViolation>[];
741 3 : for (final key in value.keys.cast<String>()) {
742 2 : final fieldPath = path.isEmpty ? key : '$path.$key';
743 2 : for (final (regex, rule) in patterns) {
744 : // Per JSON Schema spec §6.5.5, pattern matching is unanchored.
745 1 : if (regex.hasMatch(key)) {
746 3 : violations.addAll(rule.validate(value[key], fieldPath));
747 : }
748 : }
749 : }
750 : return violations;
751 : }
752 : }
753 :
754 : // ── AdditionalPropertiesSchema ────────────────────────────────────────────────
755 :
756 : /// Validates properties not covered by `properties` or `patternProperties`
757 : /// against a sub-schema.
758 : ///
759 : /// Corresponds to a schema-valued `additionalProperties` in JSON Schema spec
760 : /// §6.5.6. Properties whose names are in [declaredKeys] or match any regex in
761 : /// [patternRegexes] are considered "evaluated" and are skipped. All remaining
762 : /// properties are validated against [schema].
763 : ///
764 : /// Non-object instances are silently skipped (no violation).
765 : ///
766 : /// This complements [AdditionalPropertiesRule] (which uses `false` to reject
767 : /// extra properties outright). When `additionalProperties` is a schema, this
768 : /// rule is emitted instead.
769 : final class AdditionalPropertiesSchemaRule extends SchemaRule {
770 : /// Creates a rule that applies [schema] to every non-evaluated property.
771 : ///
772 : /// [declaredKeys] is the set of keys covered by `properties`.
773 : /// [patternRegexes] is the list of compiled regex patterns from
774 : /// `patternProperties`. A property is "additional" only if its name is
775 : /// neither in [declaredKeys] nor matched by any regex in [patternRegexes].
776 2 : const AdditionalPropertiesSchemaRule({
777 : required this.schema,
778 : required this.declaredKeys,
779 : required this.patternRegexes,
780 : });
781 :
782 : /// The sub-schema applied to each additional property value.
783 : final SchemaRule schema;
784 :
785 : /// Keys explicitly declared under `properties`.
786 : final Set<String> declaredKeys;
787 :
788 : /// Compiled regexes from `patternProperties` used to identify
789 : /// pattern-matched keys.
790 : final List<RegExp> patternRegexes;
791 :
792 2 : @override
793 : List<SchemaViolation> validate(dynamic value, String path) {
794 : // Applies only to objects (maps); other types are silently skipped.
795 3 : if (value is! Map) return [];
796 2 : final violations = <SchemaViolation>[];
797 6 : for (final key in value.keys.cast<String>()) {
798 : // Skip keys covered by `properties`.
799 4 : if (declaredKeys.contains(key)) continue;
800 : // Skip keys matched by any `patternProperties` pattern.
801 6 : if (patternRegexes.any((r) => r.hasMatch(key))) continue;
802 : // Apply the sub-schema to the additional property value.
803 3 : final fieldPath = path.isEmpty ? key : '$path.$key';
804 8 : violations.addAll(schema.validate(value[key], fieldPath));
805 : }
806 : return violations;
807 : }
808 : }
809 :
810 : // ── DependentRequired ─────────────────────────────────────────────────────────
811 :
812 : /// Validates conditional property dependencies in an object (map).
813 : ///
814 : /// Corresponds to `dependentRequired` in JSON Schema spec §6.5.4. Each entry
815 : /// in [dependencies] maps a trigger property name to a list of property names
816 : /// that must also be present whenever the trigger is present. If the trigger
817 : /// is absent, no validation is performed for that entry.
818 : ///
819 : /// Non-map instances are silently skipped. One [SchemaViolation] is emitted
820 : /// per missing dependent property, with the path pointing at the missing
821 : /// property name (consistent with [RequiredRule]).
822 : final class DependentRequiredRule extends SchemaRule {
823 : /// Creates a rule from a dependency map.
824 : ///
825 : /// [dependencies] maps each trigger property name to the list of property
826 : /// names that must be present when the trigger is present.
827 1 : const DependentRequiredRule(this.dependencies);
828 :
829 : /// The dependency map: trigger → required dependents.
830 : final Map<String, List<String>> dependencies;
831 :
832 1 : @override
833 : List<SchemaViolation> validate(dynamic value, String path) {
834 : // Applies only to objects (maps); other types are silently skipped.
835 2 : if (value is! Map) return [];
836 1 : final violations = <SchemaViolation>[];
837 5 : for (final MapEntry(:key, value: dependents) in dependencies.entries) {
838 : // Only validate when the trigger property is present.
839 1 : if (!value.containsKey(key)) continue;
840 2 : for (final dependent in dependents) {
841 1 : if (!value.containsKey(dependent)) {
842 : // Emit one violation per missing dependent, with the path pointing
843 : // at the missing property — consistent with RequiredRule path format.
844 1 : violations.add(
845 1 : SchemaViolation(
846 2 : path: path.isEmpty ? dependent : '$path.$dependent',
847 : message: 'required field is missing',
848 : ),
849 : );
850 : }
851 : }
852 : }
853 : return violations;
854 : }
855 : }
|