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.
@@ -0,0 +1,31 @@
1
+ module MultiXML
2
+ # Catalog of process-wide mutexes used to serialize MultiXML's mutable
3
+ # state. Each mutex protects a distinct piece of state. Callers go
4
+ # through {.synchronize} rather than touching the mutex constants
5
+ # directly so the constants themselves can stay {.private_constant}
6
+ # and the surface of the module is documented in one place.
7
+ #
8
+ # @api private
9
+ module Concurrency
10
+ # Catalog of mutexes keyed by symbolic name. Each entry maps the
11
+ # public name passed to {.synchronize} to the underlying mutex
12
+ # instance.
13
+ MUTEXES = {
14
+ # Guards the DEPRECATION_WARNINGS_SHOWN set in MultiXML so the
15
+ # check-then-add pair in warn_deprecation_once doesn't race.
16
+ deprecation_warnings: Mutex.new
17
+ }.freeze
18
+ private_constant :MUTEXES
19
+
20
+ # Run a block while holding the named mutex
21
+ #
22
+ # @api private
23
+ # @param name [Symbol] mutex identifier
24
+ # @yield block to execute while holding the mutex
25
+ # @return [Object] the block's return value
26
+ # @raise [KeyError] when name does not match a known mutex
27
+ def self.synchronize(name, &)
28
+ MUTEXES.fetch(name).synchronize(&)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,179 @@
1
+ # Shared constants and converter lambdas used across parser backends.
2
+ module MultiXML
3
+ # Hash key for storing text content within element hashes
4
+ #
5
+ # @api public
6
+ # @return [String] the key "__content__" used for text content
7
+ # @example Accessing text content
8
+ # result = MultiXML.parse('<name>John</name>')
9
+ # result["name"] #=> "John" (simplified, but internally uses __content__)
10
+ TEXT_CONTENT_KEY = "__content__".freeze
11
+
12
+ # Maps Ruby class names to XML type attribute values
13
+ #
14
+ # @api public
15
+ # @return [Hash{String => String}] mapping of Ruby class names to XML types
16
+ # @example Check XML type for a Ruby class
17
+ # RUBY_TYPE_TO_XML["Integer"] #=> "integer"
18
+ RUBY_TYPE_TO_XML = {
19
+ "Symbol" => "symbol",
20
+ "Integer" => "integer",
21
+ "BigDecimal" => "decimal",
22
+ "Float" => "float",
23
+ "TrueClass" => "boolean",
24
+ "FalseClass" => "boolean",
25
+ "Date" => "date",
26
+ "DateTime" => "datetime",
27
+ "Time" => "datetime",
28
+ "Array" => "array",
29
+ "Hash" => "hash"
30
+ }.freeze
31
+
32
+ # XML type attributes disallowed by default for security
33
+ #
34
+ # These types are blocked to prevent code execution vulnerabilities.
35
+ #
36
+ # @api public
37
+ # @return [Array<String>] list of disallowed type names
38
+ # @example Check default disallowed types
39
+ # DISALLOWED_TYPES #=> ["symbol", "yaml"]
40
+ DISALLOWED_TYPES = %w[symbol yaml].freeze
41
+
42
+ # Values that represent false in XML boolean attributes
43
+ #
44
+ # @api public
45
+ # @return [Set<String>] values considered false
46
+ # @example Check false values
47
+ # FALSE_BOOLEAN_VALUES.include?("0") #=> true
48
+ FALSE_BOOLEAN_VALUES = Set.new(%w[0 false]).freeze
49
+
50
+ # Supported values for the :namespaces parse option
51
+ #
52
+ # @api public
53
+ # @return [Array<Symbol>] the valid namespace handling modes
54
+ # @example Parse with namespace preservation
55
+ # MultiXML.parse(xml, namespaces: :preserve)
56
+ NAMESPACE_MODES = %i[strip preserve].freeze
57
+
58
+ # Default parsing options
59
+ #
60
+ # @api public
61
+ # @return [Hash] default options for parse method
62
+ # @example View defaults
63
+ # DEFAULT_OPTIONS[:symbolize_names] #=> false
64
+ DEFAULT_OPTIONS = {
65
+ typecast_xml_value: true,
66
+ disallowed_types: DISALLOWED_TYPES,
67
+ symbolize_names: false,
68
+ namespaces: :strip
69
+ }.freeze
70
+
71
+ # Parser libraries in preference order (fastest first)
72
+ #
73
+ # TruffleRuby's JIT favors pure-Ruby parsers and penalizes FFI-bound
74
+ # ones, so rexml jumps to the head of the list (after ox, which is
75
+ # filtered out of auto-detection by ParserResolution#skip_on_platform?)
76
+ # and nokogiri falls to last.
77
+ #
78
+ # @api public
79
+ # @return [Array<Array>] pairs of [require_path, parser_symbol]
80
+ # @example View parser order
81
+ # PARSER_PREFERENCE.first #=> ["ox", :ox]
82
+ # :nocov:
83
+ PARSER_PREFERENCE = if RUBY_ENGINE == "truffleruby"
84
+ [
85
+ ["ox", :ox],
86
+ ["rexml/document", :rexml],
87
+ ["libxml-ruby", :libxml],
88
+ ["oga", :oga],
89
+ ["nokogiri", :nokogiri]
90
+ ].freeze
91
+ else
92
+ [
93
+ ["ox", :ox],
94
+ ["libxml-ruby", :libxml],
95
+ ["nokogiri", :nokogiri],
96
+ ["oga", :oga],
97
+ ["rexml/document", :rexml]
98
+ ].freeze
99
+ end
100
+ # :nocov:
101
+
102
+ # Parses datetime strings, trying Time first then DateTime
103
+ #
104
+ # @api private
105
+ # @return [Proc] lambda that parses datetime strings
106
+ PARSE_DATETIME = lambda do |string|
107
+ Time.parse(string).utc
108
+ rescue ArgumentError
109
+ begin
110
+ DateTime.parse(string).to_time.utc
111
+ rescue ArgumentError, NoMethodError
112
+ MultiXML.send(:parse_iso_week_datetime, string)
113
+ end
114
+ end
115
+
116
+ # Regex matching ISO week dates like YYYY-Www or YYYY-Www-d.
117
+ #
118
+ # @api private
119
+ ISO_WEEK_DATE = /\A(?<year>\d{4})-W(?<week>\d{2})(?:-(?<day>\d))?\z/
120
+ private_constant :ISO_WEEK_DATE
121
+
122
+ # Parse YYYY-Www[-d] ISO week dates into a UTC Time
123
+ #
124
+ # @api private
125
+ # @param string [String] ISO week date string
126
+ # @return [Time] UTC midnight for the given ISO week date
127
+ # @raise [ArgumentError] if the string is not a supported ISO week date
128
+ def self.parse_iso_week_datetime(string)
129
+ match = ISO_WEEK_DATE.match(string)
130
+ raise ArgumentError, "invalid date" unless match
131
+
132
+ date = Date.commercial(Integer(match[:year]), Integer(match[:week]), Integer(match[:day] || "1"))
133
+ Time.utc(date.year, date.month, date.day)
134
+ end
135
+ private_class_method :parse_iso_week_datetime
136
+
137
+ # Creates a file-like StringIO from base64-encoded content
138
+ #
139
+ # @api private
140
+ # @return [Proc] lambda that creates file objects
141
+ FILE_CONVERTER = lambda do |content, entity|
142
+ StringIO.new(content.unpack1("m")).tap do |io|
143
+ io.extend(FileLike)
144
+ file_io = io # : FileIO
145
+ file_io.original_filename = entity["name"]
146
+ file_io.content_type = entity["content_type"]
147
+ end
148
+ end
149
+
150
+ # Type converters for XML type attributes
151
+ #
152
+ # Maps type attribute values to lambdas that convert string content.
153
+ # Converters with arity 2 receive the content and the full entity hash.
154
+ #
155
+ # @api public
156
+ # @return [Hash{String => Proc}] mapping of type names to converter procs
157
+ # @example Using a converter
158
+ # TYPE_CONVERTERS["integer"].call("42") #=> 42
159
+ TYPE_CONVERTERS = {
160
+ "symbol" => ->(s) { s.to_sym },
161
+ "string" => :to_s.to_proc,
162
+ "integer" => :to_i.to_proc,
163
+ "float" => :to_f.to_proc,
164
+ "double" => :to_f.to_proc,
165
+ "decimal" => ->(s) { BigDecimal(s) },
166
+ "boolean" => ->(s) { !FALSE_BOOLEAN_VALUES.include?(s.strip) },
167
+ "date" => Date.method(:parse),
168
+ "datetime" => PARSE_DATETIME,
169
+ "dateTime" => PARSE_DATETIME,
170
+ "base64Binary" => ->(s) { s.unpack1("m") },
171
+ "binary" => ->(s, entity) { (entity["encoding"] == "base64") ? s.unpack1("m") : s },
172
+ "file" => FILE_CONVERTER,
173
+ "yaml" => lambda do |string|
174
+ YAML.safe_load(string, permitted_classes: [Symbol, Date, Time])
175
+ rescue ArgumentError, Psych::SyntaxError
176
+ string
177
+ end
178
+ }.freeze
179
+ end
@@ -0,0 +1,35 @@
1
+ # Deprecated public API kept around for one major release
2
+ #
3
+ # Each method here emits a one-time deprecation warning on first call and
4
+ # delegates to its current-API counterpart. The whole file is loaded by
5
+ # {MultiXML} so the deprecation surface stays out of the main module
6
+ # definition.
7
+ #
8
+ # @api private
9
+ module MultiXML
10
+ class << self
11
+ private
12
+
13
+ # Define a deprecated alias that delegates to a new method name
14
+ #
15
+ # The generated singleton method emits a one-time deprecation
16
+ # warning naming the replacement, then forwards all positional and
17
+ # keyword arguments plus any block to replacement.
18
+ #
19
+ # @api private
20
+ # @param name [Symbol] deprecated method name
21
+ # @param replacement [Symbol] current-API method to delegate to
22
+ # @return [Symbol] the defined method name
23
+ # @example
24
+ # deprecate_alias :load, :parse
25
+ def deprecate_alias(name, replacement)
26
+ message = "MultiXML.#{name} is deprecated and will be removed in v1.0. Use MultiXML.#{replacement} instead."
27
+ define_singleton_method(name) do |*args, **kwargs, &block|
28
+ warn_deprecation_once(name, message)
29
+ public_send(replacement, *args, **kwargs, &block)
30
+ end
31
+ end
32
+ end
33
+
34
+ deprecate_alias :load, :parse
35
+ end
@@ -0,0 +1,147 @@
1
+ module MultiXML
2
+ # Raised when XML parsing fails
3
+ #
4
+ # Preserves the original XML and underlying cause for debugging.
5
+ #
6
+ # @api public
7
+ # @example Catching a parse error
8
+ # begin
9
+ # MultiXML.parse('<invalid>')
10
+ # rescue MultiXML::ParseError => e
11
+ # puts e.xml # The malformed XML
12
+ # puts e.cause # The underlying parser exception
13
+ # end
14
+ class ParseError < StandardError
15
+ # The original XML that failed to parse
16
+ #
17
+ # @api public
18
+ # @return [String, nil] the XML string that caused the error
19
+ # @example Access the failing XML
20
+ # error.xml #=> "<invalid>"
21
+ attr_reader :xml
22
+
23
+ # The underlying parser exception
24
+ #
25
+ # @api public
26
+ # @return [Exception, nil] the original exception from the parser
27
+ # @example Access the cause
28
+ # error.cause #=> #<Nokogiri::XML::SyntaxError: ...>
29
+ attr_reader :cause
30
+
31
+ # Create a new ParseError
32
+ #
33
+ # @api public
34
+ # @param message [String, nil] Error message
35
+ # @param xml [String, nil] The original XML that failed to parse
36
+ # @param cause [Exception, nil] The underlying parser exception
37
+ # @return [ParseError] the new error instance
38
+ # @example Create a parse error
39
+ # ParseError.new("Invalid XML", xml: "<bad>", cause: original_error)
40
+ def initialize(message = nil, xml: nil, cause: nil)
41
+ @xml = xml
42
+ @cause = cause
43
+ super(message)
44
+ end
45
+ end
46
+
47
+ # Raised when no XML parser library is available
48
+ #
49
+ # This error is raised when MultiXML cannot find any supported XML parser.
50
+ # Install one of: ox, nokogiri, libxml-ruby, or oga.
51
+ #
52
+ # @api public
53
+ # @example Catching the error
54
+ # begin
55
+ # MultiXML.parse('<root/>')
56
+ # rescue MultiXML::NoParserError => e
57
+ # puts "Please install an XML parser gem"
58
+ # end
59
+ class NoParserError < StandardError; end
60
+
61
+ # Raised when a parser cannot be loaded or is not recognized
62
+ #
63
+ # Covers three failure modes in one typed error, so callers can catch
64
+ # all "I couldn't even get to parsing" problems with one rescue:
65
+ # - Invalid spec type (not a Symbol, String, or Module)
66
+ # - LoadError from requiring the parser file
67
+ # - A custom parser that doesn't satisfy the contract
68
+ # (no .parse method or no parse_error method / ParseError constant)
69
+ #
70
+ # Matches the role of {MultiJSON::AdapterError}.
71
+ #
72
+ # @api public
73
+ # @example Catching a load error
74
+ # begin
75
+ # MultiXML.parser = :bogus
76
+ # rescue MultiXML::ParserLoadError => e
77
+ # puts e.message
78
+ # end
79
+ class ParserLoadError < ArgumentError
80
+ # Create a new ParserLoadError
81
+ #
82
+ # @api public
83
+ # @param message [String, nil] error message
84
+ # @param cause [Exception, nil] the original exception
85
+ # @return [ParserLoadError] new error instance
86
+ # @example
87
+ # ParserLoadError.new("Unknown parser", cause: original_error)
88
+ def initialize(message = nil, cause: nil)
89
+ super(message)
90
+ set_backtrace(cause.backtrace) if cause
91
+ end
92
+
93
+ # Build a ParserLoadError from an original exception
94
+ #
95
+ # The original exception's class name is included in the message so
96
+ # a downstream consumer reading just the ParserLoadError can tell
97
+ # whether the underlying failure was a ``LoadError``, an
98
+ # ``ArgumentError`` from the spec validator, or some other class
99
+ # without having to look at ``error.cause`` separately.
100
+ #
101
+ # @api public
102
+ # @param original_exception [Exception] the original load error
103
+ # @return [ParserLoadError] new error with formatted message
104
+ # @example
105
+ # ParserLoadError.build(LoadError.new("cannot load such file"))
106
+ def self.build(original_exception)
107
+ new(
108
+ "Did not recognize your parser specification " \
109
+ "(#{original_exception.class}: #{original_exception.message}).",
110
+ cause: original_exception
111
+ )
112
+ end
113
+ end
114
+
115
+ # Raised when an XML type attribute is in the disallowed list
116
+ #
117
+ # By default, 'yaml' and 'symbol' types are disallowed for security reasons.
118
+ #
119
+ # @api public
120
+ # @example Catching a disallowed type error
121
+ # begin
122
+ # MultiXML.parse('<data type="yaml">--- :key</data>')
123
+ # rescue MultiXML::DisallowedTypeError => e
124
+ # puts e.type #=> "yaml"
125
+ # end
126
+ class DisallowedTypeError < StandardError
127
+ # The disallowed type that was encountered
128
+ #
129
+ # @api public
130
+ # @return [String] the type attribute value that was disallowed
131
+ # @example Access the disallowed type
132
+ # error.type #=> "yaml"
133
+ attr_reader :type
134
+
135
+ # Create a new DisallowedTypeError
136
+ #
137
+ # @api public
138
+ # @param type [String] The disallowed type attribute value
139
+ # @return [DisallowedTypeError] the new error instance
140
+ # @example Create a disallowed type error
141
+ # DisallowedTypeError.new("yaml")
142
+ def initialize(type)
143
+ @type = type
144
+ super("Disallowed type attribute: #{type.inspect}")
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,62 @@
1
+ module MultiXML
2
+ # Mixin that provides file-like metadata to StringIO objects
3
+ #
4
+ # Used when parsing base64-encoded file content from XML.
5
+ # Adds original_filename and content_type attributes to StringIO.
6
+ #
7
+ # @api public
8
+ # @example Extending a StringIO
9
+ # io = StringIO.new("file content")
10
+ # io.extend(MultiXML::FileLike)
11
+ # io.original_filename = "document.pdf"
12
+ # io.content_type = "application/pdf"
13
+ module FileLike
14
+ # Default filename when none is specified
15
+ # @api public
16
+ # @return [String] the default filename "untitled"
17
+ DEFAULT_FILENAME = "untitled".freeze
18
+
19
+ # Default content type when none is specified
20
+ # @api public
21
+ # @return [String] the default MIME type "application/octet-stream"
22
+ DEFAULT_CONTENT_TYPE = "application/octet-stream".freeze
23
+
24
+ # Set the original filename
25
+ #
26
+ # @api public
27
+ # @param value [String] The filename to set
28
+ # @return [String] the filename that was set
29
+ # @example Set filename
30
+ # io.original_filename = "report.pdf"
31
+ attr_writer :original_filename
32
+
33
+ # Set the content type
34
+ #
35
+ # @api public
36
+ # @param value [String] The MIME type to set
37
+ # @return [String] the content type that was set
38
+ # @example Set content type
39
+ # io.content_type = "application/pdf"
40
+ attr_writer :content_type
41
+
42
+ # Get the original filename
43
+ #
44
+ # @api public
45
+ # @return [String] the original filename or "untitled" if not set
46
+ # @example Get filename
47
+ # io.original_filename #=> "document.pdf"
48
+ def original_filename
49
+ @original_filename || DEFAULT_FILENAME
50
+ end
51
+
52
+ # Get the content type
53
+ #
54
+ # @api public
55
+ # @return [String] the content type or "application/octet-stream" if not set
56
+ # @example Get content type
57
+ # io.content_type #=> "application/pdf"
58
+ def content_type
59
+ @content_type || DEFAULT_CONTENT_TYPE
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,228 @@
1
+ module MultiXML
2
+ # Methods for transforming parsed XML hash structures
3
+ #
4
+ # These helper methods handle key transformation and type casting
5
+ # of parsed XML data structures.
6
+ #
7
+ # @api public
8
+ module Helpers
9
+ module_function
10
+
11
+ # Recursively convert all hash keys to symbols
12
+ #
13
+ # @api private
14
+ # @param data [Hash, Array, Object] Data to transform
15
+ # @return [Hash, Array, Object] Transformed data with symbolized keys
16
+ # @example Symbolize hash keys
17
+ # symbolize_keys({"name" => "John"}) #=> {name: "John"}
18
+ def symbolize_keys(data)
19
+ transform_keys(data, &:to_sym)
20
+ end
21
+
22
+ # Recursively convert dashes in hash keys to underscores
23
+ #
24
+ # @api private
25
+ # @param data [Hash, Array, Object] Data to transform
26
+ # @return [Hash, Array, Object] Transformed data with undasherized keys
27
+ # @example Convert dashed keys
28
+ # undasherize_keys({"first-name" => "John"}) #=> {"first_name" => "John"}
29
+ def undasherize_keys(data)
30
+ transform_keys(data) { |key| key.tr("-", "_") }
31
+ end
32
+
33
+ # Recursively typecast XML values based on type attributes
34
+ #
35
+ # @api private
36
+ # @param value [Hash, Array, Object] Value to typecast
37
+ # @param disallowed_types [Array<String>] Types to reject
38
+ # @return [Object] Typecasted value
39
+ # @raise [DisallowedTypeError] if a disallowed type is encountered
40
+ # @example Typecast integer value
41
+ # typecast_xml_value({"__content__" => "42", "type" => "integer"})
42
+ # #=> 42
43
+ def typecast_xml_value(value, disallowed_types = DISALLOWED_TYPES)
44
+ case value
45
+ when Hash then typecast_hash(value, disallowed_types)
46
+ when Array then typecast_array(value, disallowed_types)
47
+ else value
48
+ end
49
+ end
50
+
51
+ # Typecast array elements and unwrap single-element arrays
52
+ #
53
+ # @api private
54
+ # @param array [Array] Array to typecast
55
+ # @param disallowed_types [Array<String>] Types to reject
56
+ # @return [Object, Array] Typecasted array or single element
57
+ def typecast_array(array, disallowed_types)
58
+ array.map! { |item| typecast_xml_value(item, disallowed_types) }
59
+ (array.size == 1) ? array.first : array
60
+ end
61
+
62
+ # Typecast a hash based on its type attribute
63
+ #
64
+ # @api private
65
+ # @param hash [Hash] Hash to typecast
66
+ # @param disallowed_types [Array<String>] Types to reject
67
+ # @return [Object] Typecasted value
68
+ # @raise [DisallowedTypeError] if type is disallowed
69
+ def typecast_hash(hash, disallowed_types)
70
+ type = hash["type"]
71
+ raise DisallowedTypeError, type if disallowed_type?(type, disallowed_types)
72
+
73
+ convert_hash(hash, type, disallowed_types)
74
+ end
75
+
76
+ # Check if a type is in the disallowed list
77
+ #
78
+ # @api private
79
+ # @param type [String, nil] Type to check
80
+ # @param disallowed_types [Array<String>] Disallowed type list
81
+ # @return [Boolean] true if type is disallowed
82
+ def disallowed_type?(type, disallowed_types)
83
+ type && !type.is_a?(Hash) && disallowed_types.include?(type)
84
+ end
85
+
86
+ # Convert a hash based on its type and content
87
+ #
88
+ # @api private
89
+ # @param hash [Hash] Hash to convert
90
+ # @param type [String, nil] Type attribute value
91
+ # @param disallowed_types [Array<String>] Types to reject
92
+ # @return [Object] Converted value
93
+ def convert_hash(hash, type, disallowed_types)
94
+ return extract_array_entries(hash, disallowed_types) if type == "array"
95
+ return convert_text_content(hash) if hash.key?(TEXT_CONTENT_KEY)
96
+ return "" if type == "string" && !hash["nil"].eql?("true")
97
+ return nil if empty_value?(hash, type)
98
+
99
+ typecast_children(hash, disallowed_types)
100
+ end
101
+
102
+ # Typecast all child values in a hash
103
+ #
104
+ # @api private
105
+ # @param hash [Hash] Hash with children to typecast
106
+ # @param disallowed_types [Array<String>] Types to reject
107
+ # @return [Hash, StringIO] Typecasted hash or unwrapped file
108
+ def typecast_children(hash, disallowed_types)
109
+ result = hash.transform_values { |v| typecast_xml_value(v, disallowed_types) }
110
+ unwrap_file_if_present(result)
111
+ end
112
+
113
+ # Extract array entries from element with type="array"
114
+ #
115
+ # @api private
116
+ # @param hash [Hash] Hash containing array entries
117
+ # @param disallowed_types [Array<String>] Types to reject
118
+ # @return [Array] Extracted and typecasted entries
119
+ # @see https://github.com/jnunemaker/httparty/issues/102
120
+ def extract_array_entries(hash, disallowed_types)
121
+ entries = find_array_entries(hash)
122
+ return [] unless entries
123
+
124
+ wrap_and_typecast(entries, disallowed_types)
125
+ end
126
+
127
+ # Find array or hash entries in a hash, excluding the type key
128
+ #
129
+ # @api private
130
+ # @param hash [Hash] Hash to search
131
+ # @return [Array, Hash, nil] Found entries or nil
132
+ def find_array_entries(hash)
133
+ hash.each do |key, value|
134
+ return value if !key.eql?("type") && (value.is_a?(Array) || value.is_a?(Hash))
135
+ end
136
+ nil
137
+ end
138
+
139
+ # Wrap hash in array if needed and typecast all entries
140
+ #
141
+ # @api private
142
+ # @param entries [Array, Hash] Entries to process
143
+ # @param disallowed_types [Array<String>] Types to reject
144
+ # @return [Array] Typecasted entries
145
+ def wrap_and_typecast(entries, disallowed_types)
146
+ entries = [entries] if entries.is_a?(Hash)
147
+ entries.map { |entry| typecast_xml_value(entry, disallowed_types) }
148
+ end
149
+
150
+ # Convert text content using type converters
151
+ #
152
+ # @api private
153
+ # @param hash [Hash] Hash containing text content and type
154
+ # @return [Object] Converted value
155
+ def convert_text_content(hash)
156
+ content = hash.fetch(TEXT_CONTENT_KEY)
157
+ converter = TYPE_CONVERTERS[hash["type"]]
158
+
159
+ return unwrap_if_simple(hash, content) unless converter
160
+
161
+ apply_converter(hash, content, converter)
162
+ end
163
+
164
+ # Unwrap value if hash has no other significant keys
165
+ #
166
+ # @api private
167
+ # @param hash [Hash] Original hash
168
+ # @param value [Object] Converted value
169
+ # @return [Object, Hash] Value or hash with merged content
170
+ def unwrap_if_simple(hash, value)
171
+ (hash.size > 1) ? hash.merge(TEXT_CONTENT_KEY => value) : value
172
+ end
173
+
174
+ # Check if a hash represents an empty value
175
+ #
176
+ # @api private
177
+ # @param hash [Hash] Hash to check
178
+ # @param type [String, nil] Type attribute value
179
+ # @return [Boolean] true if value should be nil
180
+ def empty_value?(hash, type)
181
+ hash.empty? ||
182
+ hash["nil"] == "true" ||
183
+ (type && hash.size == 1 && !type.is_a?(Hash))
184
+ end
185
+
186
+ private
187
+
188
+ # Recursively transform hash keys using a block
189
+ #
190
+ # @api private
191
+ # @param data [Hash, Array, Object] Data to transform
192
+ # @return [Hash, Array, Object] Transformed data
193
+ def transform_keys(data, &block)
194
+ case data
195
+ when Hash then data.each_with_object(
196
+ {} #: Hash[Symbol, MultiXML::xmlValue] # rubocop:disable Layout/LeadingCommentSpace
197
+ ) { |(key, value), acc| acc[yield(key)] = transform_keys(value, &block) }
198
+ when Array then data.map { |item| transform_keys(item, &block) }
199
+ else data
200
+ end
201
+ end
202
+
203
+ # Unwrap a file object from the result hash if present
204
+ #
205
+ # @api private
206
+ # @param result [Hash] Hash that may contain a file
207
+ # @return [Hash, StringIO] The file if present, otherwise the hash
208
+ def unwrap_file_if_present(result)
209
+ file = result["file"]
210
+ file.is_a?(StringIO) ? file : result
211
+ end
212
+
213
+ # Apply a type converter to content
214
+ #
215
+ # @api private
216
+ # @param hash [Hash] Original hash with type info
217
+ # @param content [String] Content to convert
218
+ # @param converter [Proc] Converter to apply
219
+ # @return [Object] Converted value
220
+ def apply_converter(hash, content, converter)
221
+ # Binary converters need access to entity attributes (e.g., encoding, name)
222
+ return converter.call(content, hash) if converter.arity == 2
223
+
224
+ hash.delete("type")
225
+ unwrap_if_simple(hash, converter.call(content))
226
+ end
227
+ end
228
+ end