kataba 1.1.2 → 1.1.4
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/lib/kataba/fetcher.rb +45 -3
- data/lib/kataba.rb +65 -12
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8ae88ae5dafc92e24f90d773300f44d096a5efb5d42e82c047e48c413ffcf852
|
|
4
|
+
data.tar.gz: 6954878cb4e2fb52646f294efda7a53dd0c11ac97e9a08554691d298ac5e961e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4be9c7f61034d3d0dbabcb65ccd56169f1f1f7e5aafeaa583a6cd5ab5cf348a5dac1c378d44a715f8b2aeecac8059a1af39a47a1cce53120bf2a5f3153d20630
|
|
7
|
+
data.tar.gz: bf9b63fcf72d6ae6c4ee1e515ff81854ae4f88d79b0a4496bdc2bdccd63fce49bcded29d8eb0fddc7c22f83860956716c7c114d101fe4e18a5b02e5f2e735c7e
|
data/lib/kataba/fetcher.rb
CHANGED
|
@@ -32,10 +32,25 @@ module Kataba
|
|
|
32
32
|
'/mods/xml.xsd' => '/standards/mods/xml.xsd',
|
|
33
33
|
}.freeze
|
|
34
34
|
|
|
35
|
+
# Seconds to wait for a schema host to accept the connection, and then to
|
|
36
|
+
# deliver the body. Net::HTTP's own defaults are 60s each, which is a
|
|
37
|
+
# fallback rather than a considered number: a schema is a small file from a
|
|
38
|
+
# public host, and both are paid per redirect hop and again on the
|
|
39
|
+
# alternate-scheme attempt, so an unbounded fetch can hold a caller's
|
|
40
|
+
# thread for many minutes.
|
|
41
|
+
DEFAULT_OPEN_TIMEOUT = 3
|
|
42
|
+
DEFAULT_READ_TIMEOUT = 10
|
|
43
|
+
|
|
35
44
|
class FetchError < StandardError; end
|
|
36
45
|
|
|
37
|
-
|
|
46
|
+
# A subclass, so a consumer's `rescue FetchError` covers a timeout while
|
|
47
|
+
# one that wants to retry a timeout without retrying a 404 still can.
|
|
48
|
+
class FetchTimeout < FetchError; end
|
|
49
|
+
|
|
50
|
+
def initialize(uri, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT)
|
|
38
51
|
@original_uri = uri
|
|
52
|
+
@open_timeout = open_timeout
|
|
53
|
+
@read_timeout = read_timeout
|
|
39
54
|
end
|
|
40
55
|
|
|
41
56
|
def fetch
|
|
@@ -72,10 +87,26 @@ module Kataba
|
|
|
72
87
|
def request_with_redirects(uri, redirect_depth:)
|
|
73
88
|
raise FetchError, "too many redirects fetching #{@original_uri}" if redirect_depth > MAX_REDIRECTS
|
|
74
89
|
|
|
75
|
-
parsed =
|
|
90
|
+
parsed = parse_http_uri(uri)
|
|
76
91
|
http = Net::HTTP.new(parsed.host, parsed.port)
|
|
77
92
|
http.use_ssl = (parsed.scheme == 'https')
|
|
78
|
-
|
|
93
|
+
http.open_timeout = @open_timeout
|
|
94
|
+
http.read_timeout = @read_timeout
|
|
95
|
+
# Net::HTTP retries an idempotent request once on a read timeout, so the
|
|
96
|
+
# ceiling per hop would otherwise be twice read_timeout. Nothing here
|
|
97
|
+
# reuses a socket, so that retry can only re-wait a timeout that has
|
|
98
|
+
# already expired.
|
|
99
|
+
http.max_retries = 0
|
|
100
|
+
|
|
101
|
+
response =
|
|
102
|
+
begin
|
|
103
|
+
http.get(parsed.request_uri)
|
|
104
|
+
rescue Timeout::Error => e
|
|
105
|
+
# Both Net::OpenTimeout and Net::ReadTimeout are Timeout::Errors, and
|
|
106
|
+
# Timeout::Error is a RuntimeError — so an unwrapped timeout lands in
|
|
107
|
+
# a consumer's generic rescue rather than its fetch-failure branch.
|
|
108
|
+
raise FetchTimeout, "timed out fetching #{uri} (#{e.class}: #{e.message})"
|
|
109
|
+
end
|
|
79
110
|
|
|
80
111
|
if response.is_a?(Net::HTTPRedirection)
|
|
81
112
|
target = resolve_redirect(parsed, response['location'])
|
|
@@ -85,6 +116,17 @@ module Kataba
|
|
|
85
116
|
end
|
|
86
117
|
end
|
|
87
118
|
|
|
119
|
+
# A bare filename parses as a URI with no host, and Net::HTTP then fails
|
|
120
|
+
# with NoMethodError, which no consumer's fetch-failure branch rescues.
|
|
121
|
+
def parse_http_uri(uri)
|
|
122
|
+
parsed = URI.parse(uri)
|
|
123
|
+
return parsed if parsed.is_a?(URI::HTTP) && parsed.host
|
|
124
|
+
|
|
125
|
+
raise FetchError, "not an absolute http(s) URI: #{uri}"
|
|
126
|
+
rescue URI::InvalidURIError => e
|
|
127
|
+
raise FetchError, "not a valid URI: #{uri} (#{e.message})"
|
|
128
|
+
end
|
|
129
|
+
|
|
88
130
|
def resolve_redirect(from, location)
|
|
89
131
|
raise FetchError, "redirect with no Location header from #{from}" if location.nil? || location.empty?
|
|
90
132
|
|
data/lib/kataba.rb
CHANGED
|
@@ -3,6 +3,8 @@ require 'tmpdir'
|
|
|
3
3
|
require 'digest/md5'
|
|
4
4
|
require 'fileutils'
|
|
5
5
|
require 'yaml'
|
|
6
|
+
require 'uri'
|
|
7
|
+
require 'set'
|
|
6
8
|
require 'kataba/fetcher'
|
|
7
9
|
|
|
8
10
|
module Kataba
|
|
@@ -46,9 +48,23 @@ module Kataba
|
|
|
46
48
|
# Kataba.configuration.mirror_list = File.join(Rails.root, 'config', 'mirror.yml')
|
|
47
49
|
attr_accessor :mirror_list
|
|
48
50
|
|
|
51
|
+
# Seconds Kataba waits for a schema host to accept a connection, and then
|
|
52
|
+
# to deliver the body. Raise them for a slow mirror; lower them when the
|
|
53
|
+
# caller is a request thread that must not be held. Both are paid per
|
|
54
|
+
# redirect hop and again on the alternate-scheme attempt, so the ceiling
|
|
55
|
+
# for one schema is a multiple of these, not either on its own.
|
|
56
|
+
#
|
|
57
|
+
# Example:
|
|
58
|
+
# Kataba.configuration.open_timeout = 5
|
|
59
|
+
# Kataba.configuration.read_timeout = 20
|
|
60
|
+
attr_accessor :open_timeout
|
|
61
|
+
attr_accessor :read_timeout
|
|
62
|
+
|
|
49
63
|
# Default configuration values
|
|
50
64
|
def initialize
|
|
51
65
|
@offline_storage = "#{Dir.tmpdir}/kataba"
|
|
66
|
+
@open_timeout = Kataba::Fetcher::DEFAULT_OPEN_TIMEOUT
|
|
67
|
+
@read_timeout = Kataba::Fetcher::DEFAULT_READ_TIMEOUT
|
|
52
68
|
end
|
|
53
69
|
end
|
|
54
70
|
|
|
@@ -102,12 +118,17 @@ module Kataba
|
|
|
102
118
|
|
|
103
119
|
private
|
|
104
120
|
|
|
105
|
-
def self.download_xsd(xsd_uri_array)
|
|
121
|
+
def self.download_xsd(xsd_uri_array, visited = Set.new)
|
|
106
122
|
new_xsd_uris = []
|
|
107
123
|
file_paths = []
|
|
108
124
|
|
|
109
125
|
# Download files
|
|
110
126
|
xsd_uri_array.each do |xsd_uri|
|
|
127
|
+
# Schemas may import each other (tdwg_dwc_simple.xsd and
|
|
128
|
+
# tdwg_dwc_extensions.xsd do), so fetch each URI once per call or the
|
|
129
|
+
# recursion never ends.
|
|
130
|
+
next unless visited.add?(xsd_uri)
|
|
131
|
+
|
|
111
132
|
uri_md5 = Digest::MD5.hexdigest(xsd_uri)
|
|
112
133
|
|
|
113
134
|
dir_name = "#{self.configuration.offline_storage}"
|
|
@@ -123,16 +144,22 @@ module Kataba
|
|
|
123
144
|
# Resolve mirror first, if configured, then fetch. Fetching BEFORE
|
|
124
145
|
# opening tmp_path means a failed network fetch can't leave an
|
|
125
146
|
# orphaned 0-byte .part on disk — the file simply isn't created.
|
|
126
|
-
|
|
147
|
+
mirror = nil
|
|
127
148
|
if !self.configuration.mirror_list.to_s.empty?
|
|
128
149
|
# YAML.load_file returns nil for a comments-only or empty file.
|
|
129
150
|
# Treat that as "no mirror configured" rather than NoMethodError.
|
|
130
151
|
mirror_list = YAML.load_file(self.configuration.mirror_list) || {}
|
|
131
|
-
mirror = mirror_list[xsd_uri]
|
|
132
|
-
|
|
152
|
+
mirror = mirror_list[xsd_uri].to_s
|
|
153
|
+
mirror = nil if mirror.empty?
|
|
133
154
|
end
|
|
134
155
|
|
|
135
|
-
body =
|
|
156
|
+
body = if mirror && !mirror.match?(%r{\Ahttps?://}i)
|
|
157
|
+
read_local_mirror(mirror)
|
|
158
|
+
else
|
|
159
|
+
Kataba::Fetcher.new(mirror || xsd_uri,
|
|
160
|
+
open_timeout: self.configuration.open_timeout,
|
|
161
|
+
read_timeout: self.configuration.read_timeout).fetch
|
|
162
|
+
end
|
|
136
163
|
|
|
137
164
|
# Write to a .part file first; only rename to the final cache path
|
|
138
165
|
# after we've confirmed the bytes parse as XML. Without this, a
|
|
@@ -149,20 +176,20 @@ module Kataba
|
|
|
149
176
|
end
|
|
150
177
|
|
|
151
178
|
File.rename(tmp_path, file_path)
|
|
152
|
-
file_paths << file_path
|
|
179
|
+
file_paths << [file_path, xsd_uri]
|
|
153
180
|
end
|
|
154
181
|
|
|
155
182
|
# Search inside for other schemaLocations
|
|
156
|
-
file_paths.each do |file_path|
|
|
157
|
-
new_xsd_uris
|
|
183
|
+
file_paths.each do |file_path, xsd_uri|
|
|
184
|
+
new_xsd_uris.concat(find_schemas(file_path, xsd_uri))
|
|
158
185
|
end
|
|
159
186
|
|
|
160
187
|
if !new_xsd_uris.reject(&:empty?).empty?
|
|
161
|
-
download_xsd(new_xsd_uris)
|
|
188
|
+
download_xsd(new_xsd_uris, visited)
|
|
162
189
|
end
|
|
163
190
|
end
|
|
164
191
|
|
|
165
|
-
def self.find_schemas(xml_file_path)
|
|
192
|
+
def self.find_schemas(xml_file_path, base_uri)
|
|
166
193
|
xsd_uri_array = []
|
|
167
194
|
|
|
168
195
|
# Open XML file
|
|
@@ -170,10 +197,11 @@ module Kataba
|
|
|
170
197
|
# search for schemaLocations
|
|
171
198
|
doc.xpath("//@schemaLocation").each do |node|
|
|
172
199
|
if !node.value.to_s.empty?
|
|
200
|
+
schema_uri = resolve_schema_location(node.value, base_uri)
|
|
173
201
|
# Add to array
|
|
174
|
-
xsd_uri_array <<
|
|
202
|
+
xsd_uri_array << schema_uri
|
|
175
203
|
# Get MD5
|
|
176
|
-
uri_md5 = Digest::MD5.hexdigest(
|
|
204
|
+
uri_md5 = Digest::MD5.hexdigest(schema_uri)
|
|
177
205
|
# Reassign attribute value
|
|
178
206
|
node.value = "#{uri_md5}.xsd"
|
|
179
207
|
end
|
|
@@ -184,4 +212,29 @@ module Kataba
|
|
|
184
212
|
|
|
185
213
|
return xsd_uri_array
|
|
186
214
|
end
|
|
215
|
+
|
|
216
|
+
# A mirror value that is not an http(s) URL is a file, so a consumer can
|
|
217
|
+
# ship a corrected schema with its own code. A relative path resolves
|
|
218
|
+
# against the mirror list's directory, never the working directory, which
|
|
219
|
+
# fetch_schema changes while it parses.
|
|
220
|
+
def self.read_local_mirror(path)
|
|
221
|
+
full_path = File.expand_path(path, File.dirname(self.configuration.mirror_list))
|
|
222
|
+
File.read(full_path)
|
|
223
|
+
rescue SystemCallError => e
|
|
224
|
+
raise Kataba::Fetcher::FetchError, "could not read mirror file #{full_path} (#{e.class})"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# An absolute location is returned byte for byte. Its MD5 is the cache
|
|
228
|
+
# filename and the mirror_list key, so normalising it would orphan every
|
|
229
|
+
# cached schema and miss every mirror. A relative one resolves against the
|
|
230
|
+
# canonical URI of the schema importing it, never a mirror or a redirect
|
|
231
|
+
# target, so its cache name does not depend on where the bytes came from.
|
|
232
|
+
def self.resolve_schema_location(location, base_uri)
|
|
233
|
+
return location if URI.parse(location).absolute?
|
|
234
|
+
|
|
235
|
+
URI.join(base_uri, location).to_s
|
|
236
|
+
rescue URI::Error
|
|
237
|
+
# Left as written, so the Fetcher reports it as a FetchError.
|
|
238
|
+
location
|
|
239
|
+
end
|
|
187
240
|
end
|