xsd 2.7.10 → 2.8.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: a878430d5f353e97e8051c23a339f05c87949d9f385fb538a555a68f43658698
4
- data.tar.gz: 1f46317798a47375938d185acb9a908f517e0078f5669d39c28c8120c09d4cd5
3
+ metadata.gz: 40e6cbfb1356c6d21529ad7339f3ada85dbf85be27d7d0fcd3ecd59e55250192
4
+ data.tar.gz: 544ea96a3cc35ac6a37f8ab8971ae7d30c1b4a89c11c80ae6475ad1fcc61a6ae
5
5
  SHA512:
6
- metadata.gz: 56465f9472a9373a2c87a38125bb9426a27ed7bcec3757bc98b5ed9dc93cf44b88753f628769e800838c6ef527ea131a2f4b3deaab3c898a59db46ea5cd7a3df
7
- data.tar.gz: e6cd2ade397762b2ff9661de36bf7d5c8e0a7e488b49e408019e9e4dca6c6bb0416b459e16890e1d1980aadef02a590ae0d0cf530a869e8ed4b8c9b4c223579f
6
+ metadata.gz: 6fbc961bab3eac175a05dd99a8cbb34dce5384f682def039f708407f281cbbbe75f5ec6a57a751fff2c8fbad927fd83edf2b1e8e488f255655777a0ce7cf36d0
7
+ data.tar.gz: 4f8c2fa9687ed1f4647e91884abd60d63be92fcbda2475cc3b0aa3b340f9ca6ae7fbe12d91343389de1cd0616a676c45e018e88e8dcbe557d7014ce24c02d227
data/.rubocop.yml CHANGED
@@ -23,9 +23,6 @@ Layout/HashAlignment:
23
23
  Layout/LineLength:
24
24
  Enabled: false
25
25
 
26
- Lint/HashCompareByIdentity:
27
- Enabled: false
28
-
29
26
  Layout/SpaceAroundOperators:
30
27
  Enabled: false
31
28
 
data/CHANGELOG.md CHANGED
@@ -1,3 +1,34 @@
1
+ ## [2.8.0] - 2026-09-21
2
+
3
+ ### Fixed
4
+
5
+ - `XSD::XML.open` resolves relative import/include locations against the schema they were read from, so the default
6
+ resource resolver no longer requires a custom one for ordinary multi-file schemas
7
+ - `Schema#validate` writes included schemas into the temporary directory too, so schemas wired together with
8
+ `<xs:include>` can be validated
9
+ - `Schema#validate` serializes the temporary schemas as UTF-8, so schemas in another encoding no longer fail to load
10
+ - `XML#logger` no longer raises `NameError` when no logger is configured (`logger` was never required)
11
+ - `BaseObject#parent` returns `nil` instead of raising when the parent node has no XSD object (document, `wsdl:types`)
12
+ - `object_by_name` passes the searched name as an xpath variable instead of interpolating it into the expression
13
+ - `ComplexType#mixed_content?` and `#data_type` no longer raise on a `complexContent`/`simpleContent` without a
14
+ derivation
15
+ - An `<xs:include>` of a schema without a target namespace ("chameleon" include) is accepted
16
+ - Import/include cycles no longer yield duplicated definitions
17
+ - The compiled validator no longer leaks an open file descriptor
18
+ - `Element#attributes?` and `ComplexType#complex_content?` return booleans instead of the underlying objects
19
+ - Import/include error messages name the namespaces that did not match
20
+ - `require 'xsd'` defines `XSD::VERSION`, which previously needed `require 'xsd/version'` on top of it
21
+
22
+ ### Changed
23
+
24
+ - Imported/included schemas are loaded once per location and shared, instead of being re-read and re-parsed for every
25
+ reference to them
26
+ - Property, link and child lookups cache `nil` and `false` results, which cuts repeated xpath searches (roughly 2.5x
27
+ faster traversal of the bundled example schemas)
28
+ - `XSD::XML#add_schema_xml`/`#add_schema_node` accept a `location:` keyword, and `Schema#location` exposes it
29
+ - `XML#object_cache` is keyed by node identity (`Hash#compare_by_identity`) rather than by `node.object_id`, which
30
+ lets the `Lint/HashCompareByIdentity` rule be enabled again
31
+
1
32
  ## [2.7.10] - 2024-04-26
2
33
 
3
34
  - Fix data_type detection in complex_type simple_content extension
@@ -16,6 +16,9 @@ module XSD
16
16
  # Base XMLSchema namespace
17
17
  XML_SCHEMA = 'http://www.w3.org/2001/XMLSchema'
18
18
 
19
+ # Namespace map used for all internal xpath queries
20
+ XML_SCHEMA_NS = { 'xs' => XML_SCHEMA }.freeze
21
+
19
22
  class << self
20
23
  attr_reader :properties, :children, :links
21
24
 
@@ -33,10 +36,10 @@ module XSD
33
36
  end
34
37
 
35
38
  def initialize(options = {})
36
- @options = options
37
- @cache = {}
39
+ raise Error, "#{self.class}.new expects a hash parameter" unless options.is_a?(Hash)
38
40
 
39
- raise Error, "#{self.class}.new expects a hash parameter" unless @options.is_a?(Hash)
41
+ @options = options
42
+ @cache = {}
40
43
  end
41
44
 
42
45
  # Get current XML node
@@ -67,9 +70,10 @@ module XSD
67
70
 
68
71
  # Get child nodes
69
72
  # @param [Symbol] name
73
+ # @param [Boolean] deep
70
74
  # @return Nokogiri::XML::NodeSet
71
75
  def nodes(name = :*, deep = false)
72
- node.xpath("./#{deep ? '/' : ''}xs:#{name}", { 'xs' => XML_SCHEMA })
76
+ node.xpath("./#{deep ? '/' : ''}xs:#{name}", XML_SCHEMA_NS)
73
77
  end
74
78
 
75
79
  # Get schemas by namespace or prefix
@@ -77,7 +81,7 @@ module XSD
77
81
  # @return Array<Schema>
78
82
  def schemas_for_namespace(ns_or_prefix)
79
83
  # resolve namespace for current node if prefix was provided
80
- prefix = node.namespaces[['xmlns', (ns_or_prefix == '' ? nil : ns_or_prefix)].compact.join(':')]
84
+ prefix = namespaces[['xmlns', (ns_or_prefix == '' ? nil : ns_or_prefix)].compact.join(':')]
81
85
  ns = prefix || ns_or_prefix
82
86
 
83
87
  if schema.targets_namespace?(ns)
@@ -116,7 +120,7 @@ module XSD
116
120
  def object_by_name(node_name, name)
117
121
  # get prefix and local name
118
122
  name_prefix = get_prefix(name)
119
- name_local = strip_prefix(name)
123
+ name_local = strip_prefix(name)
120
124
 
121
125
  # do not search for built-in types
122
126
  return nil if schema.namespace_prefix == name_prefix
@@ -124,9 +128,9 @@ module XSD
124
128
  # determine schema for namespace
125
129
  search_schemas = schemas_for_namespace(name_prefix)
126
130
 
127
- # find element in target schema
131
+ # find element in target schema, name is passed as an xpath variable to stay safe with any name contents
128
132
  search_schemas.each do |s|
129
- node = s.node.at_xpath("./xs:#{node_name}[@name=\"#{name_local}\"]", { 'xs' => XML_SCHEMA })
133
+ node = s.node.at_xpath("./xs:#{node_name}[@name=$name]", XML_SCHEMA_NS, name: name_local)
130
134
  return s.node_to_object(node) if node
131
135
  end
132
136
 
@@ -134,22 +138,30 @@ module XSD
134
138
  end
135
139
 
136
140
  # Get reader object for node
137
- # @param [Nokogiri::XML::Node]
141
+ # @param [Nokogiri::XML::Node] node
138
142
  # @return BaseObject
139
143
  def node_to_object(node)
140
144
  # check object in cache first
141
- return reader.object_cache[node.object_id] if reader.object_cache[node.object_id]
145
+ cached = reader.object_cache[node]
146
+ return cached if cached
142
147
 
143
148
  klass = XML::CLASS_MAP[node.name]
144
149
  raise Error, "Object class not found for '#{node.name}'" unless klass
145
150
 
146
- reader.object_cache[node.object_id] = klass.new(options.merge(node: node, schema: schema))
151
+ reader.object_cache[node] = klass.new(options.merge(node: node, schema: schema))
147
152
  end
148
153
 
149
154
  # Get xml parent object
150
155
  # @return BaseObject, nil
151
156
  def parent
152
- node.respond_to?(:parent) && node.parent ? node_to_object(node.parent) : nil
157
+ return @parent if defined?(@parent)
158
+
159
+ parent_node = node.respond_to?(:parent) ? node.parent : nil
160
+
161
+ # the parent may be a document or a node of a foreign schema (e.g. wsdl:types), there is no object for those
162
+ @parent = if parent_node.is_a?(Nokogiri::XML::Element) && XML::CLASS_MAP.key?(parent_node.name)
163
+ node_to_object(parent_node)
164
+ end
153
165
  end
154
166
 
155
167
  # Get current schema object
@@ -205,55 +217,47 @@ module XSD
205
217
  # @param [Nokogiri::XML::Node] node
206
218
  # @return Array<String>
207
219
  def documentation_for(node)
208
- node.xpath('./xs:annotation/xs:documentation/text()', { 'xs' => XML_SCHEMA }).map { |x| x.text.strip }
220
+ node.xpath('./xs:annotation/xs:documentation/text()', XML_SCHEMA_NS).map { |x| x.text.strip }
209
221
  end
210
222
 
211
223
  # Get all available elements on the current stack level
212
224
  # @return Array<Element>
213
225
  def collect_elements(*)
214
- return @collect_elements if @collect_elements
215
-
216
- r = if NO_ELEMENTS_CONTAINER.include?(self.class.mapped_name)
217
- []
218
- elsif is_a?(Referenced) && ref
219
- reference.collect_elements
220
- else
221
- # map children recursive
222
- map_children(:*).map do |obj|
223
- if obj.is_a?(Element) || obj.is_a?(Any)
224
- obj
225
- else
226
- # get elements considering references
227
- (obj.is_a?(Referenced) && obj.ref ? obj.reference : obj).collect_elements
228
- end
229
- end.flatten
230
- end
231
-
232
- @collect_elements = r
226
+ @collect_elements ||= if NO_ELEMENTS_CONTAINER.include?(self.class.mapped_name)
227
+ []
228
+ elsif is_a?(Referenced) && ref
229
+ reference.collect_elements
230
+ else
231
+ # map children recursive
232
+ map_children(:*).flat_map do |obj|
233
+ if obj.is_a?(Element) || obj.is_a?(Any)
234
+ obj
235
+ else
236
+ # get elements considering references
237
+ (obj.is_a?(Referenced) && obj.ref ? obj.reference : obj).collect_elements
238
+ end
239
+ end
240
+ end
233
241
  end
234
242
 
235
243
  # Get all available attributes on the current stack level
236
244
  # @return Array<Attribute>
237
245
  def collect_attributes(*)
238
- return @collect_attributes if @collect_attributes
239
-
240
- r = if NO_ATTRIBUTES_CONTAINER.include?(self.class.mapped_name)
241
- []
242
- elsif is_a?(Referenced) && ref
243
- reference.collect_attributes
244
- else
245
- # map children recursive
246
- map_children(:*).map do |obj|
247
- if obj.is_a?(Attribute) || obj.is_a?(AnyAttribute)
248
- obj
249
- else
250
- # get attributes considering references
251
- (obj.is_a?(Referenced) && obj.ref ? obj.reference : obj).collect_attributes
252
- end
253
- end.flatten
254
- end
255
-
256
- @collect_attributes = r
246
+ @collect_attributes ||= if NO_ATTRIBUTES_CONTAINER.include?(self.class.mapped_name)
247
+ []
248
+ elsif is_a?(Referenced) && ref
249
+ reference.collect_attributes
250
+ else
251
+ # map children recursive
252
+ map_children(:*).flat_map do |obj|
253
+ if obj.is_a?(Attribute) || obj.is_a?(AnyAttribute)
254
+ obj
255
+ else
256
+ # get attributes considering references
257
+ (obj.is_a?(Referenced) && obj.ref ? obj.reference : obj).collect_attributes
258
+ end
259
+ end
260
+ end
257
261
  end
258
262
 
259
263
  # Get reader instance
@@ -307,35 +311,17 @@ module XSD
307
311
  # @param [Symbol] method
308
312
  # @param [Array] args
309
313
  def method_missing(method, *args)
310
- # check cache first
311
- return @cache[method] if @cache[method]
314
+ # check cache first, nil and false results are cached as well
315
+ return @cache[method] if @cache.key?(method)
312
316
 
313
317
  # check for property first
314
318
  if (property = self.class.properties[method])
315
- value = property[:resolve] ? property[:resolve].call(self) : node[property[:name].to_s]
316
- r = if value.nil?
317
- if node['ref']
318
- # if object has reference - search property in referenced object
319
- reference.send(method, *args)
320
- else
321
- property[:default].is_a?(Proc) ? instance_eval(&property[:default]) : property[:default]
322
- end
323
- else
324
- case property[:type]
325
- when :integer
326
- property[:name] == :maxOccurs && value == 'unbounded' ? :unbounded : value.to_i
327
- when :boolean
328
- value == 'true'
329
- else
330
- value
331
- end
332
- end
333
- return @cache[method] = r
319
+ return @cache[method] = property_value(property, method, args)
334
320
  end
335
321
 
336
322
  # if object has ref it cannot contain any type and children, so proxy call to target object
337
323
  if node['ref'] && method != :ref && method != :reference
338
- return reference.send(method, *args)
324
+ return @cache[method] = reference.send(method, *args)
339
325
  end
340
326
 
341
327
  # then check for linked types
@@ -345,7 +331,7 @@ module XSD
345
331
  return @cache[method] = object_by_name(link[:type], name)
346
332
  elsif is_a?(Restriction) && %i[base_simple_type base_complex_type].include?(method)
347
333
  # handle restriction without base
348
- return nil
334
+ return @cache[method] = nil
349
335
  end
350
336
  end
351
337
 
@@ -356,26 +342,24 @@ module XSD
356
342
  end
357
343
 
358
344
  super
359
- # api = self.class.properties.keys + self.class.links.keys + self.class.children.keys
360
- # raise Error, "Tried to access unknown object '#{method}' on '#{self.class.name}'. Available options are: #{api}"
361
345
  end
362
346
 
363
347
  # Does object has property/link/child?
364
348
  # @param [Symbol] method
365
- # @param [Array] args
366
- def respond_to_missing?(method, *args)
367
- self.class.properties[method] || self.class.links[method] || self.class.children[method] || super
349
+ # @param [Boolean] include_private
350
+ def respond_to_missing?(method, include_private = false)
351
+ !self.class.properties[method].nil? || !self.class.links[method].nil? ||
352
+ !self.class.children[method].nil? || super
368
353
  end
369
354
 
370
355
  # Get mapped element name
371
356
  # @return Symbol
372
357
  def self.mapped_name
373
- # @mapped_name ||= XML::CLASS_MAP.each { |k, v| return k.to_sym if v == self }
374
358
  @mapped_name ||= begin
375
- name = self.name.split('::').last
376
- name[0] = name[0].downcase
377
- name.to_sym
378
- end
359
+ name = self.name.split('::').last
360
+ name[0] = name[0].downcase
361
+ name.to_sym
362
+ end
379
363
  end
380
364
 
381
365
  # Return string if it is not empty, or nil otherwise
@@ -387,6 +371,34 @@ module XSD
387
371
 
388
372
  private
389
373
 
374
+ # Resolve property value from the node attribute, resolver block, reference or default
375
+ # @param [Hash] property
376
+ # @param [Symbol] method
377
+ # @param [Array] args
378
+ def property_value(property, method, args)
379
+ value = property[:resolve] ? property[:resolve].call(self) : node[property[:name].to_s]
380
+ return cast_property_value(property, value) unless value.nil?
381
+
382
+ # if object has reference - search property in referenced object
383
+ return reference.send(method, *args) if node['ref']
384
+
385
+ property[:default].is_a?(Proc) ? instance_eval(&property[:default]) : property[:default]
386
+ end
387
+
388
+ # Cast raw attribute value to the declared property type
389
+ # @param [Hash] property
390
+ # @param [String] value
391
+ def cast_property_value(property, value)
392
+ case property[:type]
393
+ when :integer
394
+ property[:name] == :maxOccurs && value == 'unbounded' ? :unbounded : value.to_i
395
+ when :boolean
396
+ value == 'true'
397
+ else
398
+ value
399
+ end
400
+ end
401
+
390
402
  def definition_match?(definition, query)
391
403
  # namespace included in query
392
404
  if query.start_with?('{')
@@ -395,7 +407,7 @@ module XSD
395
407
 
396
408
  namespace, name = parts
397
409
 
398
- return false if namespace != definition.namespace
410
+ return false if nil_if_empty(namespace) != definition.namespace
399
411
  else
400
412
  name = query
401
413
  end
@@ -88,7 +88,9 @@ module XSD
88
88
  if complex_content
89
89
  return true if complex_content.mixed
90
90
 
91
- return (complex_content.extension || complex_content.restriction).base_complex_type&.mixed_content?
91
+ derivation = complex_content.extension || complex_content.restriction
92
+
93
+ return derivation&.base_complex_type&.mixed_content? || false
92
94
  end
93
95
 
94
96
  false
@@ -102,7 +104,7 @@ module XSD
102
104
  elsif complex_content
103
105
  true
104
106
  else
105
- group || all || choice || sequence
107
+ !!(group || all || choice || sequence)
106
108
  end
107
109
  end
108
110
 
@@ -118,28 +120,23 @@ module XSD
118
120
  return nil unless simple_content
119
121
 
120
122
  restriction = simple_content.restriction
121
- if restriction
122
- if restriction.base
123
- if restriction.base_simple_type
124
- restriction.base_simple_type.data_type
125
- elsif restriction.base_complex_type
126
- restriction.base_complex_type.data_type
127
- else
128
- strip_prefix(restriction.base)
129
- end
130
- else
131
- restriction.simple_type&.data_type
132
- end
133
- else
134
- extension = simple_content.extension
135
- if extension.base_simple_type
136
- extension.base_simple_type.data_type
137
- elsif extension.base_complex_type
138
- extension.base_complex_type.data_type
139
- else
140
- strip_prefix(extension.base)
141
- end
142
- end
123
+ return base_data_type(simple_content.extension) unless restriction
124
+ return restriction.simple_type&.data_type unless restriction.base
125
+
126
+ base_data_type(restriction)
127
+ end
128
+
129
+ private
130
+
131
+ # Get data type of an extension/restriction base type
132
+ # @param [Extension, Restriction, nil] derivation
133
+ # @return String, nil
134
+ def base_data_type(derivation)
135
+ return nil if derivation.nil?
136
+
137
+ derivation.base_simple_type&.data_type ||
138
+ derivation.base_complex_type&.data_type ||
139
+ strip_prefix(derivation.base)
143
140
  end
144
141
  end
145
142
  end
@@ -121,7 +121,7 @@ module XSD
121
121
  # Determine if element has attributes
122
122
  # @return Boolean
123
123
  def attributes?
124
- complex_type && collect_attributes.any?
124
+ !!complex_type && collect_attributes.any?
125
125
  end
126
126
 
127
127
  # Determine if element has mixed content
@@ -133,17 +133,16 @@ module XSD
133
133
  # Get substitutable elements
134
134
  # @return Array<Element>
135
135
  def substitutable_elements
136
- @substitutable_elements ||= begin
137
- if ref
138
- reference.substitutable_elements
139
- elsif !global? || %w[#all substitution].include?(block)
140
- []
141
- else
142
- # TODO: we search substituted element only in the same schema with head element, is this enough?
143
- # TODO: maybe don't collect all elements but search with xpath first?
144
- schema.collect_elements.select do |element|
145
- strip_prefix(element.substitution_group) == name
146
- end
136
+ @substitutable_elements ||= if ref
137
+ reference.substitutable_elements
138
+ elsif !global? || %w[#all substitution].include?(block)
139
+ []
140
+ else
141
+ # TODO: we search substituted element only in the same schema with head element,
142
+ # is this enough?
143
+ # TODO: maybe don't collect all elements but search with xpath first?
144
+ schema.collect_elements.select do |element|
145
+ strip_prefix(element.substitution_group) == name
147
146
  end
148
147
  end
149
148
  end
@@ -18,18 +18,20 @@ module XSD
18
18
  # Get imported schema
19
19
  # @return Schema
20
20
  def imported_schema
21
- known_schemas = reader.schemas_for_namespace(namespace)
22
- return known_schemas.first if known_schemas.any?
21
+ # a schema for the namespace may already be known, either added manually or by another import
22
+ known_schema = reader.schemas_for_namespace(namespace).find { |s| !s.equal?(schema) }
23
+ return known_schema if known_schema
23
24
 
24
25
  unless schema_location
25
- raise ImportError, "Schema location not provided for namespace '#{namespace}', use add_schema_xml()/add_schema_node()"
26
+ raise ImportError,
27
+ "Schema location not provided for namespace '#{namespace}', use add_schema_xml()/add_schema_node()"
26
28
  end
27
29
 
28
- xml = reader.resource_resolver.call(schema_location, namespace)
29
- new_schema = reader.add_schema_xml(xml)
30
+ new_schema = reader.load_schema(schema_location, namespace: namespace, base: schema.location)
30
31
 
31
32
  unless namespace == new_schema.target_namespace
32
- raise ImportError, 'Import namespace does not match imported schema targetNamespace'
33
+ raise ImportError, "Import namespace '#{namespace}' does not match imported schema targetNamespace " \
34
+ "'#{new_schema.target_namespace}'"
33
35
  end
34
36
 
35
37
  new_schema
@@ -10,20 +10,20 @@ module XSD
10
10
  # @return String
11
11
  property :schemaLocation, :string
12
12
 
13
- # Get imported schema
13
+ # Get included schema
14
14
  # @return Schema
15
15
  def imported_schema
16
- # cache included schema locally as it has no unique namespace to check in global registry
17
- return @imported_schema if @imported_schema
16
+ raise ImportError, 'Schema location not provided for include' unless schema_location
18
17
 
19
- xml = reader.resource_resolver.call(schema_location)
20
- new_schema = reader.add_schema_xml(xml)
18
+ new_schema = reader.load_schema(schema_location, base: schema.location)
21
19
 
22
- unless schema.target_namespace == new_schema.target_namespace
23
- raise ImportError, 'Schema targetNamespace does not match included schema targetNamespace'
20
+ # an included schema without a target namespace adopts the namespace of the including schema ("chameleon" include)
21
+ unless new_schema.target_namespace.nil? || schema.target_namespace == new_schema.target_namespace
22
+ raise ImportError, "Schema targetNamespace '#{schema.target_namespace}' does not match included schema " \
23
+ "targetNamespace '#{new_schema.target_namespace}'"
24
24
  end
25
25
 
26
- @imported_schema = new_schema
26
+ new_schema
27
27
  end
28
28
  end
29
29
  end
@@ -39,22 +39,20 @@ module XSD
39
39
  # Get restriction facets
40
40
  # @return Hash
41
41
  def facets
42
- nodes.inject({}) do |hash, node|
43
- if FACET_ELEMENTS.include?(node.name)
44
- key = node.name
45
- value = node['value']
42
+ @facets ||= nodes.each_with_object({}) do |node, hash|
43
+ next unless FACET_ELEMENTS.include?(node.name)
46
44
 
47
- if key == 'enumeration'
48
- hash[key] ||= {}
49
- hash[key][value] = documentation_for(node)
50
- elsif key == 'pattern'
51
- hash[key] ||= []
52
- hash[key].push(value)
53
- else
54
- hash[key] = value
55
- end
45
+ key = node.name
46
+ value = node['value']
47
+
48
+ case key
49
+ when 'enumeration'
50
+ (hash[key] ||= {})[value] = documentation_for(node)
51
+ when 'pattern'
52
+ (hash[key] ||= []).push(value)
53
+ else
54
+ hash[key] = value
56
55
  end
57
- hash
58
56
  end
59
57
  end
60
58
 
@@ -73,7 +71,7 @@ module XSD
73
71
  def collect_attributes(include_base = true)
74
72
  result = super(include_base)
75
73
  # Filter restricted attributes to avoid duplicates from restricting and restricted type
76
- include_base ? result.inject({}) { |hash, item| hash[item.name] = item; hash }.values : result
74
+ include_base ? result.to_h { |item| [item.name, item] }.values : result
77
75
  end
78
76
  end
79
77
  end
@@ -120,7 +120,13 @@ module XSD
120
120
  # Get schema namespace prefix
121
121
  # @return String, nil
122
122
  def namespace_prefix
123
- nil_if_empty(@namespace_prefix ||= namespaces.key(XML_SCHEMA).sub(/^xmlns:?/, ''))
123
+ nil_if_empty(@namespace_prefix ||= namespaces.key(XML_SCHEMA)&.sub(/^xmlns:?/, '') || '')
124
+ end
125
+
126
+ # Location the schema was read from, used to resolve relative import/include locations
127
+ # @return String, nil
128
+ def location
129
+ options[:location]
124
130
  end
125
131
 
126
132
  # Check if namespace is a target namespace
@@ -132,26 +138,26 @@ module XSD
132
138
 
133
139
  # Override map_children on schema to get objects from all loaded schemas
134
140
  # @param [Symbol] name
141
+ # @param [Hash] cache Schema nodes already visited, guards against import/include cycles
135
142
  # @return Array<BaseObject>
136
143
  def map_children(name, cache = {})
144
+ cache[node] = true
137
145
  super(name) + import_map_children(name, cache)
138
146
  end
139
147
 
140
148
  # Get children from all loaded schemas
141
149
  # @param [Symbol] name
150
+ # @param [Hash] cache
142
151
  # @return Array<BaseObject>
143
152
  def import_map_children(name, cache)
144
153
  return [] if %i[import include].include?(name.to_sym)
145
154
 
146
- (includes + imports).map do |import|
147
- key = import.respond_to?(:namespace) && import.namespace ? import.namespace : import.schema_location
148
- if cache.key?(key)
149
- nil
150
- else
151
- cache[key] = true
152
- import.imported_schema.map_children(name, cache)
153
- end
154
- end.compact.flatten
155
+ (includes + imports).flat_map do |import|
156
+ imported = import.imported_schema
157
+ next [] if cache.key?(imported.node)
158
+
159
+ imported.map_children(name, cache)
160
+ end
155
161
  end
156
162
 
157
163
  # Get import by namespace or prefix
@@ -170,7 +176,9 @@ module XSD
170
176
  # @param [String, Pathname, Nokogiri::XML::Document] xml
171
177
  def validate_xml(xml)
172
178
  # validate input
173
- raise ValidationError unless xml.is_a?(Nokogiri::XML::Document) || xml.is_a?(Pathname) || xml.is_a?(String)
179
+ unless xml.is_a?(Nokogiri::XML::Document) || xml.is_a?(Pathname) || xml.is_a?(String)
180
+ raise ValidationError, "XML must be a String, Pathname or Nokogiri::XML::Document, got #{xml.class}"
181
+ end
174
182
 
175
183
  begin
176
184
  document = xml.is_a?(Nokogiri::XML::Document) ? xml : Nokogiri::XML(xml)
@@ -200,69 +208,61 @@ module XSD
200
208
  def schema_validator
201
209
  return @schema_validator if @schema_validator
202
210
 
203
- # if !imported_xsd.empty?
204
- # imports are explicitly provided - put all files in one tmpdir and update import paths appropriately
205
- # TODO: save file/path map to display in errors
211
+ # imports/includes are resolved by the reader, so put all schemas in one tmpdir with rewritten locations
206
212
  Dir.mktmpdir('XSD', reader.tmp_dir) do |dir|
207
- # create primary xsd file
208
- file = "#{::SecureRandom.urlsafe_base64}.xsd"
209
-
210
- # create imported xsd files
211
- recursive_import_xsd(self, file, Set.new) do |f, data|
212
- File.write("#{dir}/#{f}", data)
213
+ file = recursive_import_xsd(self, {}) do |name, data|
214
+ File.write(File.join(dir, name), data, encoding: Encoding::UTF_8)
213
215
  end
214
216
 
215
217
  # read schema from tmp file descriptor
216
- io = File.open("#{dir}/#{file}")
217
- @schema_validator = create_xml_schema(io)
218
+ File.open(File.join(dir, file)) { |io| @schema_validator = create_xml_schema(io) }
218
219
  end
219
- # else
220
- # @schema_validator = create_xml_schema(schema.node.to_xml)
221
- # end
222
220
 
223
221
  @schema_validator
224
222
  end
225
223
 
226
- # Сформировать имена файлов и содержимое XSD схем для корректной валидации
224
+ # Write the schema and all of its imports/includes as a flat set of files with rewritten locations
227
225
  # @param [Schema] schema
228
- # @param [String] file
229
- # @param [Set] processed
230
- def recursive_import_xsd(schema, file, processed, &block)
231
- # handle recursion
232
- return if processed.include?(schema.target_namespace)
233
-
234
- processed.add(schema.target_namespace)
235
-
236
- # prepare schema XML with all namespaces included, clone node to avoid mutating original schema
237
- node = schema.node
238
- if node.namespaces.size != node.namespace_definitions.size
239
- prefixes = node.namespace_definitions.map(&:prefix)
240
- node = schema.node.dup
241
-
242
- schema.node.namespaces.each do |attr, ns|
243
- prefix = attr == 'xmlns' ? nil : attr.sub('xmlns:', '')
244
- # does not work!
245
- # node.add_namespace_definition(prefix, ns) unless prefixes.include?(prefix)
246
- node[attr] = ns unless prefixes.include?(prefix)
247
- end
226
+ # @param [Hash] processed Maps already written schema nodes to generated file names
227
+ # @return String Name of the file the schema was written to
228
+ def recursive_import_xsd(schema, processed, &block)
229
+ # handle recursion, schemas are identified by node because includes share the target namespace
230
+ return processed[schema.node] if processed.key?(schema.node)
231
+
232
+ file = "#{::SecureRandom.urlsafe_base64}.xsd"
233
+ processed[schema.node] = file
234
+
235
+ node = schema_node_with_namespaces(schema)
236
+ references = schema.imports + schema.includes
237
+ nodes = node.xpath('./xs:import', XML_SCHEMA_NS).to_a + node.xpath('./xs:include', XML_SCHEMA_NS).to_a
238
+
239
+ # point every import/include to the generated file of the referenced schema
240
+ references.zip(nodes).each do |reference, reference_node|
241
+ next if reference_node.nil?
242
+
243
+ reference_node['schemaLocation'] = recursive_import_xsd(reference.imported_schema, processed, &block)
248
244
  end
249
245
 
250
- data = node.to_xml
246
+ # always serialize as UTF-8 with an explicit declaration, the source document may use another encoding
247
+ block.call(file, %(<?xml version="1.0" encoding="UTF-8"?>\n#{node.to_xml(encoding: 'UTF-8')}))
251
248
 
252
- schema.imports.each do |import|
253
- name = "#{::SecureRandom.urlsafe_base64}.xsd"
254
- location = import.schema_location
255
- namespace = import.namespace
249
+ file
250
+ end
256
251
 
257
- if location
258
- data = data.sub("schemaLocation=\"#{location}\"", "schemaLocation=\"#{name}\"")
259
- else
260
- data = data.sub("namespace=\"#{namespace}\"", "namespace=\"#{namespace}\" schemaLocation=\"#{name}\"")
261
- end
262
- recursive_import_xsd(import.imported_schema, name, processed, &block)
252
+ # Get a detached copy of the schema node with all inherited namespaces declared on it
253
+ # @param [Schema] schema
254
+ # @return Nokogiri::XML::Node
255
+ def schema_node_with_namespaces(schema)
256
+ node = schema.node.dup
257
+ prefixes = schema.node.namespace_definitions.map(&:prefix)
258
+
259
+ schema.node.namespaces.each do |attr, ns|
260
+ prefix = attr == 'xmlns' ? nil : attr.sub('xmlns:', '')
261
+ # add_namespace_definition does not work on a detached node, declare the namespace as an attribute instead
262
+ node[attr] = ns unless prefixes.include?(prefix)
263
263
  end
264
264
 
265
- block.call(file, data)
265
+ node
266
266
  end
267
267
 
268
268
  # Create Nokogiri XML Schema instance with preconfigured options
data/lib/xsd/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module XSD
4
- VERSION = '2.7.10'
4
+ VERSION = '2.8.0'
5
5
  end
data/lib/xsd/xml.rb CHANGED
@@ -2,13 +2,21 @@
2
2
 
3
3
  require 'nokogiri'
4
4
  require 'net/http'
5
+ require 'pathname'
6
+ require 'logger'
7
+ require 'uri'
5
8
 
6
9
  module XSD
7
10
  class XML
8
11
  attr_reader :options, :object_cache, :schemas
9
12
 
13
+ # Matches locations that have to be fetched over the network
14
+ REMOTE_LOCATION = %r{\Ahttps?://}i
15
+
16
+ # Legacy resource resolver, kept for backwards compatibility. Resolves absolute paths and URLs only,
17
+ # relative locations are resolved by the reader itself, see #read_resource
10
18
  DEFAULT_RESOURCE_RESOLVER = proc do |location, namespace|
11
- if location =~ /^https?:/
19
+ if location.match?(REMOTE_LOCATION)
12
20
  Net::HTTP.get(URI(location))
13
21
  elsif Pathname.new(location).absolute?
14
22
  File.read(location)
@@ -62,31 +70,35 @@ module XSD
62
70
  }.freeze
63
71
 
64
72
  # Create reader from a file path
65
- # @param [String] path
73
+ # @param [String, Pathname] path
66
74
  # @param [Hash] options
67
75
  # @return XML
68
76
  def self.open(path, **options)
69
77
  reader = new(**options)
70
- reader.add_schema_xml(File.read(path))
78
+ reader.add_schema_xml(File.read(path.to_s), location: path.to_s)
71
79
  reader
72
80
  end
73
81
 
74
82
  def initialize(**options)
75
- @options = options
76
- @object_cache = {}
77
- @schemas = []
83
+ @options = options
84
+ # keyed by node identity, the parsed documents are treated as immutable
85
+ @object_cache = {}.compare_by_identity
86
+ @schemas = []
87
+ @schemas_by_location = {}
78
88
  end
79
89
 
80
90
  def logger
81
91
  options[:logger] || default_logger
82
92
  end
83
93
 
94
+ # Resolver used to fetch imported/included schemas
95
+ # @return Proc
84
96
  def resource_resolver
85
97
  @options[:resource_resolver] || DEFAULT_RESOURCE_RESOLVER
86
98
  end
87
99
 
88
100
  def tmp_dir
89
- @tmp_dir ||= options[:tmp_dir]
101
+ options[:tmp_dir]
90
102
  end
91
103
 
92
104
  def read_document(xml)
@@ -94,24 +106,51 @@ module XSD
94
106
  end
95
107
 
96
108
  # Add schema xml to reader instance
109
+ # @param [String] xml
110
+ # @param [String, nil] location Location the xml was read from, used to resolve relative imports
97
111
  # @return Schema
98
- def add_schema_xml(xml)
112
+ def add_schema_xml(xml, location: nil)
99
113
  doc = read_document(xml)
100
114
  raise Error, 'Schema node not found, xml does not seem to be a valid XSD' unless doc.root&.name == 'schema'
101
115
 
102
- add_schema_node(doc.root)
116
+ add_schema_node(doc.root, location: location)
103
117
  end
104
118
 
105
119
  # Add schema node to reader instance
120
+ # @param [Nokogiri::XML::Node] node
121
+ # @param [String, nil] location Location the node was read from, used to resolve relative imports
106
122
  # @return Schema
107
- def add_schema_node(node)
123
+ def add_schema_node(node, location: nil)
108
124
  raise Error, 'Added schema must be of type Nokogiri::XML::Node' unless node.is_a?(Nokogiri::XML::Node)
109
125
 
110
- new_schema = Schema.new(options.merge(node: node, reader: self))
126
+ new_schema = Schema.new(options.merge(node: node, reader: self, location: location))
111
127
  schemas.push(new_schema)
128
+ # register the schema so that an import/include pointing back at this location reuses it
129
+ @schemas_by_location[location] ||= new_schema if location
112
130
  new_schema
113
131
  end
114
132
 
133
+ # Load schema referenced by an import/include, reusing an already loaded one when possible
134
+ # @param [String] location
135
+ # @param [String, nil] namespace
136
+ # @param [String, nil] base Location of the referencing schema
137
+ # @return Schema
138
+ def load_schema(location, namespace: nil, base: nil)
139
+ raise ImportError, 'Schema location must be provided' if location.nil? || location.empty?
140
+
141
+ expanded = expand_location(location, base)
142
+ return @schemas_by_location[expanded] if @schemas_by_location.key?(expanded)
143
+
144
+ # a custom resolver is responsible for resolving relative locations on its own
145
+ xml = if options[:resource_resolver]
146
+ options[:resource_resolver].call(location, namespace)
147
+ else
148
+ read_resource(expanded, namespace)
149
+ end
150
+
151
+ @schemas_by_location[expanded] = add_schema_xml(xml, location: expanded)
152
+ end
153
+
115
154
  # Get first added (considered primary) schema
116
155
  # @return Schema, nil
117
156
  def schema
@@ -160,10 +199,34 @@ module XSD
160
199
 
161
200
  private
162
201
 
202
+ # Read schema contents from an absolute path or URL
203
+ # @param [String] location
204
+ # @param [String, nil] namespace
205
+ # @return String
206
+ def read_resource(location, namespace = nil)
207
+ if location.match?(REMOTE_LOCATION)
208
+ Net::HTTP.get(URI(location))
209
+ elsif File.file?(location)
210
+ File.read(location)
211
+ else
212
+ raise ImportError, "Failed to locate import '#{location}'#{namespace ? " for namespace '#{namespace}'" : ''}"
213
+ end
214
+ end
215
+
216
+ # Resolve a possibly relative location against the location of the referencing schema
217
+ # @param [String] location
218
+ # @param [String, nil] base
219
+ # @return String
220
+ def expand_location(location, base)
221
+ return location if location.match?(REMOTE_LOCATION) || Pathname.new(location).absolute?
222
+ return location if base.nil?
223
+ return URI.join(base, location).to_s if base.match?(REMOTE_LOCATION)
224
+
225
+ Pathname.new(base).dirname.join(location).cleanpath.to_s
226
+ end
227
+
163
228
  def collect(name, *args)
164
- result = Set.new
165
- schemas.each { |schema| result.merge(schema.send(name, *args)) }
166
- result.to_a
229
+ schemas.flat_map { |schema| schema.send(name, *args) }.uniq
167
230
  end
168
231
 
169
232
  def default_logger
data/lib/xsd.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'xsd/version'
3
4
  require 'xsd/base_object'
4
5
  require 'xsd/shared/named'
5
6
  require 'xsd/shared/min_max_occurs'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: xsd
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.7.10
4
+ version: 2.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - d.arkhipov
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-04-26 00:00:00.000000000 Z
11
+ date: 2026-09-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: builder
@@ -55,7 +55,7 @@ dependencies:
55
55
  description: The Ruby XSD library is an XML Schema implementation for Ruby.Provides
56
56
  easy and flexible access to XSD information
57
57
  email:
58
- - d.arkhipov@netcitylife.ru
58
+ - d.arkhipov@ekzo.dev
59
59
  executables: []
60
60
  extensions: []
61
61
  extra_rdoc_files: []
@@ -111,14 +111,13 @@ files:
111
111
  - lib/xsd/shared/simple_typed.rb
112
112
  - lib/xsd/version.rb
113
113
  - lib/xsd/xml.rb
114
- - xsd.gemspec
115
114
  homepage: https://github.com/ekzo-dev/ruby-xsd
116
115
  licenses:
117
116
  - MIT
118
117
  metadata:
119
118
  homepage_uri: https://github.com/ekzo-dev/ruby-xsd
120
119
  source_code_uri: https://github.com/ekzo-dev/ruby-xsd
121
- changelog_uri: https://github.com/ekzo-dev/ruby-xsd/CHANGELOG.md
120
+ changelog_uri: https://github.com/ekzo-dev/ruby-xsd/blob/main/CHANGELOG.md
122
121
  post_install_message:
123
122
  rdoc_options: []
124
123
  require_paths:
data/xsd.gemspec DELETED
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'lib/xsd/version'
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = 'xsd'
7
- spec.version = XSD::VERSION
8
- spec.authors = ['d.arkhipov']
9
- spec.email = ['d.arkhipov@netcitylife.ru']
10
-
11
- spec.summary = 'The Ruby XSD library is an XML Schema implementation for Ruby.'
12
- spec.description =
13
- 'The Ruby XSD library is an XML Schema implementation for Ruby.Provides easy and flexible access to XSD information'
14
-
15
- spec.homepage = 'https://github.com/ekzo-dev/ruby-xsd'
16
- spec.license = 'MIT'
17
- spec.required_ruby_version = '>= 2.7.0'
18
-
19
- spec.metadata['homepage_uri'] = spec.homepage
20
- spec.metadata['source_code_uri'] = spec.homepage
21
- spec.metadata['changelog_uri'] = 'https://github.com/ekzo-dev/ruby-xsd/CHANGELOG.md'
22
-
23
- # Specify which files should be added to the gem when it is released.
24
- # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
25
- spec.files = Dir.chdir(__dir__) do
26
- `git ls-files -z`.split("\x0").reject do |f|
27
- (File.expand_path(f) == __FILE__) || f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor])
28
- end
29
- end
30
- spec.bindir = 'exe'
31
- spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
32
- spec.require_paths = ['lib']
33
-
34
- spec.add_dependency 'builder', '~> 3.2'
35
- spec.add_dependency 'nokogiri', '~> 1.11'
36
- spec.add_dependency 'rake', '~> 13.0'
37
- end