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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/lib/kataba/fetcher.rb +45 -3
  3. data/lib/kataba.rb +65 -12
  4. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3e2048c2adaac9976b8feb4fcea1da32d937a7d39955152a579f18d81e9fe3f2
4
- data.tar.gz: 806c80fff00c2a8dd637b42e1df485787e041a514ecc6372df5cb4902db8889e
3
+ metadata.gz: 8ae88ae5dafc92e24f90d773300f44d096a5efb5d42e82c047e48c413ffcf852
4
+ data.tar.gz: 6954878cb4e2fb52646f294efda7a53dd0c11ac97e9a08554691d298ac5e961e
5
5
  SHA512:
6
- metadata.gz: 15b1a25d1ed46d7510f829f4a6b51ca6516ed29edfa011b4a8395cee0bc36092e37d4ecb925e24791da6d4790435896059cabd8fb375b17278c724dff61540c9
7
- data.tar.gz: 64fa54356f4d0c0387a9c52898795462524363b6fccf6632259b635adb7d2c19544760c2e0b91256cf1e22b50a3e3836868ce5581c73499bda3184f32838b086
6
+ metadata.gz: 4be9c7f61034d3d0dbabcb65ccd56169f1f1f7e5aafeaa583a6cd5ab5cf348a5dac1c378d44a715f8b2aeecac8059a1af39a47a1cce53120bf2a5f3153d20630
7
+ data.tar.gz: bf9b63fcf72d6ae6c4ee1e515ff81854ae4f88d79b0a4496bdc2bdccd63fce49bcded29d8eb0fddc7c22f83860956716c7c114d101fe4e18a5b02e5f2e735c7e
@@ -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
- def initialize(uri)
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 = URI.parse(uri)
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
- response = http.get(parsed.request_uri)
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
- fetch_uri = xsd_uri
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
- fetch_uri = mirror unless mirror.to_s.empty?
152
+ mirror = mirror_list[xsd_uri].to_s
153
+ mirror = nil if mirror.empty?
133
154
  end
134
155
 
135
- body = Kataba::Fetcher.new(fetch_uri).fetch
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 = find_schemas(file_path)
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 << node.value
202
+ xsd_uri_array << schema_uri
175
203
  # Get MD5
176
- uri_md5 = Digest::MD5.hexdigest(node.value)
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kataba
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.2
4
+ version: 1.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cliff