├── .devcontainer └── devcontainer.json ├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── .gitignore ├── .rspec ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── benchmark ├── benchmark.rb └── soap_response.xml ├── lib ├── nori.rb └── nori │ ├── core_ext.rb │ ├── core_ext │ └── hash.rb │ ├── parser │ ├── nokogiri.rb │ └── rexml.rb │ ├── string_io_file.rb │ ├── string_utils.rb │ ├── string_with_attributes.rb │ ├── version.rb │ └── xml_utility_node.rb ├── nori.gemspec └── spec ├── nori ├── api_spec.rb ├── core_ext │ └── hash_spec.rb ├── nori_spec.rb └── string_utils_spec.rb └── spec_helper.rb /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 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 | } 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 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 13 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 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 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | doc 3 | coverage 4 | *~ 5 | *.gem 6 | .bundle 7 | Gemfile.lock 8 | /.idea 9 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --colour 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 2.7.1 (2024-07-28) 2 | 3 | * Stop monkey-patching String with #snakecase by @mchu in https://github.com/savonrb/nori/pull/102 4 | 5 | # 2.7.0 (2024-02-13) 6 | 7 | * Added support for ruby 3.1, 3.2, 3.3. Dropped support for ruby 2.7 and below. 8 | * Feature: `Nori::Parser` has a new option, `:scrub_xml`, which defaults to true, for scrubbing invalid characters ([#72](https://github.com/savonrb/nori/pull/72)). This should allow documents containing invalid characters to still be parsed. 9 | * Fix: REXML parser changes `<` inside CDATA to `<` ([#94](https://github.com/savonrb/nori/pull/94)) 10 | * Change: `Object#blank?` is no longer patched in. 11 | 12 | # 2.6.0 (2015-05-06) 13 | 14 | * Feature: [#69](https://github.com/savonrb/nori/pull/69) Add option to convert empty tags to a value other than nil. 15 | 16 | # 2.5.0 (2015-03-31) 17 | 18 | * Formally drop support for ruby 1.8.7. Installing Nori from rubygems for that version should no longer attempt to install versions that will not work. 19 | * BREAKING CHANGE: Newlines are now preserved when present in the value of inner text nodes. See the example below: 20 | 21 | before: 22 | 23 | ``` 24 | Nori.new.parse("\n<embedded>\n<one></one>\n<two></two>\n<embedded>\n") 25 | => {"outer"=>""} 26 | ``` 27 | 28 | after: 29 | ``` 30 | Nori.new.parse("\n<embedded>\n<one></one>\n<two></two>\n<embedded>\n") 31 | => {"outer"=>"\n\n\n\n"} 32 | ``` 33 | 34 | # 2.4.0 (2014-04-19) 35 | 36 | * Change: Dropped support for ruby 1.8, rubinius and ree 37 | 38 | * Feature: Added `:convert_attributes` feature similar to `:convert_tags_to` 39 | 40 | * Feature: Added `:convert_dashes_to_underscore` option 41 | 42 | # 2.3.0 (2013-07-26) 43 | 44 | * Change: `Nori#find` now ignores namespace prefixes in Hash keys it is searching through. 45 | 46 | * Fix: Limited Nokogiri to < 1.6, because v1.6 dropped support for Ruby 1.8. 47 | 48 | # 2.2.0 (2013-04-25) 49 | 50 | * Feature: [#42](https://github.com/savonrb/nori/pull/42) adds the `:delete_namespace_attributes` 51 | option to remove namespace attributes like `xmlns:*` or `xsi:*`. 52 | 53 | # 2.1.0 (2013-04-21) 54 | 55 | * Feature: Added `Nori.hash_key` and `Nori#find` to work with Hash keys generated by Nori. 56 | Original issue: [savonrb/savon#393](https://github.com/savonrb/savon/pull/393) 57 | 58 | # 2.0.4 (2013-02-26) 59 | 60 | * Fix: [#37](https://github.com/savonrb/nori/issues/37) special characters 61 | problem on Ruby 1.9.3-p392. 62 | 63 | # 2.0.3 (2013-01-10) 64 | 65 | * Fix for remote code execution bug. For more in-depth information, read about the 66 | recent [Rails hotfix](https://groups.google.com/forum/?fromgroups=#!topic/rubyonrails-security/61bkgvnSGTQ). 67 | Please make sure to upgrade now! 68 | 69 | # 2.0.2 (YANKED) 70 | 71 | * Yanked because of a problem with XML that starts with an instruction tag. 72 | 73 | # 2.0.1 (YANKED) 74 | 75 | * Yanked because of a problem with XML that starts with an instruction tag. 76 | 77 | # 2.0.0 (2012-12-12) 78 | 79 | Please make sure to read the updated README for how to use the new version. 80 | 81 | * Change: Nori now defaults to use the Nokogiri parser. 82 | 83 | * Refactoring: Changed the `Nori` module to a class. This might cause problems if you 84 | included the `Nori` module somewhere in your application. This use case was removed 85 | for overall simplicity. 86 | 87 | * Refactoring: Changed the interface to remove any global state. The global configuration 88 | is gone and replaced with simple options to be passed to `Nori.new`. 89 | 90 | ``` ruby 91 | parser = Nori.new(strip_namespaces: true) 92 | parser.parse(xml) 93 | ``` 94 | 95 | * Refactoring: Removed the `Nori::Parser` module methods. After refactoring the rest, 96 | there was only a single method left for this module and that was moved to `Nori`. 97 | 98 | * Fix: [#16](https://github.com/savonrb/nori/issues/16) strip XML passed to Nori. 99 | 100 | ## 1.1.5 (2013-03-03) 101 | 102 | * Fix: [#37](https://github.com/savonrb/nori/issues/37) special characters 103 | problem on Ruby 1.9.3-p392. 104 | 105 | ## 1.1.4 (2013-01-10) 106 | 107 | * Fix for remote code execution bug. For more in-depth information, read about the 108 | recent [Rails hotfix](https://groups.google.com/forum/?fromgroups=#!topic/rubyonrails-security/61bkgvnSGTQ). 109 | Please make sure to upgrade now! 110 | 111 | ## 1.1.3 (2012-07-12) 112 | 113 | * Fix: Merged [pull request 21](https://github.com/savonrb/nori/pull/21) to fix an 114 | issue with date/time/datetime regexes not matching positive time zone offsets and 115 | datetime strings with seconds. 116 | 117 | ## 1.1.2 (2012-06-30) 118 | 119 | * Fix: Reverted `Object#xml_attributes` feature which is planned for version 2.0. 120 | 121 | ## 1.1.1 (2012-06-29) - yanked 122 | 123 | * Fix: Merged [pull request 17](https://github.com/savonrb/nori/pull/17) for improved 124 | xs:time/xs:date/xs:dateTime regular expression matchers. 125 | 126 | ## 1.1.0 (2012-02-17) 127 | 128 | * Improvement: Merged [pull request 9](https://github.com/savonrb/nori/pull/9) to 129 | allow multiple configurations of Nori. 130 | 131 | * Fix: Merged [pull request 10](https://github.com/savonrb/nori/pull/10) to handle 132 | date/time parsing errors. Fixes a couple of similar error reports. 133 | 134 | ## 1.0.2 (2011-07-04) 135 | 136 | * Fix: When specifying a custom formula to convert tags, XML attributes were ignored. 137 | Now, a formula is applied to both XML tags and attributes. 138 | 139 | ## 1.0.1 (2011-06-21) 140 | 141 | * Fix: Make sure to always load both StringWithAttributes and StringIOFile 142 | to prevent NameError's. 143 | 144 | ## 1.0.0 (2011-06-20) 145 | 146 | * Notice: As of v1.0.0, Nori will follow [Semantic Versioning](http://semver.org). 147 | 148 | * Feature: Added somewhat advanced typecasting: 149 | 150 | What this means: 151 | 152 | * "true" and "false" are converted to TrueClass and FalseClass 153 | * Strings matching an xs:time, xs:date and xs:dateTime are converted 154 | to Time, Date and DateTime objects. 155 | 156 | You can disable this feature via: 157 | 158 | Nori.advanced_typecasting = false 159 | 160 | * Feature: Added an option to strip the namespaces from every tag. 161 | This feature might raise problems and is therefore disabled by default. 162 | 163 | Nori.strip_namespaces = true 164 | 165 | * Feature: Added an option to specify a custom formula to convert tags. 166 | Here's an example: 167 | 168 | Nori.configure do |config| 169 | config.convert_tags_to { |tag| tag.snake_case.to_sym } 170 | end 171 | 172 | xml = 'active' 173 | parse(xml).should ## { :user_response => { :account_status => "active" } 174 | 175 | ## 0.2.4 (2011-06-21) 176 | 177 | * Fix: backported fixes from v1.0.1 178 | 179 | ## 0.2.3 (2011-05-26) 180 | 181 | * Fix: Use extended core classes StringWithAttributes and StringIOFile instead of 182 | creating singletons to prevent serialization problems. 183 | 184 | ## 0.2.2 (2011-05-16) 185 | 186 | * Fix: namespaced xs:nil values should be nil objects. 187 | 188 | ## 0.2.1 (2011-05-15) 189 | 190 | * Fix: Changed XML attributes converted to Hash keys to be prefixed with an @-sign. 191 | This avoids problems with attributes and child nodes having the same name. 192 | 193 | 194 | true 195 | 76737 196 | 197 | 198 | is now translated to: 199 | 200 | { "multiRef" => { "@id" => "id1", "id" => "76737", "approved" => "true" } } 201 | 202 | ## 0.2.0 (2011-04-30) 203 | 204 | * Removed JSON from the original Crack basis 205 | * Fixed a problem with Object#blank? 206 | * Added swappable parsers 207 | * Added a Nokogiri parser with you can switch to via: 208 | 209 | Nori.parser = :nokogiri 210 | 211 | ## 0.1.7 2010-02-19 212 | * 1 minor patch 213 | * Added patch from @purp for ISO 8601 date/time format 214 | 215 | ## 0.1.6 2010-01-31 216 | * 1 minor patch 217 | * Added Crack::VERSION constant - http://weblog.rubyonrails.org/2009/9/1/gem-packaging-best-practices 218 | 219 | ## 0.1.5 2010-01-27 220 | * 1 minor patch 221 | * Strings that begin with dates shouldn't be parsed as such (sandro) 222 | 223 | ## 0.1.3 2009-06-22 224 | * 1 minor patch 225 | * Parsing a text node with attributes stores them in the attributes method (tamalw) 226 | 227 | ## 0.1.2 2009-04-21 228 | * 2 minor patches 229 | * Correct unnormalization of attribute values (der-flo) 230 | * Fix error in parsing YAML in the case where a hash value ends with backslashes, and there are subsequent values in the hash (deadprogrammer) 231 | 232 | ## 0.1.1 2009-03-31 233 | * 1 minor patch 234 | * Parsing empty or blank xml now returns empty hash instead of raising error. 235 | 236 | ## 0.1.0 2009-03-28 237 | * Initial release. 238 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | 4 | if RUBY_VERSION >= "3" 5 | gem "rexml", "~> 3.2" 6 | end 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Daniel Harrington 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Nori 2 | ==== 3 | 4 | [![CI](https://github.com/savonrb/nori/actions/workflows/test.yml/badge.svg)](https://github.com/savonrb/nori/actions/workflows/test.yml) 5 | [![Gem Version](https://badge.fury.io/rb/nori.svg)](http://badge.fury.io/rb/nori) 6 | [![Code Climate](https://codeclimate.com/github/savonrb/nori.svg)](https://codeclimate.com/github/savonrb/nori) 7 | 8 | Really simple XML parsing ripped from Crack, which ripped it from Merb. 9 | 10 | Nori supports pluggable parsers and ships with both REXML and Nokogiri implementations. 11 | It defaults to Nokogiri since v2.0.0, but you can change it to use REXML via: 12 | 13 | ``` ruby 14 | Nori.new(:parser => :rexml) # or :nokogiri 15 | ``` 16 | 17 | Make sure Nokogiri is in your LOAD_PATH when parsing XML, because Nori tries to load it when it's needed. 18 | 19 | # Examples 20 | 21 | ```ruby 22 | Nori.new.parse("This is the content") 23 | # => {"tag"=>"This is the content"} 24 | 25 | Nori.new.parse('') 26 | #=> {"foo"=>nil} 27 | 28 | Nori.new.parse('') 29 | #=> {} 30 | 31 | Nori.new.parse('') 32 | #=> {"foo"=>{"@bar"=>"baz"}} 33 | 34 | Nori.new.parse('Content') 35 | #=> {"foo"=>"Content"} 36 | ``` 37 | 38 | ## Nori::StringWithAttributes 39 | 40 | You can access a string node's attributes via `attributes`. 41 | 42 | ```ruby 43 | result = Nori.new.parse('Content') 44 | #=> {"foo"=>"Content"} 45 | 46 | result["foo"].class 47 | # => Nori::StringWithAttributes 48 | 49 | result["foo"].attributes 50 | # => {"bar"=>"baz"} 51 | ``` 52 | 53 | ## advanced_typecasting 54 | 55 | Nori can automatically convert string values to `TrueClass`, `FalseClass`, `Time`, `Date`, and `DateTime`: 56 | 57 | ```ruby 58 | # "true" and "false" String values are converted to `TrueClass` and `FalseClass`. 59 | Nori.new.parse("true") 60 | # => {"value"=>true} 61 | 62 | # String values matching xs:time, xs:date and xs:dateTime are converted to `Time`, `Date` and `DateTime` objects. 63 | Nori.new.parse("09:33:55.7Z") 64 | # => {"value"=>2022-09-29 09:33:55.7 UTC 65 | 66 | # disable with advanced_typecasting: false 67 | Nori.new(advanced_typecasting: false).parse("true") 68 | # => {"value"=>"true"} 69 | 70 | ``` 71 | 72 | ## strip_namespaces 73 | 74 | Nori can strip the namespaces from your XML tags. This feature is disabled by default. 75 | 76 | ``` ruby 77 | Nori.new.parse('') 78 | # => {"soap:Envelope"=>{"@xmlns:soap"=>"http://schemas.xmlsoap.org/soap/envelope/"}} 79 | 80 | Nori.new(:strip_namespaces => true).parse('') 81 | # => {"Envelope"=>{"@xmlns:soap"=>"http://schemas.xmlsoap.org/soap/envelope/"}} 82 | ``` 83 | 84 | ## convert_tags_to 85 | 86 | Nori lets you specify a custom formula to convert XML tags to Hash keys using `convert_tags_to`. 87 | 88 | ``` ruby 89 | Nori.new.parse('active') 90 | # => {"userResponse"=>{"accountStatus"=>"active"}} 91 | 92 | parser = Nori.new(:convert_tags_to => lambda { |tag| Nori::StringUtils.snakecase(tag).to_sym }) 93 | parser.parse('active') 94 | # => {:user_response=>{:account_status=>"active"}} 95 | ``` 96 | 97 | ## convert_dashes_to_underscores 98 | 99 | By default, Nori will automatically convert dashes in tag names to underscores. 100 | 101 | ```ruby 102 | Nori.new.parse('foo bar') 103 | # => {"any_tag"=>"foo bar"} 104 | 105 | # disable with convert_dashes_to_underscores 106 | parser = Nori.new(:convert_dashes_to_underscores => false) 107 | parser.parse('foo bar') 108 | # => {"any-tag"=>"foo bar"} 109 | ``` 110 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 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 13 | -------------------------------------------------------------------------------- /benchmark/benchmark.rb: -------------------------------------------------------------------------------- 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 20 | -------------------------------------------------------------------------------- /benchmark/soap_response.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | PALMA DE MALLORCA 108 | Spain Schengen 109 | Son San Juan 110 | 11 km 111 | 08.02.10 112 | 08.02.10 113 | Passengers 114 | 1 115 | Disembarking: 116 | -1 117 | 2 118 | 0 119 | Baby buggies are delivered at aircraft door or stairs. 120 | 2008-09-01T00:00:00+02:00 121 | -1 122 | 0 123 | PMI 124 | 125 | 126 | PALMA DE MALLORCA 127 | Spain Schengen 128 | Son San Juan 129 | 11 km 130 | 08.02.10 131 | 08.02.10 132 | Aircraft 133 | 2 134 | Turnaround: 135 | -1 136 | 5.30 137 | 0 138 | Station applies EST process. 139 | 2009-05-05T00:00:00+02:00 140 | -1 141 | -1 142 | PMI 143 | 144 | 145 | PALMA DE MALLORCA 146 | Spain Schengen 147 | Son San Juan 148 | 11 km 149 | 08.02.10 150 | 08.02.10 151 | General 152 | 4 153 | Addresses: 154 | -1 155 | 8.50 156 | 1 157 | 158 | Station Manager: 159 | YYYYYYYY XXXXXXX 160 | PMIKXXX 161 | Tel.:+34 971 xxx xxx 162 | Mobile:+ 34 600 46 xx xx 163 | 164 | 2010-02-08T00:00:00+01:00 165 | -1 166 | -1 167 | PMI 168 | 169 | 170 | PALMA DE MALLORCA 171 | Spain Schengen 172 | Son San Juan 173 | 11 km 174 | 08.02.10 175 | 08.02.10 176 | General 177 | 4 178 | Addresses: 179 | -1 180 | 8.50 181 | 2 182 | 183 | Handling Agent: 184 | xxxxxxx Airport Services 185 | Operations 186 | PMIIxxx 187 | Tel +34 971 xxx xxx 188 | Passenger Services 189 | PMIPXXX 190 | Tel : +34 971 xxx xxx 191 | 192 | 2010-02-08T00:00:00+01:00 193 | -1 194 | -1 195 | PMI 196 | 197 | 198 | PALMA DE MALLORCA 199 | Spain Schengen 200 | Son San Juan 201 | 11 km 202 | 08.02.10 203 | 08.02.10 204 | General 205 | 4 206 | Announcements: 207 | -1 208 | 11 209 | 1 210 | Prerecorded Spanish announcements available. 211 | 2009-12-30T00:00:00+01:00 212 | -1 213 | 0 214 | PMI 215 | 216 | 217 | PALMA DE MALLORCA 218 | Spain Schengen 219 | Son San Juan 220 | 11 km 221 | 08.02.10 222 | 08.02.10 223 | General 224 | 4 225 | Life jackets / DEMO: 226 | -1 227 | 12 228 | 0 229 | 230 | Infant life jackets to be distributed. 231 | DEMO with life jackets. 232 | 233 | 2002-07-24T00:00:00+02:00 234 | -1 235 | 0 236 | PMI 237 | 238 | 239 | PALMA DE MALLORCA 240 | Spain Schengen 241 | Son San Juan 242 | 11 km 243 | 08.02.10 244 | 08.02.10 245 | Catering 246 | 5 247 | General: 248 | 0 249 | 1 250 | 1 251 | 252 | LSG XXX XXXX 253 | Tel.: +34 971 xxx xxx or xxx xxx 254 | Sita: PMIAXXX 255 | 256 | 2005-06-01T00:00:00+02:00 257 | -1 258 | 0 259 | PMI 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | -------------------------------------------------------------------------------- /lib/nori.rb: -------------------------------------------------------------------------------- 1 | require "nori/version" 2 | require "nori/core_ext" 3 | require "nori/xml_utility_node" 4 | 5 | class Nori 6 | 7 | def self.hash_key(name, options = {}) 8 | name = name.tr("-", "_") if options[:convert_dashes_to_underscores] 9 | name = name.split(":").last if options[:strip_namespaces] 10 | name = options[:convert_tags_to].call(name) if options[:convert_tags_to].respond_to? :call 11 | name 12 | end 13 | 14 | PARSERS = { :rexml => "REXML", :nokogiri => "Nokogiri" } 15 | 16 | def initialize(options = {}) 17 | defaults = { 18 | :strip_namespaces => false, 19 | :delete_namespace_attributes => false, 20 | :convert_tags_to => nil, 21 | :convert_attributes_to => nil, 22 | :empty_tag_value => nil, 23 | :advanced_typecasting => true, 24 | :convert_dashes_to_underscores => true, 25 | :scrub_xml => true, 26 | :parser => :nokogiri 27 | } 28 | 29 | validate_options! defaults.keys, options.keys 30 | @options = defaults.merge(options) 31 | end 32 | 33 | def find(hash, *path) 34 | return hash if path.empty? 35 | 36 | key = path.shift 37 | key = self.class.hash_key(key, @options) 38 | 39 | value = find_value(hash, key) 40 | find(value, *path) if value 41 | end 42 | 43 | def parse(xml) 44 | cleaned_xml = scrub_xml(xml).strip 45 | return {} if cleaned_xml.empty? 46 | 47 | parser = load_parser @options[:parser] 48 | parser.parse(cleaned_xml, @options) 49 | end 50 | 51 | private 52 | def load_parser(parser) 53 | require "nori/parser/#{parser}" 54 | Parser.const_get PARSERS[parser] 55 | end 56 | 57 | # Expects a +block+ which receives a tag to convert. 58 | # Accepts +nil+ for a reset to the default behavior of not converting tags. 59 | def convert_tags_to(reset = nil, &block) 60 | @convert_tag = reset || block 61 | end 62 | 63 | def validate_options!(available_options, options) 64 | spurious_options = options - available_options 65 | 66 | unless spurious_options.empty? 67 | raise ArgumentError, "Spurious options: #{spurious_options.inspect}\n" \ 68 | "Available options are: #{available_options.inspect}" 69 | end 70 | end 71 | 72 | def find_value(hash, key) 73 | hash.each do |k, v| 74 | key_without_namespace = k.to_s.split(':').last 75 | return v if key_without_namespace == key.to_s 76 | end 77 | 78 | nil 79 | end 80 | 81 | def scrub_xml(string) 82 | if @options[:scrub_xml] 83 | if string.respond_to? :scrub 84 | string.scrub 85 | else 86 | if string.valid_encoding? 87 | string 88 | else 89 | enc = string.encoding 90 | mid_enc = (["UTF-8", "UTF-16BE"].map { |e| Encoding.find(e) } - [enc]).first 91 | string.encode(mid_enc, undef: :replace, invalid: :replace).encode(enc) 92 | end 93 | end 94 | else 95 | string 96 | end 97 | end 98 | 99 | end 100 | -------------------------------------------------------------------------------- /lib/nori/core_ext.rb: -------------------------------------------------------------------------------- 1 | require "nori/string_utils" 2 | require "nori/core_ext/hash" 3 | -------------------------------------------------------------------------------- /lib/nori/core_ext/hash.rb: -------------------------------------------------------------------------------- 1 | require "uri" 2 | 3 | class Nori 4 | module CoreExt 5 | module Hash 6 | 7 | # @param key The key for the param. 8 | # @param value The value for the param. 9 | # 10 | # @return This key value pair as a param 11 | # 12 | # @example normalize_param(:name, "Bob Jones") #=> "name=Bob%20Jones" 13 | def normalize_param(key, value) 14 | if value.is_a?(Array) 15 | normalize_array_params(key, value) 16 | elsif value.is_a?(Hash) 17 | normalize_hash_params(key, value) 18 | else 19 | normalize_simple_type_params(key, value) 20 | end 21 | end 22 | 23 | # @return The hash as attributes for an XML tag. 24 | # 25 | # @example 26 | # { :one => 1, "two"=>"TWO" }.to_xml_attributes 27 | # #=> 'one="1" two="TWO"' 28 | def to_xml_attributes 29 | map do |k, v| 30 | %{#{StringUtils.snakecase(k.to_s).sub(/^(.{1,1})/) { |m| m.downcase }}="#{v}"} 31 | end.join(' ') 32 | end 33 | 34 | private 35 | 36 | def normalize_simple_type_params(key, value) 37 | ["#{key}=#{encode_simple_value(value)}"] 38 | end 39 | 40 | def normalize_array_params(key, array) 41 | array.map do |element| 42 | normalize_param("#{key}[]", element) 43 | end 44 | end 45 | 46 | def normalize_hash_params(key, hash) 47 | hash.map do |nested_key, element| 48 | normalize_param("#{key}[#{nested_key}]", element) 49 | end 50 | end 51 | 52 | def encode_simple_value(value) 53 | URI.encode(value.to_s, Regexp.new("[^#{URI::PATTERN::UNRESERVED}]")) 54 | end 55 | 56 | end 57 | end 58 | end 59 | 60 | Hash.send :include, Nori::CoreExt::Hash 61 | -------------------------------------------------------------------------------- /lib/nori/parser/nokogiri.rb: -------------------------------------------------------------------------------- 1 | require "nokogiri" 2 | 3 | class Nori 4 | module Parser 5 | 6 | # = Nori::Parser::Nokogiri 7 | # 8 | # Nokogiri SAX parser. 9 | module Nokogiri 10 | 11 | class Document < ::Nokogiri::XML::SAX::Document 12 | attr_accessor :options 13 | 14 | def stack 15 | @stack ||= [] 16 | end 17 | 18 | def start_element(name, attrs = []) 19 | stack.push Nori::XMLUtilityNode.new(options, name, Hash[*attrs.flatten]) 20 | end 21 | 22 | # To keep backward behaviour compatibility 23 | # delete last child if it is a space-only text node 24 | def end_element(name) 25 | if stack.size > 1 26 | last = stack.pop 27 | maybe_string = last.children.last 28 | if maybe_string.is_a?(String) and maybe_string.strip.empty? 29 | last.children.pop 30 | end 31 | stack.last.add_node last 32 | end 33 | end 34 | 35 | # If this node is a successive character then add it as is. 36 | # First child being a space-only text node will not be added 37 | # because there is no previous characters. 38 | def characters(string) 39 | last = stack.last 40 | if last and last.children.last.is_a?(String) or string.strip.size > 0 41 | last.add_node(string) 42 | end 43 | end 44 | 45 | alias cdata_block characters 46 | 47 | end 48 | 49 | def self.parse(xml, options) 50 | document = Document.new 51 | document.options = options 52 | parser = ::Nokogiri::XML::SAX::Parser.new document 53 | parser.parse xml 54 | document.stack.length > 0 ? document.stack.pop.to_hash : {} 55 | end 56 | 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/nori/parser/rexml.rb: -------------------------------------------------------------------------------- 1 | require "rexml/parsers/baseparser" 2 | require "rexml/text" 3 | require "rexml/document" 4 | 5 | class Nori 6 | module Parser 7 | 8 | # = Nori::Parser::REXML 9 | # 10 | # REXML pull parser. 11 | module REXML 12 | 13 | def self.parse(xml, options) 14 | stack = [] 15 | parser = ::REXML::Parsers::BaseParser.new(xml) 16 | 17 | while true 18 | raw_data = parser.pull 19 | event = unnormalize(raw_data) 20 | case event[0] 21 | when :end_document 22 | break 23 | when :end_doctype, :start_doctype 24 | # do nothing 25 | when :start_element 26 | stack.push Nori::XMLUtilityNode.new(options, event[1], event[2]) 27 | when :end_element 28 | if stack.size > 1 29 | temp = stack.pop 30 | stack.last.add_node(temp) 31 | end 32 | when :text 33 | stack.last.add_node(event[1]) unless event[1].strip.length == 0 || stack.empty? 34 | when :cdata 35 | stack.last.add_node(raw_data[1]) unless raw_data[1].strip.length == 0 || stack.empty? 36 | end 37 | end 38 | stack.length > 0 ? stack.pop.to_hash : {} 39 | end 40 | 41 | def self.unnormalize(event) 42 | event.map do |el| 43 | if el.is_a?(String) 44 | ::REXML::Text.unnormalize(el) 45 | elsif el.is_a?(Hash) 46 | el.each {|k,v| el[k] = ::REXML::Text.unnormalize(v)} 47 | else 48 | el 49 | end 50 | end 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/nori/string_io_file.rb: -------------------------------------------------------------------------------- 1 | class Nori 2 | class StringIOFile < StringIO 3 | 4 | attr_accessor :original_filename, :content_type 5 | 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/nori/string_utils.rb: -------------------------------------------------------------------------------- 1 | class Nori 2 | module StringUtils 3 | # Converts a string to snake case. 4 | # 5 | # @param inputstring [String] The string to be converted to snake case. 6 | # @return [String] A copy of the input string converted to snake case. 7 | def self.snakecase(inputstring) 8 | str = inputstring.dup 9 | str.gsub!(/::/, '/') 10 | str.gsub!(/([A-Z]+)([A-Z][a-z])/, '\1_\2') 11 | str.gsub!(/([a-z\d])([A-Z])/, '\1_\2') 12 | str.tr!(".", "_") 13 | str.tr!("-", "_") 14 | str.downcase! 15 | str 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/nori/string_with_attributes.rb: -------------------------------------------------------------------------------- 1 | class Nori 2 | class StringWithAttributes < String 3 | 4 | attr_accessor :attributes 5 | 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/nori/version.rb: -------------------------------------------------------------------------------- 1 | class Nori 2 | VERSION = '2.7.1' 3 | end 4 | -------------------------------------------------------------------------------- /lib/nori/xml_utility_node.rb: -------------------------------------------------------------------------------- 1 | require "date" 2 | require "time" 3 | require "yaml" 4 | require "bigdecimal" 5 | 6 | require "nori/string_with_attributes" 7 | require "nori/string_io_file" 8 | 9 | class Nori 10 | 11 | # This is a slighly modified version of the XMLUtilityNode from 12 | # http://merb.devjavu.com/projects/merb/ticket/95 (has.sox@gmail.com) 13 | # 14 | # John Nunemaker: 15 | # It's mainly just adding vowels, as I ht cd wth n vwls :) 16 | # This represents the hard part of the work, all I did was change the 17 | # underlying parser. 18 | class XMLUtilityNode 19 | 20 | # Simple xs:time Regexp. 21 | # Valid xs:time formats 22 | # 13:20:00 1:20 PM 23 | # 13:20:30.5555 1:20 PM and 30.5555 seconds 24 | # 13:20:00-05:00 1:20 PM, US Eastern Standard Time 25 | # 13:20:00+02:00 1:20 PM, Central European Standard Time 26 | # 13:20:00Z 1:20 PM, Coordinated Universal Time (UTC) 27 | # 13:20:30.5555Z 1:20 PM and 30.5555 seconds, Coordinated Universal Time (UTC) 28 | # 00:00:00 midnight 29 | # 24:00:00 midnight 30 | 31 | XS_TIME = /^\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/ 32 | 33 | # Simple xs:date Regexp. 34 | # Valid xs:date formats 35 | # 2004-04-12 April 12, 2004 36 | # -0045-01-01 January 1, 45 BC 37 | # 12004-04-12 April 12, 12004 38 | # 2004-04-12-05:00 April 12, 2004, US Eastern Standard Time, which is 5 hours behind Coordinated Universal Time (UTC) 39 | # 2004-04-12+02:00 April 12, 2004, Central European Summer Time, which is 2 hours ahead of Coordinated Universal Time (UTC) 40 | # 2004-04-12Z April 12, 2004, Coordinated Universal Time (UTC) 41 | 42 | XS_DATE = /^-?\d{4}-\d{2}-\d{2}(?:Z|[+-]\d{2}:?\d{2})?$/ 43 | 44 | # Simple xs:dateTime Regexp. 45 | # Valid xs:dateTime formats 46 | # 2004-04-12T13:20:00 1:20 pm on April 12, 2004 47 | # 2004-04-12T13:20:15.5 1:20 pm and 15.5 seconds on April 12, 2004 48 | # 2004-04-12T13:20:00-05:00 1:20 pm on April 12, 2004, US Eastern Standard Time 49 | # 2004-04-12T13:20:00+02:00 1:20 pm on April 12, 2004, Central European Summer Time 50 | # 2004-04-12T13:20:15.5-05:00 1:20 pm and 15.5 seconds on April 12, 2004, US Eastern Standard Time 51 | # 2004-04-12T13:20:00Z 1:20 pm on April 12, 2004, Coordinated Universal Time (UTC) 52 | # 2004-04-12T13:20:15.5Z 1:20 pm and 15.5 seconds on April 12, 2004, Coordinated Universal Time (UTC) 53 | 54 | XS_DATE_TIME = /^-?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/ 55 | 56 | def self.typecasts 57 | @@typecasts 58 | end 59 | 60 | def self.typecasts=(obj) 61 | @@typecasts = obj 62 | end 63 | 64 | def self.available_typecasts 65 | @@available_typecasts 66 | end 67 | 68 | def self.available_typecasts=(obj) 69 | @@available_typecasts = obj 70 | end 71 | 72 | self.typecasts = {} 73 | self.typecasts["integer"] = lambda { |v| v.nil? ? nil : v.to_i } 74 | self.typecasts["boolean"] = lambda { |v| v.nil? ? nil : (v.strip != "false") } 75 | self.typecasts["datetime"] = lambda { |v| v.nil? ? nil : Time.parse(v).utc } 76 | self.typecasts["date"] = lambda { |v| v.nil? ? nil : Date.parse(v) } 77 | self.typecasts["dateTime"] = lambda { |v| v.nil? ? nil : Time.parse(v).utc } 78 | self.typecasts["decimal"] = lambda { |v| v.nil? ? nil : BigDecimal(v.to_s) } 79 | self.typecasts["double"] = lambda { |v| v.nil? ? nil : v.to_f } 80 | self.typecasts["float"] = lambda { |v| v.nil? ? nil : v.to_f } 81 | self.typecasts["string"] = lambda { |v| v.to_s } 82 | self.typecasts["base64Binary"] = lambda { |v| v.unpack('m').first } 83 | 84 | self.available_typecasts = self.typecasts.keys 85 | 86 | def initialize(options, name, attributes = {}) 87 | @options = options 88 | @name = Nori.hash_key(name, options) 89 | 90 | if converter = options[:convert_attributes_to] 91 | intermediate = attributes.map {|k, v| converter.call(k, v) }.flatten 92 | attributes = Hash[*intermediate] 93 | end 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"] 97 | 98 | @nil_element = false 99 | attributes.keys.each do |key| 100 | if result = /^((.*):)?nil$/.match(key) 101 | @nil_element = attributes.delete(key) == "true" 102 | attributes.delete("xmlns:#{result[2]}") if result[1] 103 | end 104 | attributes.delete(key) if @options[:delete_namespace_attributes] && key[/^(xmlns|xsi)/] 105 | end 106 | @attributes = undasherize_keys(attributes) 107 | @children = [] 108 | @text = false 109 | end 110 | 111 | attr_accessor :name, :attributes, :children, :type 112 | 113 | def prefixed_attributes 114 | attributes.inject({}) do |memo, (key, value)| 115 | memo[prefixed_attribute_name("@#{key}")] = value 116 | memo 117 | end 118 | end 119 | 120 | def prefixed_attribute_name(attribute) 121 | return attribute unless @options[:convert_tags_to].respond_to? :call 122 | @options[:convert_tags_to].call(attribute) 123 | end 124 | 125 | def add_node(node) 126 | @text = true if node.is_a? String 127 | @children << node 128 | end 129 | 130 | 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] 141 | 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 183 | end 184 | 185 | # Typecasts a value based upon its type. For instance, if 186 | # +node+ has #type == "integer", 187 | # {{[node.typecast_value("12") #=> 12]}} 188 | # 189 | # @param value The value that is being typecast. 190 | # 191 | # @details [:type options] 192 | # "integer":: 193 | # converts +value+ to an integer with #to_i 194 | # "boolean":: 195 | # checks whether +value+, after removing spaces, is the literal 196 | # "true" 197 | # "datetime":: 198 | # Parses +value+ using Time.parse, and returns a UTC Time 199 | # "date":: 200 | # Parses +value+ using Date.parse 201 | # 202 | # @return 203 | # The result of typecasting +value+. 204 | # 205 | # @note 206 | # If +self+ does not have a "type" key, or if it's not one of the 207 | # options specified above, the raw +value+ will be returned. 208 | def typecast_value(value) 209 | return value unless @type 210 | proc = self.class.typecasts[@type] 211 | proc.nil? ? value : proc.call(value) 212 | end 213 | 214 | def advanced_typecasting(value) 215 | split = value.split 216 | return value if split.size > 1 217 | 218 | case split.first 219 | when "true" then true 220 | when "false" then false 221 | when XS_DATE_TIME then try_to_convert(value) {|x| DateTime.parse(x)} 222 | when XS_DATE then try_to_convert(value) {|x| Date.parse(x)} 223 | when XS_TIME then try_to_convert(value) {|x| Time.parse(x)} 224 | else value 225 | end 226 | end 227 | 228 | # Take keys of the form foo-bar and convert them to foo_bar 229 | def undasherize_keys(params) 230 | params.keys.each do |key, value| 231 | params[key.tr("-", "_")] = params.delete(key) 232 | end 233 | params 234 | end 235 | 236 | # Get the inner_html of the REXML node. 237 | def inner_html 238 | @children.join 239 | end 240 | 241 | # Converts the node into a readable HTML node. 242 | # 243 | # @return The HTML node in text form. 244 | def to_html 245 | attributes.merge!(:type => @type ) if @type 246 | "<#{name}#{attributes.to_xml_attributes}>#{@nil_element ? '' : inner_html}" 247 | end 248 | alias to_s to_html 249 | 250 | private 251 | def try_to_convert(value, &block) 252 | block.call(value) 253 | rescue ArgumentError 254 | value 255 | end 256 | 257 | def strip_namespace(string) 258 | string.split(":").last 259 | end 260 | end 261 | end 262 | -------------------------------------------------------------------------------- /nori.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "nori/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "nori" 7 | s.version = Nori::VERSION 8 | s.authors = ["Daniel Harrington", "John Nunemaker", "Wynn Netherland"] 9 | s.email = "me@rubiii.com" 10 | s.homepage = "https://github.com/savonrb/nori" 11 | s.summary = "XML to Hash translator" 12 | s.description = s.summary 13 | s.required_ruby_version = '>= 3.0' 14 | 15 | s.license = "MIT" 16 | 17 | s.add_dependency "bigdecimal" 18 | 19 | s.add_development_dependency "rake", "~> 12.3.3" 20 | s.add_development_dependency "nokogiri", ">= 1.4.0" 21 | s.add_development_dependency "rspec", "~> 3.11.0" 22 | 23 | s.metadata["rubygems_mfa_required"] = "true" 24 | 25 | s.files = `git ls-files`.split("\n") 26 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 27 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 28 | s.require_paths = ["lib"] 29 | end 30 | -------------------------------------------------------------------------------- /spec/nori/api_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Nori do 4 | 5 | describe "PARSERS" do 6 | it "should return a Hash of parser details" do 7 | expect(Nori::PARSERS).to eq({ :rexml => "REXML", :nokogiri => "Nokogiri" }) 8 | end 9 | end 10 | 11 | context ".new" do 12 | it "defaults to not strip any namespace identifiers" do 13 | xml = <<-XML 14 | 15 | a_case 16 | 17 | XML 18 | 19 | expect(nori.parse(xml)["history"]["ns10:case"]).to eq("a_case") 20 | end 21 | 22 | it "defaults to not change XML tags" do 23 | xml = 'active' 24 | expect(nori.parse(xml)).to eq({ "userResponse" => { "@id" => "1", "accountStatus" => "active" } }) 25 | end 26 | 27 | it "raises when passed unknown global options" do 28 | expect { Nori.new(:invalid => true) }. 29 | to raise_error(ArgumentError, /Spurious options: \[:invalid\]/) 30 | end 31 | end 32 | 33 | context ".new with :strip_namespaces" do 34 | it "strips the namespace identifiers when set to true" do 35 | xml = '' 36 | expect(nori(:strip_namespaces => true).parse(xml)).to have_key("Envelope") 37 | end 38 | 39 | it "still converts namespaced entries to array elements" do 40 | xml = <<-XML 41 | 44 | a_name 45 | another_name 46 | 47 | XML 48 | 49 | expected = [{ "name" => "a_name" }, { "name" => "another_name" }] 50 | expect(nori(:strip_namespaces => true).parse(xml)["history"]["case"]).to eq(expected) 51 | end 52 | end 53 | 54 | context ".new with :convert_tags_to" do 55 | it "converts all tags by a given formula" do 56 | xml = 'active' 57 | 58 | snakecase_symbols = lambda { |tag| Nori::StringUtils.snakecase(tag).to_sym } 59 | nori = nori(:convert_tags_to => snakecase_symbols) 60 | 61 | expect(nori.parse(xml)).to eq({ :user_response => { :@id => "1", :account_status => "active" } }) 62 | end 63 | end 64 | 65 | context '#find' do 66 | before do 67 | upcase = lambda { |tag| tag.upcase } 68 | @nori = nori(:convert_tags_to => upcase) 69 | 70 | xml = 'active' 71 | @hash = @nori.parse(xml) 72 | end 73 | 74 | it 'returns the Hash when the path is empty' do 75 | result = @nori.find(@hash) 76 | expect(result).to eq("USERRESPONSE" => { "ACCOUNTSTATUS" => "active", "@ID" => "1" }) 77 | end 78 | 79 | it 'returns the result for a single key' do 80 | result = @nori.find(@hash, 'userResponse') 81 | expect(result).to eq("ACCOUNTSTATUS" => "active", "@ID" => "1") 82 | end 83 | 84 | it 'returns the result for nested keys' do 85 | result = @nori.find(@hash, 'userResponse', 'accountStatus') 86 | expect(result).to eq("active") 87 | end 88 | 89 | it 'strips the namespaces from Hash keys' do 90 | xml = 'active' 91 | hash = @nori.parse(xml) 92 | 93 | result = @nori.find(hash, 'userResponse', 'accountStatus') 94 | expect(result).to eq("active") 95 | end 96 | end 97 | 98 | context "#parse" do 99 | it "defaults to use advanced typecasting" do 100 | hash = nori.parse("true") 101 | expect(hash["value"]).to eq(true) 102 | end 103 | 104 | it "defaults to use the Nokogiri parser" do 105 | # parsers are loaded lazily by default 106 | require "nori/parser/nokogiri" 107 | 108 | expect(Nori::Parser::Nokogiri).to receive(:parse).and_return({}) 109 | nori.parse("thing") 110 | end 111 | end 112 | 113 | context "#parse without :advanced_typecasting" do 114 | it "can be changed to not typecast too much" do 115 | hash = nori(:advanced_typecasting => false).parse("true") 116 | expect(hash["value"]).to eq("true") 117 | end 118 | end 119 | 120 | context "#parse with :parser" do 121 | it "can be configured to use the REXML parser" do 122 | # parsers are loaded lazily by default 123 | require "nori/parser/rexml" 124 | 125 | expect(Nori::Parser::REXML).to receive(:parse).and_return({}) 126 | nori(:parser => :rexml).parse("thing") 127 | end 128 | end 129 | 130 | context "#parse without :delete_namespace_attributes" do 131 | it "can be changed to not delete xmlns attributes" do 132 | xml = 'active' 133 | hash = nori(:delete_namespace_attributes => false).parse(xml) 134 | expect(hash).to eq({"userResponse" => {"@xmlns" => "http://schema.company.com/some/path/to/namespace/v1", "accountStatus" => "active"}}) 135 | end 136 | 137 | it "can be changed to not delete xsi attributes" do 138 | xml = 'active' 139 | hash = nori(:delete_namespace_attributes => false).parse(xml) 140 | expect(hash).to eq({"userResponse" => {"@xsi" => "abc:myType", "accountStatus" => "active"}}) 141 | end 142 | end 143 | 144 | context "#parse with :delete_namespace_attributes" do 145 | it "can be changed to delete xmlns attributes" do 146 | xml = 'active' 147 | hash = nori(:delete_namespace_attributes => true).parse(xml) 148 | expect(hash).to eq({"userResponse" => {"accountStatus" => "active"}}) 149 | end 150 | 151 | it "can be changed to delete xsi attributes" do 152 | xml = 'active' 153 | hash = nori(:delete_namespace_attributes => true).parse(xml) 154 | expect(hash).to eq({"userResponse" => {"accountStatus" => "active"}}) 155 | end 156 | end 157 | 158 | context "#parse with :convert_dashes_to_underscores" do 159 | it "can be configured to skip dash to underscore conversion" do 160 | xml = 'foo bar' 161 | hash = nori(:convert_dashes_to_underscores => false).parse(xml) 162 | expect(hash).to eq({'any-tag' => 'foo bar'}) 163 | end 164 | end 165 | 166 | context "#parse with :empty_tag_value set to empty string" do 167 | it "can be configured to convert empty tags to given value" do 168 | xml = "" 169 | hash = nori(:empty_tag_value => "").parse(xml) 170 | expect(hash).to eq("parentTag" => { "tag" => "" }) 171 | end 172 | end 173 | 174 | def nori(options = {}) 175 | Nori.new(options) 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /spec/nori/core_ext/hash_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Hash do 4 | 5 | describe "#normalize_param" do 6 | it "should have specs" 7 | end 8 | 9 | describe "#to_xml_attributes" do 10 | 11 | it "should turn the hash into xml attributes" do 12 | attrs = { :one => "ONE", "two" => "TWO" }.to_xml_attributes 13 | expect(attrs).to match(/one="ONE"/m) 14 | expect(attrs).to match(/two="TWO"/m) 15 | end 16 | 17 | it "should preserve _ in hash keys" do 18 | attrs = { 19 | :some_long_attribute => "with short value", 20 | :crash => :burn, 21 | :merb => "uses extlib" 22 | }.to_xml_attributes 23 | 24 | expect(attrs).to match(/some_long_attribute="with short value"/) 25 | expect(attrs).to match(/merb="uses extlib"/) 26 | expect(attrs).to match(/crash="burn"/) 27 | end 28 | end 29 | 30 | end 31 | -------------------------------------------------------------------------------- /spec/nori/nori_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Nori do 4 | 5 | Nori::PARSERS.each do |parser, class_name| 6 | context "using the :#{parser} parser" do 7 | 8 | let(:parser) { parser } 9 | 10 | it "should work with unnormalized characters" do 11 | xml = '&' 12 | expect(parse(xml)).to eq({ 'root' => "&" }) 13 | end 14 | 15 | it "should transform a simple tag with content" do 16 | xml = "This is the contents" 17 | expect(parse(xml)).to eq({ 'tag' => 'This is the contents' }) 18 | end 19 | 20 | it "should work with cdata tags" do 21 | xml = <<-END 22 | 23 | 26 | 27 | END 28 | expect(parse(xml)["tag"].strip).to eq("text inside cdata < test") 29 | end 30 | 31 | it "should scrub bad characters" do 32 | xml = "a\xfbc".force_encoding('UTF-8') 33 | expect(parse(xml)["tag"]).to eq("a\uFFFDc") 34 | end 35 | 36 | it "should transform a simple tag with attributes" do 37 | xml = "" 38 | hash = { 'tag' => { '@attr1' => '1', '@attr2' => '2' } } 39 | expect(parse(xml)).to eq(hash) 40 | end 41 | 42 | it "should transform repeating siblings into an array" do 43 | xml =<<-XML 44 | 45 | 46 | 47 | 48 | XML 49 | 50 | expect(parse(xml)['opt']['user'].class).to eq(Array) 51 | 52 | hash = { 53 | 'opt' => { 54 | 'user' => [{ 55 | '@login' => 'grep', 56 | '@fullname' => 'Gary R Epstein' 57 | },{ 58 | '@login' => 'stty', 59 | '@fullname' => 'Simon T Tyson' 60 | }] 61 | } 62 | } 63 | 64 | expect(parse(xml)).to eq(hash) 65 | end 66 | 67 | it "should not transform non-repeating siblings into an array" do 68 | xml =<<-XML 69 | 70 | 71 | 72 | XML 73 | 74 | expect(parse(xml)['opt']['user'].class).to eq(Hash) 75 | 76 | hash = { 77 | 'opt' => { 78 | 'user' => { 79 | '@login' => 'grep', 80 | '@fullname' => 'Gary R Epstein' 81 | } 82 | } 83 | } 84 | 85 | expect(parse(xml)).to eq(hash) 86 | end 87 | 88 | it "should prefix attributes with an @-sign to avoid problems with overwritten values" do 89 | xml =<<-XML 90 | 91 | grep 92 | 76737 93 | 94 | XML 95 | 96 | expect(parse(xml)["multiRef"]).to eq({ "login" => "grep", "@id" => "id1", "id" => "76737" }) 97 | end 98 | 99 | context "without advanced typecasting" do 100 | it "should not transform 'true'" do 101 | hash = parse("true", :advanced_typecasting => false) 102 | expect(hash["value"]).to eq("true") 103 | end 104 | 105 | it "should not transform 'false'" do 106 | hash = parse("false", :advanced_typecasting => false) 107 | expect(hash["value"]).to eq("false") 108 | end 109 | 110 | it "should not transform Strings matching the xs:time format" do 111 | hash = parse("09:33:55Z", :advanced_typecasting => false) 112 | expect(hash["value"]).to eq("09:33:55Z") 113 | end 114 | 115 | it "should not transform Strings matching the xs:date format" do 116 | hash = parse("1955-04-18-05:00", :advanced_typecasting => false) 117 | expect(hash["value"]).to eq("1955-04-18-05:00") 118 | end 119 | 120 | it "should not transform Strings matching the xs:dateTime format" do 121 | hash = parse("1955-04-18T11:22:33-05:00", :advanced_typecasting => false) 122 | expect(hash["value"]).to eq("1955-04-18T11:22:33-05:00") 123 | end 124 | end 125 | 126 | context "with advanced typecasting" do 127 | it "should transform 'true' to TrueClass" do 128 | expect(parse("true")["value"]).to eq(true) 129 | end 130 | 131 | it "should transform 'false' to FalseClass" do 132 | expect(parse("false")["value"]).to eq(false) 133 | end 134 | 135 | it "should transform Strings matching the xs:time format to Time objects" do 136 | expect(parse("09:33:55.7Z")["value"]).to eq(Time.parse("09:33:55.7Z")) 137 | end 138 | 139 | it "should transform Strings matching the xs:time format ahead of utc to Time objects" do 140 | expect(parse("09:33:55+02:00")["value"]).to eq(Time.parse("09:33:55+02:00")) 141 | end 142 | 143 | it "should transform Strings matching the xs:date format to Date objects" do 144 | expect(parse("1955-04-18-05:00")["value"]).to eq(Date.parse("1955-04-18-05:00")) 145 | end 146 | 147 | it "should transform Strings matching the xs:dateTime format ahead of utc to Date objects" do 148 | expect(parse("1955-04-18+02:00")["value"]).to eq(Date.parse("1955-04-18+02:00")) 149 | end 150 | 151 | it "should transform Strings matching the xs:dateTime format to DateTime objects" do 152 | expect(parse("1955-04-18T11:22:33.5Z")["value"]).to eq( 153 | DateTime.parse("1955-04-18T11:22:33.5Z") 154 | ) 155 | end 156 | 157 | it "should transform Strings matching the xs:dateTime format ahead of utc to DateTime objects" do 158 | expect(parse("1955-04-18T11:22:33+02:00")["value"]).to eq( 159 | DateTime.parse("1955-04-18T11:22:33+02:00") 160 | ) 161 | end 162 | 163 | it "should transform Strings matching the xs:dateTime format with seconds and an offset to DateTime objects" do 164 | expect(parse("2004-04-12T13:20:15.5-05:00")["value"]).to eq( 165 | DateTime.parse("2004-04-12T13:20:15.5-05:00") 166 | ) 167 | end 168 | 169 | it "should not transform Strings containing an xs:time String and more" do 170 | expect(parse("09:33:55Z is a time")["value"]).to eq("09:33:55Z is a time") 171 | expect(parse("09:33:55Z_is_a_file_name")["value"]).to eq("09:33:55Z_is_a_file_name") 172 | end 173 | 174 | it "should not transform Strings containing an xs:date String and more" do 175 | expect(parse("1955-04-18-05:00 is a date")["value"]).to eq("1955-04-18-05:00 is a date") 176 | expect(parse("1955-04-18-05:00_is_a_file_name")["value"]).to eq("1955-04-18-05:00_is_a_file_name") 177 | end 178 | 179 | it "should not transform Strings containing an xs:dateTime String and more" do 180 | expect(parse("1955-04-18T11:22:33-05:00 is a dateTime")["value"]).to eq( 181 | "1955-04-18T11:22:33-05:00 is a dateTime" 182 | ) 183 | expect(parse("1955-04-18T11:22:33-05:00_is_a_file_name")["value"]).to eq( 184 | "1955-04-18T11:22:33-05:00_is_a_file_name" 185 | ) 186 | end 187 | 188 | ["00-00-00", "0000-00-00", "0000-00-00T00:00:00", "0569-23-0141", "DS2001-19-1312654773", "e6:53:01:00:ce:b4:06"].each do |date_string| 189 | it "should not transform a String like '#{date_string}' to date or time" do 190 | expect(parse("#{date_string}")["value"]).to eq(date_string) 191 | end 192 | end 193 | end 194 | 195 | context "Parsing xml with text and attributes" do 196 | before do 197 | xml =<<-XML 198 | 199 | Gary R Epstein 200 | Simon T Tyson 201 | 202 | XML 203 | @data = parse(xml) 204 | end 205 | 206 | it "correctly parse text nodes" do 207 | expect(@data).to eq({ 208 | 'opt' => { 209 | 'user' => [ 210 | 'Gary R Epstein', 211 | 'Simon T Tyson' 212 | ] 213 | } 214 | }) 215 | end 216 | 217 | it "parses attributes for text node if present" do 218 | expect(@data['opt']['user'][0].attributes).to eq({'login' => 'grep'}) 219 | end 220 | 221 | it "default attributes to empty hash if not present" do 222 | expect(@data['opt']['user'][1].attributes).to eq({}) 223 | end 224 | 225 | it "add 'attributes' accessor methods to parsed instances of String" do 226 | expect(@data['opt']['user'][0]).to respond_to(:attributes) 227 | expect(@data['opt']['user'][0]).to respond_to(:attributes=) 228 | end 229 | 230 | it "not add 'attributes' accessor methods to all instances of String" do 231 | expect("some-string").not_to respond_to(:attributes) 232 | expect("some-string").not_to respond_to(:attributes=) 233 | end 234 | end 235 | 236 | it "should typecast an integer" do 237 | xml = "10" 238 | expect(parse(xml)['tag']).to eq(10) 239 | end 240 | 241 | it "should typecast a true boolean" do 242 | xml = "true" 243 | expect(parse(xml)['tag']).to be(true) 244 | end 245 | 246 | it "should typecast a false boolean" do 247 | ["false"].each do |w| 248 | expect(parse("#{w}")['tag']).to be(false) 249 | end 250 | end 251 | 252 | it "should typecast a datetime" do 253 | xml = "2007-12-31 10:32" 254 | expect(parse(xml)['tag']).to eq(Time.parse( '2007-12-31 10:32' ).utc) 255 | end 256 | 257 | it "should typecast a date" do 258 | xml = "2007-12-31" 259 | expect(parse(xml)['tag']).to eq(Date.parse('2007-12-31')) 260 | end 261 | 262 | xml_entities = { 263 | "<" => "<", 264 | ">" => ">", 265 | '"' => """, 266 | "'" => "'", 267 | "&" => "&" 268 | } 269 | 270 | it "should unescape html entities" do 271 | xml_entities.each do |k,v| 272 | xml = "Some content #{v}" 273 | expect(parse(xml)['tag']).to match(Regexp.new(k)) 274 | end 275 | end 276 | 277 | it "should unescape XML entities in attributes" do 278 | xml_entities.each do |key, value| 279 | xml = "" 280 | expect(parse(xml)['tag']['@attr']).to match(Regexp.new(key)) 281 | end 282 | end 283 | 284 | it "should undasherize keys as tags" do 285 | xml = "Stuff" 286 | expect(parse(xml).keys).to include('tag_1') 287 | end 288 | 289 | it "should undasherize keys as attributes" do 290 | xml = "" 291 | expect(parse(xml)['tag1'].keys).to include('@attr_1') 292 | end 293 | 294 | it "should undasherize keys as tags and attributes" do 295 | xml = "" 296 | expect(parse(xml).keys).to include('tag_1') 297 | expect(parse(xml)['tag_1'].keys).to include('@attr_1') 298 | end 299 | 300 | it "should render nested content correctly" do 301 | xml = "Tag1 Content This is strong" 302 | expect(parse(xml)['root']['tag1']).to eq("Tag1 Content This is strong") 303 | end 304 | 305 | it "should render nested content with text nodes correctly" do 306 | xml = "Tag1 ContentStuff Hi There" 307 | expect(parse(xml)['root']).to eq("Tag1 ContentStuff Hi There") 308 | end 309 | 310 | it "should ignore attributes when a child is a text node" do 311 | xml = "Stuff" 312 | expect(parse(xml)).to eq({ "root" => "Stuff" }) 313 | end 314 | 315 | it "should ignore attributes when any child is a text node" do 316 | xml = "Stuff in italics" 317 | expect(parse(xml)).to eq({ "root" => "Stuff in italics" }) 318 | end 319 | 320 | it "should correctly transform multiple children" do 321 | xml = <<-XML 322 | 323 | 35 324 | Home Simpson 325 | 1988-01-01 326 | 2000-04-28 23:01 327 | true 328 | 329 | XML 330 | 331 | hash = { 332 | "user" => { 333 | "@gender" => "m", 334 | "age" => 35, 335 | "name" => "Home Simpson", 336 | "dob" => Date.parse('1988-01-01'), 337 | "joined_at" => Time.parse("2000-04-28 23:01"), 338 | "is_cool" => true 339 | } 340 | } 341 | 342 | expect(parse(xml)).to eq(hash) 343 | end 344 | 345 | it "should properly handle nil values (ActiveSupport Compatible)" do 346 | topic_xml = <<-EOT 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | EOT 359 | 360 | expected_topic_hash = { 361 | 'title' => nil, 362 | 'id' => nil, 363 | 'approved' => nil, 364 | 'written_on' => nil, 365 | 'viewed_at' => nil, 366 | # don't execute arbitary YAML code 367 | 'content' => { "@type" => "yaml" }, 368 | 'parent_id' => nil, 369 | 'nil_true' => nil, 370 | 'namespaced' => nil 371 | } 372 | expect(parse(topic_xml)["topic"]).to eq(expected_topic_hash) 373 | end 374 | 375 | it "should handle a single record from xml (ActiveSupport Compatible)" do 376 | topic_xml = <<-EOT 377 | 378 | The First Topic 379 | David 380 | 1 381 | true 382 | 0 383 | 2592000000 384 | 2003-07-16 385 | 2003-07-16T09:28:00+0000 386 | --- \n1: should be an integer\n:message: Have a nice day\narray: \n- should-have-dashes: true\n should_have_underscores: true 387 | david@loudthinking.com 388 | 389 | 1.5 390 | 135 391 | yes 392 | 393 | EOT 394 | 395 | expected_topic_hash = { 396 | 'title' => "The First Topic", 397 | 'author_name' => "David", 398 | 'id' => 1, 399 | 'approved' => true, 400 | 'replies_count' => 0, 401 | 'replies_close_in' => 2592000000, 402 | 'written_on' => Date.new(2003, 7, 16), 403 | 'viewed_at' => Time.utc(2003, 7, 16, 9, 28), 404 | # Changed this line where the key is :message. The yaml specifies this as a symbol, and who am I to change what you specify 405 | # The line in ActiveSupport is 406 | # 'content' => { 'message' => "Have a nice day", 1 => "should be an integer", "array" => [{ "should-have-dashes" => true, "should_have_underscores" => true }] }, 407 | 'content' => "--- \n1: should be an integer\n:message: Have a nice day\narray: \n- should-have-dashes: true\n should_have_underscores: true", 408 | 'author_email_address' => "david@loudthinking.com", 409 | 'parent_id' => nil, 410 | 'ad_revenue' => BigDecimal("1.50"), 411 | 'optimum_viewing_angle' => 135.0, 412 | # don't create symbols from arbitary remote code 413 | 'resident' => "yes" 414 | } 415 | 416 | parse(topic_xml)["topic"].each do |k,v| 417 | expect(v).to eq(expected_topic_hash[k]) 418 | end 419 | end 420 | 421 | it "should handle multiple records (ActiveSupport Compatible)" do 422 | topics_xml = <<-EOT 423 | 424 | 425 | The First Topic 426 | David 427 | 1 428 | false 429 | 0 430 | 2592000000 431 | 2003-07-16 432 | 2003-07-16T09:28:00+0000 433 | Have a nice day 434 | david@loudthinking.com 435 | 436 | 437 | 438 | The Second Topic 439 | Jason 440 | 1 441 | false 442 | 0 443 | 2592000000 444 | 2003-07-16 445 | 2003-07-16T09:28:00+0000 446 | Have a nice day 447 | david@loudthinking.com 448 | 449 | 450 | 451 | EOT 452 | 453 | expected_topic_hash = { 454 | 'title' => "The First Topic", 455 | 'author_name' => "David", 456 | 'id' => 1, 457 | 'approved' => false, 458 | 'replies_count' => 0, 459 | 'replies_close_in' => 2592000000, 460 | 'written_on' => Date.new(2003, 7, 16), 461 | 'viewed_at' => Time.utc(2003, 7, 16, 9, 28), 462 | 'content' => "Have a nice day", 463 | 'author_email_address' => "david@loudthinking.com", 464 | 'parent_id' => nil 465 | } 466 | 467 | # puts Nori.parse(topics_xml)['topics'].first.inspect 468 | parse(topics_xml)["topics"].first.each do |k,v| 469 | expect(v).to eq(expected_topic_hash[k]) 470 | end 471 | end 472 | 473 | context "with convert_attributes_to set to a custom formula" do 474 | it "alters attributes and values" do 475 | converter = lambda {|key, value| ["#{key}_k", "#{value}_v"] } 476 | xml = <<-XML 477 | 21 478 | XML 479 | 480 | expect(parse(xml, :convert_attributes_to => converter)).to eq({'user' => {'@name_k' => 'value_v', 'age' => '21'}}) 481 | end 482 | end 483 | 484 | it "should handle a single record from_xml with attributes other than type (ActiveSupport Compatible)" do 485 | topic_xml = <<-EOT 486 | 487 | 488 | 489 | 490 | 491 | EOT 492 | 493 | expected_topic_hash = { 494 | '@id' => "175756086", 495 | '@owner' => "55569174@N00", 496 | '@secret' => "0279bf37a1", 497 | '@server' => "76", 498 | '@title' => "Colored Pencil PhotoBooth Fun", 499 | '@ispublic' => "1", 500 | '@isfriend' => "0", 501 | '@isfamily' => "0", 502 | } 503 | 504 | parse(topic_xml)["rsp"]["photos"]["photo"].each do |k, v| 505 | expect(v).to eq(expected_topic_hash[k]) 506 | end 507 | end 508 | 509 | it "should handle an emtpy array (ActiveSupport Compatible)" do 510 | blog_xml = <<-XML 511 | 512 | 513 | 514 | XML 515 | expected_blog_hash = {"blog" => {"posts" => []}} 516 | expect(parse(blog_xml)).to eq(expected_blog_hash) 517 | end 518 | 519 | it "should handle empty array with whitespace from xml (ActiveSupport Compatible)" do 520 | blog_xml = <<-XML 521 | 522 | 523 | 524 | 525 | XML 526 | expected_blog_hash = {"blog" => {"posts" => []}} 527 | expect(parse(blog_xml)).to eq(expected_blog_hash) 528 | end 529 | 530 | it "should handle array with one entry from_xml (ActiveSupport Compatible)" do 531 | blog_xml = <<-XML 532 | 533 | 534 | a post 535 | 536 | 537 | XML 538 | expected_blog_hash = {"blog" => {"posts" => ["a post"]}} 539 | expect(parse(blog_xml)).to eq(expected_blog_hash) 540 | end 541 | 542 | it "should handle array with multiple entries from xml (ActiveSupport Compatible)" do 543 | blog_xml = <<-XML 544 | 545 | 546 | a post 547 | another post 548 | 549 | 550 | XML 551 | expected_blog_hash = {"blog" => {"posts" => ["a post", "another post"]}} 552 | expect(parse(blog_xml)).to eq(expected_blog_hash) 553 | end 554 | 555 | it "should handle file types (ActiveSupport Compatible)" do 556 | blog_xml = <<-XML 557 | 558 | 559 | 560 | 561 | XML 562 | hash = parse(blog_xml) 563 | expect(hash.keys).to include('blog') 564 | expect(hash['blog'].keys).to include('logo') 565 | 566 | file = hash['blog']['logo'] 567 | expect(file.original_filename).to eq('logo.png') 568 | expect(file.content_type).to eq('image/png') 569 | end 570 | 571 | it "should handle file from xml with defaults (ActiveSupport Compatible)" do 572 | blog_xml = <<-XML 573 | 574 | 575 | 576 | 577 | XML 578 | file = parse(blog_xml)['blog']['logo'] 579 | expect(file.original_filename).to eq('untitled') 580 | expect(file.content_type).to eq('application/octet-stream') 581 | end 582 | 583 | it "should handle xsd like types from xml (ActiveSupport Compatible)" do 584 | bacon_xml = <<-EOT 585 | 586 | 0.5 587 | 12.50 588 | 1 589 | 2007-12-25T12:34:56+0000 590 | 591 | YmFiZS5wbmc= 592 | 593 | EOT 594 | 595 | expected_bacon_hash = { 596 | 'weight' => 0.5, 597 | 'chunky' => true, 598 | 'price' => BigDecimal("12.50"), 599 | 'expires_at' => Time.utc(2007,12,25,12,34,56), 600 | 'notes' => "", 601 | 'illustration' => "babe.png" 602 | } 603 | 604 | expect(parse(bacon_xml)["bacon"]).to eq(expected_bacon_hash) 605 | end 606 | 607 | it "should let type trickle through when unknown (ActiveSupport Compatible)" do 608 | product_xml = <<-EOT 609 | 610 | 0.5 611 | image.gif 612 | 613 | 614 | EOT 615 | 616 | expected_product_hash = { 617 | 'weight' => 0.5, 618 | 'image' => {'@type' => 'ProductImage', 'filename' => 'image.gif' }, 619 | } 620 | 621 | expect(parse(product_xml)["product"]).to eq(expected_product_hash) 622 | end 623 | 624 | it "should handle unescaping from xml (ActiveResource Compatible)" do 625 | xml_string = 'First & Last NameFirst &amp; Last Name' 626 | expected_hash = { 627 | 'bare_string' => 'First & Last Name', 628 | 'pre_escaped_string' => 'First & Last Name' 629 | } 630 | 631 | expect(parse(xml_string)['person']).to eq(expected_hash) 632 | end 633 | 634 | it "handle an empty xml string" do 635 | expect(parse('')).to eq({}) 636 | end 637 | 638 | # As returned in the response body by the unfuddle XML API when creating objects 639 | it "handle an xml string containing a single space" do 640 | expect(parse(' ')).to eq({}) 641 | end 642 | 643 | end 644 | end 645 | 646 | def parse(xml, options = {}) 647 | defaults = {:parser => parser} 648 | Nori.new(defaults.merge(options)).parse(xml) 649 | end 650 | end 651 | -------------------------------------------------------------------------------- /spec/nori/string_utils_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Nori::StringUtils do 4 | 5 | describe ".snakecase" do 6 | it "lowercases one word CamelCase" do 7 | expect(Nori::StringUtils.snakecase("Merb")).to eq("merb") 8 | end 9 | 10 | it "makes one underscore snakecase two word CamelCase" do 11 | expect(Nori::StringUtils.snakecase("MerbCore")).to eq("merb_core") 12 | end 13 | 14 | it "handles CamelCase with more than 2 words" do 15 | expect(Nori::StringUtils.snakecase("SoYouWantContributeToMerbCore")).to eq("so_you_want_contribute_to_merb_core") 16 | end 17 | 18 | it "handles CamelCase with more than 2 capital letter in a row" do 19 | expect(Nori::StringUtils.snakecase("CNN")).to eq("cnn") 20 | expect(Nori::StringUtils.snakecase("CNNNews")).to eq("cnn_news") 21 | expect(Nori::StringUtils.snakecase("HeadlineCNNNews")).to eq("headline_cnn_news") 22 | end 23 | 24 | it "does NOT change one word lowercase" do 25 | expect(Nori::StringUtils.snakecase("merb")).to eq("merb") 26 | end 27 | 28 | it "leaves snake_case as is" do 29 | expect(Nori::StringUtils.snakecase("merb_core")).to eq("merb_core") 30 | end 31 | end 32 | 33 | end 34 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler" 2 | Bundler.require :default, :development 3 | --------------------------------------------------------------------------------