api_cache 0.1.2 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.rdoc ADDED
@@ -0,0 +1,94 @@
1
+ = APICache (aka api_cache)
2
+
3
+ APICache allows any API client library to be easily wrapped with a robust caching layer. It supports caching (obviously), serving stale data and limits on the number of API calls. It's also got a handy syntax if all you want to do is cache a bothersome url.
4
+
5
+ == For the impatient
6
+
7
+ # Install
8
+ sudo gem install api_cache -s http://gemcutter.org
9
+
10
+ # Require
11
+ require 'rubygems'
12
+ require 'api_cache'
13
+
14
+ # Use
15
+ APICache.get("http://twitter.com/statuses/public_timeline.rss")
16
+
17
+ # Use a proper store
18
+ require 'moneta/memcache'
19
+ APICache.store = Moneta::Memcache.new(:server => "localhost")
20
+
21
+ # Wrap an API, and handle the failure case
22
+
23
+ APICache.get("my_albums", :fail => []) do
24
+ FlickrRb.get_all_sets
25
+ end
26
+
27
+ == The longer version
28
+
29
+ You want to use the Twitter API but you don't want to die?
30
+
31
+ APICache.get("http://twitter.com/statuses/public_timeline.rss")
32
+
33
+ This works better than a standard HTTP get because you get the following functionality for free:
34
+
35
+ * Cached response returned for 10 minutes
36
+ * Stale response returned for a day if twitter is down
37
+ * Limited to attempt a connection at most once a minute
38
+
39
+ To understand what <tt>APICache</tt> does here's an example: Given cached data less than 10 minutes old, it returns that. Otherwise, assuming it didn't try to request the URL within the last minute (to avoid the rate limit), it makes a get request to the supplied url. If the Twitter API timeouts or doesn't return a 2xx code (very likely) we're still fine: it just returns the last data fetched (as long as it's less than a day old). In the exceptional case that all is lost and no data can be returned, a subclass of <tt>APICache::APICacheError</tt> is raised which you're responsible for rescuing.
40
+
41
+ Assuming that you don't care whether it was a timeout error or an invalid response (for example) you could do this:
42
+
43
+ begin
44
+ APICache.get("http://twitter.com/statuses/public_timeline.rss")
45
+ rescue APICache::APICacheError
46
+ "Fail Whale"
47
+ end
48
+
49
+ However there's an easier way if you don't care exactly why the API call failed. You can just pass the :fail parameter (procs are accepted too) and all exceptions will be rescued for you. So this is exactly equivalent:
50
+
51
+ APICache.get("http://twitter.com/statuses/public_timeline.rss", {
52
+ :fail => "Fail Whale"
53
+ })
54
+
55
+ The real value however is not caching HTTP calls, but allowing caching functionality to be easily added to existing API client gems, or in fact any arbitrary code which is either slow or not guaranteed to succeed every time.
56
+
57
+ APICache.get('twitter_replies', :cache => 3600) do
58
+ Net::HTTP.start('twitter.com') do |http|
59
+ req = Net::HTTP::Get.new('/statuses/replies.xml')
60
+ req.basic_auth 'username', 'password'
61
+ response = http.request(req)
62
+ case response
63
+ when Net::HTTPSuccess
64
+ # 2xx response code
65
+ response.body
66
+ else
67
+ raise APICache::InvalidResponse
68
+ end
69
+ end
70
+ end
71
+
72
+ The first argument to <tt>APICache.get</tt> is now assumed to be a unique key rather than a URL. As you'd expect, the block will only be called if the request cannot be fulfilled by the cache. Throwing any exception signals to <tt>APICache</tt> that the request was not successful, should not be cached, and a cached value should be returned if available. If a cached value is not available then the exception will be re-raised for you to handle.
73
+
74
+ You can send any of the following options to <tt>APICache.get(url, options = {}, &block)</tt>. These are the default values (times are all in seconds):
75
+
76
+ {
77
+ :cache => 600, # 10 minutes After this time fetch new data
78
+ :valid => 86400, # 1 day Maximum time to use old data
79
+ # :forever is a valid option
80
+ :period => 60, # 1 minute Maximum frequency to call API
81
+ :timeout => 5 # 5 seconds API response timeout
82
+ :fail => # Value returned instead of exception on failure
83
+ }
84
+
85
+ Before using the APICache you should set the cache to use. By default an in memory hash is used - obviously not a great idea. Thankfully APICache can use any moneta store, so for example if you wanted to use memcache you'd do this:
86
+
87
+ require 'moneta/memcache'
88
+ APICache.store = Moneta::Memcache.new(:server => "localhost")
89
+
90
+ Please be liberal with the github issue tracker, more so with pull requests, or drop me a mail to me [at] mloughran [dot] com. I'd love to hear from you.
91
+
92
+ == Copyright
93
+
94
+ Copyright (c) 2008 Martyn Loughran. See LICENSE for details.
data/VERSION.yml CHANGED
@@ -1,4 +1,4 @@
1
1
  ---
2
- :patch: 2
2
+ :patch: 0
3
3
  :major: 0
4
- :minor: 1
4
+ :minor: 2
data/lib/api_cache.rb CHANGED
@@ -1,64 +1,128 @@
1
+ require 'logger'
2
+
3
+ # Contains the complete public API for APICache.
4
+ #
5
+ # See APICache.get method and the README file.
6
+ #
7
+ # Before using APICache you should set the store to use using
8
+ # APICache.store=. For convenience, when no store is set an in memory store
9
+ # will be used (and a warning will be logged).
10
+ #
1
11
  class APICache
2
- class NotAvailableError < RuntimeError; end
3
- class Invalid < RuntimeError; end
4
- class CannotFetch < RuntimeError; end
5
-
12
+ class APICacheError < RuntimeError; end
13
+ class NotAvailableError < APICacheError; end
14
+ class TimeoutError < NotAvailableError; end
15
+ class InvalidResponse < NotAvailableError; end
16
+ class CannotFetch < NotAvailableError; end
17
+
6
18
  class << self
7
- attr_accessor :cache
8
- attr_accessor :api
9
19
  attr_accessor :logger
20
+ attr_accessor :store
21
+
22
+ def logger # :nodoc:
23
+ @logger ||= begin
24
+ log = Logger.new(STDOUT)
25
+ log.level = Logger::INFO
26
+ log
27
+ end
28
+ end
29
+
30
+ # Set the logger to use. If not set, <tt>Logger.new(STDOUT)</tt> will be
31
+ # used.
32
+ #
33
+ def logger=(logger)
34
+ @logger = logger
35
+ end
36
+
37
+ def store # :nodoc:
38
+ @store ||= begin
39
+ APICache.logger.warn("Using in memory store")
40
+ APICache::MemoryStore.new
41
+ end
42
+ end
43
+
44
+ # Set the cache store to use. This should either be an instance of a
45
+ # moneta store or a subclass of APICache::AbstractStore. Moneta is
46
+ # recommended.
47
+ #
48
+ def store=(store)
49
+ @store = begin
50
+ if store.class < APICache::AbstractStore
51
+ store
52
+ elsif store.class.to_s =~ /Moneta/
53
+ MonetaStore.new(store)
54
+ elsif store.nil?
55
+ nil
56
+ else
57
+ raise ArgumentError, "Please supply an instance of either a moneta store or a subclass of APICache::AbstractStore"
58
+ end
59
+ end
60
+ end
10
61
  end
11
-
12
- # Initializes the cache
13
- def self.start(store = APICache::MemcacheStore, logger = APICache::Logger.new)
14
- APICache.logger = logger
15
- APICache.cache = APICache::Cache.new(store)
16
- APICache.api = APICache::API.new
17
- end
18
-
19
- # Raises an APICache::NotAvailableError if it can't get a value. You should rescue this
20
- # if your application code.
21
- #
22
- # Optionally call with a block. The value of the block is then used to
62
+
63
+ # Optionally call with a block. The value of the block is then used to
23
64
  # set the cache rather than calling the url. Use it for example if you need
24
65
  # to make another type of request, catch custom error codes etc. To signal
25
- # that the call failed just raise APICache::Invalid - the value will then
26
- # not be cached and the api will not be called again for options[:timeout]
27
- # seconds. If an old value is available in the cache then it will be used.
28
- #
66
+ # that the call failed just raise any exception - the value will then
67
+ # not be cached and the api will not be called again for options[:timeout]
68
+ # seconds. If an old value is available in the cache then it will be
69
+ # returned.
70
+ #
71
+ # An exception will be raised if the API cannot be fetched and the request
72
+ # cannot be served by the cache. This will either be a subclass of
73
+ # APICache::Error or an exception raised by the provided block.
74
+ #
29
75
  # For example:
30
76
  # APICache.get("http://twitter.com/statuses/user_timeline/6869822.atom")
31
- #
77
+ #
32
78
  # APICache.get \
33
79
  # "http://twitter.com/statuses/user_timeline/6869822.atom",
34
80
  # :cache => 60, :valid => 600
81
+ #
35
82
  def self.get(key, options = {}, &block)
36
83
  options = {
37
84
  :cache => 600, # 10 minutes After this time fetch new data
38
85
  :valid => 86400, # 1 day Maximum time to use old data
39
- # :forever is a valid option
86
+ # :forever is a valid option
40
87
  :period => 60, # 1 minute Maximum frequency to call API
41
88
  :timeout => 5 # 5 seconds API response timeout
42
89
  }.merge(options)
43
-
44
- cache_state = cache.state(key, options[:cache], options[:valid])
45
-
90
+
91
+ cache = APICache::Cache.new(key, {
92
+ :cache => options[:cache],
93
+ :valid => options[:valid]
94
+ })
95
+
96
+ api = APICache::API.new(key, {
97
+ :period => options[:period],
98
+ :timeout => options[:timeout]
99
+ }, &block)
100
+
101
+ cache_state = cache.state
102
+
46
103
  if cache_state == :current
47
- cache.get(key)
104
+ cache.get
48
105
  else
49
106
  begin
50
- raise APICache::CannotFetch unless api.queryable?(key, options[:period])
51
- value = api.get(key, options[:timeout], &block)
52
- cache.set(key, value)
107
+ value = api.get
108
+ cache.set(value)
53
109
  value
54
- rescue APICache::CannotFetch => e
55
- APICache.logger.log "Failed to fetch new data from API because " \
110
+ rescue => e
111
+ APICache.logger.info "Failed to fetch from API - Exception: " \
56
112
  "#{e.class}: #{e.message}"
113
+ # No point outputting backgraces for internal APICache errors
114
+ APICache.logger.debug "Backtrace:\n#{e.backtrace.join("\n")}" unless e.kind_of?(APICacheError)
115
+
57
116
  if cache_state == :refetch
58
- cache.get(key)
117
+ cache.get
59
118
  else
60
- APICache.logger.log "Data not available in the cache or from API"
61
- raise APICache::NotAvailableError, e.message
119
+ APICache.logger.warn "Data not available in the cache or from API for key #{@key}"
120
+ if options.has_key?(:fail)
121
+ fail = options[:fail]
122
+ fail.respond_to?(:call) ? fail.call : fail
123
+ else
124
+ raise e
125
+ end
62
126
  end
63
127
  end
64
128
  end
@@ -67,8 +131,7 @@ end
67
131
 
68
132
  require 'api_cache/cache'
69
133
  require 'api_cache/api'
70
- require 'api_cache/logger'
71
134
 
72
135
  APICache.autoload 'AbstractStore', 'api_cache/abstract_store'
73
136
  APICache.autoload 'MemoryStore', 'api_cache/memory_store'
74
- APICache.autoload 'MemcacheStore', 'api_cache/memcache_store'
137
+ APICache.autoload 'MonetaStore', 'api_cache/moneta_store'
@@ -1,25 +1,27 @@
1
- class APICache::AbstractStore
2
- def initialize
3
- raise "Method not implemented. Called abstract class."
4
- end
5
-
6
- # Set value. Returns true if success.
7
- def set(key, value)
8
- raise "Method not implemented. Called abstract class."
9
- end
10
-
11
- # Get value.
12
- def get(key)
13
- raise "Method not implemented. Called abstract class."
14
- end
15
-
16
- # Does a given key exist in the cache?
17
- def exists?(key)
18
- raise "Method not implemented. Called abstract class."
19
- end
20
-
21
- # Has a given time passed since the key was set?
22
- def expired?(key, timeout)
23
- raise "Method not implemented. Called abstract class."
1
+ class APICache
2
+ class AbstractStore
3
+ def initialize
4
+ raise "Method not implemented. Called abstract class."
5
+ end
6
+
7
+ # Set value. Returns true if success.
8
+ def set(key, value)
9
+ raise "Method not implemented. Called abstract class."
10
+ end
11
+
12
+ # Get value.
13
+ def get(key)
14
+ raise "Method not implemented. Called abstract class."
15
+ end
16
+
17
+ # Does a given key exist in the cache?
18
+ def exists?(key)
19
+ raise "Method not implemented. Called abstract class."
20
+ end
21
+
22
+ # Has a given time passed since the key was set?
23
+ def expired?(key, timeout)
24
+ raise "Method not implemented. Called abstract class."
25
+ end
24
26
  end
25
27
  end
data/lib/api_cache/api.rb CHANGED
@@ -1,70 +1,97 @@
1
1
  require 'net/http'
2
2
 
3
- # This class wraps up querying the API and remembers when each API was
4
- # last queried in case there is a limit to the number that can be made.
5
- class APICache::API
6
- def initialize
7
- @query_times = {}
8
- end
9
-
10
- # Checks whether the API can be queried (i.e. whether retry_time has passed
11
- # since the last query to the API).
12
- #
13
- # If retry_time is 0 then there is no limit on how frequently queries can
14
- # be made to the API.
15
- def queryable?(key, retry_time)
16
- if @query_times[key]
17
- if Time.now - @query_times[key] > retry_time
18
- APICache.logger.log "Queryable: true - retry_time has passed"
19
- true
3
+ class APICache
4
+ # Wraps up querying the API.
5
+ #
6
+ # Ensures that the API is not called more frequently than every +period+
7
+ # seconds, and times out API requests after +timeout+ seconds.
8
+ #
9
+ class API
10
+ # Takes the following options
11
+ #
12
+ # period:: Maximum frequency to call the API. If set to 0 then there is no
13
+ # limit on how frequently queries can be made to the API.
14
+ # timeout:: Timeout when calling api (either to the proviced url or
15
+ # excecuting the passed block)
16
+ # block:: If passed then the block is excecuted instead of HTTP GET
17
+ # against the provided key
18
+ #
19
+ def initialize(key, options, &block)
20
+ @key, @block = key, block
21
+ @timeout = options[:timeout]
22
+ @period = options[:period]
23
+ end
24
+
25
+ # Fetch data from the API.
26
+ #
27
+ # If no block is given then the key is assumed to be a URL and which will
28
+ # be queried expecting a 200 response. Otherwise the return value of the
29
+ # block will be used.
30
+ #
31
+ # This method can raise Timeout::Error, APICache::InvalidResponse, or any
32
+ # exception raised in the block passed to APICache.get
33
+ #
34
+ def get
35
+ check_queryable!
36
+ APICache.logger.debug "Fetching data from the API"
37
+ set_queried_at
38
+ Timeout::timeout(@timeout) do
39
+ if @block
40
+ # If this call raises an error then the response is not cached
41
+ @block.call
42
+ else
43
+ get_key_via_http
44
+ end
45
+ end
46
+ rescue Timeout::Error => e
47
+ raise APICache::TimeoutError, "Timed out when calling API (timeout #{@timeout}s)"
48
+ end
49
+
50
+ private
51
+
52
+ def get_key_via_http
53
+ response = redirecting_get(@key)
54
+ case response
55
+ when Net::HTTPSuccess
56
+ # 2xx response code
57
+ response.body
20
58
  else
21
- APICache.logger.log "Queryable: false - queried too recently"
22
- false
59
+ raise APICache::InvalidResponse, "InvalidResponse http response: #{response.code}"
23
60
  end
24
- else
25
- APICache.logger.log "Queryable: true - never used API before"
26
- true
27
61
  end
28
- end
29
-
30
- # Fetch data from the API.
31
- #
32
- # If no block is given then the key is assumed to be a URL and which will
33
- # be queried expecting a 200 response. Otherwise the return value of the
34
- # block will be used.
35
- #
36
- # If the block is unable to fetch the value from the API it should raise
37
- # APICache::Invalid.
38
- def get(key, timeout, &block)
39
- APICache.logger.log "Fetching data from the API"
40
- @query_times[key] = Time.now
41
- Timeout::timeout(timeout) do
42
- if block_given?
43
- # This should raise APICache::Invalid if it is not correct
44
- yield
62
+
63
+ def redirecting_get(url)
64
+ r = Net::HTTP.get_response(URI.parse(url))
65
+ r.header['location'] ? redirecting_get(r.header['location']) : r
66
+ end
67
+
68
+ # Checks whether the API can be queried (i.e. whether :period has passed
69
+ # since the last query to the API).
70
+ #
71
+ def check_queryable!
72
+ if previously_queried?
73
+ if Time.now - queried_at > @period
74
+ APICache.logger.debug "Queryable: true - retry_time has passed"
75
+ else
76
+ APICache.logger.debug "Queryable: false - queried too recently"
77
+ raise APICache::CannotFetch,
78
+ "Cannot fetch #{@key}: queried too recently"
79
+ end
45
80
  else
46
- get_via_http(key, timeout)
81
+ APICache.logger.debug "Queryable: true - never used API before"
47
82
  end
48
83
  end
49
- rescue Timeout::Error, APICache::Invalid => e
50
- raise APICache::CannotFetch, e.message
51
- end
52
-
53
- private
54
-
55
- def get_via_http(key, timeout)
56
- response = redirecting_get(key)
57
- case response
58
- when Net::HTTPSuccess
59
- # 2xx response code
60
- response.body
61
- else
62
- raise APICache::Invalid, "Invalid http response: #{response.code}"
84
+
85
+ def previously_queried?
86
+ APICache.store.exists?("#{@key}_queried_at")
87
+ end
88
+
89
+ def queried_at
90
+ APICache.store.get("#{@key}_queried_at")
63
91
  end
64
- end
65
92
 
66
- def redirecting_get(url)
67
- r = Net::HTTP.get_response(URI.parse(url))
68
- r.header['location'] ? redirecting_get(r.header['location']) : r
93
+ def set_queried_at
94
+ APICache.store.set("#{@key}_queried_at", Time.now)
95
+ end
69
96
  end
70
97
  end
@@ -1,41 +1,60 @@
1
- # Cache performs calculations relating to the status of items stored in the
2
- # cache and delegates storage to the various cache stores.
3
1
  require 'digest/md5'
4
- class APICache::Cache
5
- attr_accessor :store
6
- def initialize(store)
7
- @store = store.send(:new)
8
- end
9
-
10
- # Returns one of the following options depending on the state of the key:
11
- #
12
- # * :current (key has been set recently)
13
- # * :refetch (data should be refetched but is still available for use)
14
- # * :invalid (data is too old to be useful)
15
- # * :missing (do data for this key)
16
- def state(key, refetch_time, invalid_time)
17
- if @store.exists?(encode(key))
18
- if !@store.expired?(encode(key), refetch_time)
19
- :current
20
- elsif (invalid_time == :forever) || !@store.expired?(encode(key), invalid_time)
21
- :refetch
2
+
3
+ class APICache
4
+ # Cache performs calculations relating to the status of items stored in the
5
+ # cache and delegates storage to the various cache stores.
6
+ #
7
+ class Cache
8
+ # Takes the following options
9
+ #
10
+ # cache:: Length of time to cache before re-requesting
11
+ # valid:: Length of time to consider data still valid if API cannot be
12
+ # fetched - :forever is a valid option.
13
+ #
14
+ def initialize(key, options)
15
+ @key = key
16
+ @cache = options[:cache]
17
+ @valid = options[:valid]
18
+ end
19
+
20
+ # Returns one of the following options depending on the state of the key:
21
+ #
22
+ # * :current (key has been set recently)
23
+ # * :refetch (data should be refetched but is still available for use)
24
+ # * :invalid (data is too old to be useful)
25
+ # * :missing (do data for this key)
26
+ #
27
+ def state
28
+ if store.exists?(hash)
29
+ if !store.expired?(hash, @cache)
30
+ :current
31
+ elsif (@valid == :forever) || !store.expired?(hash, @valid)
32
+ :refetch
33
+ else
34
+ :invalid
35
+ end
22
36
  else
23
- :invalid
37
+ :missing
24
38
  end
25
- else
26
- :missing
27
39
  end
28
- end
29
-
30
- def get(key)
31
- @store.get(encode(key))
32
- end
33
-
34
- def set(key, value)
35
- @store.set(encode(key), value)
36
- true
37
- end
38
- def encode(key)
39
- Digest::MD5.hexdigest key
40
+
41
+ def get
42
+ store.get(hash)
43
+ end
44
+
45
+ def set(value)
46
+ store.set(hash, value)
47
+ true
48
+ end
49
+
50
+ private
51
+
52
+ def hash
53
+ Digest::MD5.hexdigest @key
54
+ end
55
+
56
+ def store
57
+ APICache.store
58
+ end
40
59
  end
41
60
  end
@@ -1,33 +1,35 @@
1
- class APICache::MemoryStore < APICache::AbstractStore
2
- def initialize
3
- APICache.logger.log "Using memory store"
4
- @cache = {}
5
- true
6
- end
1
+ class APICache
2
+ class MemoryStore < APICache::AbstractStore
3
+ def initialize
4
+ APICache.logger.debug "Using memory store"
5
+ @cache = {}
6
+ true
7
+ end
7
8
 
8
- def set(key, value)
9
- APICache.logger.log("cache: set (#{key})")
10
- @cache[key] = [Time.now, value]
11
- true
12
- end
9
+ def set(key, value)
10
+ APICache.logger.debug("cache: set (#{key})")
11
+ @cache[key] = [Time.now, value]
12
+ true
13
+ end
13
14
 
14
- def get(key)
15
- data = @cache[key][1]
16
- APICache.logger.log("cache: #{data.nil? ? "miss" : "hit"} (#{key})")
17
- data
18
- end
15
+ def get(key)
16
+ data = @cache[key][1]
17
+ APICache.logger.debug("cache: #{data.nil? ? "miss" : "hit"} (#{key})")
18
+ data
19
+ end
19
20
 
20
- def exists?(key)
21
- !@cache[key].nil?
22
- end
23
-
24
- def expired?(key, timeout)
25
- Time.now - created(key) > timeout
26
- end
21
+ def exists?(key)
22
+ !@cache[key].nil?
23
+ end
24
+
25
+ def expired?(key, timeout)
26
+ Time.now - created(key) > timeout
27
+ end
27
28
 
28
- private
29
+ private
29
30
 
30
- def created(key)
31
- @cache[key][0]
31
+ def created(key)
32
+ @cache[key][0]
33
+ end
32
34
  end
33
35
  end
@@ -0,0 +1,29 @@
1
+ class APICache
2
+ class MonetaStore < APICache::AbstractStore
3
+ def initialize(store)
4
+ @moneta = store
5
+ end
6
+
7
+ # Set value. Returns true if success.
8
+ def set(key, value)
9
+ @moneta[key] = value
10
+ @moneta["#{key}_created_at"] = Time.now
11
+ true
12
+ end
13
+
14
+ # Get value.
15
+ def get(key)
16
+ @moneta[key]
17
+ end
18
+
19
+ # Does a given key exist in the cache?
20
+ def exists?(key)
21
+ @moneta.key?(key)
22
+ end
23
+
24
+ # Has a given time passed since the key was set?
25
+ def expired?(key, timeout)
26
+ Time.now - @moneta["#{key}_created_at"] > timeout
27
+ end
28
+ end
29
+ end
@@ -3,70 +3,99 @@ require File.dirname(__FILE__) + '/spec_helper'
3
3
  describe APICache do
4
4
  before :each do
5
5
  @key = 'random_key'
6
- @encoded_key = "1520171c64bfb71a95c97d310eea3492"
7
- @data = 'some bit of data'
6
+ @cache_data = 'data from the cache'
7
+ @api_data = 'data from the api'
8
8
  end
9
9
 
10
- describe "get method" do
11
-
12
- it "should MD5 encode the cache key" do
13
- APICache::Cache.new(APICache::MemoryStore).encode(@key).should == @encoded_key
10
+ describe "store=" do
11
+ before :each do
12
+ APICache.store = nil
13
+ end
14
+
15
+ it "should use APICache::MemoryStore by default" do
16
+ APICache.store.should be_kind_of(APICache::MemoryStore)
17
+ end
18
+
19
+ it "should allow instances of APICache::AbstractStore to be passed" do
20
+ APICache.store = APICache::MemoryStore.new
21
+ APICache.store.should be_kind_of(APICache::MemoryStore)
22
+ end
23
+
24
+ it "should allow moneta instances to be passed" do
25
+ require 'moneta'
26
+ require 'moneta/memory'
27
+ APICache.store = Moneta::Memory.new
28
+ APICache.store.should be_kind_of(APICache::MonetaStore)
14
29
  end
15
- it "should encode the cache key before calling the store" do
16
- APICache.cache.should_receive(:state).and_return(:current)
17
- APICache.cache.store.should_receive(:get).with(@encoded_key).and_return(@data)
18
- APICache.cache.should_receive(:encode).with(@key).and_return(@encoded_key)
19
- APICache.get(@key).should != @data
30
+
31
+ it "should raise an exception if anything else is passed" do
32
+ lambda {
33
+ APICache.store = Class
34
+ }.should raise_error(ArgumentError, 'Please supply an instance of either a moneta store or a subclass of APICache::AbstractStore')
35
+ end
36
+
37
+ after :all do
38
+ APICache.store = nil
39
+ end
40
+ end
41
+
42
+ describe "get method" do
43
+ before :each do
44
+ @api = mock(APICache::API, :get => @api_data)
45
+ @cache = mock(APICache::Cache, :get => @cache_data, :set => true)
46
+
47
+ APICache::API.stub!(:new).and_return(@api)
48
+ APICache::Cache.stub!(:new).and_return(@cache)
20
49
  end
21
50
 
22
51
  it "should fetch data from the cache if the state is :current" do
23
- APICache.cache.should_receive(:state).and_return(:current)
24
- APICache.cache.should_receive(:get).and_return(@data)
52
+ @cache.stub!(:state).and_return(:current)
25
53
 
26
- APICache.get(@key).should == @data
54
+ APICache.get(@key).should == @cache_data
27
55
  end
28
56
 
29
- it "should make new request to API if the state is :refetch" do
30
- APICache.cache.should_receive(:state).and_return(:refetch)
31
- APICache.api.should_receive(:get).with(@key, 5).and_return(@data)
57
+ it "should make new request to API if the state is :refetch and store result in cache" do
58
+ @cache.stub!(:state).and_return(:refetch)
59
+ @cache.should_receive(:set).with(@api_data)
32
60
 
33
- APICache.get(@key).should == @data
61
+ APICache.get(@key).should == @api_data
34
62
  end
35
63
 
36
- it "should return the cached value if the api cannot fetch data and state is :refetch" do
37
- APICache.cache.should_receive(:state).and_return(:refetch)
38
- APICache.api.should_receive(:get).with(@key, 5).and_raise(APICache::CannotFetch)
39
- APICache.cache.should_receive(:get).and_return(@data)
64
+ it "should return the cached value if the state is :refetch but the api is not accessible" do
65
+ @cache.stub!(:state).and_return(:refetch)
66
+ @api.should_receive(:get).with.and_raise(APICache::CannotFetch)
40
67
 
41
- APICache.get(@key).should == @data
68
+ APICache.get(@key).should == @cache_data
42
69
  end
43
70
 
44
71
  it "should make new request to API if the state is :invalid" do
45
- APICache.cache.should_receive(:state).and_return(:invalid)
46
- APICache.api.should_receive(:get).with(@key, 5).and_return(@data)
72
+ @cache.stub!(:state).and_return(:invalid)
47
73
 
48
- APICache.get(@key).should == @data
74
+ APICache.get(@key).should == @api_data
49
75
  end
50
76
 
51
- it "should raise an exception if the api cannot fetch data and state is :invalid" do
52
- APICache.cache.should_receive(:state).and_return(:invalid)
53
- APICache.api.should_receive(:get).with(@key, 5).and_raise(APICache::CannotFetch)
77
+ it "should raise CannotFetch if the api cannot fetch data and the cache state is :invalid" do
78
+ @cache.stub!(:state).and_return(:invalid)
79
+ @api.should_receive(:get).with.and_raise(APICache::CannotFetch)
54
80
 
55
- lambda { APICache.get(@key).should }.should raise_error(APICache::NotAvailableError)
81
+ lambda {
82
+ APICache.get(@key).should
83
+ }.should raise_error(APICache::CannotFetch)
56
84
  end
57
85
 
58
86
  it "should make new request to API if the state is :missing" do
59
- APICache.cache.should_receive(:state).and_return(:missing)
60
- APICache.api.should_receive(:get).with(@key, 5).and_return(@data)
87
+ @cache.stub!(:state).and_return(:missing)
61
88
 
62
- APICache.get(@key).should == @data
89
+ APICache.get(@key).should == @api_data
63
90
  end
64
91
 
65
- it "should raise an exception if the api cannot fetch data and state is :missing" do
66
- APICache.cache.should_receive(:state).and_return(:missing)
67
- APICache.api.should_receive(:get).with(@key, 5).and_raise(APICache::CannotFetch)
92
+ it "should raise an exception if the api cannot fetch data and the cache state is :missing" do
93
+ @cache.stub!(:state).and_return(:missing)
94
+ @api.should_receive(:get).with.and_raise(APICache::CannotFetch)
68
95
 
69
- lambda { APICache.get(@key).should }.should raise_error(APICache::NotAvailableError)
96
+ lambda {
97
+ APICache.get(@key).should
98
+ }.should raise_error(APICache::CannotFetch)
70
99
  end
71
100
  end
72
101
  end
data/spec/api_spec.rb CHANGED
@@ -1,10 +1,65 @@
1
1
  require File.dirname(__FILE__) + '/spec_helper'
2
2
 
3
3
  describe APICache::API do
4
- it "should handle redirecting get requests" do
5
- api = APICache::API.new
4
+ before :each do
5
+ FakeWeb.register_uri(:get, "http://www.google.com/", :body => "Google")
6
+ FakeWeb.register_uri(:get, "http://froogle.google.com/", :status => 302, :location => "http://products.google.com")
7
+ FakeWeb.register_uri(:get, "http://products.google.com/", :body => "Google Product Search")
8
+
9
+ @options = {
10
+ :period => 1,
11
+ :timeout => 5
12
+ }
13
+
14
+ # Reset the store otherwise get queried too recently erros
15
+ APICache.store = nil
16
+ end
17
+
18
+ it "should not be queryable for :period seconds after a request" do
19
+ api = APICache::API.new('http://www.google.com/', @options)
20
+
21
+ api.get
6
22
  lambda {
7
- api.get('http://froogle.google.com', 5)
8
- }.should_not raise_error(APICache::CannotFetch)
23
+ api.get
24
+ }.should raise_error(APICache::CannotFetch, "Cannot fetch http://www.google.com/: queried too recently")
25
+
26
+ sleep 1
27
+ lambda {
28
+ api.get
29
+ }.should_not raise_error
30
+ end
31
+
32
+ describe "without a block - key is the url" do
33
+
34
+ it "should return body of a http GET against the key" do
35
+ api = APICache::API.new('http://www.google.com/', @options)
36
+ api.get.should =~ /Google/
37
+ end
38
+
39
+ it "should handle redirecting get requests" do
40
+ api = APICache::API.new('http://froogle.google.com/', @options)
41
+ api.get.should =~ /Google Product Search/
42
+ end
43
+
44
+ end
45
+
46
+ describe "with a block" do
47
+
48
+ it "should return the return value of the block" do
49
+ api = APICache::API.new('http://www.google.com/', @options) do
50
+ 42
51
+ end
52
+ api.get.should == 42
53
+ end
54
+
55
+ it "should return the raised exception if the block raises one" do
56
+ api = APICache::API.new('foobar', @options) do
57
+ raise RuntimeError, 'foo'
58
+ end
59
+ lambda {
60
+ api.get
61
+ }.should raise_error(StandardError, 'foo')
62
+ end
63
+
9
64
  end
10
- end
65
+ end
@@ -0,0 +1,36 @@
1
+ require File.dirname(__FILE__) + '/spec_helper'
2
+
3
+ describe APICache::Cache do
4
+ before :each do
5
+ @options = {
6
+ :cache => 1, # After this time fetch new data
7
+ :valid => 2 # Maximum time to use old data
8
+ }
9
+ end
10
+
11
+ it "should set and get" do
12
+ cache = APICache::Cache.new('flubble', @options)
13
+
14
+ cache.set('Hello world')
15
+ cache.get.should == 'Hello world'
16
+ end
17
+
18
+ it "should md5 encode the provided key" do
19
+ cache = APICache::Cache.new('test_md5', @options)
20
+ APICache.store.should_receive(:set).
21
+ with('9050bddcf415f2d0518804e551c1be98', 'md5ing?')
22
+ cache.set('md5ing?')
23
+ end
24
+
25
+ it "should report correct invalid states" do
26
+ cache = APICache::Cache.new('foo', @options)
27
+
28
+ cache.state.should == :missing
29
+ cache.set('foo')
30
+ cache.state.should == :current
31
+ sleep 1
32
+ cache.state.should == :refetch
33
+ sleep 1
34
+ cache.state.should == :invalid
35
+ end
36
+ end
@@ -0,0 +1,73 @@
1
+ require File.dirname(__FILE__) + '/spec_helper'
2
+
3
+ describe "api_cache" do
4
+ before :each do
5
+ FakeWeb.register_uri(:get, "http://www.google.com/", :body => "Google")
6
+
7
+ APICache.store = nil
8
+ end
9
+
10
+ it "should work when url supplied" do
11
+ APICache.get('http://www.google.com/').should =~ /Google/
12
+ end
13
+
14
+ it "should work when block supplied" do
15
+ APICache.get('foobar') do
16
+ 42
17
+ end.should == 42
18
+ end
19
+
20
+ it "should raise error raised in block unless key available in cache" do
21
+ lambda {
22
+ APICache.get('foo') do
23
+ raise RuntimeError, 'foo'
24
+ end
25
+ }.should raise_error(RuntimeError, 'foo')
26
+
27
+ APICache.get('foo', :period => 0) do
28
+ 'bar'
29
+ end
30
+
31
+ lambda {
32
+ APICache.get('foo') do
33
+ raise RuntimeError, 'foo'
34
+ end
35
+ }.should_not raise_error
36
+ end
37
+
38
+ it "should raise APICache::TimeoutError if the API call times out unless data available in cache" do
39
+ lambda {
40
+ APICache.get('foo', :timeout => 1) do
41
+ sleep 1.1
42
+ end
43
+ }.should raise_error APICache::TimeoutError, 'Timed out when calling API (timeout 1s)'
44
+
45
+ APICache.get('foo', :period => 0) do
46
+ 'bar'
47
+ end
48
+
49
+ lambda {
50
+ APICache.get('foo', :timeout => 1) do
51
+ sleep 1.1
52
+ end
53
+ }.should_not raise_error
54
+ end
55
+
56
+ it "should return a default value rather than raising an exception if :fail passed" do
57
+ APICache.get('foo', :fail => "bar") do
58
+ raise 'foo'
59
+ end.should == 'bar'
60
+ end
61
+
62
+ it "should accept a proc to fail" do
63
+ APICache.get('foo', :fail => lambda { "bar" }) do
64
+ raise 'foo'
65
+ end.should == 'bar'
66
+ end
67
+
68
+ it "should accept nil values for :fail" do
69
+ APICache.get('foo', :fail => nil) do
70
+ raise 'foo'
71
+ end.should == nil
72
+ end
73
+ end
@@ -0,0 +1,30 @@
1
+ require File.dirname(__FILE__) + '/spec_helper'
2
+
3
+ require 'moneta/memcache'
4
+
5
+ describe APICache::MonetaStore do
6
+ before :each do
7
+ @moneta = Moneta::Memcache.new(:server => "localhost")
8
+ @moneta.delete('foo')
9
+ @store = APICache::MonetaStore.new(@moneta)
10
+ end
11
+
12
+ it "should set and get" do
13
+ @store.set("key", "value")
14
+ @store.get("key").should == "value"
15
+ end
16
+
17
+ it "should allow checking whether a key exists" do
18
+ @store.exists?('foo').should be_false
19
+ @store.set('foo', 'bar')
20
+ @store.exists?('foo').should be_true
21
+ end
22
+
23
+ it "should allow checking whether a given amount of time has passed since the key was set" do
24
+ @store.expired?('foo', 1).should be_false
25
+ @store.set('foo', 'bar')
26
+ @store.expired?('foo', 1).should be_false
27
+ sleep 1
28
+ @store.expired?('foo', 1).should be_true
29
+ end
30
+ end
data/spec/spec_helper.rb CHANGED
@@ -1,7 +1,7 @@
1
- $TESTING=true
2
1
  $:.push File.join(File.dirname(__FILE__), '..', 'lib')
3
2
  require "rubygems"
4
3
  require "api_cache"
5
4
  require "spec"
5
+ require "fakeweb"
6
6
 
7
- APICache.start(APICache::MemoryStore)
7
+ APICache.logger.level = Logger::FATAL
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: api_cache
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Martyn Loughran
@@ -9,11 +9,30 @@ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
11
 
12
- date: 2009-03-30 00:00:00 +02:00
12
+ date: 2009-08-21 00:00:00 +02:00
13
13
  default_executable:
14
- dependencies: []
15
-
16
- description: Library to handle caching external API calls
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: rspec
17
+ type: :development
18
+ version_requirement:
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: "0"
24
+ version:
25
+ - !ruby/object:Gem::Dependency
26
+ name: fakeweb
27
+ type: :development
28
+ version_requirement:
29
+ version_requirements: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: "0"
34
+ version:
35
+ description: APICache allows any API client library to be easily wrapped with a robust caching layer. It supports caching (obviously), serving stale data and limits on the number of API calls. It's also got a handy syntax if all you want to do is cache a bothersome url.
17
36
  email: me@mloughran.com
18
37
  executables: []
19
38
 
@@ -22,20 +41,22 @@ extensions: []
22
41
  extra_rdoc_files: []
23
42
 
24
43
  files:
25
- - README.markdown
44
+ - README.rdoc
26
45
  - VERSION.yml
27
46
  - lib/api_cache/abstract_store.rb
28
47
  - lib/api_cache/api.rb
29
48
  - lib/api_cache/cache.rb
30
- - lib/api_cache/logger.rb
31
- - lib/api_cache/memcache_store.rb
32
49
  - lib/api_cache/memory_store.rb
50
+ - lib/api_cache/moneta_store.rb
33
51
  - lib/api_cache.rb
34
52
  - spec/api_cache_spec.rb
35
53
  - spec/api_spec.rb
54
+ - spec/cache_spec.rb
55
+ - spec/integration_spec.rb
56
+ - spec/monteta_store_spec.rb
36
57
  - spec/spec_helper.rb
37
58
  has_rdoc: true
38
- homepage: http://github.com/mloughran/api_cache
59
+ homepage: http://mloughran.github.com/api_cache/
39
60
  licenses: []
40
61
 
41
62
  post_install_message:
@@ -61,7 +82,7 @@ requirements: []
61
82
  rubyforge_project:
62
83
  rubygems_version: 1.3.5
63
84
  signing_key:
64
- specification_version: 2
65
- summary: Library to handle caching external API calls
85
+ specification_version: 3
86
+ summary: API Cache allows advanced caching of APIs
66
87
  test_files: []
67
88
 
data/README.markdown DELETED
@@ -1,80 +0,0 @@
1
- APICache (aka api_cache)
2
- ========================
3
-
4
- For the impatient
5
- -----------------
6
-
7
- # Install
8
- sudo gem install mloughran-api_cache -s http://gems.github.com
9
-
10
- # Require
11
- require 'rubygems'
12
- gem 'mloughran-api_cache'
13
- require 'api_cache'
14
-
15
- # Configure
16
- APICache.start(APICache::MemoryStore)
17
-
18
- # Use
19
- APICache.get("http://twitter.com/statuses/public_timeline.rss")
20
-
21
- For everyone else
22
- -----------------
23
-
24
- You want to use the Twitter API but you don't want to die? I have the solution to API caching:
25
-
26
- APICache.get("http://twitter.com/statuses/public_timeline.rss")
27
-
28
- You get the following functionality for free:
29
-
30
- * New data every 10 minutes
31
- * If the twitter API dies then keep using the last data received for a day. Then assume it's invalid and announce that Twitter has FAILED (optional).
32
- * Don't hit the rate limit (70 requests per 60 minutes)
33
-
34
- So what exactly does `APICache` do? Given cached data less than 10 minutes old, it returns that. Otherwise, assuming it didn't try to request the URL within the last minute (to avoid the rate limit), it makes a get request to the Twitter API. If the Twitter API timeouts or doesn't return a 2xx code (very likely) we're still fine: it just returns the last data fetched (as long as it's less than a day old). In the exceptional case that all is lost and no data can be returned, it raises an `APICache::NotAvailableError` exception. You're responsible for catching this exception and complaining bitterly to the internet.
35
-
36
- All very simple. What if you need to do something more complicated? Say you need authentication or the silly API you're using doesn't follow a nice convention of returning 2xx for success. Then you need a block:
37
-
38
- APICache.get('twitter_replies', :cache => 3600) do
39
- Net::HTTP.start('twitter.com') do |http|
40
- req = Net::HTTP::Get.new('/statuses/replies.xml')
41
- req.basic_auth 'username', 'password'
42
- response = http.request(req)
43
- case response
44
- when Net::HTTPSuccess
45
- # 2xx response code
46
- response.body
47
- else
48
- raise APICache::Invalid
49
- end
50
- end
51
- end
52
-
53
- All the caching is still handled for you. If you supply a block then the first argument to `APICache.get` is assumed to be a unique key rather than a URL. Throwing `APICache::Invalid` signals to `APICache` that the request was not successful.
54
-
55
- You can send any of the following options to `APICache.get(url, options = {}, &block)`. These are the default values (times are all in seconds):
56
-
57
- {
58
- :cache => 600, # 10 minutes After this time fetch new data
59
- :valid => 86400, # 1 day Maximum time to use old data
60
- # :forever is a valid option
61
- :period => 60, # 1 minute Maximum frequency to call API
62
- :timeout => 5 # 5 seconds API response timeout
63
- }
64
-
65
- Before using the APICache you need to initialize the caches. In merb, for example, put this in your `init.rb`:
66
-
67
- APICache.start
68
-
69
- Currently there are two stores available: `MemcacheStore` and `MemoryStore`. `MemcacheStore` is the default but if you'd like to use `MemoryStore`, or another store - see `AbstractStore`, just supply it to the start method:
70
-
71
- APICache.start(APICache::MemoryStore)
72
-
73
- I suppose you'll want to get your hands on this magic! Just take a look at the instructions above for the impatient. Well done for reading this first!
74
-
75
- Please send feedback to me [at] mloughran [dot] com if you think of any other functionality that would be handy.
76
-
77
- Copyright
78
- =========
79
-
80
- Copyright (c) 2008 Martyn Loughran. See LICENSE for details.
@@ -1,9 +0,0 @@
1
- class APICache::Logger
2
- def initialize
3
-
4
- end
5
-
6
- def log(message)
7
- # puts "APICache: #{message}"
8
- end
9
- end
@@ -1,54 +0,0 @@
1
- require 'memcache'
2
-
3
- class APICache::MemcacheStore < APICache::AbstractStore
4
- class NotReady < Exception #:nodoc:
5
- def initialize
6
- super("Memcache server is not ready")
7
- end
8
- end
9
-
10
- class NotDefined < Exception #:nodoc:
11
- def initialize
12
- super("Memcache is not defined")
13
- end
14
- end
15
-
16
- def initialize
17
- APICache.logger.log "Using memcached store"
18
- namespace = 'api_cache'
19
- host = '127.0.0.1:11211'
20
- @memcache = MemCache.new(host, {:namespace => namespace})
21
- raise NotReady unless @memcache.active?
22
- true
23
- # rescue NameError
24
- # raise NotDefined
25
- end
26
-
27
- def set(key, data)
28
- @memcache.set(key, data)
29
- @memcache.set("#{key}_created_at", Time.now)
30
- APICache.logger.log("cache: set (#{key})")
31
- true
32
- end
33
-
34
- def get(key)
35
- data = @memcache.get(key)
36
- APICache.logger.log("cache: #{data.nil? ? "miss" : "hit"} (#{key})")
37
- data
38
- end
39
-
40
- def exists?(key)
41
- # TODO: inefficient - is there a better way?
42
- !@memcache.get(key).nil?
43
- end
44
-
45
- def expired?(key, timeout)
46
- Time.now - created(key) > timeout
47
- end
48
-
49
- private
50
-
51
- def created(key)
52
- @memcache.get("#{key}_created_at")
53
- end
54
- end