├── TODO
├── .gitignore
├── VERSION.yml
├── init.rb
├── spec
├── spec_helper.rb
├── monteta_store_spec.rb
├── cache_spec.rb
├── api_spec.rb
└── api_cache_spec.rb
├── lib
├── api_cache
│ ├── abstract_store.rb
│ ├── moneta_store.rb
│ ├── memory_store.rb
│ ├── cache.rb
│ └── api.rb
└── api_cache.rb
├── LICENSE
├── Rakefile
├── api_cache.gemspec
└── README.rdoc
/TODO:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | pkg
2 | .DS_Store
3 | rdoc
4 |
--------------------------------------------------------------------------------
/VERSION.yml:
--------------------------------------------------------------------------------
1 | ---
2 | :patch: 2
3 | :major: 0
4 | :minor: 1
5 |
--------------------------------------------------------------------------------
/init.rb:
--------------------------------------------------------------------------------
1 | #for using api_cache as a rails plugin
2 | require "api_cache"
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | $TESTING=true
2 | $:.push File.join(File.dirname(__FILE__), '..', 'lib')
3 | require "rubygems"
4 | require "api_cache"
5 | require "spec"
6 |
7 | APICache.logger.level = Logger::FATAL
8 |
--------------------------------------------------------------------------------
/lib/api_cache/abstract_store.rb:
--------------------------------------------------------------------------------
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
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/lib/api_cache/moneta_store.rb:
--------------------------------------------------------------------------------
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 | def delete(key)
25 | @moneta.delete(key)
26 | end
27 |
28 | # Has a given time passed since the key was set?
29 | def expired?(key, timeout)
30 | Time.now - @moneta["#{key}_created_at"] > timeout
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/lib/api_cache/memory_store.rb:
--------------------------------------------------------------------------------
1 | class APICache
2 | class MemoryStore < APICache::AbstractStore
3 | def initialize
4 | APICache.logger.debug "Using memory store"
5 | @cache = {}
6 | true
7 | end
8 |
9 | def set(key, value)
10 | APICache.logger.debug("cache: set (#{key})")
11 | @cache[key] = [Time.now, value]
12 | true
13 | end
14 |
15 | def get(key)
16 | data = @cache[key][1]
17 | APICache.logger.debug("cache: #{data.nil? ? "miss" : "hit"} (#{key})")
18 | data
19 | end
20 |
21 | def exists?(key)
22 | !@cache[key].nil?
23 | end
24 |
25 | def delete(key)
26 | puts "#{key}: EXPIRED"
27 | @cache.delete(key)
28 | end
29 |
30 | def expired?(key, timeout)
31 | Time.now - created(key) > timeout
32 | end
33 |
34 | private
35 |
36 | def created(key)
37 | @cache[key][0]
38 | end
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/spec/monteta_store_spec.rb:
--------------------------------------------------------------------------------
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
31 |
--------------------------------------------------------------------------------
/spec/cache_spec.rb:
--------------------------------------------------------------------------------
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
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2008 Martyn Loughran
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining
4 | a copy of this software and associated documentation files (the
5 | "Software"), to deal in the Software without restriction, including
6 | without limitation the rights to use, copy, modify, merge, publish,
7 | distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to
9 | the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be
12 | included in all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require 'rake'
2 |
3 | begin
4 | require 'jeweler'
5 | Jeweler::Tasks.new do |s|
6 | s.name = "api_cache"
7 | s.summary = %Q{Library to handle caching external API calls}
8 | s.email = "me@mloughran.com"
9 | s.homepage = "http://github.com/mloughran/api_cache"
10 | s.description = "Library to handle caching external API calls"
11 | s.authors = ["Martyn Loughran"]
12 | end
13 | rescue LoadError
14 | puts "Jeweler not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
15 | end
16 |
17 | require 'rake/rdoctask'
18 | Rake::RDocTask.new do |rdoc|
19 | rdoc.rdoc_dir = 'rdoc'
20 | rdoc.title = 'api_cache'
21 | rdoc.options << '--line-numbers' << '--inline-source'
22 | rdoc.rdoc_files.include('README*')
23 | rdoc.rdoc_files.include('lib/**/*.rb')
24 | end
25 |
26 | require 'spec/rake/spectask'
27 | Spec::Rake::SpecTask.new(:spec) do |t|
28 | t.libs << 'lib' << 'spec'
29 | t.spec_files = FileList['spec/**/*_spec.rb']
30 | end
31 |
32 | Spec::Rake::SpecTask.new(:rcov) do |t|
33 | t.libs << 'lib' << 'spec'
34 | t.spec_files = FileList['spec/**/*_spec.rb']
35 | t.rcov = true
36 | end
37 |
38 | task :default => :test
39 |
--------------------------------------------------------------------------------
/api_cache.gemspec:
--------------------------------------------------------------------------------
1 | # -*- encoding: utf-8 -*-
2 |
3 | Gem::Specification.new do |s|
4 | s.name = %q{api_cache}
5 | s.version = "0.1.2"
6 |
7 | s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
8 | s.authors = ["Martyn Loughran"]
9 | s.date = %q{2009-03-30}
10 | s.description = %q{Library to handle caching external API calls}
11 | s.email = %q{me@mloughran.com}
12 | s.files = ["README.rdoc", "VERSION.yml", "lib/api_cache", "lib/api_cache/abstract_store.rb", "lib/api_cache/api.rb", "lib/api_cache/cache.rb", "lib/api_cache/memory_store.rb", "lib/api_cache.rb", "spec/api_cache_spec.rb", "spec/api_spec.rb", "spec/spec_helper.rb"]
13 | s.has_rdoc = true
14 | s.homepage = %q{http://github.com/mloughran/api_cache}
15 | s.rdoc_options = ["--inline-source", "--charset=UTF-8"]
16 | s.require_paths = ["lib"]
17 | s.rubygems_version = %q{1.3.1}
18 | s.summary = %q{Library to handle caching external API calls}
19 |
20 | if s.respond_to? :specification_version then
21 | current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
22 | s.specification_version = 2
23 |
24 | if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
25 | else
26 | end
27 | else
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/spec/api_spec.rb:
--------------------------------------------------------------------------------
1 | require File.dirname(__FILE__) + '/spec_helper'
2 |
3 | describe APICache::API do
4 | before :each do
5 | @options = {
6 | :period => 1,
7 | :timeout => 5
8 | }
9 |
10 | # Reset the store otherwise get queried too recently erros
11 | APICache.store = nil
12 | end
13 |
14 | it "should not be queryable for :period seconds after a request" do
15 | api = APICache::API.new('http://www.google.com/', @options)
16 |
17 | api.get
18 | lambda {
19 | api.get
20 | }.should raise_error(APICache::CannotFetch, "Cannot fetch http://www.google.com/: queried too recently")
21 |
22 | sleep 1
23 | lambda {
24 | api.get
25 | }.should_not raise_error
26 | end
27 |
28 | describe "without a block - key is the url" do
29 |
30 | it "should return body of a http GET against the key" do
31 | api = APICache::API.new('http://www.google.com/', @options)
32 | api.get.should =~ /Google/
33 | end
34 |
35 | it "should handle redirecting get requests" do
36 | api = APICache::API.new('http://froogle.google.com/', @options)
37 | api.get.should =~ /Google Product Search/
38 | end
39 |
40 | end
41 |
42 | describe "with a block" do
43 |
44 | it "should return the return value of the block" do
45 | api = APICache::API.new('http://froogle.google.com/', @options) do
46 | 42
47 | end
48 | api.get.should == 42
49 | end
50 |
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/lib/api_cache/cache.rb:
--------------------------------------------------------------------------------
1 | require 'digest/md5'
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
36 | else
37 | :missing
38 | end
39 | end
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
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/lib/api_cache/api.rb:
--------------------------------------------------------------------------------
1 | require 'net/http'
2 |
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 | # If the block is unable to fetch the value from the API it should raise
32 | # APICache::Invalid.
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 | # This should raise APICache::Invalid if it is not correct
41 | @block.call
42 | else
43 | get_key_via_http
44 | end
45 | end
46 | rescue Timeout::Error, APICache::Invalid => e
47 | raise APICache::CannotFetch, e.message
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
58 | else
59 | raise APICache::Invalid, "Invalid http response: #{response.code}"
60 | end
61 | end
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 | end
76 | else
77 | APICache.logger.debug "Queryable: true - never used API before"
78 | end
79 | end
80 |
81 | def previously_queried?
82 | APICache.store.exists?("#{@key}_queried_at")
83 | end
84 |
85 | def queried_at
86 | APICache.store.get("#{@key}_queried_at")
87 | end
88 |
89 | def set_queried_at
90 | APICache.store.set("#{@key}_queried_at", Time.now)
91 | end
92 | end
93 | end
94 |
--------------------------------------------------------------------------------
/spec/api_cache_spec.rb:
--------------------------------------------------------------------------------
1 | require File.dirname(__FILE__) + '/spec_helper'
2 |
3 | describe APICache do
4 | before :each do
5 | @key = 'random_key'
6 | @cache_data = 'data from the cache'
7 | @api_data = 'data from the api'
8 | end
9 |
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)
29 | end
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)
49 | end
50 |
51 | it "should fetch data from the cache if the state is :current" do
52 | @cache.stub!(:state).and_return(:current)
53 |
54 | APICache.get(@key).should == @cache_data
55 | end
56 |
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)
60 |
61 | APICache.get(@key).should == @api_data
62 | end
63 |
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)
67 |
68 | APICache.get(@key).should == @cache_data
69 | end
70 |
71 | it "should make new request to API if the state is :invalid" do
72 | @cache.stub!(:state).and_return(:invalid)
73 |
74 | APICache.get(@key).should == @api_data
75 | end
76 |
77 | it "should raise NotAvailableError if the api cannot fetch data and state is :invalid" do
78 | @cache.stub!(:state).and_return(:invalid)
79 | @api.should_receive(:get).with.and_raise(APICache::CannotFetch)
80 |
81 | lambda {
82 | APICache.get(@key).should
83 | }.should raise_error(APICache::NotAvailableError)
84 | end
85 |
86 | it "should make new request to API if the state is :missing" do
87 | @cache.stub!(:state).and_return(:missing)
88 |
89 | APICache.get(@key).should == @api_data
90 | end
91 |
92 | it "should raise an exception if the api cannot fetch data and state is :missing" do
93 | @cache.stub!(:state).and_return(:missing)
94 | @api.should_receive(:get).with.and_raise(APICache::CannotFetch)
95 |
96 | lambda {
97 | APICache.get(@key).should
98 | }.should raise_error(APICache::NotAvailableError)
99 | end
100 | end
101 | end
102 |
--------------------------------------------------------------------------------
/README.rdoc:
--------------------------------------------------------------------------------
1 | = APICache (aka api_cache)
2 |
3 | == For the impatient
4 |
5 | # Install
6 | sudo gem install mloughran-api_cache -s http://gems.github.com
7 |
8 | # Require
9 | require 'rubygems'
10 | gem 'mloughran-api_cache'
11 | require 'api_cache'
12 |
13 | # Use
14 | APICache.get("http://twitter.com/statuses/public_timeline.rss")
15 |
16 | # Use a proper store
17 | require 'moneta/memcache'
18 | APICache.store = Moneta::Memcache.new(:server => "localhost")
19 |
20 | == For everyone else
21 |
22 | You want to use the Twitter API but you don't want to die? I have the solution to API caching:
23 |
24 | APICache.get("http://twitter.com/statuses/public_timeline.rss")
25 |
26 | You get the following functionality for free:
27 |
28 | * New data every 10 minutes
29 | * 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).
30 | * Don't hit the rate limit (70 requests per 60 minutes)
31 |
32 | 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.
33 |
34 | 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:
35 |
36 | APICache.get('twitter_replies', :cache => 3600) do
37 | Net::HTTP.start('twitter.com') do |http|
38 | req = Net::HTTP::Get.new('/statuses/replies.xml')
39 | req.basic_auth 'username', 'password'
40 | response = http.request(req)
41 | case response
42 | when Net::HTTPSuccess
43 | # 2xx response code
44 | response.body
45 | else
46 | raise APICache::Invalid
47 | end
48 | end
49 | end
50 |
51 | 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.
52 |
53 | You can send any of the following options to APICache.get(url, options = {}, &block). These are the default values (times are all in seconds):
54 |
55 | {
56 | :cache => 600, # 10 minutes After this time fetch new data
57 | :valid => 86400, # 1 day Maximum time to use old data
58 | # :forever is a valid option
59 | :period => 60, # 1 minute Maximum frequency to call API
60 | :timeout => 5 # 5 seconds API response timeout
61 | }
62 |
63 | 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:
64 |
65 | require 'moneta/memcache'
66 | APICache.store = Moneta::Memcache.new(:server => "localhost")
67 |
68 | 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!
69 |
70 | Please send feedback to me [at] mloughran [dot] com if you think of any other functionality that would be handy.
71 |
72 | == Copyright
73 |
74 | Copyright (c) 2008 Martyn Loughran. See LICENSE for details.
75 |
--------------------------------------------------------------------------------
/lib/api_cache.rb:
--------------------------------------------------------------------------------
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 | #
11 | class APICache
12 | class NotAvailableError < RuntimeError; end
13 | class Invalid < RuntimeError; end
14 | class CannotFetch < RuntimeError; end
15 |
16 | class << self
17 | attr_accessor :logger
18 | attr_accessor :store
19 |
20 | def logger # :nodoc:
21 | @logger ||= begin
22 | log = Logger.new(STDOUT)
23 | log.level = Logger::INFO
24 | log
25 | end
26 | end
27 |
28 | # Set the logger to use. If not set, Logger.new(STDOUT) will be
29 | # used.
30 | #
31 | def logger=(logger)
32 | @logger = logger
33 | end
34 |
35 | def store # :nodoc:
36 | @store ||= begin
37 | APICache.logger.warn("Using in memory store")
38 | APICache::MemoryStore.new
39 | end
40 | end
41 |
42 | # Set the cache store to use. This should either be an instance of a
43 | # moneta store or a subclass of APICache::AbstractStore. Moneta is
44 | # recommended.
45 | #
46 | def store=(store)
47 | @store = begin
48 | if store.class < APICache::AbstractStore
49 | store
50 | elsif store.class.to_s =~ /Moneta/
51 | MonetaStore.new(store)
52 | elsif store.nil?
53 | nil
54 | else
55 | raise ArgumentError, "Please supply an instance of either a moneta store or a subclass of APICache::AbstractStore"
56 | end
57 | end
58 | end
59 | end
60 |
61 | # Raises an APICache::NotAvailableError if it can't get a value. You should
62 | # rescue this if your application code.
63 | #
64 | # Optionally call with a block. The value of the block is then used to
65 | # set the cache rather than calling the url. Use it for example if you need
66 | # to make another type of request, catch custom error codes etc. To signal
67 | # that the call failed just raise APICache::Invalid - the value will then
68 | # not be cached and the api will not be called again for options[:timeout]
69 | # seconds. If an old value is available in the cache then it will be used.
70 | #
71 | # For example:
72 | # APICache.get("http://twitter.com/statuses/user_timeline/6869822.atom")
73 | #
74 | # APICache.get \
75 | # "http://twitter.com/statuses/user_timeline/6869822.atom",
76 | # :cache => 60, :valid => 600
77 | #
78 | def self.get(key, options = {}, &block)
79 | options = {
80 | :cache => 600, # 10 minutes After this time fetch new data
81 | :valid => 86400, # 1 day Maximum time to use old data
82 | # :forever is a valid option
83 | :period => 60, # 1 minute Maximum frequency to call API
84 | :timeout => 5 # 5 seconds API response timeout
85 | }.merge(options)
86 |
87 | cache = APICache::Cache.new(key, {
88 | :cache => options[:cache],
89 | :valid => options[:valid]
90 | })
91 |
92 | api = APICache::API.new(key, {
93 | :period => options[:period],
94 | :timeout => options[:timeout]
95 | }, &block)
96 |
97 | cache_state = cache.state
98 |
99 | if cache_state == :current
100 | cache.get
101 | else
102 | begin
103 | value = api.get
104 | cache.set(value)
105 | value
106 | rescue APICache::CannotFetch => e
107 | APICache.logger.info "Failed to fetch new data from API because " \
108 | "#{e.class}: #{e.message}"
109 | if cache_state == :refetch
110 | cache.get
111 | else
112 | APICache.logger.warn "Data not available in the cache or from API"
113 | raise APICache::NotAvailableError, e.message
114 | end
115 | end
116 | end
117 | end
118 | end
119 |
120 | require 'api_cache/cache'
121 | require 'api_cache/api'
122 |
123 | APICache.autoload 'AbstractStore', 'api_cache/abstract_store'
124 | APICache.autoload 'MemoryStore', 'api_cache/memory_store'
125 | APICache.autoload 'MonetaStore', 'api_cache/moneta_store'
126 |
--------------------------------------------------------------------------------