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 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,4 @@
1
+ ---
2
+ :patch: 2
3
+ :major: 0
4
+ :minor: 1
@@ -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,9 @@
1
+ class APICache::Logger
2
+ def initialize
3
+
4
+ end
5
+
6
+ def log(message)
7
+ # puts "APICache: #{message}"
8
+ end
9
+ 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
@@ -0,0 +1,7 @@
1
+ $TESTING=true
2
+ $:.push File.join(File.dirname(__FILE__), '..', 'lib')
3
+ require "rubygems"
4
+ require "api_cache"
5
+ require "spec"
6
+
7
+ APICache.start(APICache::MemoryStore)
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
+