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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 75e811de4008546530bcdad74ce44cfbaebe56d15319c6c7673951ebff8dd798
4
- data.tar.gz: 7be187b0e4e0cd1b8b205c3c09d99d556b16a3b28de74c8d00b8ea6b3a7012f3
3
+ metadata.gz: f870a1cfa2e4a2067e7fae0ad819f2a037a04156501413c9342c4fbd0be44624
4
+ data.tar.gz: 6db39ad31391b121bd41effec1dabeee9837e51887aec6511a33173e06e89764
5
5
  SHA512:
6
- metadata.gz: 9b5d2f5c58b89f766fe8d6828d6dbcd37ce38eb6f86625693e5421e82f04204b711a2ae1affb2460bf56ff55e7757f2a58cfef96be977686b8db379b24704eef
7
- data.tar.gz: 2265c5828667cab6b02078138d12f82314e22c1cf1e853d8a9757ab231546ad3d903da9ee6fcf8883de4c14c75c4ab0cf894ca6f75171526056a56910ab6e407
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
- # Validate a schema string
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
- def self.validate(schema_json)
24
- raise ArgumentError, 'schema_json must be a String' unless schema_json.is_a?(String)
40
+ def validate(schema_json)
41
+ raise ArgumentError, 'schema_json must be a String' unless schema_json.is_a?(String)
25
42
 
26
- JsonStructure.validation_started
27
- begin
28
- result_ptr = ::FFI::MemoryPointer.new(FFI::JSResult.size)
29
- FFI.js_result_init(result_ptr)
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
- FFI.js_validate_schema(schema_json, result_ptr)
32
- ValidationResult.from_ffi(result_ptr)
33
- ensure
34
- JsonStructure.validation_completed
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
- # Validate a schema string, raising an exception on failure
39
- #
40
- # @param schema_json [String] JSON string containing the schema
41
- # @return [ValidationResult] validation result (only if valid)
42
- # @raise [SchemaValidationError] if validation fails
43
- #
44
- # @example
45
- # begin
46
- # JsonStructure::SchemaValidator.validate!(schema)
47
- # puts "Schema is valid!"
48
- # rescue JsonStructure::SchemaValidationError => e
49
- # puts "Validation failed: #{e.message}"
50
- # end
51
- def self.validate!(schema_json)
52
- result = validate(schema_json)
53
- raise SchemaValidationError.new(result) unless result.valid?
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
- result
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
 
@@ -2,5 +2,5 @@
2
2
 
3
3
  module JsonStructure
4
4
  # Version of the JSON Structure Ruby SDK
5
- VERSION = '0.6.2'
5
+ VERSION = '0.7.0'
6
6
  end
@@ -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.6.2
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-04 00:00:00.000000000 Z
11
+ date: 2026-06-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ffi