cacho 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
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