doc_repo 0.1.1 → 1.0.0.pre.beta.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/.gitignore +3 -0
- data/.travis.yml +4 -4
- data/CHANGELOG.md +47 -0
- data/Gemfile +5 -0
- data/README.md +282 -16
- data/benchmarks/digests.rb +55 -0
- data/doc_repo.gemspec +5 -5
- data/lib/doc_repo/configuration.rb +65 -4
- data/lib/doc_repo/doc.rb +84 -0
- data/lib/doc_repo/error.rb +14 -0
- data/lib/doc_repo/gateway_error.rb +37 -0
- data/lib/doc_repo/http_error.rb +32 -0
- data/lib/doc_repo/http_result.rb +29 -0
- data/lib/doc_repo/net_http_adapter.rb +203 -0
- data/lib/doc_repo/rails/legacy_versioned_cache.rb +19 -0
- data/lib/doc_repo/rails.rb +37 -0
- data/lib/doc_repo/redirect.rb +19 -0
- data/lib/doc_repo/repository.rb +72 -20
- data/lib/doc_repo/result_handler.rb +30 -0
- data/lib/doc_repo/version.rb +1 -1
- data/lib/doc_repo.rb +20 -17
- data/spec/doc_repo/configuration_spec.rb +86 -31
- data/spec/doc_repo/doc_spec.rb +442 -0
- data/spec/doc_repo/net_http_adapter_spec.rb +435 -0
- data/spec/doc_repo/repository_spec.rb +325 -13
- data/spec/doc_repo/result_handler_spec.rb +43 -0
- data/spec/doc_repo_spec.rb +25 -3
- data/spec/spec_helper.rb +88 -3
- data/spec/support/in_memory_cache.rb +33 -0
- metadata +33 -20
- data/lib/doc_repo/github_file.rb +0 -45
- data/lib/doc_repo/page.rb +0 -35
- data/lib/doc_repo/response.rb +0 -25
- data/spec/doc_repo/page_spec.rb +0 -44
- data/spec/doc_repo/response_spec.rb +0 -19
@@ -0,0 +1,435 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require 'support/in_memory_cache'
|
3
|
+
|
4
|
+
RSpec.describe DocRepo::NetHttpAdapter do
|
5
|
+
|
6
|
+
subject(:an_adapter) { DocRepo::NetHttpAdapter.new("any.host") }
|
7
|
+
|
8
|
+
describe "creating an adapter" do
|
9
|
+
it "requires a host" do
|
10
|
+
expect {
|
11
|
+
DocRepo::NetHttpAdapter.new
|
12
|
+
}.to raise_error ArgumentError
|
13
|
+
end
|
14
|
+
|
15
|
+
it "creates a defensive frozen copy of the host", :aggregate_failures do
|
16
|
+
original_host = String.new("Any Host") # So it's not frozen
|
17
|
+
an_adapter = DocRepo::NetHttpAdapter.new(original_host)
|
18
|
+
expect(an_adapter.host).to eq(original_host).and be_frozen
|
19
|
+
expect(original_host).not_to be_frozen
|
20
|
+
expect {
|
21
|
+
original_host.upcase!
|
22
|
+
}.not_to change {
|
23
|
+
an_adapter.host
|
24
|
+
}.from("Any Host")
|
25
|
+
end
|
26
|
+
|
27
|
+
it "has default timeouts of 10 seconds" do
|
28
|
+
expect(DocRepo::NetHttpAdapter.new("any.host").opts).to include(
|
29
|
+
open_timeout: 10,
|
30
|
+
read_timeout: 10,
|
31
|
+
ssl_timeout: 10,
|
32
|
+
)
|
33
|
+
end
|
34
|
+
|
35
|
+
it "allows custom opts overwriting defaults" do
|
36
|
+
an_adapter = DocRepo::NetHttpAdapter.new(
|
37
|
+
"any.host",
|
38
|
+
open_timeout: 100,
|
39
|
+
verify_depth: 2,
|
40
|
+
)
|
41
|
+
expect(an_adapter.opts).to include(
|
42
|
+
open_timeout: 100,
|
43
|
+
verify_depth: 2,
|
44
|
+
)
|
45
|
+
end
|
46
|
+
|
47
|
+
it "freezes the options once set", :aggregate_failures do
|
48
|
+
custom_opts = {
|
49
|
+
open_timeout: 100,
|
50
|
+
verify_depth: 2,
|
51
|
+
}
|
52
|
+
an_adapter = DocRepo::NetHttpAdapter.new("any.host", custom_opts)
|
53
|
+
expect(an_adapter.opts).to be_frozen
|
54
|
+
expect(custom_opts).not_to be_frozen
|
55
|
+
end
|
56
|
+
|
57
|
+
it "forces use of SSL", :aggregate_failures do
|
58
|
+
default_opts = DocRepo::NetHttpAdapter.new("any.host").opts
|
59
|
+
expect(default_opts).to include(use_ssl: true)
|
60
|
+
|
61
|
+
custom_opts = DocRepo::NetHttpAdapter.new("any.host", use_ssl: false).opts
|
62
|
+
expect(custom_opts).to include(use_ssl: true)
|
63
|
+
end
|
64
|
+
|
65
|
+
it "makes a defensive frozen copy of the cache options", :aggregate_failures do
|
66
|
+
cache_opts = { any: :opts }
|
67
|
+
cache_adapter = DocRepo::NetHttpAdapter.new(
|
68
|
+
"any.host",
|
69
|
+
cache_options: cache_opts,
|
70
|
+
)
|
71
|
+
expect(cache_adapter.cache_options).to eq(cache_opts).and be_frozen
|
72
|
+
expect(cache_opts).not_to be_frozen
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
it "retrieving a document returns the result" do
|
77
|
+
stub_request(:get, "https://any.host/any-document.ext").to_return(
|
78
|
+
status: 200,
|
79
|
+
body: "Any Document Content",
|
80
|
+
)
|
81
|
+
expect(an_adapter.retrieve("/any-document.ext")).to be_an_instance_of(
|
82
|
+
DocRepo::Doc
|
83
|
+
).and have_attributes(
|
84
|
+
code: 200,
|
85
|
+
content: "Any Document Content",
|
86
|
+
)
|
87
|
+
end
|
88
|
+
|
89
|
+
it "retrieving a moved document returns a redirect result" do
|
90
|
+
stub_request(:get, "https://any.host/any-document.ext").to_return(
|
91
|
+
status: 302,
|
92
|
+
headers: { "Location" => "https://new.host/any-location" },
|
93
|
+
)
|
94
|
+
expect(an_adapter.retrieve("/any-document.ext")).to be_an_instance_of(
|
95
|
+
DocRepo::Redirect
|
96
|
+
).and have_attributes(
|
97
|
+
code: 302,
|
98
|
+
url: "https://new.host/any-location",
|
99
|
+
)
|
100
|
+
end
|
101
|
+
|
102
|
+
it "retrieving a missing document returns a not found result" do
|
103
|
+
stub_request(:get, "https://any.host/any-document.ext").to_return(
|
104
|
+
status: [404, "Not Found"],
|
105
|
+
body: "Any Error Details",
|
106
|
+
)
|
107
|
+
expect(an_adapter.retrieve("/any-document.ext")).to be_an_instance_of(
|
108
|
+
DocRepo::HttpError
|
109
|
+
).and have_attributes(
|
110
|
+
code: 404,
|
111
|
+
details: "Any Error Details",
|
112
|
+
message: '404 "Not Found"',
|
113
|
+
)
|
114
|
+
end
|
115
|
+
|
116
|
+
it "retrieving a document causing an HTTP error returns the error" do
|
117
|
+
stub_request(:get, "https://any.host/any-document.ext").to_return(
|
118
|
+
status: [404, "Not Found"],
|
119
|
+
body: "Any Error Details",
|
120
|
+
)
|
121
|
+
expect(an_adapter.retrieve("/any-document.ext")).to be_an_instance_of(
|
122
|
+
DocRepo::HttpError
|
123
|
+
).and have_attributes(
|
124
|
+
code: 404,
|
125
|
+
details: "Any Error Details",
|
126
|
+
message: '404 "Not Found"',
|
127
|
+
)
|
128
|
+
end
|
129
|
+
|
130
|
+
it "retrieving a document which times out returns a gateway error" do
|
131
|
+
stub_request(:get, "https://any.host/any-document.ext").to_timeout
|
132
|
+
expect(an_adapter.retrieve("/any-document.ext")).to be_an_instance_of(
|
133
|
+
DocRepo::GatewayError
|
134
|
+
).and have_attributes(
|
135
|
+
code: 504,
|
136
|
+
message: '504 "Gateway Timeout"',
|
137
|
+
details: "execution expired",
|
138
|
+
)
|
139
|
+
end
|
140
|
+
|
141
|
+
it "retrieving a document from a problematic server returns a network error" do
|
142
|
+
stub_request(:get, "https://any.host/any-document.ext").to_raise(
|
143
|
+
Net::ProtocolError.new("Any Protocol Error")
|
144
|
+
)
|
145
|
+
expect(an_adapter.retrieve("/any-document.ext")).to be_an_instance_of(
|
146
|
+
DocRepo::GatewayError
|
147
|
+
).and have_attributes(
|
148
|
+
code: 502,
|
149
|
+
message: '502 "Bad Gateway"',
|
150
|
+
details: "Any Protocol Error",
|
151
|
+
)
|
152
|
+
end
|
153
|
+
|
154
|
+
it "converts SSL certificate errors" do
|
155
|
+
stub_request(:get, "https://any.host/any-document.ext").to_raise(
|
156
|
+
OpenSSL::SSL::SSLError.new(
|
157
|
+
"SSL_connect returned=1 errno=0 state=error: certificate verify failed"
|
158
|
+
)
|
159
|
+
)
|
160
|
+
expect(an_adapter.retrieve("/any-document.ext")).to be_an_instance_of(
|
161
|
+
DocRepo::GatewayError
|
162
|
+
).and have_attributes(
|
163
|
+
code: 502,
|
164
|
+
message: '502 "Bad Gateway"',
|
165
|
+
details: "SSL_connect returned=1 errno=0 state=error: certificate verify failed",
|
166
|
+
)
|
167
|
+
end
|
168
|
+
|
169
|
+
it "retrieving a document over a bad network wraps the network error" do
|
170
|
+
stub_request(:get, "https://any.host/any-document.ext").to_raise(
|
171
|
+
SocketError.new("Any Socket Error")
|
172
|
+
)
|
173
|
+
expect(an_adapter.retrieve("/any-document.ext")).to be_an_instance_of(
|
174
|
+
DocRepo::GatewayError
|
175
|
+
).and have_attributes(
|
176
|
+
code: 520,
|
177
|
+
message: '520 "Unknown Error"',
|
178
|
+
details: "Any Socket Error",
|
179
|
+
)
|
180
|
+
end
|
181
|
+
|
182
|
+
it "with no cache requests are always made over the network", :aggregate_failures do
|
183
|
+
no_cache = DocRepo::NetHttpAdapter.new("any.host")
|
184
|
+
stub_request(:get, "https://any.host/any.doc")
|
185
|
+
.to_return(status: 200)
|
186
|
+
.to_return(status: 500)
|
187
|
+
expect(no_cache.retrieve("/any.doc")).to be_an_instance_of(DocRepo::Doc)
|
188
|
+
expect(no_cache.retrieve("/any.doc")).to be_an_instance_of(DocRepo::HttpError)
|
189
|
+
end
|
190
|
+
|
191
|
+
context "with a cache store" do
|
192
|
+
subject(:cached_adapter) {
|
193
|
+
DocRepo::NetHttpAdapter.new(
|
194
|
+
"any.host",
|
195
|
+
cache: mem_cache,
|
196
|
+
cache_options: { any: :options },
|
197
|
+
)
|
198
|
+
}
|
199
|
+
|
200
|
+
let(:mem_cache) { DocRepo::Spec::InMemoryCache.new }
|
201
|
+
|
202
|
+
it "provides the cache options on every request", :aggregate_failures do
|
203
|
+
stub_request(:get, /.*/).to_return(status: 200)
|
204
|
+
expect {
|
205
|
+
cached_adapter.retrieve "/uri/1"
|
206
|
+
}.to change {
|
207
|
+
mem_cache.options
|
208
|
+
}.to eq "any.host:/uri/1" => { any: :options }
|
209
|
+
|
210
|
+
expect {
|
211
|
+
cached_adapter.retrieve "/uri/2"
|
212
|
+
}.to change {
|
213
|
+
mem_cache.options
|
214
|
+
}.to eq(
|
215
|
+
"any.host:/uri/1" => { any: :options },
|
216
|
+
"any.host:/uri/2" => { any: :options },
|
217
|
+
)
|
218
|
+
|
219
|
+
# Clear just the options, leave the cache alone
|
220
|
+
mem_cache.options.clear
|
221
|
+
|
222
|
+
expect {
|
223
|
+
cached_adapter.retrieve "/uri/2"
|
224
|
+
}.to change {
|
225
|
+
mem_cache.options
|
226
|
+
}.to eq "any.host:/uri/2" => { any: :options }
|
227
|
+
end
|
228
|
+
|
229
|
+
it "makes an HTTP request when the URI is not cached" do
|
230
|
+
http_request = stub_request(:get, /.*/).to_return(status: 200)
|
231
|
+
cached_adapter.retrieve "/uri/1"
|
232
|
+
expect(http_request).to have_been_requested
|
233
|
+
end
|
234
|
+
|
235
|
+
it "stores the HTTP response in the cache" do
|
236
|
+
stub_request(:get, /.*/).to_return(
|
237
|
+
status: [200, "Custom Code"],
|
238
|
+
body: "Any Content Body",
|
239
|
+
)
|
240
|
+
expect {
|
241
|
+
cached_adapter.retrieve "/uri/1"
|
242
|
+
}.to change {
|
243
|
+
mem_cache.cache
|
244
|
+
}.from(
|
245
|
+
{}
|
246
|
+
).to(
|
247
|
+
"any.host:/uri/1" => an_instance_of(Net::HTTPOK).and(have_attributes(
|
248
|
+
code: "200",
|
249
|
+
message: "Custom Code",
|
250
|
+
body: "Any Content Body",
|
251
|
+
))
|
252
|
+
)
|
253
|
+
end
|
254
|
+
|
255
|
+
it "re-uses the cached HTTP response" do
|
256
|
+
# Populate the cache with a response so we don't have to work with sockets
|
257
|
+
stub_request(:get, /.*/)
|
258
|
+
.to_return(status: 200, body: "Original Body")
|
259
|
+
.to_raise("Cache Not Used!")
|
260
|
+
cached_adapter.retrieve "/uri/1"
|
261
|
+
|
262
|
+
# Customize cache to show changes
|
263
|
+
cached_response = mem_cache.cache["any.host:/uri/1"]
|
264
|
+
cached_response.body = "Cached Body"
|
265
|
+
cached_response.content_type = "Cached Content"
|
266
|
+
|
267
|
+
expect(cached_adapter.retrieve("/uri/1")).to be_an_instance_of(
|
268
|
+
DocRepo::Doc
|
269
|
+
).and have_attributes(
|
270
|
+
code: 200,
|
271
|
+
content: "Cached Body",
|
272
|
+
content_type: "Cached Content",
|
273
|
+
)
|
274
|
+
end
|
275
|
+
|
276
|
+
it "performs a conditional GET when cache is expired according to the `Expires` header", :aggregate_failures do
|
277
|
+
# Populate the current cache via a response to avoid sockets
|
278
|
+
stub_request(:get, /.*/)
|
279
|
+
.to_return(
|
280
|
+
status: 200,
|
281
|
+
body: "Original Body",
|
282
|
+
headers: {
|
283
|
+
"ETag" => "Any ETag",
|
284
|
+
"Last-Modified" => "Sat, 01 Jul 2017 18:18:33 GMT",
|
285
|
+
"Expires" => (Time.now + 60).httpdate, # 1 minute from now
|
286
|
+
},
|
287
|
+
)
|
288
|
+
.to_raise("Cache Not Used!")
|
289
|
+
cached_adapter.retrieve "/uri/1"
|
290
|
+
|
291
|
+
# Cache is current so this should return it
|
292
|
+
expect(cached_adapter.retrieve("/uri/1").content).to eq "Original Body"
|
293
|
+
|
294
|
+
# Artificially expire cache
|
295
|
+
mem_cache.cache["any.host:/uri/1"]["Expires"] = (Time.now - 1).httpdate
|
296
|
+
|
297
|
+
conditional_get = stub_request(:get, %r(.*/uri/1))
|
298
|
+
.with(
|
299
|
+
headers: {
|
300
|
+
"If-None-Match" => "Any ETag",
|
301
|
+
"If-Modified-Since" => "Sat, 01 Jul 2017 18:18:33 GMT",
|
302
|
+
}
|
303
|
+
)
|
304
|
+
.to_return(status: 304)
|
305
|
+
cached_adapter.retrieve "/uri/1"
|
306
|
+
expect(conditional_get).to have_been_requested
|
307
|
+
end
|
308
|
+
|
309
|
+
it "considers an invalid `Expires` header as expired" do
|
310
|
+
# Populate the current cache via a response to avoid sockets
|
311
|
+
stub_request(:get, /.*/)
|
312
|
+
.to_return(
|
313
|
+
status: 200,
|
314
|
+
body: "Original Body",
|
315
|
+
headers: {
|
316
|
+
"ETag" => "Any ETag",
|
317
|
+
},
|
318
|
+
)
|
319
|
+
.to_raise("Cache Not Used!")
|
320
|
+
cached_adapter.retrieve "/uri/1"
|
321
|
+
|
322
|
+
# Artificially expire cache with invalid value
|
323
|
+
mem_cache.cache["any.host:/uri/1"]["Expires"] = "0"
|
324
|
+
|
325
|
+
conditional_get = stub_request(:get, %r(.*/uri/1))
|
326
|
+
.with(
|
327
|
+
headers: {
|
328
|
+
"If-None-Match" => "Any ETag",
|
329
|
+
}
|
330
|
+
)
|
331
|
+
.to_return(status: 200)
|
332
|
+
cached_adapter.retrieve "/uri/1"
|
333
|
+
expect(conditional_get).to have_been_requested
|
334
|
+
end
|
335
|
+
|
336
|
+
it "refreshes expired cache which hasn't been modified", :aggregate_failures do
|
337
|
+
# Populate the current cache via a response to avoid sockets then expire
|
338
|
+
stub_request(:get, /.*/)
|
339
|
+
.to_return(
|
340
|
+
status: 200,
|
341
|
+
body: "Original Body",
|
342
|
+
headers: {
|
343
|
+
"ETag" => "Any ETag",
|
344
|
+
"Last-Modified" => "Sat, 01 Jul 2017 18:18:33 GMT",
|
345
|
+
"Expires" => (Time.now + 60).httpdate, # 1 minute from now
|
346
|
+
"Content-Type" => "Original Content",
|
347
|
+
"Custom-A" => "Original Header",
|
348
|
+
"Custom-B" => "Original Header",
|
349
|
+
},
|
350
|
+
)
|
351
|
+
.to_raise("Cache Not Used!")
|
352
|
+
stub_request(:get, %r(.*/uri/1))
|
353
|
+
.with(
|
354
|
+
headers: {
|
355
|
+
"If-None-Match" => "Any ETag",
|
356
|
+
"If-Modified-Since" => "Sat, 01 Jul 2017 18:18:33 GMT",
|
357
|
+
}
|
358
|
+
)
|
359
|
+
.to_return(
|
360
|
+
status: 304,
|
361
|
+
body: "Updated Body",
|
362
|
+
headers: {
|
363
|
+
"ETag" => "Updated ETag",
|
364
|
+
"Content-Type" => "Updated Content",
|
365
|
+
"Custom-B" => "Updated Header",
|
366
|
+
},
|
367
|
+
)
|
368
|
+
cached_adapter.retrieve "/uri/1"
|
369
|
+
mem_cache.cache["any.host:/uri/1"]["Expires"] = (Time.now - 1).httpdate
|
370
|
+
|
371
|
+
expect(cached_adapter.retrieve("/uri/1")).to have_attributes(
|
372
|
+
code: 200,
|
373
|
+
content: "Original Body",
|
374
|
+
content_type: "Updated Content",
|
375
|
+
)
|
376
|
+
# Working with headers is painful with Net::HTTP responses
|
377
|
+
expect(mem_cache.cache["any.host:/uri/1"].to_hash).to include(
|
378
|
+
"etag" => ["Updated ETag"],
|
379
|
+
"last-modified" => ["Sat, 01 Jul 2017 18:18:33 GMT"],
|
380
|
+
"content-type" => ["Updated Content"],
|
381
|
+
"custom-a" => ["Original Header"],
|
382
|
+
"custom-b" => ["Updated Header"],
|
383
|
+
)
|
384
|
+
end
|
385
|
+
|
386
|
+
it "replaces expired cache which has been modified" do
|
387
|
+
# Populate the current cache via a response to avoid sockets then expire
|
388
|
+
stub_request(:get, /.*/)
|
389
|
+
.to_return(
|
390
|
+
status: 200,
|
391
|
+
body: "Original Body",
|
392
|
+
headers: {
|
393
|
+
"ETag" => "Any ETag",
|
394
|
+
"Last-Modified" => "Sat, 01 Jul 2017 18:18:33 GMT",
|
395
|
+
"Expires" => (Time.now + 60).httpdate, # 1 minute from now
|
396
|
+
"Content-Type" => "Original Content",
|
397
|
+
"Custom-A" => "Original Header",
|
398
|
+
"Custom-B" => "Original Header",
|
399
|
+
},
|
400
|
+
)
|
401
|
+
.to_raise("Cache Not Used!")
|
402
|
+
stub_request(:get, %r(.*/uri/1))
|
403
|
+
.with(
|
404
|
+
headers: {
|
405
|
+
"If-None-Match" => "Any ETag",
|
406
|
+
"If-Modified-Since" => "Sat, 01 Jul 2017 18:18:33 GMT",
|
407
|
+
}
|
408
|
+
)
|
409
|
+
.to_return(
|
410
|
+
status: 201,
|
411
|
+
body: "Updated Body",
|
412
|
+
headers: {
|
413
|
+
"ETag" => "Updated ETag",
|
414
|
+
"Content-Type" => "Updated Content",
|
415
|
+
"Custom-B" => "Updated Header",
|
416
|
+
},
|
417
|
+
)
|
418
|
+
cached_adapter.retrieve "/uri/1"
|
419
|
+
mem_cache.cache["any.host:/uri/1"]["Expires"] = (Time.now - 1).httpdate
|
420
|
+
|
421
|
+
expect(cached_adapter.retrieve("/uri/1")).to have_attributes(
|
422
|
+
code: 201,
|
423
|
+
content: "Updated Body",
|
424
|
+
content_type: "Updated Content",
|
425
|
+
)
|
426
|
+
# Working with headers is painful with Net::HTTP responses
|
427
|
+
expect(mem_cache.cache["any.host:/uri/1"].to_hash).to eq(
|
428
|
+
"etag" => ["Updated ETag"],
|
429
|
+
"content-type" => ["Updated Content"],
|
430
|
+
"custom-b" => ["Updated Header"],
|
431
|
+
)
|
432
|
+
end
|
433
|
+
end
|
434
|
+
|
435
|
+
end
|