multi_xml 0.6.0 → 0.9.1

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.
data/lib/multi_xml.rb CHANGED
@@ -1,305 +1,208 @@
1
- require 'base64'
2
- require 'bigdecimal'
3
- require 'date'
4
- require 'stringio'
5
- require 'time'
6
- require 'yaml'
7
-
8
- module MultiXml # rubocop:disable ModuleLength
9
- class ParseError < StandardError; end
10
- class NoParserError < StandardError; end
11
- class DisallowedTypeError < StandardError
12
- def initialize(type)
13
- super "Disallowed type attribute: #{type.inspect}"
1
+ require "bigdecimal"
2
+ require "date"
3
+ require "stringio"
4
+ require "time"
5
+ require "yaml"
6
+ require_relative "multi_xml/concurrency"
7
+ require_relative "multi_xml/constants"
8
+ require_relative "multi_xml/errors"
9
+ require_relative "multi_xml/file_like"
10
+ require_relative "multi_xml/helpers"
11
+ require_relative "multi_xml/options"
12
+ require_relative "multi_xml/options_normalization"
13
+ require_relative "multi_xml/parser"
14
+ require_relative "multi_xml/parser_resolution"
15
+ require_relative "multi_xml/parse_support"
16
+
17
+ # A generic swappable back-end for parsing XML
18
+ #
19
+ # MultiXML provides a unified interface for XML parsing across different
20
+ # parser libraries. It automatically selects the best available parser
21
+ # (Ox, LibXML, Nokogiri, Oga, or REXML) and converts XML to Ruby hashes.
22
+ #
23
+ # @api public
24
+ # @example Parse XML
25
+ # MultiXML.parse('<root><name>John</name></root>')
26
+ # #=> {"root"=>{"name"=>"John"}}
27
+ #
28
+ # @example Set the parser
29
+ # MultiXML.parser = :nokogiri
30
+ module MultiXML
31
+ extend Options
32
+
33
+ # Tracks which deprecation warnings have already been emitted so each
34
+ # one fires at most once per process. Stored as a Set rather than a
35
+ # Hash so presence checks have unambiguous semantics for mutation tests.
36
+ DEPRECATION_WARNINGS_SHOWN = Set.new
37
+ private_constant :DEPRECATION_WARNINGS_SHOWN
38
+
39
+ # Emit a deprecation warning at most once per process for the given key
40
+ #
41
+ # The warning is tagged with the :deprecated category so callers can
42
+ # silence the whole set with Warning[:deprecated] = false or surface
43
+ # it via ruby -W:deprecated — the standard Ruby idiom for library
44
+ # deprecations since 2.7.
45
+ #
46
+ # @api private
47
+ # @param key [Symbol] identifier for the deprecation (typically the method name)
48
+ # @param message [String] warning message to emit on first call
49
+ # @return [void]
50
+ def self.warn_deprecation_once(key, message)
51
+ Concurrency.synchronize(:deprecation_warnings) do
52
+ return if DEPRECATION_WARNINGS_SHOWN.include?(key)
53
+
54
+ Kernel.warn(message, category: :deprecated)
55
+ DEPRECATION_WARNINGS_SHOWN.add(key)
14
56
  end
15
57
  end
16
58
 
17
- unless defined?(REQUIREMENT_MAP)
18
- REQUIREMENT_MAP = [
19
- ['ox', :ox],
20
- ['libxml', :libxml],
21
- ['nokogiri', :nokogiri],
22
- ['rexml/document', :rexml],
23
- ['oga', :oga],
24
- ].freeze
25
- end
26
-
27
- CONTENT_ROOT = '__content__'.freeze unless defined?(CONTENT_ROOT)
28
-
29
- unless defined?(PARSING)
30
- float_proc = proc { |float| float.to_f }
31
- datetime_proc = proc { |time| Time.parse(time).utc rescue DateTime.parse(time).utc } # rubocop:disable RescueModifier
32
-
33
- PARSING = {
34
- 'symbol' => proc { |symbol| symbol.to_sym },
35
- 'date' => proc { |date| Date.parse(date) },
36
- 'datetime' => datetime_proc,
37
- 'dateTime' => datetime_proc,
38
- 'integer' => proc { |integer| integer.to_i },
39
- 'float' => float_proc,
40
- 'double' => float_proc,
41
- 'decimal' => proc { |number| BigDecimal(number) },
42
- 'boolean' => proc { |boolean| !%w(0 false).include?(boolean.strip) },
43
- 'string' => proc { |string| string.to_s },
44
- 'yaml' => proc { |yaml| YAML.load(yaml) rescue yaml }, # rubocop:disable RescueModifier
45
- 'base64Binary' => proc { |binary| ::Base64.decode64(binary) },
46
- 'binary' => proc { |binary, entity| parse_binary(binary, entity) },
47
- 'file' => proc { |file, entity| parse_file(file, entity) },
48
- }.freeze
49
- end
50
-
51
- unless defined?(TYPE_NAMES)
52
- TYPE_NAMES = {
53
- 'Symbol' => 'symbol',
54
- 'Integer' => 'integer',
55
- 'BigDecimal' => 'decimal',
56
- 'Float' => 'float',
57
- 'TrueClass' => 'boolean',
58
- 'FalseClass' => 'boolean',
59
- 'Date' => 'date',
60
- 'DateTime' => 'datetime',
61
- 'Time' => 'datetime',
62
- 'Array' => 'array',
63
- 'Hash' => 'hash',
64
- }.freeze
65
- end
66
-
67
- DISALLOWED_XML_TYPES = %w(symbol yaml).freeze
68
-
69
- DEFAULT_OPTIONS = {
70
- :typecast_xml_value => true,
71
- :disallowed_types => DISALLOWED_XML_TYPES,
72
- :symbolize_keys => false,
73
- }.freeze
74
-
75
59
  class << self
76
- # Get the current parser class.
77
- def parser
78
- return @parser if defined?(@parser)
79
- self.parser = default_parser
80
- @parser
81
- end
60
+ include Helpers
61
+ include ParserResolution
62
+ include ParseSupport
82
63
 
83
- # The default parser based on what you currently
84
- # have loaded and installed. First checks to see
85
- # if any parsers are already loaded, then checks
86
- # to see which are installed if none are loaded.
87
- def default_parser
88
- return :ox if defined?(::Ox)
89
- return :libxml if defined?(::LibXML)
90
- return :nokogiri if defined?(::Nokogiri)
91
- return :oga if defined?(::Oga)
92
-
93
- REQUIREMENT_MAP.each do |library, parser|
94
- begin
95
- require library
96
- return parser
97
- rescue LoadError
98
- next
99
- end
100
- end
101
- raise(NoParserError.new("No XML parser detected. If you're using Rubinius and Bundler, try adding an XML parser to your Gemfile (e.g. libxml-ruby, nokogiri, or rubysl-rexml). For more information, see https://github.com/sferik/multi_xml/issues/42."))
102
- end
103
-
104
- # Set the XML parser utilizing a symbol, string, or class.
105
- # Supported by default are:
106
- #
107
- # * <tt>:libxml</tt>
108
- # * <tt>:nokogiri</tt>
109
- # * <tt>:ox</tt>
110
- # * <tt>:rexml</tt>
111
- # * <tt>:oga</tt>
112
- def parser=(new_parser)
113
- case new_parser
114
- when String, Symbol
115
- require "multi_xml/parsers/#{new_parser.to_s.downcase}"
116
- @parser = MultiXml::Parsers.const_get(new_parser.to_s.split('_').collect(&:capitalize).join('').to_s)
117
- when Class, Module
118
- @parser = new_parser
119
- else
120
- raise('Did not recognize your parser specification. Please specify either a symbol or a class.')
121
- end
122
- end
123
-
124
- # Parse an XML string or IO into Ruby.
125
- #
126
- # <b>Options</b>
64
+ # Get the current XML parser module
127
65
  #
128
- # <tt>:symbolize_keys</tt> :: If true, will use symbols instead of strings for the keys.
66
+ # Returns the currently configured parser, auto-detecting one if not set.
67
+ # Parsers are checked in order of performance: Ox, LibXML, Nokogiri, Oga, REXML.
129
68
  #
130
- # <tt>:disallowed_types</tt> :: Types to disallow from being typecasted. Defaults to `['yaml', 'symbol']`. Use `[]` to allow all types.
69
+ # @api public
70
+ # @return [Module] the current parser module
71
+ # Honors a fiber-local override set by {.with_parser} so concurrent
72
+ # blocks observe their own parser without clobbering the process-wide
73
+ # default. Falls back to the process default when no override is set.
131
74
  #
132
- # <tt>:typecast_xml_value</tt> :: If true, won't typecast values for parsed document
133
- def parse(xml, options = {}) # rubocop:disable AbcSize, CyclomaticComplexity, MethodLength, PerceivedComplexity
134
- xml ||= ''
135
-
136
- options = DEFAULT_OPTIONS.merge(options)
137
-
138
- xml = xml.strip if xml.respond_to?(:strip)
139
- begin
140
- xml = StringIO.new(xml) unless xml.respond_to?(:read)
141
-
142
- char = xml.getc
143
- return {} if char.nil?
144
- xml.ungetc(char)
145
-
146
- hash = undasherize_keys(parser.parse(xml) || {})
147
- hash = options[:typecast_xml_value] ? typecast_xml_value(hash, options[:disallowed_types]) : hash
148
- rescue DisallowedTypeError
149
- raise
150
- rescue parser.parse_error => error
151
- raise(ParseError, error.message, error.backtrace) # rubocop:disable RaiseArgs
152
- end
153
- hash = symbolize_keys(hash) if options[:symbolize_keys]
154
- hash
155
- end
156
-
157
- # This module decorates files with the <tt>original_filename</tt>
158
- # and <tt>content_type</tt> methods.
159
- module FileLike #:nodoc:
160
- attr_writer :original_filename, :content_type
161
-
162
- def original_filename
163
- @original_filename || 'untitled'
164
- end
75
+ # @example Get current parser
76
+ # MultiXML.parser #=> MultiXML::Parsers::Ox
77
+ def parser
78
+ override = Fiber[:multi_xml_parser]
79
+ return override if override
165
80
 
166
- def content_type
167
- @content_type || 'application/octet-stream'
168
- end
81
+ @parser ||= resolve_parser(detect_parser)
169
82
  end
170
83
 
171
- private
172
-
173
- # TODO: Add support for other encodings
174
- def parse_binary(binary, entity) #:nodoc:
175
- case entity['encoding']
176
- when 'base64'
177
- Base64.decode64(binary)
178
- else
179
- binary
180
- end
84
+ # Set the XML parser to use
85
+ #
86
+ # @api public
87
+ # @param new_parser [Symbol, String, Module] Parser specification
88
+ # - Symbol/String: :libxml, :nokogiri, :ox, :rexml, :oga
89
+ # - Module: Custom parser implementing parse(io) or
90
+ # parse(io, namespaces: ...) and parse_error
91
+ # @return [Module] the newly configured parser module
92
+ # @example Set parser by symbol
93
+ # MultiXML.parser = :nokogiri
94
+ # @example Set parser by module
95
+ # MultiXML.parser = MyCustomParser
96
+ def parser=(new_parser)
97
+ @parser = resolve_parser(new_parser)
181
98
  end
182
99
 
183
- def parse_file(file, entity)
184
- f = StringIO.new(Base64.decode64(file))
185
- f.extend(FileLike)
186
- f.original_filename = entity['name']
187
- f.content_type = entity['content_type']
188
- f
100
+ # Parse XML into a Ruby Hash
101
+ #
102
+ # @api public
103
+ # @param xml [String, IO] XML content as a string or IO-like object
104
+ # @param options [Hash] Parsing options
105
+ # @option options [Symbol, String, Module] :parser Parser to use for this call
106
+ # @option options [Boolean] :symbolize_names Convert keys to symbols (default: false)
107
+ # @option options [Array<String>] :disallowed_types Types to reject (default: ['yaml', 'symbol'])
108
+ # @option options [Boolean] :typecast_xml_value Apply type conversions (default: true)
109
+ # @option options [Symbol] :namespaces Namespace handling mode (:strip or :preserve)
110
+ # @return [Hash] Parsed XML as nested hash
111
+ # @raise [ParseError] if XML is malformed
112
+ # @raise [DisallowedTypeError] if XML contains a disallowed type attribute
113
+ # @example Parse simple XML
114
+ # MultiXML.parse('<root><name>John</name></root>')
115
+ # #=> {"root"=>{"name"=>"John"}}
116
+ # @example Parse with symbolized names
117
+ # MultiXML.parse('<root><name>John</name></root>', symbolize_names: true)
118
+ # #=> {root: {name: "John"}}
119
+ def parse(xml, options = {})
120
+ call_site = OptionsNormalization.normalize_symbolize_option(options)
121
+ global = OptionsNormalization.normalize_symbolize_option(parse_options(call_site))
122
+ options = DEFAULT_OPTIONS.merge(global, call_site)
123
+ namespaces = validate_namespaces_mode(options.fetch(:namespaces))
124
+ io = normalize_input(xml)
125
+ return {} if io.eof?
126
+
127
+ result = parse_with_error_handling(io, xml, resolve_parse_parser(options), namespaces)
128
+ apply_postprocessing(result, options)
189
129
  end
130
+ end
190
131
 
191
- def symbolize_keys(params)
192
- case params
193
- when Hash
194
- params.inject({}) do |result, (key, value)|
195
- result.merge(key.to_sym => symbolize_keys(value))
196
- end
197
- when Array
198
- params.collect { |value| symbolize_keys(value) }
199
- else
200
- params
201
- end
202
- end
132
+ # Execute a block with a temporarily-swapped parser
133
+ #
134
+ # The override is stored in fiber-local storage so concurrent fibers
135
+ # and threads each see their own parser without racing on a shared
136
+ # module variable; nested calls save and restore the previous
137
+ # fiber-local value. Matches {MultiJSON.with_adapter}.
138
+ #
139
+ # @api public
140
+ # @param new_parser [Symbol, String, Module] parser to use
141
+ # @yield block to execute with the temporary parser
142
+ # @return [Object] result of the block
143
+ # @example
144
+ # MultiXML.with_parser(:rexml) { MultiXML.parse("<a>1</a>") }
145
+ def self.with_parser(new_parser)
146
+ previous_override = Fiber[:multi_xml_parser]
147
+ Fiber[:multi_xml_parser] = resolve_parser(new_parser)
148
+ yield
149
+ ensure
150
+ Fiber[:multi_xml_parser] = previous_override
151
+ end
152
+ end
203
153
 
204
- def undasherize_keys(params)
205
- case params
206
- when Hash
207
- params.inject({}) do |hash, (key, value)|
208
- hash[key.to_s.tr('-'.freeze, '_'.freeze)] = undasherize_keys(value)
209
- hash
210
- end
211
- when Array
212
- params.collect { |value| undasherize_keys(value) }
213
- else
214
- params
215
- end
154
+ require_relative "multi_xml/deprecated"
155
+
156
+ # Backward-compatible alias for the legacy MultiXml constant name
157
+ #
158
+ # Downstream code that still writes MultiXml.parse(...) or
159
+ # rescue MultiXml::ParseError continues to work, but emits a one-time
160
+ # deprecation warning pointing at MultiXML. Each public method on
161
+ # {MultiXML} gets an explicit forwarder defined on this module, and
162
+ # constant access resolves via {.const_missing}, so both dotted calls
163
+ # and :: constant lookups (including rescue clauses) route through
164
+ # the canonical module.
165
+ #
166
+ # @api public
167
+ # @deprecated Use {MultiXML} (all-caps) instead. Will be removed in v1.0.
168
+ module MultiXml
169
+ # Forward every public method MultiXML exposes through an explicit
170
+ # singleton method on the legacy MultiXml module, so callers that
171
+ # capture the method as a Method object (``MultiXml.method(:load)``)
172
+ # find this forwarder instead of falling back to inherited methods like
173
+ # ``Kernel#load``. The earlier ``method_missing``-based shim left
174
+ # ``MultiXml.method(:load)`` resolving to ``Kernel#load`` (because
175
+ # ``Module#method`` doesn't consult ``method_missing``) so a captured
176
+ # ``MultiXml.method(:load)`` would interpret the XML payload as a file
177
+ # path and crash with ``LoadError``. Forwarding eagerly fixes the
178
+ # capture path while preserving the one-time deprecation warning each
179
+ # call emits.
180
+ (::MultiXML.public_methods - ::Module.public_methods).each do |forwarded|
181
+ define_singleton_method(forwarded) do |*args, **kwargs, &block|
182
+ ::MultiXML.warn_deprecation_once(:multi_xml_constant,
183
+ "The MultiXml constant is deprecated and will be removed in v1.0. Use MultiXML instead.")
184
+ ::MultiXML.public_send(forwarded, *args, **kwargs, &block)
216
185
  end
186
+ end
217
187
 
218
- def typecast_xml_value(value, disallowed_types = nil) # rubocop:disable AbcSize, CyclomaticComplexity, MethodLength, PerceivedComplexity
219
- disallowed_types ||= DISALLOWED_XML_TYPES
220
-
221
- case value
222
- when Hash
223
- if value.include?('type') && !value['type'].is_a?(Hash) && disallowed_types.include?(value['type'])
224
- raise(DisallowedTypeError.new(value['type']))
225
- end
226
-
227
- if value['type'] == 'array'
228
-
229
- # this commented-out suggestion helps to avoid the multiple attribute
230
- # problem, but it breaks when there is only one item in the array.
231
- #
232
- # from: https://github.com/jnunemaker/httparty/issues/102
233
- #
234
- # _, entries = value.detect { |k, v| k != 'type' && v.is_a?(Array) }
235
-
236
- # This attempt fails to consider the order that the detect method
237
- # retrieves the entries.
238
- # _, entries = value.detect {|key, _| key != 'type'}
239
-
240
- # This approach ignores attribute entries that are not convertable
241
- # to an Array which allows attributes to be ignored.
242
- _, entries = value.detect { |k, v| k != 'type' && (v.is_a?(Array) || v.is_a?(Hash)) }
243
-
244
- case entries
245
- when NilClass
246
- []
247
- when String
248
- [] if entries.strip.empty?
249
- when Array
250
- entries.collect { |entry| typecast_xml_value(entry, disallowed_types) }
251
- when Hash
252
- [typecast_xml_value(entries, disallowed_types)]
253
- else
254
- raise("can't typecast #{entries.class.name}: #{entries.inspect}")
255
- end
256
-
257
- elsif value.key?(CONTENT_ROOT)
258
- content = value[CONTENT_ROOT]
259
- block = PARSING[value['type']]
260
- if block
261
- if block.arity == 1
262
- value.delete('type') if PARSING[value['type']]
263
- if value.keys.size > 1
264
- value[CONTENT_ROOT] = block.call(content)
265
- value
266
- else
267
- block.call(content)
268
- end
269
- else
270
- block.call(content, value)
271
- end
272
- else
273
- value.keys.size > 1 ? value : content
274
- end
275
- elsif value['type'] == 'string' && value['nil'] != 'true'
276
- ''
277
- # blank or nil parsed values are represented by nil
278
- elsif value.empty? || value['nil'] == 'true'
279
- nil
280
- # If the type is the only element which makes it then
281
- # this still makes the value nil, except if type is
282
- # a XML node(where type['value'] is a Hash)
283
- elsif value['type'] && value.size == 1 && !value['type'].is_a?(Hash)
284
- nil
285
- else
286
- xml_value = value.inject({}) do |hash, (k, v)|
287
- hash[k] = typecast_xml_value(v, disallowed_types)
288
- hash
289
- end
290
-
291
- # Turn {:files => {:file => #<StringIO>} into {:files => #<StringIO>} so it is compatible with
292
- # how multipart uploaded files from HTML appear
293
- xml_value['file'].is_a?(StringIO) ? xml_value['file'] : xml_value
294
- end
295
- when Array
296
- value.map! { |i| typecast_xml_value(i, disallowed_types) }
297
- value.length > 1 ? value : value.first
298
- when String
299
- value
300
- else
301
- raise("can't typecast #{value.class.name}: #{value.inspect}")
302
- end
188
+ class << self
189
+ # Resolve missing constants to their {MultiXML} counterparts
190
+ #
191
+ # The lookup is performed with ``inherit: false`` so a stray
192
+ # top-level ``::ParseError`` constant in the host process (Racc
193
+ # defines one when Nokogiri is loaded) is correctly ignored. Enables
194
+ # rescue MultiXml::ParseError and MultiXml::Parsers::Ox to keep
195
+ # working during the deprecation cycle.
196
+ #
197
+ # @api public
198
+ # @param name [Symbol] constant name
199
+ # @return [Object] the resolved constant from {MultiXML}
200
+ # @example
201
+ # MultiXml::Parsers::Ox # returns MultiXML::Parsers::Ox
202
+ def const_missing(name)
203
+ ::MultiXML.warn_deprecation_once(:multi_xml_constant,
204
+ "The MultiXml constant is deprecated and will be removed in v1.0. Use MultiXML instead.")
205
+ ::MultiXML.const_get(name, false)
303
206
  end
304
207
  end
305
208
  end