nori 2.7.1 → 2.9.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.
@@ -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,138 @@ 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.
259
+ def text_value
260
+ value = typecast_value(inner_html)
261
+ value = advanced_typecasting(value) if value.is_a?(String) && @options[:advanced_typecasting]
262
+ value.is_a?(String) ? string_with_attributes(value) : value
263
+ end
264
+
265
+ # Groups the child nodes by their name so repeating siblings can be
266
+ # folded into arrays.
267
+ #
268
+ # @return [Hash{String => Array<XMLUtilityNode>}]
269
+ def group_children
270
+ @children.inject({}) { |hash, child| (hash[child.name] ||= []) << child; hash }
271
+ end
272
+
273
+ # Collects the values of all child nodes for a node typed as "array".
274
+ def array_value(groups)
275
+ values = []
276
+ groups.each do |child_name, nodes|
277
+ if nodes.size == 1
278
+ values << nodes.first.to_hash.entries.first.last
279
+ else
280
+ values << nodes.map { |node| node.to_hash[child_name] }
281
+ end
282
+ end
283
+ values.flatten
284
+ end
285
+
286
+ # Folds the child nodes and the prefixed attributes into a hash.
287
+ # An empty result becomes the :empty_tag_value option.
288
+ def hash_value(groups)
289
+ return consistent_empty_value if @options[:consistent_empty_tags] && groups.empty?
290
+
291
+ value = {}
292
+ groups.each do |child_name, nodes|
293
+ if nodes.size == 1
294
+ value.merge!(nodes.first.to_hash)
295
+ else
296
+ value.merge!(child_name => nodes.map { |node| node.to_hash[child_name] })
297
+ end
298
+ end
299
+ value.merge!(prefixed_attributes) unless attributes.empty?
300
+ value.empty? ? @options[:empty_tag_value] : value
301
+ end
302
+
303
+ # Resolves an element without children and without text when the
304
+ # :consistent_empty_tags option is set. The element becomes the
305
+ # :empty_tag_value option no matter which attributes it carries.
306
+ # A string value keeps the attributes accessible on the value.
307
+ # An explicit xsi:nil="true" wins over the option and becomes nil.
308
+ def consistent_empty_value
309
+ return nil if @nil_element
310
+
311
+ value = @options[:empty_tag_value]
312
+ value.is_a?(String) ? string_with_attributes(value) : value
313
+ end
314
+
315
+ # Combines a string +value+ with the node's attributes in the shape the
316
+ # active output profile calls for.
317
+ #
318
+ # By default the value is a {StringWithAttributes}: a String carrying the
319
+ # node's attributes on {StringWithAttributes#attributes}. Under the
320
+ # +:serializable+ profile the value becomes plain, directly-serializable
321
+ # data instead, so no custom String subclass is returned.
322
+ #
323
+ # @param value [String] the typecast text content of the node
324
+ # @return [StringWithAttributes, Hash{String => String}, String] the value
325
+ # in the configured representation
326
+ def string_with_attributes(value)
327
+ return serializable_value(value) if @options[:serializable]
328
+
329
+ string = StringWithAttributes.new(value)
330
+ string.attributes = attributes
331
+ string
332
+ end
333
+
334
+ # The +:serializable+ representation of a string +value+ and the node's
335
+ # attributes. A node with attributes maps to the XML JSON convention
336
+ # (+{"#text" => value}+ merged with the "@"-prefixed attributes) and a
337
+ # node without attributes maps to the plain String. The attribute keys go
338
+ # through the same prefixing and tag conversion as element-node attributes
339
+ # ({#prefixed_attributes}), so every node kind shares one convention.
340
+ #
341
+ # @param value [String] the typecast text content of the node
342
+ # @return [Hash{String => String}, String] the hash shape when the node
343
+ # has attributes, otherwise the plain +value+
344
+ def serializable_value(value)
345
+ return value if attributes.empty?
346
+ { "#text" => value }.merge(prefixed_attributes)
347
+ end
348
+
251
349
  def try_to_convert(value, &block)
252
350
  block.call(value)
253
351
  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.0
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