xml_node_stream 2.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/VERSION +1 -1
- data/lib/xml_node_stream/http_stream.rb +76 -27
- data/lib/xml_node_stream/parser/libxml_parser.rb +1 -1
- data/lib/xml_node_stream/parser.rb +1 -3
- data/lib/xml_node_stream/selector.rb +27 -28
- data/lib/xml_node_stream.rb +1 -0
- data/xml_node_stream.gemspec +1 -1
- metadata +3 -17
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: abb1e46f305cb09b383415a0fab348290c2058c98282360ceef41b73f0e6a0c6
|
|
4
|
+
data.tar.gz: fa5503fb2b14409bae9c92eadbf0cf525ce683e137709723b85f7622a599a831
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c6059c96f122e4e2312b7283b49a11a27c621833df66e3c3434bd330db838c13264c514b7523863d48f5ea364e9c63b0c7a4b03eeec936e7e15fb034fbb2edaa
|
|
7
|
+
data.tar.gz: 91b44394a4983f916f11ea4366493ed5f04edbbdc6a1ee3ded867d717973592f64a23343fd3cfb88c7fb607ea68f9c0a9b00003ae5fe6ac6d586685a17eb02ed
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## 2.0.1
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Added missing `stringio` require which caused a `NameError` when parsing an XML string on Ruby versions where the standard library no longer loads it implicitly.
|
|
12
|
+
- XPath selectors no longer drop nested elements with the same name (e.g. `.//a/a` now matches `a` elements nested inside other `a` elements).
|
|
13
|
+
- XPath selectors with a leading `//` now correctly search all descendants of the document root instead of only children of the root element.
|
|
14
|
+
- XPath selectors ending a `//` step with `*` (e.g. `book//*`) now match all descendants instead of skipping the first level.
|
|
15
|
+
- The `text()` selector no longer removes duplicate values when different nodes contain identical text.
|
|
16
|
+
- HTTP responses are now streamed incrementally instead of being read entirely into memory before parsing.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- HTTP requests that return an error status now raise `XmlNodeStream::HttpError` instead of parsing the error response body as XML.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- HTTP redirects are now followed (up to 5) when parsing from a URL.
|
|
25
|
+
|
|
7
26
|
## 2.0.0
|
|
8
27
|
|
|
9
28
|
### Changed
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.0.
|
|
1
|
+
2.0.1
|
|
@@ -1,14 +1,30 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "net/http"
|
|
4
|
+
require "fiber"
|
|
4
5
|
|
|
5
6
|
module XmlNodeStream
|
|
7
|
+
# Error raised when an HTTP request returns an unsuccessful response.
|
|
8
|
+
class HttpError < StandardError
|
|
9
|
+
attr_reader :response
|
|
10
|
+
|
|
11
|
+
# @param message [String] the error message
|
|
12
|
+
# @param response [Net::HTTPResponse, nil] the HTTP response that caused the error
|
|
13
|
+
def initialize(message, response = nil)
|
|
14
|
+
super(message)
|
|
15
|
+
@response = response
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
6
19
|
# IO-like wrapper for HTTP responses that allows streaming
|
|
7
20
|
class HttpStream
|
|
8
21
|
# Default timeout values in seconds
|
|
9
22
|
DEFAULT_OPEN_TIMEOUT = 10
|
|
10
23
|
DEFAULT_READ_TIMEOUT = 60
|
|
11
24
|
|
|
25
|
+
# Maximum number of redirects to follow
|
|
26
|
+
MAX_REDIRECTS = 5
|
|
27
|
+
|
|
12
28
|
# Create a new HttpStream.
|
|
13
29
|
#
|
|
14
30
|
# @param uri [URI] the URI to stream from
|
|
@@ -16,15 +32,13 @@ module XmlNodeStream
|
|
|
16
32
|
# @param read_timeout [Integer] read timeout in seconds (default 60)
|
|
17
33
|
def initialize(uri, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT)
|
|
18
34
|
@uri = uri
|
|
19
|
-
@
|
|
20
|
-
@
|
|
21
|
-
@http
|
|
22
|
-
@http.read_timeout = read_timeout
|
|
23
|
-
@request = Net::HTTP::Get.new(uri.request_uri)
|
|
35
|
+
@open_timeout = open_timeout
|
|
36
|
+
@read_timeout = read_timeout
|
|
37
|
+
@http = nil
|
|
24
38
|
@buffer = +""
|
|
25
39
|
@eof = false
|
|
26
40
|
@response = nil
|
|
27
|
-
@
|
|
41
|
+
@fiber = nil
|
|
28
42
|
end
|
|
29
43
|
|
|
30
44
|
# Read data from the stream.
|
|
@@ -108,7 +122,7 @@ module XmlNodeStream
|
|
|
108
122
|
return nil if @buffer.empty?
|
|
109
123
|
|
|
110
124
|
line = @buffer
|
|
111
|
-
@buffer = ""
|
|
125
|
+
@buffer = +""
|
|
112
126
|
line
|
|
113
127
|
end
|
|
114
128
|
|
|
@@ -148,32 +162,67 @@ module XmlNodeStream
|
|
|
148
162
|
private
|
|
149
163
|
|
|
150
164
|
def ensure_response_started
|
|
151
|
-
return if @
|
|
165
|
+
return if @fiber
|
|
166
|
+
|
|
167
|
+
# The request runs inside a fiber so that the response body can be consumed incrementally.
|
|
168
|
+
# Net::HTTP#request with a block streams the body via read_body; each chunk is yielded out
|
|
169
|
+
# of the fiber and pulled on demand by read_chunk without buffering the whole response.
|
|
170
|
+
@fiber = Fiber.new do
|
|
171
|
+
uri = @uri
|
|
172
|
+
redirects = 0
|
|
173
|
+
|
|
174
|
+
loop do
|
|
175
|
+
connect(uri)
|
|
176
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
177
|
+
redirect_location = nil
|
|
178
|
+
|
|
179
|
+
@http.request(request) do |response|
|
|
180
|
+
@response = response
|
|
181
|
+
|
|
182
|
+
case response
|
|
183
|
+
when Net::HTTPRedirection
|
|
184
|
+
redirects += 1
|
|
185
|
+
if redirects > MAX_REDIRECTS
|
|
186
|
+
raise HttpError.new("Too many redirects requesting #{@uri}", response)
|
|
187
|
+
end
|
|
188
|
+
redirect_location = response["Location"]
|
|
189
|
+
unless redirect_location
|
|
190
|
+
raise HttpError.new("Redirect without Location header from #{uri}", response)
|
|
191
|
+
end
|
|
192
|
+
when Net::HTTPSuccess
|
|
193
|
+
response.read_body do |chunk|
|
|
194
|
+
Fiber.yield(chunk) unless chunk.empty?
|
|
195
|
+
end
|
|
196
|
+
else
|
|
197
|
+
raise HttpError.new("HTTP error #{response.code} requesting #{uri}", response)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
break unless redirect_location
|
|
202
|
+
|
|
203
|
+
uri = URI.join(uri.to_s, redirect_location)
|
|
204
|
+
end
|
|
152
205
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def connect(uri)
|
|
211
|
+
close
|
|
212
|
+
|
|
213
|
+
@http = Net::HTTP.new(uri.host, uri.port)
|
|
214
|
+
@http.use_ssl = (uri.scheme == "https")
|
|
215
|
+
@http.open_timeout = @open_timeout
|
|
216
|
+
@http.read_timeout = @read_timeout
|
|
217
|
+
@http.start
|
|
156
218
|
end
|
|
157
219
|
|
|
158
220
|
def read_chunk
|
|
159
221
|
return nil if @eof
|
|
160
222
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
@eof = true
|
|
165
|
-
return nil
|
|
166
|
-
end
|
|
167
|
-
# Simulate chunking for consistency
|
|
168
|
-
chunk = @body_reader.byteslice(0, 8192) || +""
|
|
169
|
-
@body_reader = @body_reader.byteslice(8192..-1) || +""
|
|
170
|
-
@eof = true if @body_reader.empty?
|
|
171
|
-
chunk
|
|
172
|
-
else
|
|
173
|
-
# Should not happen with webmock but handling for real HTTP
|
|
174
|
-
@eof = true
|
|
175
|
-
nil
|
|
176
|
-
end
|
|
223
|
+
chunk = (@fiber.alive? ? @fiber.resume : nil)
|
|
224
|
+
@eof = true if chunk.nil?
|
|
225
|
+
chunk
|
|
177
226
|
end
|
|
178
227
|
end
|
|
179
228
|
end
|
|
@@ -9,8 +9,6 @@ module XmlNodeStream
|
|
|
9
9
|
class Parser
|
|
10
10
|
SUPPORTED_PARSERS = [:nokogiri, :libxml, :rexml]
|
|
11
11
|
|
|
12
|
-
@parser = :rexml
|
|
13
|
-
|
|
14
12
|
class << self
|
|
15
13
|
# Set the parser implementation. The parser argument should be one of :nokogiri, :libxml, or :rexml. If this method
|
|
16
14
|
# is not called, it will default to :rexml which is the slowest choice possible. If you set the parser to one of the
|
|
@@ -84,7 +82,7 @@ module XmlNodeStream
|
|
|
84
82
|
@loaded_parsers ||= {}
|
|
85
83
|
klass = @loaded_parsers[class_symbol]
|
|
86
84
|
unless klass
|
|
87
|
-
|
|
85
|
+
require_relative "parser/#{class_symbol}_parser"
|
|
88
86
|
class_name = "#{class_symbol.to_s.capitalize}Parser"
|
|
89
87
|
klass = const_get(class_name)
|
|
90
88
|
@loaded_parsers[class_symbol] = klass
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "set"
|
|
4
|
-
|
|
5
3
|
module XmlNodeStream
|
|
6
4
|
# Partial implementation of XPath selectors. Only abbreviated paths and the text() function are supported. The rest of XPath
|
|
7
5
|
# is unecessary in the context of a Ruby application since XPath is also a programming language. If you really need an XPath
|
|
@@ -38,11 +36,10 @@ module XmlNodeStream
|
|
|
38
36
|
matched = [node]
|
|
39
37
|
@parts.each do |part_matchers|
|
|
40
38
|
context = matched
|
|
41
|
-
context_set = context.to_set
|
|
42
39
|
matched = []
|
|
43
40
|
|
|
44
41
|
part_matchers.each do |matcher|
|
|
45
|
-
matched.concat(matcher.select(context
|
|
42
|
+
matched.concat(matcher.select(context))
|
|
46
43
|
end
|
|
47
44
|
|
|
48
45
|
break if matched.empty?
|
|
@@ -66,15 +63,9 @@ module XmlNodeStream
|
|
|
66
63
|
path_length = path.length
|
|
67
64
|
|
|
68
65
|
while i < path_length
|
|
69
|
-
#
|
|
70
|
-
if i == 0 && path[i] == "/"
|
|
71
|
-
parts << [Matcher.new("")]
|
|
72
|
-
i += 1
|
|
73
|
-
next
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
# Look for // (descendant operator)
|
|
66
|
+
# Look for // (descendant operator); a leading // searches from the document root
|
|
77
67
|
if i < path_length - 1 && path[i] == "/" && path[i + 1] == "/"
|
|
68
|
+
parts << [Matcher.new("")] if i == 0
|
|
78
69
|
i += 2
|
|
79
70
|
# Check if there's a name after //
|
|
80
71
|
name_match = path[i..].match(/\A([a-zA-Z_][\w-]*)/)
|
|
@@ -84,12 +75,23 @@ module XmlNodeStream
|
|
|
84
75
|
elsif i >= path_length
|
|
85
76
|
# // at end of path is invalid
|
|
86
77
|
raise ArgumentError, "Invalid XPath pattern: #{path} (// cannot be at end)"
|
|
78
|
+
elsif path[i] == "*" && (i + 1 >= path_length || path[i + 1] == "/")
|
|
79
|
+
# //* selects all descendants
|
|
80
|
+
parts << [Matcher.new("%")]
|
|
81
|
+
i += 1
|
|
87
82
|
else
|
|
88
83
|
parts << [Matcher.new("%")]
|
|
89
84
|
end
|
|
90
85
|
next
|
|
91
86
|
end
|
|
92
87
|
|
|
88
|
+
# Skip leading slash for absolute paths
|
|
89
|
+
if i == 0 && path[i] == "/"
|
|
90
|
+
parts << [Matcher.new("")]
|
|
91
|
+
i += 1
|
|
92
|
+
next
|
|
93
|
+
end
|
|
94
|
+
|
|
93
95
|
# Regular path segment
|
|
94
96
|
if path[i] == "/"
|
|
95
97
|
i += 1
|
|
@@ -139,43 +141,40 @@ module XmlNodeStream
|
|
|
139
141
|
# @param path [String] the path pattern to match
|
|
140
142
|
def initialize(path)
|
|
141
143
|
@path = path
|
|
144
|
+
@text = (path == "text()")
|
|
142
145
|
@extractor = case path
|
|
143
146
|
when "text()"
|
|
144
|
-
lambda { |node
|
|
147
|
+
lambda { |node| node.value unless node.value.nil? || node.value.empty? }
|
|
145
148
|
when "%"
|
|
146
|
-
lambda { |node
|
|
149
|
+
lambda { |node| node.descendants }
|
|
147
150
|
when "*"
|
|
148
|
-
lambda { |node
|
|
151
|
+
lambda { |node| node.children }
|
|
149
152
|
when "."
|
|
150
|
-
lambda { |node
|
|
153
|
+
lambda { |node| node }
|
|
151
154
|
when ".."
|
|
152
|
-
lambda { |node
|
|
155
|
+
lambda { |node| node.parent || [] }
|
|
153
156
|
when ""
|
|
154
|
-
lambda { |node
|
|
157
|
+
lambda { |node|
|
|
155
158
|
root = Node.new(nil)
|
|
156
159
|
root.children << node.root
|
|
157
160
|
root
|
|
158
161
|
}
|
|
159
162
|
when /^%(.+)$/ # descendants with name filter: %name
|
|
160
163
|
name = $1
|
|
161
|
-
lambda { |node
|
|
164
|
+
lambda { |node| node.descendants.select { |d| d.name == name } }
|
|
162
165
|
else
|
|
163
|
-
lambda { |node
|
|
164
|
-
# Only return children matching the name
|
|
165
|
-
# Don't include children that are already in the context
|
|
166
|
-
node.children.select { |child| child.name == @path && !context_set&.include?(child) }
|
|
167
|
-
}
|
|
166
|
+
lambda { |node| node.children.select { |child| child.name == @path } }
|
|
168
167
|
end
|
|
169
168
|
end
|
|
170
169
|
|
|
171
170
|
# Select all nodes that match a partial path.
|
|
172
171
|
#
|
|
173
172
|
# @param context_nodes [Array<Node>] the nodes to select from
|
|
174
|
-
# @param context_set [Set<Node>, nil] optional set version of context_nodes for performance
|
|
175
173
|
# @return [Array<Node>] the matching nodes
|
|
176
|
-
def select(context_nodes
|
|
177
|
-
|
|
178
|
-
|
|
174
|
+
def select(context_nodes)
|
|
175
|
+
results = context_nodes.collect { |node| @extractor.call(node) if node.is_a?(Node) }.flatten.compact
|
|
176
|
+
# Text values from different nodes can legitimately be equal, so only dedupe nodes.
|
|
177
|
+
@text ? results : results.uniq
|
|
179
178
|
end
|
|
180
179
|
end
|
|
181
180
|
end
|
data/lib/xml_node_stream.rb
CHANGED
data/xml_node_stream.gemspec
CHANGED
metadata
CHANGED
|
@@ -1,28 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: xml_node_stream
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.0.
|
|
4
|
+
version: 2.0.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brian Durand
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
-
dependencies:
|
|
12
|
-
- !ruby/object:Gem::Dependency
|
|
13
|
-
name: bundler
|
|
14
|
-
requirement: !ruby/object:Gem::Requirement
|
|
15
|
-
requirements:
|
|
16
|
-
- - ">="
|
|
17
|
-
- !ruby/object:Gem::Version
|
|
18
|
-
version: '0'
|
|
19
|
-
type: :development
|
|
20
|
-
prerelease: false
|
|
21
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
-
requirements:
|
|
23
|
-
- - ">="
|
|
24
|
-
- !ruby/object:Gem::Version
|
|
25
|
-
version: '0'
|
|
11
|
+
dependencies: []
|
|
26
12
|
email:
|
|
27
13
|
- bbdurand@gmail.com
|
|
28
14
|
executables: []
|
|
@@ -57,7 +43,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
57
43
|
requirements:
|
|
58
44
|
- - ">="
|
|
59
45
|
- !ruby/object:Gem::Version
|
|
60
|
-
version: '
|
|
46
|
+
version: '2.7'
|
|
61
47
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
62
48
|
requirements:
|
|
63
49
|
- - ">="
|