cacho 0.0.1 → 0.0.2
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.
- data/cacho.gemspec +1 -1
- data/lib/cacho.rb +87 -55
- data/test/cacho.rb +76 -9
- metadata +3 -3
data/cacho.gemspec
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
Gem::Specification.new do |s|
|
2
2
|
s.name = "cacho"
|
3
|
-
s.version = "0.0.
|
3
|
+
s.version = "0.0.2"
|
4
4
|
s.summary = "Cache aware, Redis based HTTP client."
|
5
5
|
s.description = "HTTP client that understands cache responses and stores results in Redis."
|
6
6
|
s.authors = ["Damian Janowski", "Michel Martens"]
|
data/lib/cacho.rb
CHANGED
@@ -1,104 +1,136 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
require "json" unless defined?(JSON)
|
1
4
|
require "curb"
|
2
|
-
require "
|
3
|
-
require "json"
|
5
|
+
require "nest"
|
4
6
|
|
5
7
|
class Cacho
|
6
|
-
VERSION = "0.0.
|
8
|
+
VERSION = "0.0.2"
|
7
9
|
|
8
10
|
def self.get(url, request_headers = {})
|
9
|
-
|
11
|
+
_request(:get, url, request_headers)
|
12
|
+
end
|
10
13
|
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
+
def self.head(url, request_headers = {})
|
15
|
+
_request(:head, url, request_headers)
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.options(url, request_headers = {})
|
19
|
+
_request(:options, url, request_headers)
|
20
|
+
end
|
21
|
+
|
22
|
+
def self._request(verb, url, request_headers = {})
|
23
|
+
local = Local[verb, url]
|
24
|
+
|
25
|
+
unless local.fresh?
|
26
|
+
remote = Remote.request(verb, url, local.build_headers.merge(request_headers))
|
27
|
+
|
28
|
+
local.set(remote) unless remote.first == 304
|
14
29
|
end
|
15
30
|
|
16
|
-
response
|
31
|
+
local.response
|
17
32
|
end
|
18
33
|
|
19
34
|
class Local
|
20
|
-
|
21
|
-
|
35
|
+
attr :etag
|
36
|
+
attr :last_modified
|
37
|
+
attr :expire
|
38
|
+
attr :response
|
39
|
+
|
40
|
+
def initialize(key)
|
41
|
+
@key = key
|
42
|
+
@etag, @last_modified, @expire, @response = @key.hmget(:etag, :last_modified, :expire, :response)
|
43
|
+
@response = JSON.parse(@response) if @response
|
44
|
+
end
|
22
45
|
|
23
|
-
|
24
|
-
|
46
|
+
def self.[](verb, url)
|
47
|
+
new(Nest.new(verb)[url])
|
48
|
+
end
|
49
|
+
|
50
|
+
def expire_in(ttl)
|
51
|
+
@expire = (Time.now + ttl).to_i
|
52
|
+
end
|
53
|
+
|
54
|
+
def build_headers
|
55
|
+
{}.tap do |headers|
|
56
|
+
headers["If-None-Match"] = etag if etag
|
57
|
+
headers["If-Modified-Since"] = last_modified if last_modified
|
25
58
|
end
|
26
59
|
end
|
27
60
|
|
28
|
-
def
|
29
|
-
|
61
|
+
def set(response)
|
62
|
+
@response = response
|
30
63
|
|
31
|
-
|
64
|
+
return unless cacheable?
|
32
65
|
|
33
|
-
|
66
|
+
_, headers, _ = response
|
34
67
|
|
35
68
|
if headers["Cache-Control"]
|
36
69
|
ttl = headers["Cache-Control"][/max\-age=(\d+)/, 1].to_i
|
37
|
-
|
38
|
-
fields[:expire] = (Time.now + ttl).to_i
|
70
|
+
expire_in(ttl)
|
39
71
|
end
|
40
72
|
|
41
|
-
|
73
|
+
@etag = headers["ETag"]
|
74
|
+
@last_modified = headers["Last-Modified"]
|
42
75
|
|
43
|
-
|
44
|
-
fields[:last_modified] = headers["Last-Modified"]
|
45
|
-
|
46
|
-
redis.hmset(url, *fields.to_a.flatten)
|
76
|
+
store
|
47
77
|
end
|
48
78
|
|
49
|
-
def
|
50
|
-
|
51
|
-
|
52
|
-
{}.tap do |headers|
|
53
|
-
headers["If-None-Match"] = etag if etag
|
54
|
-
headers["If-Modified-Since"] = last_modified if last_modified
|
55
|
-
end
|
79
|
+
def fresh?
|
80
|
+
expire && expire.to_i >= Time.now.to_i
|
56
81
|
end
|
57
82
|
|
58
|
-
|
83
|
+
protected
|
84
|
+
|
85
|
+
def cacheable?
|
59
86
|
status, headers, _ = response
|
60
87
|
|
61
|
-
status == 200 && (headers["Cache-Control"] || headers["
|
88
|
+
status == 200 && (headers["Cache-Control"] || headers["ETag"] || headers["Last-Modified"])
|
62
89
|
end
|
63
90
|
|
64
|
-
def
|
65
|
-
|
91
|
+
def store
|
92
|
+
fields = {response: response.to_json}
|
93
|
+
|
94
|
+
fields[:etag] = etag if etag
|
95
|
+
fields[:last_modified] = last_modified if last_modified
|
96
|
+
fields[:expire] = expire if expire
|
97
|
+
|
98
|
+
@key.redis.multi do
|
99
|
+
@key.del
|
100
|
+
@key.hmset(*fields.to_a.flatten)
|
101
|
+
end
|
66
102
|
end
|
67
103
|
end
|
68
104
|
|
69
105
|
class Remote
|
70
|
-
def self.
|
106
|
+
def self.request(verb, url, request_headers)
|
71
107
|
status = nil
|
72
108
|
headers = {}
|
73
109
|
body = ""
|
74
110
|
|
75
|
-
Curl::Easy.
|
76
|
-
curl.headers = request_headers
|
111
|
+
curl = Curl::Easy.new(url)
|
77
112
|
|
78
|
-
|
79
|
-
headers.store(*header.rstrip.split(": ", 2)) if header.include?(":")
|
80
|
-
header.bytesize
|
81
|
-
end
|
113
|
+
curl.headers = request_headers
|
82
114
|
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
115
|
+
curl.on_header do |header|
|
116
|
+
headers.store(*header.rstrip.split(": ", 2)) if header.include?(":")
|
117
|
+
header.bytesize
|
118
|
+
end
|
87
119
|
|
88
|
-
|
89
|
-
|
90
|
-
|
120
|
+
curl.on_body do |string|
|
121
|
+
body << string.force_encoding(Encoding::UTF_8)
|
122
|
+
string.bytesize
|
91
123
|
end
|
92
124
|
|
93
|
-
|
94
|
-
|
95
|
-
else
|
96
|
-
[status, headers, body]
|
125
|
+
curl.on_complete do |response|
|
126
|
+
status = response.response_code
|
97
127
|
end
|
98
|
-
end
|
99
128
|
|
100
|
-
|
101
|
-
|
129
|
+
curl.head = verb == :head
|
130
|
+
|
131
|
+
curl.http(verb.to_s.upcase)
|
132
|
+
|
133
|
+
[status, headers, body]
|
102
134
|
end
|
103
135
|
end
|
104
136
|
end
|
data/test/cacho.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
1
3
|
require "cutest"
|
2
4
|
require "socket"
|
3
5
|
require "mock_server"
|
@@ -7,10 +9,12 @@ require File.expand_path("../lib/cacho", File.dirname(__FILE__))
|
|
7
9
|
include MockServer::Methods
|
8
10
|
|
9
11
|
mock_server do
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
12
|
+
%w(GET OPTIONS).each do |method|
|
13
|
+
route method, "/cacheable" do
|
14
|
+
response.headers["Cache-Control"] = "public, max-age=2"
|
15
|
+
response.headers["Content-Type"] = "text/plain"
|
16
|
+
Time.now.httpdate
|
17
|
+
end
|
14
18
|
end
|
15
19
|
|
16
20
|
get "/non-cacheable" do
|
@@ -20,11 +24,11 @@ mock_server do
|
|
20
24
|
|
21
25
|
get "/etag" do
|
22
26
|
if request.env["HTTP_IF_MODIFIED_SINCE"]
|
23
|
-
halt
|
27
|
+
halt 304
|
24
28
|
else
|
25
29
|
time = Time.now
|
26
30
|
|
27
|
-
response.headers["
|
31
|
+
response.headers["ETag"] = time.hash.to_s
|
28
32
|
response.headers["Last-Modified"] = time.httpdate
|
29
33
|
response.headers["Content-Type"] = "text/plain"
|
30
34
|
|
@@ -32,7 +36,25 @@ mock_server do
|
|
32
36
|
end
|
33
37
|
end
|
34
38
|
|
35
|
-
get "/
|
39
|
+
get "/changing-etag" do
|
40
|
+
if request.env["HTTP_IF_MODIFIED_SINCE"]
|
41
|
+
time = Time.parse(request.env["HTTP_IF_MODIFIED_SINCE"]) + 1
|
42
|
+
else
|
43
|
+
time = Time.now
|
44
|
+
|
45
|
+
response.headers["ETag"] = time.hash.to_s
|
46
|
+
response.headers["Last-Modified"] = time.httpdate
|
47
|
+
response.headers["Content-Type"] = "text/plain"
|
48
|
+
end
|
49
|
+
|
50
|
+
time.httpdate
|
51
|
+
end
|
52
|
+
|
53
|
+
get "/utf" do
|
54
|
+
"Aló"
|
55
|
+
end
|
56
|
+
|
57
|
+
def route_missing
|
36
58
|
request.env.map do |name, value|
|
37
59
|
"#{name}: #{value}"
|
38
60
|
end.join("\n")
|
@@ -43,6 +65,26 @@ prepare do
|
|
43
65
|
Redis.current.flushdb
|
44
66
|
end
|
45
67
|
|
68
|
+
test "handles GET" do
|
69
|
+
_, _, body = Cacho.get("http://localhost:4000")
|
70
|
+
|
71
|
+
assert body["REQUEST_METHOD: GET"]
|
72
|
+
end
|
73
|
+
|
74
|
+
test "handles OPTIONS" do
|
75
|
+
_, _, body = Cacho.options("http://localhost:4000")
|
76
|
+
|
77
|
+
assert body["REQUEST_METHOD: OPTIONS"]
|
78
|
+
end
|
79
|
+
|
80
|
+
test "handles HEAD" do
|
81
|
+
status, headers, body = Cacho.head("http://localhost:4000")
|
82
|
+
|
83
|
+
assert status == 200
|
84
|
+
assert headers["Content-Type"]
|
85
|
+
assert body.empty?
|
86
|
+
end
|
87
|
+
|
46
88
|
test "caches cacheable responses" do
|
47
89
|
status, headers, body = Cacho.get("http://localhost:4000/cacheable")
|
48
90
|
|
@@ -51,6 +93,8 @@ test "caches cacheable responses" do
|
|
51
93
|
|
52
94
|
t1 = body
|
53
95
|
|
96
|
+
sleep 1
|
97
|
+
|
54
98
|
status, headers, body = Cacho.get("http://localhost:4000/cacheable")
|
55
99
|
|
56
100
|
assert_equal t1, body
|
@@ -62,6 +106,17 @@ test "caches cacheable responses" do
|
|
62
106
|
assert body > t1
|
63
107
|
end
|
64
108
|
|
109
|
+
test "varies cache by HTTP method" do
|
110
|
+
status1, _, body1 = Cacho.get("http://localhost:4000/cacheable")
|
111
|
+
sleep 1
|
112
|
+
status2, _, body2 = Cacho.options("http://localhost:4000/cacheable")
|
113
|
+
|
114
|
+
assert status1 == 200
|
115
|
+
assert status2 == 200
|
116
|
+
|
117
|
+
assert body2 > body1
|
118
|
+
end
|
119
|
+
|
65
120
|
test "does not cache non-cacheable responses" do
|
66
121
|
_, _, t1 = Cacho.get("http://localhost:4000/non-cacheable")
|
67
122
|
sleep 1
|
@@ -76,10 +131,22 @@ test "performs conditional GETs" do
|
|
76
131
|
_, _, t2 = Cacho.get("http://localhost:4000/etag")
|
77
132
|
|
78
133
|
assert_equal t1, t2
|
134
|
+
|
135
|
+
_, _, t1 = Cacho.get("http://localhost:4000/changing-etag")
|
136
|
+
_, _, t2 = Cacho.get("http://localhost:4000/changing-etag")
|
137
|
+
|
138
|
+
assert t2 > t1
|
79
139
|
end
|
80
140
|
|
81
141
|
test "allows to pass custom HTTP headers" do
|
82
|
-
_, _, body = Cacho.get("http://localhost:4000
|
142
|
+
_, _, body = Cacho.get("http://localhost:4000", "Accept" => "text/plain")
|
143
|
+
|
144
|
+
assert body["HTTP_ACCEPT: text/plain"]
|
145
|
+
end
|
146
|
+
|
147
|
+
test "accepts UTF-encoded bodies" do
|
148
|
+
_, _, body = Cacho.get("http://localhost:4000/utf")
|
83
149
|
|
84
|
-
assert body
|
150
|
+
assert body.include?("Aló")
|
151
|
+
assert body.encoding == Encoding::UTF_8
|
85
152
|
end
|
metadata
CHANGED
@@ -5,8 +5,8 @@ version: !ruby/object:Gem::Version
|
|
5
5
|
segments:
|
6
6
|
- 0
|
7
7
|
- 0
|
8
|
-
-
|
9
|
-
version: 0.0.
|
8
|
+
- 2
|
9
|
+
version: 0.0.2
|
10
10
|
platform: ruby
|
11
11
|
authors:
|
12
12
|
- Damian Janowski
|
@@ -15,7 +15,7 @@ autorequire:
|
|
15
15
|
bindir: bin
|
16
16
|
cert_chain: []
|
17
17
|
|
18
|
-
date: 2010-11-
|
18
|
+
date: 2010-11-26 00:00:00 -03:00
|
19
19
|
default_executable:
|
20
20
|
dependencies:
|
21
21
|
- !ruby/object:Gem::Dependency
|