├── 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 | --------------------------------------------------------------------------------