api_cache 0.1.2
Sign up to get free protection for your applications and to get access to all the features.
- data/README.markdown +80 -0
- data/VERSION.yml +4 -0
- data/lib/api_cache/abstract_store.rb +25 -0
- data/lib/api_cache/api.rb +70 -0
- data/lib/api_cache/cache.rb +41 -0
- data/lib/api_cache/logger.rb +9 -0
- data/lib/api_cache/memcache_store.rb +54 -0
- data/lib/api_cache/memory_store.rb +33 -0
- data/lib/api_cache.rb +74 -0
- data/spec/api_cache_spec.rb +72 -0
- data/spec/api_spec.rb +10 -0
- data/spec/spec_helper.rb +7 -0
- metadata +67 -0
data/README.markdown
ADDED
@@ -0,0 +1,80 @@
|
|
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.
|
data/VERSION.yml
ADDED
@@ -0,0 +1,25 @@
|
|
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."
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
require 'net/http'
|
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
|
20
|
+
else
|
21
|
+
APICache.logger.log "Queryable: false - queried too recently"
|
22
|
+
false
|
23
|
+
end
|
24
|
+
else
|
25
|
+
APICache.logger.log "Queryable: true - never used API before"
|
26
|
+
true
|
27
|
+
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
|
45
|
+
else
|
46
|
+
get_via_http(key, timeout)
|
47
|
+
end
|
48
|
+
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}"
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def redirecting_get(url)
|
67
|
+
r = Net::HTTP.get_response(URI.parse(url))
|
68
|
+
r.header['location'] ? redirecting_get(r.header['location']) : r
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# Cache performs calculations relating to the status of items stored in the
|
2
|
+
# cache and delegates storage to the various cache stores.
|
3
|
+
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
|
22
|
+
else
|
23
|
+
:invalid
|
24
|
+
end
|
25
|
+
else
|
26
|
+
:missing
|
27
|
+
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
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,54 @@
|
|
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
|
@@ -0,0 +1,33 @@
|
|
1
|
+
class APICache::MemoryStore < APICache::AbstractStore
|
2
|
+
def initialize
|
3
|
+
APICache.logger.log "Using memory store"
|
4
|
+
@cache = {}
|
5
|
+
true
|
6
|
+
end
|
7
|
+
|
8
|
+
def set(key, value)
|
9
|
+
APICache.logger.log("cache: set (#{key})")
|
10
|
+
@cache[key] = [Time.now, value]
|
11
|
+
true
|
12
|
+
end
|
13
|
+
|
14
|
+
def get(key)
|
15
|
+
data = @cache[key][1]
|
16
|
+
APICache.logger.log("cache: #{data.nil? ? "miss" : "hit"} (#{key})")
|
17
|
+
data
|
18
|
+
end
|
19
|
+
|
20
|
+
def exists?(key)
|
21
|
+
!@cache[key].nil?
|
22
|
+
end
|
23
|
+
|
24
|
+
def expired?(key, timeout)
|
25
|
+
Time.now - created(key) > timeout
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def created(key)
|
31
|
+
@cache[key][0]
|
32
|
+
end
|
33
|
+
end
|
data/lib/api_cache.rb
ADDED
@@ -0,0 +1,74 @@
|
|
1
|
+
class APICache
|
2
|
+
class NotAvailableError < RuntimeError; end
|
3
|
+
class Invalid < RuntimeError; end
|
4
|
+
class CannotFetch < RuntimeError; end
|
5
|
+
|
6
|
+
class << self
|
7
|
+
attr_accessor :cache
|
8
|
+
attr_accessor :api
|
9
|
+
attr_accessor :logger
|
10
|
+
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
|
23
|
+
# set the cache rather than calling the url. Use it for example if you need
|
24
|
+
# 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
|
+
#
|
29
|
+
# For example:
|
30
|
+
# APICache.get("http://twitter.com/statuses/user_timeline/6869822.atom")
|
31
|
+
#
|
32
|
+
# APICache.get \
|
33
|
+
# "http://twitter.com/statuses/user_timeline/6869822.atom",
|
34
|
+
# :cache => 60, :valid => 600
|
35
|
+
def self.get(key, options = {}, &block)
|
36
|
+
options = {
|
37
|
+
:cache => 600, # 10 minutes After this time fetch new data
|
38
|
+
:valid => 86400, # 1 day Maximum time to use old data
|
39
|
+
# :forever is a valid option
|
40
|
+
:period => 60, # 1 minute Maximum frequency to call API
|
41
|
+
:timeout => 5 # 5 seconds API response timeout
|
42
|
+
}.merge(options)
|
43
|
+
|
44
|
+
cache_state = cache.state(key, options[:cache], options[:valid])
|
45
|
+
|
46
|
+
if cache_state == :current
|
47
|
+
cache.get(key)
|
48
|
+
else
|
49
|
+
begin
|
50
|
+
raise APICache::CannotFetch unless api.queryable?(key, options[:period])
|
51
|
+
value = api.get(key, options[:timeout], &block)
|
52
|
+
cache.set(key, value)
|
53
|
+
value
|
54
|
+
rescue APICache::CannotFetch => e
|
55
|
+
APICache.logger.log "Failed to fetch new data from API because " \
|
56
|
+
"#{e.class}: #{e.message}"
|
57
|
+
if cache_state == :refetch
|
58
|
+
cache.get(key)
|
59
|
+
else
|
60
|
+
APICache.logger.log "Data not available in the cache or from API"
|
61
|
+
raise APICache::NotAvailableError, e.message
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
require 'api_cache/cache'
|
69
|
+
require 'api_cache/api'
|
70
|
+
require 'api_cache/logger'
|
71
|
+
|
72
|
+
APICache.autoload 'AbstractStore', 'api_cache/abstract_store'
|
73
|
+
APICache.autoload 'MemoryStore', 'api_cache/memory_store'
|
74
|
+
APICache.autoload 'MemcacheStore', 'api_cache/memcache_store'
|
@@ -0,0 +1,72 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/spec_helper'
|
2
|
+
|
3
|
+
describe APICache do
|
4
|
+
before :each do
|
5
|
+
@key = 'random_key'
|
6
|
+
@encoded_key = "1520171c64bfb71a95c97d310eea3492"
|
7
|
+
@data = 'some bit of data'
|
8
|
+
end
|
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
|
14
|
+
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
|
20
|
+
end
|
21
|
+
|
22
|
+
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)
|
25
|
+
|
26
|
+
APICache.get(@key).should == @data
|
27
|
+
end
|
28
|
+
|
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)
|
32
|
+
|
33
|
+
APICache.get(@key).should == @data
|
34
|
+
end
|
35
|
+
|
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)
|
40
|
+
|
41
|
+
APICache.get(@key).should == @data
|
42
|
+
end
|
43
|
+
|
44
|
+
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)
|
47
|
+
|
48
|
+
APICache.get(@key).should == @data
|
49
|
+
end
|
50
|
+
|
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)
|
54
|
+
|
55
|
+
lambda { APICache.get(@key).should }.should raise_error(APICache::NotAvailableError)
|
56
|
+
end
|
57
|
+
|
58
|
+
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)
|
61
|
+
|
62
|
+
APICache.get(@key).should == @data
|
63
|
+
end
|
64
|
+
|
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)
|
68
|
+
|
69
|
+
lambda { APICache.get(@key).should }.should raise_error(APICache::NotAvailableError)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
data/spec/api_spec.rb
ADDED
@@ -0,0 +1,10 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/spec_helper'
|
2
|
+
|
3
|
+
describe APICache::API do
|
4
|
+
it "should handle redirecting get requests" do
|
5
|
+
api = APICache::API.new
|
6
|
+
lambda {
|
7
|
+
api.get('http://froogle.google.com', 5)
|
8
|
+
}.should_not raise_error(APICache::CannotFetch)
|
9
|
+
end
|
10
|
+
end
|
data/spec/spec_helper.rb
ADDED
metadata
ADDED
@@ -0,0 +1,67 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: api_cache
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.2
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Martyn Loughran
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2009-03-30 00:00:00 +02:00
|
13
|
+
default_executable:
|
14
|
+
dependencies: []
|
15
|
+
|
16
|
+
description: Library to handle caching external API calls
|
17
|
+
email: me@mloughran.com
|
18
|
+
executables: []
|
19
|
+
|
20
|
+
extensions: []
|
21
|
+
|
22
|
+
extra_rdoc_files: []
|
23
|
+
|
24
|
+
files:
|
25
|
+
- README.markdown
|
26
|
+
- VERSION.yml
|
27
|
+
- lib/api_cache/abstract_store.rb
|
28
|
+
- lib/api_cache/api.rb
|
29
|
+
- lib/api_cache/cache.rb
|
30
|
+
- lib/api_cache/logger.rb
|
31
|
+
- lib/api_cache/memcache_store.rb
|
32
|
+
- lib/api_cache/memory_store.rb
|
33
|
+
- lib/api_cache.rb
|
34
|
+
- spec/api_cache_spec.rb
|
35
|
+
- spec/api_spec.rb
|
36
|
+
- spec/spec_helper.rb
|
37
|
+
has_rdoc: true
|
38
|
+
homepage: http://github.com/mloughran/api_cache
|
39
|
+
licenses: []
|
40
|
+
|
41
|
+
post_install_message:
|
42
|
+
rdoc_options:
|
43
|
+
- --inline-source
|
44
|
+
- --charset=UTF-8
|
45
|
+
require_paths:
|
46
|
+
- lib
|
47
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
48
|
+
requirements:
|
49
|
+
- - ">="
|
50
|
+
- !ruby/object:Gem::Version
|
51
|
+
version: "0"
|
52
|
+
version:
|
53
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
54
|
+
requirements:
|
55
|
+
- - ">="
|
56
|
+
- !ruby/object:Gem::Version
|
57
|
+
version: "0"
|
58
|
+
version:
|
59
|
+
requirements: []
|
60
|
+
|
61
|
+
rubyforge_project:
|
62
|
+
rubygems_version: 1.3.5
|
63
|
+
signing_key:
|
64
|
+
specification_version: 2
|
65
|
+
summary: Library to handle caching external API calls
|
66
|
+
test_files: []
|
67
|
+
|