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.
- checksums.yaml +5 -5
- data/.mutant.yml +21 -0
- data/.rspec +2 -0
- data/.rubocop.yml +65 -0
- data/CHANGELOG.md +62 -0
- data/Gemfile +26 -0
- data/LICENSE.md +1 -1
- data/README.md +181 -57
- data/Rakefile +68 -0
- data/Steepfile +29 -0
- data/benchmark/overall_parser_benchmark.rb +5 -0
- data/benchmark.rb +1002 -0
- data/lib/multi_xml/concurrency.rb +31 -0
- data/lib/multi_xml/constants.rb +179 -0
- data/lib/multi_xml/deprecated.rb +35 -0
- data/lib/multi_xml/errors.rb +147 -0
- data/lib/multi_xml/file_like.rb +62 -0
- data/lib/multi_xml/helpers.rb +228 -0
- data/lib/multi_xml/options.rb +63 -0
- data/lib/multi_xml/options_normalization.rb +40 -0
- data/lib/multi_xml/parse_support.rb +113 -0
- data/lib/multi_xml/parser.rb +47 -0
- data/lib/multi_xml/parser_resolution.rb +150 -0
- data/lib/multi_xml/parsers/dom_parser.rb +190 -0
- data/lib/multi_xml/parsers/libxml.rb +57 -17
- data/lib/multi_xml/parsers/libxml_sax.rb +188 -0
- data/lib/multi_xml/parsers/nokogiri.rb +60 -19
- data/lib/multi_xml/parsers/nokogiri_sax.rb +130 -0
- data/lib/multi_xml/parsers/oga.rb +116 -49
- data/lib/multi_xml/parsers/ox.rb +191 -66
- data/lib/multi_xml/parsers/rexml.rb +169 -75
- data/lib/multi_xml/parsers/sax_handler.rb +169 -0
- data/lib/multi_xml/version.rb +6 -44
- data/lib/multi_xml.rb +187 -284
- data/sig/multi_xml.rbs +304 -0
- metadata +55 -23
- data/lib/multi_xml/parsers/libxml2_parser.rb +0 -72
- data/multi_xml.gemspec +0 -19
|
@@ -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
|