jsonstructure 0.6.2 → 0.7.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.
- checksums.yaml +4 -4
- data/lib/jsonstructure/schema_validator.rb +375 -29
- data/lib/jsonstructure/version.rb +1 -1
- data/spec/schema_validator_spec.rb +332 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f870a1cfa2e4a2067e7fae0ad819f2a037a04156501413c9342c4fbd0be44624
|
|
4
|
+
data.tar.gz: 6db39ad31391b121bd41effec1dabeee9837e51887aec6511a33173e06e89764
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4d87870b6cc4d6f06ffe33aa347fdda0f8effa23e38f99563da008eda479f2509f55967e4b03116334bba8acf43bbb8ee0518dda2da78c8019d03dcada59473d
|
|
7
|
+
data.tar.gz: 3485c56c93501453c5eb1c7302d3349feb45ce7545c5b9eacc010d8fe1f71fa61fa358c51bb4cc45b916e1b73ae6e233086ee6cbcda7b47a538e5929a0b034cd
|
|
@@ -1,11 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
3
5
|
module JsonStructure
|
|
4
6
|
# Validates JSON Structure schema documents
|
|
5
7
|
#
|
|
6
8
|
# This class is thread-safe. Multiple threads can call validate concurrently.
|
|
7
9
|
class SchemaValidator
|
|
8
|
-
|
|
10
|
+
UCUM_NUMERIC_TYPES = %w[number integer float double decimal int32 uint32 int64 uint64 int128 uint128].freeze
|
|
11
|
+
ENUM_NUMERIC_TYPES = %w[integer int8 int16 int32 int64 uint8 uint16 uint32 uint64 float double decimal].freeze
|
|
12
|
+
RELATION_CONTAINER_TYPES = %w[object tuple].freeze
|
|
13
|
+
IDENTIFIER_PATTERN = /\A[A-Za-z_$][A-Za-z0-9_$]*\z/
|
|
14
|
+
URI_SCHEME_PATTERN = /\A[a-zA-Z][a-zA-Z0-9+\-.]*:/
|
|
15
|
+
VALIDATION_KEYWORDS = %w[
|
|
16
|
+
pattern format minLength maxLength minimum maximum exclusiveMinimum exclusiveMaximum multipleOf
|
|
17
|
+
minItems maxItems uniqueItems contains minContains maxContains
|
|
18
|
+
minProperties maxProperties propertyNames patternProperties dependentRequired
|
|
19
|
+
minEntries maxEntries patternKeys keyNames
|
|
20
|
+
contentEncoding contentMediaType has default
|
|
21
|
+
].freeze
|
|
22
|
+
UNITS_KEYWORDS = %w[unit currency symbols].freeze
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# Validate a schema string
|
|
9
26
|
#
|
|
10
27
|
# This method is thread-safe and can be called from multiple threads concurrently.
|
|
11
28
|
#
|
|
@@ -20,39 +37,368 @@ module JsonStructure
|
|
|
20
37
|
# else
|
|
21
38
|
# result.errors.each { |e| puts e.message }
|
|
22
39
|
# end
|
|
23
|
-
|
|
24
|
-
|
|
40
|
+
def validate(schema_json)
|
|
41
|
+
raise ArgumentError, 'schema_json must be a String' unless schema_json.is_a?(String)
|
|
25
42
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
43
|
+
JsonStructure.validation_started
|
|
44
|
+
begin
|
|
45
|
+
result_ptr = ::FFI::MemoryPointer.new(FFI::JSResult.size)
|
|
46
|
+
FFI.js_result_init(result_ptr)
|
|
30
47
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
48
|
+
FFI.js_validate_schema(schema_json, result_ptr)
|
|
49
|
+
base_result = ValidationResult.from_ffi(result_ptr)
|
|
50
|
+
augment_extension_validation(base_result, schema_json)
|
|
51
|
+
ensure
|
|
52
|
+
JsonStructure.validation_completed
|
|
53
|
+
end
|
|
35
54
|
end
|
|
36
|
-
end
|
|
37
55
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
# Validate a schema string, raising an exception on failure
|
|
57
|
+
#
|
|
58
|
+
# @param schema_json [String] JSON string containing the schema
|
|
59
|
+
# @return [ValidationResult] validation result (only if valid)
|
|
60
|
+
# @raise [SchemaValidationError] if validation fails
|
|
61
|
+
#
|
|
62
|
+
# @example
|
|
63
|
+
# begin
|
|
64
|
+
# JsonStructure::SchemaValidator.validate!(schema)
|
|
65
|
+
# puts "Schema is valid!"
|
|
66
|
+
# rescue JsonStructure::SchemaValidationError => e
|
|
67
|
+
# puts "Validation failed: #{e.message}"
|
|
68
|
+
# end
|
|
69
|
+
def validate!(schema_json)
|
|
70
|
+
result = validate(schema_json)
|
|
71
|
+
raise SchemaValidationError.new(result) unless result.valid?
|
|
72
|
+
|
|
73
|
+
result
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def augment_extension_validation(base_result, schema_json)
|
|
79
|
+
schema = JSON.parse(schema_json)
|
|
80
|
+
additional_errors = []
|
|
81
|
+
validate_extension_keywords(schema, schema, '#', additional_errors)
|
|
82
|
+
|
|
83
|
+
return base_result if additional_errors.empty?
|
|
84
|
+
|
|
85
|
+
errors = base_result.errors + additional_errors
|
|
86
|
+
ValidationResult.new(errors.none?(&:error?), errors)
|
|
87
|
+
rescue JSON::ParserError
|
|
88
|
+
base_result
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def validate_extension_keywords(root_schema, node, path, errors)
|
|
92
|
+
return unless node.is_a?(Hash)
|
|
93
|
+
|
|
94
|
+
type = node['type']
|
|
95
|
+
validate_root_schema_keywords(node, path, errors) if path == '#'
|
|
96
|
+
validate_validation_extension_gating(root_schema, node, path, errors)
|
|
97
|
+
validate_ucum_unit_keyword(root_schema, node, type, path, errors)
|
|
98
|
+
validate_units_keywords(root_schema, node, type, path, errors)
|
|
99
|
+
validate_relations_keywords(root_schema, node, type, path, errors)
|
|
100
|
+
validate_extends_keyword(root_schema, node, path, errors)
|
|
101
|
+
validate_tuple_ref_entries(root_schema, node, type, path, errors)
|
|
102
|
+
validate_enum_values(type, node, path, errors)
|
|
103
|
+
|
|
104
|
+
node.each do |key, value|
|
|
105
|
+
child_path = path == '#' ? "#/#{escape_json_pointer(key)}" : "#{path}/#{escape_json_pointer(key)}"
|
|
106
|
+
|
|
107
|
+
if value.is_a?(Hash)
|
|
108
|
+
validate_extension_keywords(root_schema, value, child_path, errors)
|
|
109
|
+
elsif value.is_a?(Array)
|
|
110
|
+
value.each_with_index do |item, index|
|
|
111
|
+
validate_extension_keywords(root_schema, item, "#{child_path}[#{index}]", errors)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def validate_root_schema_keywords(node, path, errors)
|
|
118
|
+
validate_root_id_keyword(node, path, errors)
|
|
119
|
+
validate_root_name_keyword(node, path, errors)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def validate_root_id_keyword(node, path, errors)
|
|
123
|
+
return unless node.key?('$id')
|
|
124
|
+
return unless node['$id'].is_a?(String)
|
|
125
|
+
|
|
126
|
+
if node['$id'].strip.empty?
|
|
127
|
+
add_manual_error(errors, '$id must not be empty', "#{path}/$id", 'SCHEMA_KEYWORD_EMPTY')
|
|
128
|
+
elsif node['$id'] !~ URI_SCHEME_PATTERN
|
|
129
|
+
add_manual_error(errors, '$id must be a URI with a scheme', "#{path}/$id", 'SCHEMA_CONSTRAINT_VALUE_INVALID')
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def validate_root_name_keyword(node, path, errors)
|
|
134
|
+
return unless node.key?('name')
|
|
135
|
+
return unless node['name'].is_a?(String)
|
|
136
|
+
return if node['name'].match?(IDENTIFIER_PATTERN)
|
|
137
|
+
|
|
138
|
+
add_manual_error(errors, 'name must be a valid identifier', "#{path}/name", 'SCHEMA_NAME_INVALID')
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def validate_validation_extension_gating(root_schema, node, path, errors)
|
|
142
|
+
return if extension_enabled?(root_schema, 'JSONStructureValidation')
|
|
143
|
+
|
|
144
|
+
VALIDATION_KEYWORDS.each do |keyword|
|
|
145
|
+
next unless node.key?(keyword)
|
|
146
|
+
|
|
147
|
+
add_manual_warning(errors, "'#{keyword}' requires JSONStructureValidation extension.", "#{path}/#{escape_json_pointer(keyword)}")
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def validate_ucum_unit_keyword(root_schema, node, type, path, errors)
|
|
152
|
+
return unless node.key?('ucumUnit')
|
|
153
|
+
|
|
154
|
+
add_manual_error(errors, "'ucumUnit' requires JSONStructureUnits extension.", "#{path}/ucumUnit") unless extension_enabled?(root_schema, 'JSONStructureUnits')
|
|
155
|
+
|
|
156
|
+
add_manual_error(errors, "'ucumUnit' must be a string.", "#{path}/ucumUnit") unless node['ucumUnit'].is_a?(String)
|
|
157
|
+
|
|
158
|
+
return if type.is_a?(String) && UCUM_NUMERIC_TYPES.include?(type)
|
|
159
|
+
|
|
160
|
+
add_manual_error(errors, "'ucumUnit' can only appear in numeric schemas.", "#{path}/ucumUnit")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def validate_units_keywords(root_schema, node, type, path, errors)
|
|
164
|
+
UNITS_KEYWORDS.each do |keyword|
|
|
165
|
+
next unless node.key?(keyword)
|
|
166
|
+
|
|
167
|
+
add_manual_error(errors, "'#{keyword}' requires JSONStructureUnits extension.", "#{path}/#{escape_json_pointer(keyword)}") unless extension_enabled?(root_schema, 'JSONStructureUnits')
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
return unless node.key?('unit')
|
|
171
|
+
|
|
172
|
+
add_manual_error(errors, "'unit' must be a string.", "#{path}/unit") unless node['unit'].is_a?(String)
|
|
173
|
+
|
|
174
|
+
return if type.is_a?(String) && UCUM_NUMERIC_TYPES.include?(type)
|
|
175
|
+
|
|
176
|
+
add_manual_error(errors, "'unit' can only appear in numeric schemas.", "#{path}/unit")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def validate_relations_keywords(root_schema, node, type, path, errors)
|
|
180
|
+
has_identity = node.key?('identity')
|
|
181
|
+
has_relations = node.key?('relations')
|
|
182
|
+
return unless has_identity || has_relations
|
|
183
|
+
|
|
184
|
+
unless extension_enabled?(root_schema, 'JSONStructureRelations')
|
|
185
|
+
add_manual_error(errors, "'identity' requires JSONStructureRelations extension.", "#{path}/identity") if has_identity
|
|
186
|
+
add_manual_error(errors, "'relations' requires JSONStructureRelations extension.", "#{path}/relations") if has_relations
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
supports_relations = type.is_a?(String) && RELATION_CONTAINER_TYPES.include?(type)
|
|
190
|
+
|
|
191
|
+
if has_identity
|
|
192
|
+
validate_identity_keyword(node, path, supports_relations, errors)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
validate_relations_object(root_schema, node, path, supports_relations, errors) if has_relations
|
|
196
|
+
end
|
|
54
197
|
|
|
55
|
-
|
|
198
|
+
def validate_identity_keyword(node, path, supports_relations, errors)
|
|
199
|
+
identity = node['identity']
|
|
200
|
+
add_manual_error(errors, "'identity' can only appear in object or tuple schemas.", "#{path}/identity") unless supports_relations
|
|
201
|
+
|
|
202
|
+
unless identity.is_a?(Array)
|
|
203
|
+
add_manual_error(errors, "'identity' must be an array of strings.", "#{path}/identity")
|
|
204
|
+
return
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
properties = node['properties'].is_a?(Hash) ? node['properties'] : {}
|
|
208
|
+
identity.each_with_index do |item, index|
|
|
209
|
+
item_path = "#{path}/identity[#{index}]"
|
|
210
|
+
unless item.is_a?(String)
|
|
211
|
+
add_manual_error(errors, "'identity[#{index}]' must be a string.", item_path)
|
|
212
|
+
next
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
unless properties.key?(item)
|
|
216
|
+
add_manual_error(errors, "'identity' references property '#{item}' that is not in 'properties'.", item_path)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def validate_relations_object(root_schema, node, path, supports_relations, errors)
|
|
222
|
+
relations = node['relations']
|
|
223
|
+
add_manual_error(errors, "'relations' can only appear in object or tuple schemas.", "#{path}/relations") unless supports_relations
|
|
224
|
+
|
|
225
|
+
unless relations.is_a?(Hash)
|
|
226
|
+
add_manual_error(errors, "'relations' must be an object.", "#{path}/relations")
|
|
227
|
+
return
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
relations.each do |relation_name, relation|
|
|
231
|
+
relation_path = "#{path}/relations/#{escape_json_pointer(relation_name.to_s)}"
|
|
232
|
+
|
|
233
|
+
unless relation.is_a?(Hash)
|
|
234
|
+
add_manual_error(errors, 'Relation declaration must be an object.', relation_path)
|
|
235
|
+
next
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
if relation.key?('targettype')
|
|
239
|
+
validate_relation_ref_object(root_schema, relation['targettype'], relation_path, 'targettype', errors)
|
|
240
|
+
else
|
|
241
|
+
add_manual_error(errors, "Relation declaration must have 'targettype'.", "#{relation_path}/targettype")
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
if relation.key?('cardinality')
|
|
245
|
+
cardinality = relation['cardinality']
|
|
246
|
+
unless cardinality.is_a?(String) && %w[single multiple].include?(cardinality)
|
|
247
|
+
add_manual_error(errors, "'cardinality' must be 'single' or 'multiple'.", "#{relation_path}/cardinality")
|
|
248
|
+
end
|
|
249
|
+
else
|
|
250
|
+
add_manual_error(errors, "Relation declaration must have 'cardinality'.", "#{relation_path}/cardinality")
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
validate_relation_scope(relation['scope'], relation_path, errors) if relation.key?('scope')
|
|
254
|
+
validate_relation_ref_object(root_schema, relation['qualifiertype'], relation_path, 'qualifiertype', errors) if relation.key?('qualifiertype')
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def validate_relation_scope(scope, relation_path, errors)
|
|
259
|
+
return if scope.is_a?(String)
|
|
260
|
+
|
|
261
|
+
if scope.is_a?(Array)
|
|
262
|
+
scope.each_with_index do |item, index|
|
|
263
|
+
next if item.is_a?(String)
|
|
264
|
+
|
|
265
|
+
add_manual_error(errors, "'scope' array items must be strings.", "#{relation_path}/scope[#{index}]")
|
|
266
|
+
end
|
|
267
|
+
return
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
add_manual_error(errors, "'scope' must be a string or an array of strings.", "#{relation_path}/scope")
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def validate_extends_keyword(root_schema, node, path, errors)
|
|
274
|
+
return unless node.key?('$extends')
|
|
275
|
+
|
|
276
|
+
refs = normalized_extends_refs(node['$extends'], path)
|
|
277
|
+
refs.each do |ref, ref_path|
|
|
278
|
+
next unless ref.start_with?('#/')
|
|
279
|
+
|
|
280
|
+
resolved = resolve_ref(root_schema, ref)
|
|
281
|
+
next unless resolved
|
|
282
|
+
next if resolved.is_a?(Hash) && (!resolved.key?('type') || %w[object tuple map array set choice].include?(resolved['type']))
|
|
283
|
+
|
|
284
|
+
add_manual_error(errors,
|
|
285
|
+
"$extends target '#{ref}' must not resolve to a primitive type",
|
|
286
|
+
ref_path,
|
|
287
|
+
'SCHEMA_CONSTRAINT_TYPE_MISMATCH')
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def normalized_extends_refs(extends_value, path)
|
|
292
|
+
case extends_value
|
|
293
|
+
when String
|
|
294
|
+
[[extends_value, "#{path}/$extends"]]
|
|
295
|
+
when Array
|
|
296
|
+
extends_value.each_with_index.filter_map do |item, index|
|
|
297
|
+
[item, "#{path}/$extends[#{index}]"] if item.is_a?(String)
|
|
298
|
+
end
|
|
299
|
+
else
|
|
300
|
+
[]
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def validate_tuple_ref_entries(root_schema, node, type, path, errors)
|
|
305
|
+
return unless type == 'tuple'
|
|
306
|
+
return unless node['tuple'].is_a?(Array)
|
|
307
|
+
|
|
308
|
+
node['tuple'].each_with_index do |entry, index|
|
|
309
|
+
next unless entry.is_a?(Hash) && entry['$ref'].is_a?(String)
|
|
310
|
+
|
|
311
|
+
ref = entry['$ref']
|
|
312
|
+
next unless ref.start_with?('#/')
|
|
313
|
+
next if resolve_ref(root_schema, ref)
|
|
314
|
+
|
|
315
|
+
add_manual_error(errors, "$ref '#{ref}' not found", "#{path}/tuple[#{index}]/$ref", 'SCHEMA_REF_NOT_FOUND')
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def validate_enum_values(type, node, path, errors)
|
|
320
|
+
return unless node['enum'].is_a?(Array)
|
|
321
|
+
return unless type.is_a?(String)
|
|
322
|
+
|
|
323
|
+
node['enum'].each_with_index do |value, index|
|
|
324
|
+
next if enum_value_valid_for_type?(type, value)
|
|
325
|
+
|
|
326
|
+
add_manual_error(errors,
|
|
327
|
+
"enum value is not valid for type '#{type}'",
|
|
328
|
+
"#{path}/enum[#{index}]",
|
|
329
|
+
'SCHEMA_CONSTRAINT_TYPE_MISMATCH')
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def enum_value_valid_for_type?(type, value)
|
|
334
|
+
case type
|
|
335
|
+
when 'string'
|
|
336
|
+
value.is_a?(String)
|
|
337
|
+
when *ENUM_NUMERIC_TYPES
|
|
338
|
+
value.is_a?(Numeric)
|
|
339
|
+
when 'boolean'
|
|
340
|
+
value == true || value == false
|
|
341
|
+
when 'null'
|
|
342
|
+
value.nil?
|
|
343
|
+
else
|
|
344
|
+
true
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def validate_relation_ref_object(root_schema, value, relation_path, keyword, errors)
|
|
349
|
+
keyword_path = "#{relation_path}/#{keyword}"
|
|
350
|
+
|
|
351
|
+
unless value.is_a?(Hash) && value['$ref'].is_a?(String)
|
|
352
|
+
add_manual_error(errors, "'#{keyword}' must be an object with '$ref'.", keyword_path)
|
|
353
|
+
return
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
ref = value['$ref']
|
|
357
|
+
return unless ref.start_with?('#/')
|
|
358
|
+
return if resolve_ref(root_schema, ref)
|
|
359
|
+
|
|
360
|
+
add_manual_error(errors, "$ref '#{ref}' not found", "#{keyword_path}/$ref")
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def extension_enabled?(root_schema, extension)
|
|
364
|
+
uses = root_schema['$uses']
|
|
365
|
+
uses.is_a?(Array) && uses.include?(extension)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def resolve_ref(root_schema, ref)
|
|
369
|
+
return nil unless ref.start_with?('#/')
|
|
370
|
+
|
|
371
|
+
ref.delete_prefix('#/').split('/').reduce(root_schema) do |current, segment|
|
|
372
|
+
segment = segment.gsub('~1', '/').gsub('~0', '~')
|
|
373
|
+
break nil unless current.is_a?(Hash) && current.key?(segment)
|
|
374
|
+
|
|
375
|
+
current[segment]
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def escape_json_pointer(segment)
|
|
380
|
+
segment.to_s.gsub('~', '~0').gsub('/', '~1')
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def add_manual_error(errors, message, path, code = 0)
|
|
384
|
+
errors << ValidationError.new(
|
|
385
|
+
code: code,
|
|
386
|
+
severity: FFI::JS_SEVERITY_ERROR,
|
|
387
|
+
path: path,
|
|
388
|
+
message: message,
|
|
389
|
+
location: { line: 0, column: 0, offset: 0 }
|
|
390
|
+
)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def add_manual_warning(errors, message, path, code = 0)
|
|
394
|
+
errors << ValidationError.new(
|
|
395
|
+
code: code,
|
|
396
|
+
severity: FFI::JS_SEVERITY_WARNING,
|
|
397
|
+
path: path,
|
|
398
|
+
message: message,
|
|
399
|
+
location: { line: 0, column: 0, offset: 0 }
|
|
400
|
+
)
|
|
401
|
+
end
|
|
56
402
|
end
|
|
57
403
|
end
|
|
58
404
|
|
|
@@ -87,4 +87,336 @@ RSpec.describe JsonStructure::SchemaValidator do
|
|
|
87
87
|
end
|
|
88
88
|
end
|
|
89
89
|
end
|
|
90
|
+
|
|
91
|
+
describe '.validate with ucumUnit keyword' do
|
|
92
|
+
it 'accepts a numeric type with ucumUnit' do
|
|
93
|
+
schema = <<~JSON
|
|
94
|
+
{
|
|
95
|
+
"$schema": "https://json-structure.org/meta/extended/v0/#",
|
|
96
|
+
"$id": "urn:example:ucum-number",
|
|
97
|
+
"name": "Length",
|
|
98
|
+
"$uses": ["JSONStructureUnits"],
|
|
99
|
+
"type": "number",
|
|
100
|
+
"ucumUnit": "m"
|
|
101
|
+
}
|
|
102
|
+
JSON
|
|
103
|
+
|
|
104
|
+
result = described_class.validate(schema)
|
|
105
|
+
|
|
106
|
+
expect(result).to be_valid
|
|
107
|
+
expect(result.error_messages).to be_empty
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it 'accepts a numeric type with unit and ucumUnit' do
|
|
111
|
+
schema = <<~JSON
|
|
112
|
+
{
|
|
113
|
+
"$schema": "https://json-structure.org/meta/extended/v0/#",
|
|
114
|
+
"$id": "urn:example:ucum-both",
|
|
115
|
+
"name": "Length",
|
|
116
|
+
"$uses": ["JSONStructureUnits"],
|
|
117
|
+
"type": "number",
|
|
118
|
+
"unit": "meter",
|
|
119
|
+
"ucumUnit": "m"
|
|
120
|
+
}
|
|
121
|
+
JSON
|
|
122
|
+
|
|
123
|
+
result = described_class.validate(schema)
|
|
124
|
+
|
|
125
|
+
expect(result).to be_valid
|
|
126
|
+
expect(result.error_messages).to be_empty
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it 'accepts extended numeric types with ucumUnit' do
|
|
130
|
+
%w[int32 float double decimal].each do |type|
|
|
131
|
+
schema = <<~JSON
|
|
132
|
+
{
|
|
133
|
+
"$schema": "https://json-structure.org/meta/extended/v0/#",
|
|
134
|
+
"$id": "urn:example:ucum-#{type}",
|
|
135
|
+
"name": "#{type}WithUcumUnit",
|
|
136
|
+
"$uses": ["JSONStructureUnits"],
|
|
137
|
+
"type": "#{type}",
|
|
138
|
+
"ucumUnit": "m"
|
|
139
|
+
}
|
|
140
|
+
JSON
|
|
141
|
+
|
|
142
|
+
result = described_class.validate(schema)
|
|
143
|
+
|
|
144
|
+
expect(result).to be_valid
|
|
145
|
+
expect(result.error_messages).to be_empty
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it 'rejects ucumUnit on non-numeric types' do
|
|
150
|
+
schema = <<~JSON
|
|
151
|
+
{
|
|
152
|
+
"$schema": "https://json-structure.org/meta/extended/v0/#",
|
|
153
|
+
"$id": "urn:example:ucum-string",
|
|
154
|
+
"name": "BadUcumType",
|
|
155
|
+
"$uses": ["JSONStructureUnits"],
|
|
156
|
+
"type": "string",
|
|
157
|
+
"ucumUnit": "m"
|
|
158
|
+
}
|
|
159
|
+
JSON
|
|
160
|
+
|
|
161
|
+
result = described_class.validate(schema)
|
|
162
|
+
|
|
163
|
+
expect(result).to be_invalid
|
|
164
|
+
expect(result.error_messages).to include("'ucumUnit' can only appear in numeric schemas.")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
it 'rejects non-string ucumUnit values' do
|
|
168
|
+
schema = <<~JSON
|
|
169
|
+
{
|
|
170
|
+
"$schema": "https://json-structure.org/meta/extended/v0/#",
|
|
171
|
+
"$id": "urn:example:ucum-non-string",
|
|
172
|
+
"name": "BadUcumValue",
|
|
173
|
+
"$uses": ["JSONStructureUnits"],
|
|
174
|
+
"type": "number",
|
|
175
|
+
"ucumUnit": 5
|
|
176
|
+
}
|
|
177
|
+
JSON
|
|
178
|
+
|
|
179
|
+
result = described_class.validate(schema)
|
|
180
|
+
|
|
181
|
+
expect(result).to be_invalid
|
|
182
|
+
expect(result.error_messages).to include("'ucumUnit' must be a string.")
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
describe '.validate with root keyword checks' do
|
|
187
|
+
it 'rejects empty $id values' do
|
|
188
|
+
schema = <<~JSON
|
|
189
|
+
{
|
|
190
|
+
"$schema": "https://json-structure.org/meta/core/v0/#",
|
|
191
|
+
"$id": " ",
|
|
192
|
+
"name": "BadId",
|
|
193
|
+
"type": "object"
|
|
194
|
+
}
|
|
195
|
+
JSON
|
|
196
|
+
|
|
197
|
+
result = described_class.validate(schema)
|
|
198
|
+
|
|
199
|
+
expect(result).to be_invalid
|
|
200
|
+
expect(result.errors).to include(have_attributes(code: 'SCHEMA_KEYWORD_EMPTY', message: '$id must not be empty'))
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
it 'rejects $id values without a URI scheme' do
|
|
204
|
+
schema = <<~JSON
|
|
205
|
+
{
|
|
206
|
+
"$schema": "https://json-structure.org/meta/core/v0/#",
|
|
207
|
+
"$id": "example.com/no-scheme",
|
|
208
|
+
"name": "BadId",
|
|
209
|
+
"type": "object"
|
|
210
|
+
}
|
|
211
|
+
JSON
|
|
212
|
+
|
|
213
|
+
result = described_class.validate(schema)
|
|
214
|
+
|
|
215
|
+
expect(result).to be_invalid
|
|
216
|
+
expect(result.errors).to include(have_attributes(code: 'SCHEMA_CONSTRAINT_VALUE_INVALID', message: '$id must be a URI with a scheme'))
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
it 'rejects invalid root names' do
|
|
220
|
+
schema = <<~JSON
|
|
221
|
+
{
|
|
222
|
+
"$schema": "https://json-structure.org/meta/core/v0/#",
|
|
223
|
+
"$id": "https://example.com/bad-name-id",
|
|
224
|
+
"name": "123invalid",
|
|
225
|
+
"type": "object"
|
|
226
|
+
}
|
|
227
|
+
JSON
|
|
228
|
+
|
|
229
|
+
result = described_class.validate(schema)
|
|
230
|
+
|
|
231
|
+
expect(result).to be_invalid
|
|
232
|
+
expect(result.errors).to include(have_attributes(code: 'SCHEMA_NAME_INVALID', message: 'name must be a valid identifier'))
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
describe '.validate with enum type checks' do
|
|
237
|
+
it 'rejects enum values that do not match the declared type' do
|
|
238
|
+
schema = <<~JSON
|
|
239
|
+
{
|
|
240
|
+
"$schema": "https://json-structure.org/meta/core/v0/#",
|
|
241
|
+
"$id": "https://example.com/enum-type-mismatch",
|
|
242
|
+
"name": "EnumTypeMismatch",
|
|
243
|
+
"type": "boolean",
|
|
244
|
+
"enum": [true, "false"]
|
|
245
|
+
}
|
|
246
|
+
JSON
|
|
247
|
+
|
|
248
|
+
result = described_class.validate(schema)
|
|
249
|
+
|
|
250
|
+
expect(result).to be_invalid
|
|
251
|
+
expect(result.errors).to include(have_attributes(code: 'SCHEMA_CONSTRAINT_TYPE_MISMATCH', message: "enum value is not valid for type 'boolean'"))
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
describe '.validate with $extends checks' do
|
|
256
|
+
it 'rejects $extends targets that are not object or tuple schemas' do
|
|
257
|
+
schema = <<~JSON
|
|
258
|
+
{
|
|
259
|
+
"$schema": "https://json-structure.org/meta/core/v0/#",
|
|
260
|
+
"$id": "https://example.com/bad-extends-target",
|
|
261
|
+
"name": "Derived",
|
|
262
|
+
"type": "object",
|
|
263
|
+
"$extends": "#/definitions/Base",
|
|
264
|
+
"definitions": {
|
|
265
|
+
"Base": {
|
|
266
|
+
"name": "Base",
|
|
267
|
+
"type": "string"
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
JSON
|
|
272
|
+
|
|
273
|
+
result = described_class.validate(schema)
|
|
274
|
+
|
|
275
|
+
expect(result).to be_invalid
|
|
276
|
+
expect(result.errors).to include(
|
|
277
|
+
have_attributes(
|
|
278
|
+
code: 'SCHEMA_CONSTRAINT_TYPE_MISMATCH',
|
|
279
|
+
message: "$extends target '#/definitions/Base' must not resolve to a primitive type"
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
describe '.validate with tuple ref checks' do
|
|
286
|
+
it 'rejects tuple refs that cannot be resolved' do
|
|
287
|
+
schema = <<~JSON
|
|
288
|
+
{
|
|
289
|
+
"$schema": "https://json-structure.org/meta/core/v0/#",
|
|
290
|
+
"$id": "https://example.com/tuple-ref",
|
|
291
|
+
"name": "TupleRef",
|
|
292
|
+
"type": "tuple",
|
|
293
|
+
"properties": {
|
|
294
|
+
"name": { "type": "string" }
|
|
295
|
+
},
|
|
296
|
+
"tuple": [{ "$ref": "#/definitions/Missing" }]
|
|
297
|
+
}
|
|
298
|
+
JSON
|
|
299
|
+
|
|
300
|
+
result = described_class.validate(schema)
|
|
301
|
+
|
|
302
|
+
expect(result).to be_invalid
|
|
303
|
+
expect(result.errors).to include(
|
|
304
|
+
have_attributes(
|
|
305
|
+
code: 'SCHEMA_REF_NOT_FOUND',
|
|
306
|
+
message: "$ref '#/definitions/Missing' not found"
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
describe '.validate with Relations extension' do
|
|
313
|
+
it 'accepts object identity arrays' do
|
|
314
|
+
schema = <<~JSON
|
|
315
|
+
{
|
|
316
|
+
"$schema": "https://json-structure.org/meta/extended/v0/#",
|
|
317
|
+
"$id": "urn:example:relations-identity",
|
|
318
|
+
"name": "OrderIdentity",
|
|
319
|
+
"$uses": ["JSONStructureRelations"],
|
|
320
|
+
"type": "object",
|
|
321
|
+
"properties": {
|
|
322
|
+
"id": { "type": "string" },
|
|
323
|
+
"tenantId": { "type": "string" }
|
|
324
|
+
},
|
|
325
|
+
"identity": ["id", "tenantId"]
|
|
326
|
+
}
|
|
327
|
+
JSON
|
|
328
|
+
|
|
329
|
+
result = described_class.validate(schema)
|
|
330
|
+
|
|
331
|
+
expect(result).to be_valid
|
|
332
|
+
expect(result.error_messages).to be_empty
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
it 'accepts valid relation declarations' do
|
|
336
|
+
schema = <<~JSON
|
|
337
|
+
{
|
|
338
|
+
"$schema": "https://json-structure.org/meta/extended/v0/#",
|
|
339
|
+
"$id": "urn:example:relations-valid",
|
|
340
|
+
"name": "OrderRelations",
|
|
341
|
+
"$uses": ["JSONStructureRelations"],
|
|
342
|
+
"type": "object",
|
|
343
|
+
"properties": {
|
|
344
|
+
"id": { "type": "string" },
|
|
345
|
+
"customerId": { "type": "string" },
|
|
346
|
+
"itemIds": { "type": "array", "items": { "type": "string" } },
|
|
347
|
+
"qualifier": { "type": "string" }
|
|
348
|
+
},
|
|
349
|
+
"relations": {
|
|
350
|
+
"customer": {
|
|
351
|
+
"cardinality": "single",
|
|
352
|
+
"targettype": { "$ref": "#/definitions/Customer" }
|
|
353
|
+
},
|
|
354
|
+
"items": {
|
|
355
|
+
"cardinality": "multiple",
|
|
356
|
+
"targettype": { "$ref": "#/definitions/Item" },
|
|
357
|
+
"scope": "line-items"
|
|
358
|
+
},
|
|
359
|
+
"qualifiedCustomer": {
|
|
360
|
+
"cardinality": "single",
|
|
361
|
+
"targettype": { "$ref": "#/definitions/Customer" },
|
|
362
|
+
"qualifiertype": { "$ref": "#/definitions/RelationQualifier" }
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
"definitions": {
|
|
366
|
+
"Customer": {
|
|
367
|
+
"name": "Customer",
|
|
368
|
+
"type": "object",
|
|
369
|
+
"properties": { "id": { "type": "string" } }
|
|
370
|
+
},
|
|
371
|
+
"Item": {
|
|
372
|
+
"name": "Item",
|
|
373
|
+
"type": "object",
|
|
374
|
+
"properties": { "id": { "type": "string" } }
|
|
375
|
+
},
|
|
376
|
+
"RelationQualifier": {
|
|
377
|
+
"name": "RelationQualifier",
|
|
378
|
+
"type": "string"
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
JSON
|
|
383
|
+
|
|
384
|
+
result = described_class.validate(schema)
|
|
385
|
+
|
|
386
|
+
expect(result).to be_valid
|
|
387
|
+
expect(result.error_messages).to be_empty
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
it 'rejects invalid Relations schemas' do
|
|
391
|
+
schema = <<~JSON
|
|
392
|
+
{
|
|
393
|
+
"$schema": "https://json-structure.org/meta/extended/v0/#",
|
|
394
|
+
"$id": "urn:example:relations-invalid",
|
|
395
|
+
"name": "BadRelations",
|
|
396
|
+
"$uses": ["JSONStructureRelations"],
|
|
397
|
+
"type": "string",
|
|
398
|
+
"identity": ["id"],
|
|
399
|
+
"relations": {
|
|
400
|
+
"customer": {
|
|
401
|
+
"cardinality": "many",
|
|
402
|
+
"targettype": { "type": "object" },
|
|
403
|
+
"scope": ["ok", 3],
|
|
404
|
+
"qualifiertype": { "type": "string" }
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
JSON
|
|
409
|
+
|
|
410
|
+
result = described_class.validate(schema)
|
|
411
|
+
|
|
412
|
+
expect(result).to be_invalid
|
|
413
|
+
expect(result.error_messages).to include("'identity' can only appear in object or tuple schemas.")
|
|
414
|
+
expect(result.error_messages).to include("'identity' references property 'id' that is not in 'properties'.")
|
|
415
|
+
expect(result.error_messages).to include("'relations' can only appear in object or tuple schemas.")
|
|
416
|
+
expect(result.error_messages).to include("'targettype' must be an object with '$ref'.")
|
|
417
|
+
expect(result.error_messages).to include("'cardinality' must be 'single' or 'multiple'.")
|
|
418
|
+
expect(result.error_messages).to include("'scope' array items must be strings.")
|
|
419
|
+
expect(result.error_messages).to include("'qualifiertype' must be an object with '$ref'.")
|
|
420
|
+
end
|
|
421
|
+
end
|
|
90
422
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jsonstructure
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- JSON Structure Contributors
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-08 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ffi
|