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 'schema_rule.dart';
16 :
17 : /// Parses a JSON Schema subset map into a [SchemaRule] tree.
18 : ///
19 : /// Example:
20 : /// ```dart
21 : /// final rule = SchemaParser().parse({
22 : /// 'required': ['name', 'email'],
23 : /// 'properties': {
24 : /// 'name': {'type': 'string', 'minLength': 1},
25 : /// 'email': {'type': 'string', 'format': 'email'},
26 : /// 'age': {'type': 'integer', 'minimum': 0},
27 : /// },
28 : /// });
29 : /// final violations = rule.validate({'name': 'Alice'}, '');
30 : /// // → [{path: 'email', message: 'required field is missing'}]
31 : /// ```
32 : final class SchemaParser {
33 : /// Parses [schema] into a [SchemaRule] tree.
34 : ///
35 : /// Returns a [CompositeRule] whose child rules correspond to each recognised
36 : /// JSON Schema keyword present in [schema]. An empty schema produces a
37 : /// [CompositeRule] with no children, which always validates successfully.
38 4 : SchemaRule parse(Map<String, dynamic> schema) {
39 4 : final rules = <SchemaRule>[];
40 :
41 : // type — spec §6.1.1 allows either a string or an array of strings.
42 4 : final type = schema['type'];
43 4 : if (type is String) {
44 8 : rules.add(TypeRule(type));
45 4 : } else if (type is List) {
46 : // Array form: value is valid when its type matches any listed entry.
47 3 : rules.add(TypeRule.fromList(List<String>.from(type)));
48 : }
49 :
50 : // required
51 4 : final required = schema['required'];
52 4 : if (required is List) {
53 9 : rules.add(RequiredRule(List<String>.from(required)));
54 : }
55 :
56 : // enum
57 4 : final enumValues = schema['enum'];
58 4 : if (enumValues is List) {
59 3 : rules.add(EnumRule(List<dynamic>.from(enumValues)));
60 : }
61 :
62 : // numeric constraints
63 4 : final minimum = schema['minimum'] as num?;
64 4 : final maximum = schema['maximum'] as num?;
65 4 : final exclusiveMinimum = schema['exclusiveMinimum'] as num?;
66 4 : final exclusiveMaximum = schema['exclusiveMaximum'] as num?;
67 : if (minimum != null ||
68 : maximum != null ||
69 : exclusiveMinimum != null ||
70 : exclusiveMaximum != null) {
71 3 : rules.add(
72 3 : NumericRule(
73 : minimum: minimum,
74 : maximum: maximum,
75 : exclusiveMinimum: exclusiveMinimum,
76 : exclusiveMaximum: exclusiveMaximum,
77 : ),
78 : );
79 : }
80 :
81 : // string constraints
82 4 : final minLength = schema['minLength'] as int?;
83 4 : final maxLength = schema['maxLength'] as int?;
84 4 : final patternStr = schema['pattern'] as String?;
85 : if (minLength != null || maxLength != null || patternStr != null) {
86 3 : rules.add(
87 3 : StringRule(
88 : minLength: minLength,
89 : maxLength: maxLength,
90 1 : pattern: patternStr != null ? RegExp(patternStr) : null,
91 : ),
92 : );
93 : }
94 :
95 : // format
96 4 : final format = schema['format'] as String?;
97 : if (format != null) {
98 2 : rules.add(FormatRule(format));
99 : }
100 :
101 : // properties — parse each sub-schema recursively
102 4 : final propertiesRaw = schema['properties'];
103 : Map<String, SchemaRule>? parsedProperties;
104 4 : if (propertiesRaw is Map) {
105 3 : parsedProperties = {
106 3 : for (final entry in propertiesRaw.entries)
107 6 : if (entry.value is Map<String, dynamic>)
108 12 : entry.key as String: parse(entry.value as Map<String, dynamic>),
109 : };
110 6 : rules.add(PropertiesRule(parsedProperties));
111 : }
112 :
113 : // patternProperties — a map of ECMA-262 regex strings to sub-schemas.
114 : // Per spec §6.5.5, pattern matching is unanchored (hasMatch).
115 : // An invalid regex key throws FormatException at parse time: malformed
116 : // regexes are schema-authoring errors that must be surfaced immediately.
117 4 : final patternPropertiesRaw = schema['patternProperties'];
118 : List<(RegExp, SchemaRule)>? parsedPatterns;
119 4 : if (patternPropertiesRaw is Map) {
120 1 : parsedPatterns = [];
121 2 : for (final entry in patternPropertiesRaw.entries) {
122 2 : if (entry.value is! Map<String, dynamic>) continue;
123 : // Throws FormatException if the key is not a valid regex.
124 2 : final regex = RegExp(entry.key as String);
125 2 : final subRule = parse(entry.value as Map<String, dynamic>);
126 1 : parsedPatterns.add((regex, subRule));
127 : }
128 1 : if (parsedPatterns.isNotEmpty) {
129 2 : rules.add(PatternPropertiesRule(parsedPatterns));
130 : }
131 : }
132 :
133 : // additionalProperties — handles both `false` (reject all extras) and a
134 : // Map sub-schema (validate extras). Removed the parsedProperties != null
135 : // guard so this activates even when `properties` is absent.
136 4 : final additionalPropertiesRaw = schema['additionalProperties'];
137 6 : final declaredKeys = parsedProperties?.keys.toSet() ?? const <String>{};
138 : final patternRegexes =
139 3 : parsedPatterns?.map((p) => p.$1).toList() ?? const <RegExp>[];
140 :
141 4 : if (additionalPropertiesRaw == false) {
142 : // Reject all properties not covered by `properties` or
143 : // `patternProperties`. Use AdditionalPropertiesSchemaRule (with an
144 : // AlwaysInvalidRule payload) in all cases so that patternProperties
145 : // patterns are respected at runtime — pattern-matched keys are skipped
146 : // and not counted as "additional". When no patterns are present the
147 : // patternRegexes list is empty so all non-declared keys are rejected.
148 2 : rules.add(
149 2 : AdditionalPropertiesSchemaRule(
150 : schema: const AlwaysInvalidRule(),
151 : declaredKeys: declaredKeys,
152 : patternRegexes: patternRegexes,
153 : ),
154 : );
155 4 : } else if (additionalPropertiesRaw is Map<String, dynamic>) {
156 1 : final additionalSchema = parse(additionalPropertiesRaw);
157 1 : rules.add(
158 1 : AdditionalPropertiesSchemaRule(
159 : schema: additionalSchema,
160 : declaredKeys: declaredKeys,
161 : patternRegexes: patternRegexes,
162 : ),
163 : );
164 : }
165 :
166 : // prefixItems — list of sub-schemas applied positionally (2020-12 §6.4.1).
167 : // Parse before `items` so we can set the start index for items validation.
168 4 : final prefixItemsRaw = schema['prefixItems'];
169 : int prefixLength = 0;
170 4 : if (prefixItemsRaw is List) {
171 1 : final prefixSchemas = <SchemaRule>[];
172 2 : for (final entry in prefixItemsRaw) {
173 1 : if (entry is Map<String, dynamic>) {
174 2 : prefixSchemas.add(parse(entry));
175 : }
176 : }
177 1 : if (prefixSchemas.isNotEmpty) {
178 2 : rules.add(PrefixItemsRule(prefixSchemas));
179 1 : prefixLength = prefixSchemas.length;
180 : }
181 : }
182 :
183 : // array constraints — `items` applies to elements beyond the prefix when
184 : // `prefixItems` is present (itemsStartIndex = prefixLength), or uniformly
185 : // to all elements when `prefixItems` is absent (itemsStartIndex = 0).
186 4 : final minItems = schema['minItems'] as int?;
187 4 : final maxItems = schema['maxItems'] as int?;
188 4 : final itemsRaw = schema['items'];
189 : SchemaRule? itemsRule;
190 4 : if (itemsRaw is Map<String, dynamic>) {
191 2 : itemsRule = parse(itemsRaw);
192 4 : } else if (itemsRaw == false) {
193 : // Boolean `items: false` — emit a rule that rejects any element in scope.
194 : // The "in scope" set is elements beyond the prefix (if prefixItems is
195 : // present) or all elements (if not).
196 1 : itemsRule = AlwaysInvalidRule();
197 : }
198 : // items: true is a no-op — leave itemsRule as null.
199 : // Also emit ArrayRule when only prefixItems is present (no items/minItems/
200 : // maxItems) so that prefixItems can still coexist with an items schema
201 : // added later. When all three are absent there is nothing to add.
202 : if (minItems != null || maxItems != null || itemsRule != null) {
203 3 : rules.add(
204 3 : ArrayRule(
205 : minItems: minItems,
206 : maxItems: maxItems,
207 : items: itemsRule,
208 : itemsStartIndex: prefixLength,
209 : ),
210 : );
211 : }
212 :
213 : // contains / minContains / maxContains (spec §6.4.5, §6.4.4, §6.4.6).
214 : // minContains and maxContains have no effect without contains.
215 : // Use `is Map` (not Map<String,dynamic>) so that the empty schema {}
216 : // (which Dart infers as Map<dynamic,dynamic>) is also accepted.
217 4 : final containsRaw = schema['contains'];
218 4 : if (containsRaw is Map) {
219 2 : final containsSchema = parse(Map<String, dynamic>.from(containsRaw));
220 1 : final minContains = schema['minContains'] as int? ?? 1;
221 1 : final maxContains = schema['maxContains'] as int?;
222 1 : rules.add(
223 1 : ContainsRule(
224 : itemSchema: containsSchema,
225 : minContains: minContains,
226 : maxContains: maxContains,
227 : ),
228 : );
229 : }
230 :
231 : // const — value may be any JSON type including null; always active when key
232 : // is present (even when the declared constant is null).
233 4 : if (schema.containsKey('const')) {
234 3 : rules.add(ConstRule(schema['const']));
235 : }
236 :
237 : // multipleOf — read as num? to support both integer and float divisors
238 : // (e.g. 0.1). Only numeric values are validated; non-numerics are skipped
239 : // by the rule itself.
240 4 : final multipleOf = schema['multipleOf'] as num?;
241 : if (multipleOf != null) {
242 2 : rules.add(MultipleOfRule(multipleOf));
243 : }
244 :
245 : // uniqueItems — only activate when the value is exactly `true`. A value of
246 : // `false` (or absence) means no uniqueness constraint, so the parser must
247 : // guard here rather than relying solely on the rule.
248 8 : if (schema['uniqueItems'] == true) {
249 1 : rules.add(const UniqueItemsRule());
250 : }
251 :
252 : // minProperties / maxProperties — both read as int?; a single ObjectSizeRule
253 : // is emitted when either key is present, mirroring NumericRule's pattern.
254 4 : final minProperties = schema['minProperties'] as int?;
255 4 : final maxProperties = schema['maxProperties'] as int?;
256 : if (minProperties != null || maxProperties != null) {
257 1 : rules.add(
258 1 : ObjectSizeRule(
259 : minProperties: minProperties,
260 : maxProperties: maxProperties,
261 : ),
262 : );
263 : }
264 :
265 : // dependentRequired — the keyword value is a JSON object mapping trigger
266 : // property names to arrays of required dependent property names.
267 4 : final dependentRequiredRaw = schema['dependentRequired'];
268 4 : if (dependentRequiredRaw is Map) {
269 : // Cast each value to List<String>; ignore entries whose value is not a
270 : // list (consistent with the silent-ignore policy for unknown keyword forms).
271 1 : final dependencies = <String, List<String>>{
272 1 : for (final entry in dependentRequiredRaw.entries)
273 2 : if (entry.value is List)
274 4 : entry.key as String: List<String>.from(entry.value as List),
275 : };
276 2 : rules.add(DependentRequiredRule(dependencies));
277 : }
278 :
279 4 : return CompositeRule(rules);
280 : }
281 : }
|