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.
Files changed (4) hide show
  1. data/cacho.gemspec +1 -1
  2. data/lib/cacho.rb +87 -55
  3. data/test/cacho.rb +76 -9
  4. metadata +3 -3
@@ -1,6 +1,6 @@
1
1
  Gem::Specification.new do |s|
2
2
  s.name = "cacho"
3
- s.version = "0.0.1"
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"]
@@ -1,104 +1,136 @@
1
+ # encoding: UTF-8
2
+
3
+ require "json" unless defined?(JSON)
1
4
  require "curb"
2
- require "redis"
3
- require "json"
5
+ require "nest"
4
6
 
5
7
  class Cacho
6
- VERSION = "0.0.1"
8
+ VERSION = "0.0.2"
7
9
 
8
10
  def self.get(url, request_headers = {})
9
- response = Local.get(url)
11
+ _request(:get, url, request_headers)
12
+ end
10
13
 
11
- if response.nil?
12
- response = Remote.get(url, Local.validation_for(url).merge(request_headers))
13
- Local.set(url, response)
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
- def self.get(url)
21
- expire, json = redis.hmget(url, :expire, :response)
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
- if json && (expire.nil? || Time.utc(expire) <= Time.now)
24
- JSON.parse(json)
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 self.set(url, response)
29
- return unless cacheable?(response)
61
+ def set(response)
62
+ @response = response
30
63
 
31
- _, headers, _ = response
64
+ return unless cacheable?
32
65
 
33
- fields = {}
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
- fields[:response] = response.to_json
73
+ @etag = headers["ETag"]
74
+ @last_modified = headers["Last-Modified"]
42
75
 
43
- fields[:etag] = headers["Etag"]
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 self.validation_for(url)
50
- etag, last_modified = redis.hmget(url, :etag, :last_modified)
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
- def self.cacheable?(response)
83
+ protected
84
+
85
+ def cacheable?
59
86
  status, headers, _ = response
60
87
 
61
- status == 200 && (headers["Cache-Control"] || headers["Etag"] || headers["Last-Modified"])
88
+ status == 200 && (headers["Cache-Control"] || headers["ETag"] || headers["Last-Modified"])
62
89
  end
63
90
 
64
- def self.redis
65
- Redis.current
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.get(url, request_headers)
106
+ def self.request(verb, url, request_headers)
71
107
  status = nil
72
108
  headers = {}
73
109
  body = ""
74
110
 
75
- Curl::Easy.http_get(url) do |curl|
76
- curl.headers = request_headers
111
+ curl = Curl::Easy.new(url)
77
112
 
78
- curl.on_header do |header|
79
- headers.store(*header.rstrip.split(": ", 2)) if header.include?(":")
80
- header.bytesize
81
- end
113
+ curl.headers = request_headers
82
114
 
83
- curl.on_body do |string|
84
- body << string
85
- string.bytesize
86
- end
115
+ curl.on_header do |header|
116
+ headers.store(*header.rstrip.split(": ", 2)) if header.include?(":")
117
+ header.bytesize
118
+ end
87
119
 
88
- curl.on_complete do |response|
89
- status = response.response_code
90
- end
120
+ curl.on_body do |string|
121
+ body << string.force_encoding(Encoding::UTF_8)
122
+ string.bytesize
91
123
  end
92
124
 
93
- if status == 301
94
- Local.get(url)
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
- def self.redis
101
- Redis.current
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
@@ -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
- get "/cacheable" do
11
- response.headers["Cache-Control"] = "public, max-age=1"
12
- response.headers["Content-Type"] = "text/plain"
13
- Time.now.httpdate
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 301
27
+ halt 304
24
28
  else
25
29
  time = Time.now
26
30
 
27
- response.headers["Etag"] = time.hash.to_s
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 "/echo" do
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/echo", "Accept" => "text/plain")
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 =~ %r{HTTP_ACCEPT: text/plain}
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
- - 1
9
- version: 0.0.1
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-22 00:00:00 -03:00
18
+ date: 2010-11-26 00:00:00 -03:00
19
19
  default_executable:
20
20
  dependencies:
21
21
  - !ruby/object:Gem::Dependency