nori 2.7.1 → 2.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.
@@ -92,12 +92,11 @@ class Nori
92
92
  attributes = Hash[*intermediate]
93
93
  end
94
94
 
95
- # leave the type alone if we don't know what it is
96
- @type = self.class.available_typecasts.include?(attributes["type"]) ? attributes.delete("type") : attributes["type"]
95
+ @type = bare_type(attributes)
97
96
 
98
97
  @nil_element = false
99
98
  attributes.keys.each do |key|
100
- if result = /^((.*):)?nil$/.match(key)
99
+ if result = nil_attribute_pattern.match(key)
101
100
  @nil_element = attributes.delete(key) == "true"
102
101
  attributes.delete("xmlns:#{result[2]}") if result[1]
103
102
  end
@@ -127,59 +126,26 @@ class Nori
127
126
  @children << node
128
127
  end
129
128
 
129
+ # Converts the node into a hash with the node name as its single key.
130
+ #
131
+ # The value depends on the shape of the node. A node typed as "file"
132
+ # becomes a {StringIOFile}, unless the +:serializable+ profile is enabled.
133
+ # That profile returns plain data only, so a file node folds into text and
134
+ # attributes like any other node and the base64 content is left undecoded.
135
+ # A node with text content becomes a typecast scalar. Every other node
136
+ # folds its children into an array or a hash. Under the +:standards+
137
+ # profile no bare type attribute is honored ({#bare_type}), so file
138
+ # decoding, array folding and typecasting never happen there.
139
+ #
140
+ # @return [Hash{String => Object}] the node name mapped to its value
130
141
  def to_hash
131
- if @type == "file"
132
- f = StringIOFile.new((@children.first || '').unpack('m').first)
133
- f.original_filename = attributes['name'] || 'untitled'
134
- f.content_type = attributes['content_type'] || 'application/octet-stream'
135
- return { name => f }
136
- end
137
-
138
- if @text
139
- t = typecast_value(inner_html)
140
- t = advanced_typecasting(t) if t.is_a?(String) && @options[:advanced_typecasting]
142
+ return { name => file_value } if @type == "file" && !@options[:serializable]
143
+ return { name => text_value } if @text
141
144
 
142
- if t.is_a?(String)
143
- t = StringWithAttributes.new(t)
144
- t.attributes = attributes
145
- end
146
-
147
- return { name => t }
148
- else
149
- #change repeating groups into an array
150
- groups = @children.inject({}) { |s,e| (s[e.name] ||= []) << e; s }
151
-
152
- out = nil
153
- if @type == "array"
154
- out = []
155
- groups.each do |k, v|
156
- if v.size == 1
157
- out << v.first.to_hash.entries.first.last
158
- else
159
- out << v.map{|e| e.to_hash[k]}
160
- end
161
- end
162
- out = out.flatten
163
-
164
- else # If Hash
165
- out = {}
166
- groups.each do |k,v|
167
- if v.size == 1
168
- out.merge!(v.first)
169
- else
170
- out.merge!( k => v.map{|e| e.to_hash[k]})
171
- end
172
- end
173
- out.merge! prefixed_attributes unless attributes.empty?
174
- out = out.empty? ? @options[:empty_tag_value] : out
175
- end
176
-
177
- if @type && out.nil?
178
- { name => typecast_value(out) }
179
- else
180
- { name => out }
181
- end
182
- end
145
+ groups = group_children
146
+ value = @type == "array" ? array_value(groups) : hash_value(groups)
147
+ value = typecast_value(value) if @type && value.nil?
148
+ { name => value }
183
149
  end
184
150
 
185
151
  # Typecasts a value based upon its type. For instance, if
@@ -248,6 +214,147 @@ class Nori
248
214
  alias to_s to_html
249
215
 
250
216
  private
217
+
218
+ # The value of the bare (un-namespaced) type attribute, or nil under
219
+ # the +:standards+ profile. A recognized type is consumed from the
220
+ # attributes because typecasting replaces it with the value it
221
+ # describes. An unrecognized type stays visible as an ordinary
222
+ # attribute. The bare attribute is a Rails +Hash.from_xml+ convention
223
+ # rather than XML, so the +:standards+ profile never reads it and the
224
+ # attribute passes through as ordinary data.
225
+ #
226
+ # @param attributes [Hash{String => String}] the element's attributes
227
+ # @return [String, nil] the type name, or nil when there is none to honor
228
+ def bare_type(attributes)
229
+ return nil if @options[:standards]
230
+
231
+ if self.class.available_typecasts.include?(attributes["type"])
232
+ attributes.delete("type")
233
+ else
234
+ attributes["type"]
235
+ end
236
+ end
237
+
238
+ # The attribute forms that declare an element nil. The prefixed form
239
+ # is the XML Schema Instance convention (xsi:nil). The bare +nil+ form
240
+ # is a Rails +Hash.from_xml+ convention, so the +:standards+ profile
241
+ # only accepts the prefixed form.
242
+ #
243
+ # @return [Regexp] the pattern, with the prefix in capture group 2
244
+ def nil_attribute_pattern
245
+ @options[:standards] ? /^((.+):)nil$/ : /^((.*):)?nil$/
246
+ end
247
+
248
+ # Decodes the base64 content of a node typed as "file" into a
249
+ # {StringIOFile} carrying the filename and content type attributes.
250
+ def file_value
251
+ file = StringIOFile.new((@children.first || '').unpack('m').first)
252
+ file.original_filename = attributes['name'] || 'untitled'
253
+ file.content_type = attributes['content_type'] || 'application/octet-stream'
254
+ file
255
+ end
256
+
257
+ # Typecasts the text content of the node. String results are wrapped
258
+ # so the node's attributes stay accessible on the value. Typecast
259
+ # non-String results can only carry the attributes under the
260
+ # +:serializable+ profile, where every value with attributes takes the
261
+ # hash shape. Without the profile they are returned bare and the
262
+ # attributes are dropped, because a {StringWithAttributes} cannot wrap
263
+ # them.
264
+ def text_value
265
+ value = typecast_value(inner_html)
266
+ value = advanced_typecasting(value) if value.is_a?(String) && @options[:advanced_typecasting]
267
+ return serializable_value(value) if @options[:serializable]
268
+ value.is_a?(String) ? string_with_attributes(value) : value
269
+ end
270
+
271
+ # Groups the child nodes by their name so repeating siblings can be
272
+ # folded into arrays.
273
+ #
274
+ # @return [Hash{String => Array<XMLUtilityNode>}]
275
+ def group_children
276
+ @children.inject({}) { |hash, child| (hash[child.name] ||= []) << child; hash }
277
+ end
278
+
279
+ # Collects the values of all child nodes for a node typed as "array".
280
+ def array_value(groups)
281
+ values = []
282
+ groups.each do |child_name, nodes|
283
+ if nodes.size == 1
284
+ values << nodes.first.to_hash.entries.first.last
285
+ else
286
+ values << nodes.map { |node| node.to_hash[child_name] }
287
+ end
288
+ end
289
+ values.flatten
290
+ end
291
+
292
+ # Folds the child nodes and the prefixed attributes into a hash.
293
+ # An empty result becomes the :empty_tag_value option.
294
+ def hash_value(groups)
295
+ return consistent_empty_value if @options[:consistent_empty_tags] && groups.empty?
296
+
297
+ value = {}
298
+ groups.each do |child_name, nodes|
299
+ if nodes.size == 1
300
+ value.merge!(nodes.first.to_hash)
301
+ else
302
+ value.merge!(child_name => nodes.map { |node| node.to_hash[child_name] })
303
+ end
304
+ end
305
+ value.merge!(prefixed_attributes) unless attributes.empty?
306
+ value.empty? ? @options[:empty_tag_value] : value
307
+ end
308
+
309
+ # Resolves an element without children and without text when the
310
+ # :consistent_empty_tags option is set. The element becomes the
311
+ # :empty_tag_value option no matter which attributes it carries.
312
+ # A string value keeps the attributes accessible on the value.
313
+ # An explicit xsi:nil="true" wins over the option and becomes nil.
314
+ def consistent_empty_value
315
+ return nil if @nil_element
316
+
317
+ value = @options[:empty_tag_value]
318
+ value.is_a?(String) ? string_with_attributes(value) : value
319
+ end
320
+
321
+ # Combines a string +value+ with the node's attributes in the shape the
322
+ # active output profile calls for.
323
+ #
324
+ # By default the value is a {StringWithAttributes}: a String carrying the
325
+ # node's attributes on {StringWithAttributes#attributes}. Under the
326
+ # +:serializable+ profile the value becomes plain, directly-serializable
327
+ # data instead, so no custom String subclass is returned.
328
+ #
329
+ # @param value [String] the typecast text content of the node
330
+ # @return [StringWithAttributes, Hash{String => String}, String] the value
331
+ # in the configured representation
332
+ def string_with_attributes(value)
333
+ return serializable_value(value) if @options[:serializable]
334
+
335
+ string = StringWithAttributes.new(value)
336
+ string.attributes = attributes
337
+ string
338
+ end
339
+
340
+ # The +:serializable+ representation of a +value+ and the node's
341
+ # attributes. A node with attributes maps to the XML JSON convention
342
+ # (+{"#text" => value}+ merged with the "@"-prefixed attributes) and a
343
+ # node without attributes maps to the plain value. This holds for
344
+ # typecast non-String values too, so attributes are never silently
345
+ # dropped under the profile. The +#text+ key and the attribute keys go
346
+ # through the same tag conversion as every other key
347
+ # ({#prefixed_attribute_name}), so a +:convert_tags_to+ formula sees
348
+ # each key Nori emits.
349
+ #
350
+ # @param value [Object] the typecast text content of the node
351
+ # @return [Hash{String => Object}, Object] the hash shape when the node
352
+ # has attributes, otherwise the plain +value+
353
+ def serializable_value(value)
354
+ return value if attributes.empty?
355
+ { prefixed_attribute_name("#text") => value }.merge(prefixed_attributes)
356
+ end
357
+
251
358
  def try_to_convert(value, &block)
252
359
  block.call(value)
253
360
  rescue ArgumentError
data/lib/nori.rb CHANGED
@@ -20,14 +20,17 @@ class Nori
20
20
  :convert_tags_to => nil,
21
21
  :convert_attributes_to => nil,
22
22
  :empty_tag_value => nil,
23
+ :consistent_empty_tags => false,
23
24
  :advanced_typecasting => true,
24
25
  :convert_dashes_to_underscores => true,
25
26
  :scrub_xml => true,
27
+ :standards => false,
28
+ :serializable => false,
26
29
  :parser => :nokogiri
27
30
  }
28
31
 
29
32
  validate_options! defaults.keys, options.keys
30
- @options = defaults.merge(options)
33
+ @options = defaults.merge(standards_defaults(options)).merge(options)
31
34
  end
32
35
 
33
36
  def find(hash, *path)
@@ -49,6 +52,29 @@ class Nori
49
52
  end
50
53
 
51
54
  private
55
+
56
+ # The defaults implied by the +:standards+ profile.
57
+ #
58
+ # The profile groups the spec-correct behaviors under a single opt-in.
59
+ # It turns on the XML string-value model for empty elements
60
+ # (+:consistent_empty_tags+ with an empty-string +:empty_tag_value+),
61
+ # turns off +:advanced_typecasting+ (schema-less values are text, their
62
+ # types are the business of a schema-aware layer) and, in the parsers,
63
+ # honors xml:space. These are defaults, so an explicit option passed by
64
+ # the caller still wins. When the profile is off the hash is empty and
65
+ # parsing is unchanged.
66
+ #
67
+ # @param options [Hash] the options passed to {#initialize}
68
+ # @return [Hash] the implied defaults, or +{}+ when the profile is off
69
+ def standards_defaults(options)
70
+ return {} unless options[:standards]
71
+ {
72
+ :consistent_empty_tags => true,
73
+ :empty_tag_value => "",
74
+ :advanced_typecasting => false
75
+ }
76
+ end
77
+
52
78
  def load_parser(parser)
53
79
  require "nori/parser/#{parser}"
54
80
  Parser.const_get PARSERS[parser]
metadata CHANGED
@@ -1,16 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nori
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.7.1
4
+ version: 2.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Harrington
8
8
  - John Nunemaker
9
9
  - Wynn Netherland
10
- autorequire:
11
10
  bindir: bin
12
11
  cert_chain: []
13
- date: 2024-07-28 00:00:00.000000000 Z
12
+ date: 1980-01-02 00:00:00.000000000 Z
14
13
  dependencies:
15
14
  - !ruby/object:Gem::Dependency
16
15
  name: bigdecimal
@@ -26,20 +25,34 @@ dependencies:
26
25
  - - ">="
27
26
  - !ruby/object:Gem::Version
28
27
  version: '0'
28
+ - !ruby/object:Gem::Dependency
29
+ name: stringio
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: '0'
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: '0'
29
42
  - !ruby/object:Gem::Dependency
30
43
  name: rake
31
44
  requirement: !ruby/object:Gem::Requirement
32
45
  requirements:
33
46
  - - "~>"
34
47
  - !ruby/object:Gem::Version
35
- version: 12.3.3
48
+ version: '13.3'
36
49
  type: :development
37
50
  prerelease: false
38
51
  version_requirements: !ruby/object:Gem::Requirement
39
52
  requirements:
40
53
  - - "~>"
41
54
  - !ruby/object:Gem::Version
42
- version: 12.3.3
55
+ version: '13.3'
43
56
  - !ruby/object:Gem::Dependency
44
57
  name: nokogiri
45
58
  requirement: !ruby/object:Gem::Requirement
@@ -74,18 +87,9 @@ executables: []
74
87
  extensions: []
75
88
  extra_rdoc_files: []
76
89
  files:
77
- - ".devcontainer/devcontainer.json"
78
- - ".github/dependabot.yml"
79
- - ".github/workflows/test.yml"
80
- - ".gitignore"
81
- - ".rspec"
82
90
  - CHANGELOG.md
83
- - Gemfile
84
91
  - LICENSE
85
92
  - README.md
86
- - Rakefile
87
- - benchmark/benchmark.rb
88
- - benchmark/soap_response.xml
89
93
  - lib/nori.rb
90
94
  - lib/nori/core_ext.rb
91
95
  - lib/nori/core_ext/hash.rb
@@ -96,18 +100,11 @@ files:
96
100
  - lib/nori/string_with_attributes.rb
97
101
  - lib/nori/version.rb
98
102
  - lib/nori/xml_utility_node.rb
99
- - nori.gemspec
100
- - spec/nori/api_spec.rb
101
- - spec/nori/core_ext/hash_spec.rb
102
- - spec/nori/nori_spec.rb
103
- - spec/nori/string_utils_spec.rb
104
- - spec/spec_helper.rb
105
103
  homepage: https://github.com/savonrb/nori
106
104
  licenses:
107
105
  - MIT
108
106
  metadata:
109
107
  rubygems_mfa_required: 'true'
110
- post_install_message:
111
108
  rdoc_options: []
112
109
  require_paths:
113
110
  - lib
@@ -122,8 +119,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
122
119
  - !ruby/object:Gem::Version
123
120
  version: '0'
124
121
  requirements: []
125
- rubygems_version: 3.4.10
126
- signing_key:
122
+ rubygems_version: 3.6.9
127
123
  specification_version: 4
128
124
  summary: XML to Hash translator
129
125
  test_files: []
@@ -1,19 +0,0 @@
1
- // For format details, see https://aka.ms/devcontainer.json. For config options, see the
2
- // README at: https://github.com/devcontainers/templates/tree/main/src/ruby
3
- {
4
- "name": "Ruby",
5
- // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
6
- "image": "mcr.microsoft.com/devcontainers/ruby:0-3-bullseye"
7
-
8
- // Features to add to the dev container. More info: https://containers.dev/features.
9
- // "features": {},
10
-
11
- // Use 'forwardPorts' to make a list of ports inside the container available locally.
12
- // "forwardPorts": [],
13
-
14
- // Configure tool-specific properties.
15
- // "customizations": {},
16
-
17
- // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
18
- // "remoteUser": "root"
19
- }
@@ -1,12 +0,0 @@
1
- # To get started with Dependabot version updates, you'll need to specify which
2
- # package ecosystems to update and where the package manifests are located.
3
- # Please see the documentation for more information:
4
- # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5
- # https://containers.dev/guide/dependabot
6
-
7
- version: 2
8
- updates:
9
- - package-ecosystem: "devcontainers"
10
- directory: "/"
11
- schedule:
12
- interval: weekly
@@ -1,26 +0,0 @@
1
- name: CI
2
-
3
- on: [push, pull_request]
4
-
5
- jobs:
6
- build:
7
- runs-on: ubuntu-latest
8
-
9
- strategy:
10
- matrix:
11
- ruby-version:
12
- - '3.0'
13
- - '3.1'
14
- - '3.2'
15
- - '3.3'
16
- - 'head'
17
- - jruby-9.4.5.0
18
- steps:
19
- - uses: actions/checkout@v4
20
- - name: Set up Ruby ${{ matrix.ruby-version }}
21
- uses: ruby/setup-ruby@v1
22
- with:
23
- ruby-version: ${{ matrix.ruby-version }}
24
- bundler-cache: true # runs 'bundle install' and caches installed gems automatically
25
- - name: Run tests
26
- run: bundle exec rake
data/.gitignore DELETED
@@ -1,8 +0,0 @@
1
- .DS_Store
2
- doc
3
- coverage
4
- *~
5
- *.gem
6
- .bundle
7
- Gemfile.lock
8
- /.idea
data/.rspec DELETED
@@ -1 +0,0 @@
1
- --colour
data/Gemfile DELETED
@@ -1,6 +0,0 @@
1
- source 'https://rubygems.org'
2
- gemspec
3
-
4
- if RUBY_VERSION >= "3"
5
- gem "rexml", "~> 3.2"
6
- end
data/Rakefile DELETED
@@ -1,12 +0,0 @@
1
- require "bundler/gem_tasks"
2
-
3
- desc "Benchmark Nori parsers"
4
- task :benchmark do
5
- require "benchmark/benchmark"
6
- end
7
-
8
- require "rspec/core/rake_task"
9
- RSpec::Core::RakeTask.new
10
-
11
- task :default => :spec
12
- task :test => :spec
@@ -1,19 +0,0 @@
1
- $:.push File.expand_path("../../lib", __FILE__)
2
- require "nori"
3
-
4
- require "benchmark"
5
-
6
- Benchmark.bm 30 do |x|
7
-
8
- num = 500
9
- xml = File.read File.expand_path("../soap_response.xml", __FILE__)
10
-
11
- x.report "rexml parser" do
12
- num.times { Nori.new(parser: :rexml).parse xml }
13
- end
14
-
15
- x.report "nokogiri parser" do
16
- num.times { Nori.new(parser: :nokogiri).parse xml }
17
- end
18
-
19
- end