diff --git a/Gemfile b/Gemfile index a05f80f..7688bdd 100644 --- a/Gemfile +++ b/Gemfile @@ -14,3 +14,7 @@ gem "rubocop", "~> 1.21" group :development do gem "dotenv" end + +group :test do + gem "webmock" +end diff --git a/Gemfile.lock b/Gemfile.lock index 1596d66..fe2df17 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,7 +8,13 @@ PATH GEM remote: https://rubygems.org/ specs: + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) ast (2.4.2) + bigdecimal (3.1.9) + crack (1.0.0) + bigdecimal + rexml dotenv (3.1.7) faraday (1.10.4) faraday-em_http (~> 1.0) @@ -35,6 +41,7 @@ GEM faraday-retry (1.0.3) faraday_middleware (1.2.1) faraday (~> 1.0) + hashdiff (1.1.2) json (2.9.1) language_server-protocol (3.17.0.4) minitest (5.25.4) @@ -43,10 +50,12 @@ GEM parser (3.3.7.0) ast (~> 2.4.1) racc + public_suffix (6.0.1) racc (1.8.1) rainbow (3.1.1) rake (13.2.1) regexp_parser (2.10.0) + rexml (3.4.0) rubocop (1.71.0) json (~> 2.3) language_server-protocol (>= 3.17.0) @@ -64,6 +73,10 @@ GEM unicode-display_width (3.1.4) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) + webmock (3.25.0) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) PLATFORMS ruby @@ -75,6 +88,7 @@ DEPENDENCIES rake (~> 13.0) ruber! rubocop (~> 1.21) + webmock BUNDLED WITH 2.5.22 diff --git a/README.md b/README.md index f4dff7c..a2d3e57 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,17 @@ If bundler is not being used to manage dependencies, install the gem by executin ```bash gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG ``` +## Cache +Ruber uses a caching solution to improve efficiency (e.g., for caching tokens). By default, it uses a simple in-memory cache, but you can change the cache method by setting the `Ruber.cache` attribute + + +```ruby +Ruber.cache = Redis.new +# or +Ruber.cache = Rails.cache +# or any object that responds to read/write/delete/clear +Ruber.cache = YourCustomCache.new +``` ## Usage diff --git a/lib/ruber.rb b/lib/ruber.rb index e6ba8b9..24282f7 100644 --- a/lib/ruber.rb +++ b/lib/ruber.rb @@ -3,11 +3,13 @@ require_relative "ruber/version" require "forwardable" require "ruber/configuration" +require "ruber/authenticator" # a Ruby wrapper for Uber API module Ruber autoload :Client, "ruber/client" autoload :Error, "ruber/error" + autoload :Authenticator, "ruber/authenticator" DEFAULT_API_BASE = "https://api.uber.com/v1" @@ -16,7 +18,7 @@ class << self def_delegators( :configuration, :customer_id, :client_id, :client_secret, - :customer_id=, :client_id=, :client_secret= + :customer_id=, :client_id=, :client_secret=, :cache, :cache= ) end end diff --git a/lib/ruber/authenticator.rb b/lib/ruber/authenticator.rb new file mode 100644 index 0000000..ce93838 --- /dev/null +++ b/lib/ruber/authenticator.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "faraday" +require "json" + +module Ruber + class Authenticator + OAUTH_URL = "https://auth.uber.com/oauth/v2/token" + GRANT_TYPE = "client_credentials" + SCOPE = "eats.deliveries" + + class << self + def access_token + @access_token = cached_token || fetch_new_token + + @access_token = refresh_access_token if token_expired? + + @access_token + end + + def refresh_access_token + Ruber.cache.delete(cache_key) + + fetch_new_token + end + + def cache_key + @cache_key ||= "#{Ruber.customer_id}_#{Ruber.client_id}_access_token" + end + + private + + def token_expired? + cached_token[:expires_at] < Time.now + end + + def cached_token + Ruber.cache.read(cache_key) + end + + def fetch_new_token + response = Faraday.post( + OAUTH_URL, + { + client_id: Ruber.client_id, + client_secret: Ruber.client_secret, + grant_type: GRANT_TYPE, + scope: SCOPE + } + ) + + data = JSON.parse(response.body.to_s) + + expires_at = Time.now + data["expires_in"].to_i + Ruber.cache.write(cache_key, { token: data["access_token"], expires_at: expires_at }) + + data["access_token"] + end + end + end +end diff --git a/lib/ruber/cache.rb b/lib/ruber/cache.rb new file mode 100644 index 0000000..e420172 --- /dev/null +++ b/lib/ruber/cache.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Ruber + class NullCache + def read(key) = memory_store[key] + def write(key, value, _options = {}) = memory_store[key] = value + def clear = memory_store.clear + def delete(key) = memory_store.delete(key) + def memory_store = @memory_store ||= {} + end + + class << self + def cache=(store) + unless %i[read write clear delete].all? { |method| store.respond_to?(method) } + raise ArgumentError, "cache_store must respond to read, write, clear, and delete" + end + + @cache = store + end + + def cache + @cache ||= NullCache.new + end + end +end diff --git a/lib/ruber/configuration.rb b/lib/ruber/configuration.rb index bf4d504..768ec6e 100644 --- a/lib/ruber/configuration.rb +++ b/lib/ruber/configuration.rb @@ -1,8 +1,22 @@ # frozen_string_literal: true +require_relative "configuration/null_cache" + module Ruber class Configuration attr_accessor :customer_id, :client_id, :client_secret + + def cache + @cache ||= NullCache.new + end + + def cache=(store) + unless %i[read write clear delete].all? { |method| store.respond_to?(method) } + raise ArgumentError, "cache_store must respond to read, write, clear, and delete" + end + + @cache = store + end end class << self @@ -10,9 +24,13 @@ def configuration @configuration ||= Configuration.new end - def configuration=(config_hash) - config_hash.each do |key, value| - configuration.send "#{key}=", value + def configuration=(config) + if config.is_a?(Hash) + config.each do |key, value| + configuration.send "#{key}=", value + end + else + @configuration = config end configuration diff --git a/lib/ruber/configuration/null_cache.rb b/lib/ruber/configuration/null_cache.rb new file mode 100644 index 0000000..90a31c5 --- /dev/null +++ b/lib/ruber/configuration/null_cache.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Ruber + class Configuration + class NullCache + def read(key) = memory_store[key] + def write(key, value, _options = {}) = memory_store[key] = value + def clear = memory_store.clear + def delete(key) = memory_store.delete(key) + def memory_store = @memory_store ||= {} + end + end +end diff --git a/test/ruber/authenticator_test.rb b/test/ruber/authenticator_test.rb new file mode 100644 index 0000000..a330617 --- /dev/null +++ b/test/ruber/authenticator_test.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "test_helper" +require "webmock/minitest" + +module Ruber + class AuthenticatorTest < Minitest::Test + include WebMock::API + + def setup + Ruber.cache.clear + end + + def test_access_token_fetches_and_caches_token + stub_token_request(access_token: "new_token", expires_in: 3600) + + token = Authenticator.access_token + cached_token = Ruber.cache.read(Authenticator.cache_key) + + assert_equal "new_token", token + assert_equal "new_token", cached_token[:token] + end + + def test_refresh_access_token_forces_new_token + Ruber.cache.write(Authenticator.cache_key, { token: "old_token", expires_at: Time.now + 3600 }) + + stub_token_request(access_token: "refreshed_token", expires_in: 3600) + + token = Authenticator.refresh_access_token + cached_token = Ruber.cache.read(Authenticator.cache_key) + + assert_equal "refreshed_token", token + assert_equal "refreshed_token", cached_token[:token] + end + + def test_refresh_access_token_if_expired + Ruber.cache.write(Authenticator.cache_key, { token: "expired_token", expires_at: Time.now - 3600 }) + + stub_token_request(access_token: "refreshed_token", expires_in: 3600) + + token = Authenticator.access_token + cached_token = Ruber.cache.read(Authenticator.cache_key) + + assert_equal "refreshed_token", token + assert_equal "refreshed_token", cached_token[:token] + end + + private + + def stub_token_request(access_token:, expires_in:) + stub_request(:post, Authenticator::OAUTH_URL) + .to_return( + status: 200, + body: { + access_token: access_token, + expires_in: expires_in + }.to_json + ) + end + end +end diff --git a/test/ruber/configuration_test.rb b/test/ruber/configuration_test.rb index f3f6317..1033a92 100644 --- a/test/ruber/configuration_test.rb +++ b/test/ruber/configuration_test.rb @@ -5,23 +5,37 @@ module Ruber class ConfigurationTest < Minitest::Test def setup - @config_values = { customer_id: "1111", client_id: "2222", client_secret: "a-secret" } + @config_values = { + customer_id: "1111", + client_id: "2222", + client_secret: "a-secret" + } + + @custom_cache = Class.new do + def read(key) = memory_store[key] + def write(key, value, _options = {}) = memory_store[key] = value + def clear = memory_store.clear + def delete(key) = memory_store.delete(key) + def memory_store = @memory_store ||= {} + end.new + + Ruber.configuration = nil end def test_configure Ruber.configure do |config| - config.customer_id = @config_values[:customer_id] - config.client_id = @config_values[:client_id] - config.client_secret = @config_values[:client_secret] + config.customer_id = @config_values[:customer_id] + config.client_id = @config_values[:client_id] + config.client_secret = @config_values[:client_secret] end assert_configuration_values end def test_direct_configuration - Ruber.customer_id = @config_values[:customer_id] - Ruber.client_id = @config_values[:client_id] - Ruber.client_secret = @config_values[:client_secret] + Ruber.customer_id = @config_values[:customer_id] + Ruber.client_id = @config_values[:client_id] + Ruber.client_secret = @config_values[:client_secret] assert_configuration_values end @@ -38,6 +52,18 @@ def test_invalid_configuration_set end end + def test_cache_can_be_set_explicitly + Ruber.cache = @custom_cache + + assert_equal @custom_cache, Ruber.cache + end + + def test_cache_must_respond_to_read_write_clear_and_delete + assert_raises(ArgumentError) do + Ruber.cache = Object.new + end + end + private def assert_configuration_values diff --git a/test/test_helper.rb b/test/test_helper.rb index 84a34ad..312aa10 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -4,3 +4,4 @@ require "ruber" require "minitest/autorun" +require "webmock/minitest"