diff --git a/lib/hawk/http/cache_adapters.rb b/lib/hawk/http/cache_adapters.rb new file mode 100644 index 0000000..12bc9f5 --- /dev/null +++ b/lib/hawk/http/cache_adapters.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Hawk + class HTTP + module CacheAdapters + autoload :DalliAdapter, 'hawk/http/cache_adapters/dalli_adapter' + autoload :RedisAdapter, 'hawk/http/cache_adapters/redis_adapter' + end + end +end diff --git a/lib/hawk/http/cache_adapters/dalli_adapter.rb b/lib/hawk/http/cache_adapters/dalli_adapter.rb new file mode 100644 index 0000000..ba2f1d3 --- /dev/null +++ b/lib/hawk/http/cache_adapters/dalli_adapter.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Hawk + class HTTP + module CacheAdapters + # Adapter for Memcached via Dalli, preserving existing behavior. + class DalliAdapter + def initialize(server, options) + @server = server + @client = Dalli::Client.new(server, options) + end + + def get(key) + @client.get(key) + end + + # For Dalli, the third parameter is TTL in seconds. + def set(key, value, ttl) + @client.set(key, value, ttl) + end + + def delete(key) + @client.delete(key) + end + + def version + @client.version.fetch(@server, nil) + rescue StandardError + nil + end + end + end + end +end diff --git a/lib/hawk/http/cache_adapters/redis_adapter.rb b/lib/hawk/http/cache_adapters/redis_adapter.rb new file mode 100644 index 0000000..3305fea --- /dev/null +++ b/lib/hawk/http/cache_adapters/redis_adapter.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Hawk + class HTTP + module CacheAdapters + # Adapter for Redis. + # Note: requires the 'redis' gem in the host application. + class RedisAdapter + def initialize(server, options) + load_redis_library + + @namespace = options[:namespace] + @client = build_client(server) + end + + def get(key) + @client.get(namespaced(key)) + end + + # TTL semantics: seconds, same as Dalli usage. + def set(key, value, ttl) + k = namespaced(key) + if ttl&.to_i&.positive? + @client.set(k, value, ex: ttl.to_i) + else + @client.set(k, value) + end + end + + def delete(key) + @client.del(namespaced(key)) + end + + def version + info = @client.info + info['redis_version'] || (info.is_a?(Hash) && info.dig('Server', 'redis_version')) + rescue StandardError + nil + end + + private + + def load_redis_library + require 'redis' # lazy load; add `gem 'redis'` to your Gemfile to use + end + + def namespaced(key) + @namespace ? "#{@namespace}:#{key}" : key + end + + def build_client(server) + s = server.to_s + if s.start_with?('redis://', 'rediss://') + Redis.new(url: s) + else + host, port = s.split(':', 2) + Redis.new(host: host || '127.0.0.1', port: (port || 6379).to_i) + end + end + end + end + end +end diff --git a/lib/hawk/http/caching.rb b/lib/hawk/http/caching.rb index 03ae32f..9c4b49c 100644 --- a/lib/hawk/http/caching.rb +++ b/lib/hawk/http/caching.rb @@ -1,12 +1,15 @@ # frozen_string_literal: true require 'dalli' +require 'multi_json' +require 'hawk/http/cache_adapters' module Hawk class HTTP module Caching DEFAULTS = { - server: 'localhost:11211', + driver: :dalli, # :dalli (default) or :redis + server: 'localhost:11211', # memcached default; for redis use 'redis://host:port' or 'host:port' namespace: 'hawk', compress: true, expires_in: 60, @@ -112,18 +115,35 @@ def initialize_cache(options) end end + def detect_driver(server, explicit = nil) + return explicit.to_sym if explicit + + s = server.to_s + return :redis if s.start_with?('redis://', 'rediss://') + + :dalli + end + def connect_cache(options) static_options = options.dup static_options.delete(:expires_in) + driver = detect_driver(options[:server], options[:driver]) - cache_servers[static_options] ||= begin + cache_servers[{ driver: driver, **static_options }] ||= begin server = options[:server] - client = Dalli::Client.new(server, static_options) - if version = client.version.fetch(server, nil) + client = + case driver + when :redis + Hawk::HTTP::CacheAdapters::RedisAdapter.new(server, static_options) + else + Hawk::HTTP::CacheAdapters::DalliAdapter.new(server, static_options) + end + + if (version = client.version) [client, server, version] else - warn "Hawk: can't connect to memcached server #{server}" + warn "Hawk: can't connect to #{driver} cache server #{server}" nil end end diff --git a/spec/hawk/http/cache_adapters_spec.rb b/spec/hawk/http/cache_adapters_spec.rb new file mode 100644 index 0000000..79febb3 --- /dev/null +++ b/spec/hawk/http/cache_adapters_spec.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Hawk::HTTP::CacheAdapters do + describe Hawk::HTTP::CacheAdapters::DalliAdapter do + subject(:adapter) { described_class.new(server, options) } + + let(:server) { 'localhost:11211' } + let(:options) { { namespace: 'test', compress: true } } + let(:dalli_client) { instance_spy(Dalli::Client) } + + before do + allow(Dalli::Client).to receive(:new).with(server, options).and_return(dalli_client) + end + + describe '#get' do + it 'delegates to Dalli client' do + allow(dalli_client).to receive(:get).with('key1').and_return('value1') + expect(adapter.get('key1')).to eq('value1') + expect(dalli_client).to have_received(:get).with('key1') + end + end + + describe '#set' do + it 'delegates to Dalli client with TTL' do + allow(dalli_client).to receive(:set).with('key1', 'value1', 60) + adapter.set('key1', 'value1', 60) + expect(dalli_client).to have_received(:set).with('key1', 'value1', 60) + end + end + + describe '#delete' do + it 'delegates to Dalli client' do + allow(dalli_client).to receive(:delete).with('key1') + adapter.delete('key1') + expect(dalli_client).to have_received(:delete).with('key1') + end + end + + describe '#version' do + it 'fetches version from Dalli client' do + version_hash = { server => '1.6.0' } + allow(dalli_client).to receive(:version).and_return(version_hash) + expect(adapter.version).to eq('1.6.0') + end + + it 'returns nil when version fetch fails' do + allow(dalli_client).to receive(:version).and_raise(StandardError) + expect(adapter.version).to be_nil + end + end + end + + describe Hawk::HTTP::CacheAdapters::RedisAdapter do + subject(:adapter) { described_class.new(server, options) } + + let(:server) { 'redis://localhost:6379' } + let(:options) { { namespace: 'hawk' } } + let(:redis_client) { instance_spy(Redis) } + let(:redis_class) do + Class.new do + def self.new(*_args, **_kwargs); end + end + end + + before do + # Stub the Redis gem loading by stubbing the private method + allow_any_instance_of(described_class).to receive(:load_redis_library) # rubocop:disable RSpec/AnyInstance + stub_const('Redis', redis_class) + allow(Redis).to receive(:new).and_return(redis_client) + end + + describe '#initialize with different server formats' do + it 'creates Redis client with redis:// URL' do + allow(Redis).to receive(:new).with(url: 'redis://localhost:6379').and_return(redis_client) + described_class.new('redis://localhost:6379', options) + expect(Redis).to have_received(:new).with(url: 'redis://localhost:6379') + end + + it 'creates Redis client with rediss:// URL' do + allow(Redis).to receive(:new).with(url: 'rediss://localhost:6379').and_return(redis_client) + described_class.new('rediss://localhost:6379', options) + expect(Redis).to have_received(:new).with(url: 'rediss://localhost:6379') + end + + it 'creates Redis client with host:port format' do + allow(Redis).to receive(:new).with(host: 'localhost', port: 6380).and_return(redis_client) + described_class.new('localhost:6380', { namespace: 'test' }) + expect(Redis).to have_received(:new).with(host: 'localhost', port: 6380) + end + + it 'creates Redis client with default port' do + allow(Redis).to receive(:new).with(host: 'myredis', port: 6379).and_return(redis_client) + described_class.new('myredis', { namespace: 'test' }) + expect(Redis).to have_received(:new).with(host: 'myredis', port: 6379) + end + end + + describe '#get' do + it 'delegates to Redis client with namespaced key' do + allow(redis_client).to receive(:get).with('hawk:key1').and_return('value1') + expect(adapter.get('key1')).to eq('value1') + expect(redis_client).to have_received(:get).with('hawk:key1') + end + end + + describe '#get without namespace' do + let(:options) { {} } + + it 'uses key without namespace' do + allow(redis_client).to receive(:get).with('key1').and_return('value1') + expect(adapter.get('key1')).to eq('value1') + expect(redis_client).to have_received(:get).with('key1') + end + end + + describe '#set' do + it 'delegates to Redis client with namespaced key and TTL' do + allow(redis_client).to receive(:set).with('hawk:key1', 'value1', ex: 60) + adapter.set('key1', 'value1', 60) + expect(redis_client).to have_received(:set).with('hawk:key1', 'value1', ex: 60) + end + + it 'sets without TTL when ttl is nil' do + allow(redis_client).to receive(:set).with('hawk:key1', 'value1') + adapter.set('key1', 'value1', nil) + expect(redis_client).to have_received(:set).with('hawk:key1', 'value1') + end + + it 'sets without TTL when ttl is 0' do + allow(redis_client).to receive(:set).with('hawk:key1', 'value1') + adapter.set('key1', 'value1', 0) + expect(redis_client).to have_received(:set).with('hawk:key1', 'value1') + end + end + + describe '#set without namespace' do + let(:options) { {} } + + it 'uses key without namespace' do + allow(redis_client).to receive(:set).with('key1', 'value1', ex: 60) + adapter.set('key1', 'value1', 60) + expect(redis_client).to have_received(:set).with('key1', 'value1', ex: 60) + end + end + + describe '#delete' do + it 'delegates to Redis client with namespaced key' do + allow(redis_client).to receive(:del).with('hawk:key1') + adapter.delete('key1') + expect(redis_client).to have_received(:del).with('hawk:key1') + end + end + + describe '#delete without namespace' do + let(:options) { {} } + + it 'uses key without namespace' do + allow(redis_client).to receive(:del).with('key1') + adapter.delete('key1') + expect(redis_client).to have_received(:del).with('key1') + end + end + + describe '#version' do + it 'fetches version from Redis INFO' do + allow(redis_client).to receive(:info).and_return({ 'redis_version' => '6.2.0' }) + expect(adapter.version).to eq('6.2.0') + end + + it 'fetches version from nested Server hash' do + allow(redis_client).to receive(:info).and_return({ 'Server' => { 'redis_version' => '7.0.0' } }) + expect(adapter.version).to eq('7.0.0') + end + + it 'returns nil when version fetch fails' do + allow(redis_client).to receive(:info).and_raise(StandardError) + expect(adapter.version).to be_nil + end + end + end +end diff --git a/spec/hawk/http/caching_spec.rb b/spec/hawk/http/caching_spec.rb new file mode 100644 index 0000000..0bf3ded --- /dev/null +++ b/spec/hawk/http/caching_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Hawk::HTTP::Caching do + describe 'driver detection and configuration' do + context 'with default configuration' do + it 'uses Dalli adapter by default' do + adapter = Hawk::HTTP::CacheAdapters::DalliAdapter.new('localhost:11211', namespace: 'hawk') + expect(adapter).to be_a(Hawk::HTTP::CacheAdapters::DalliAdapter) + end + end + + context 'with explicit driver option' do + it 'uses specified driver' do + http = Hawk::HTTP.new('https://example.org/', cache: { driver: :dalli, server: 'localhost:11211' }) + driver = http.send(:detect_driver, 'localhost:11211', :dalli) + expect(driver).to eq(:dalli) + end + + it 'uses redis driver when specified' do + # We don't instantiate HTTP here to avoid loading Redis + # Just test the driver detection logic directly + http = Hawk::HTTP.allocate + driver = http.send(:detect_driver, 'localhost:6379', :redis) + expect(driver).to eq(:redis) + end + end + + context 'with URL-based driver detection' do + it 'detects redis from redis:// URL' do + http = Hawk::HTTP.new('https://example.org/') + driver = http.send(:detect_driver, 'redis://localhost:6379', nil) + expect(driver).to eq(:redis) + end + + it 'detects redis from rediss:// URL' do + http = Hawk::HTTP.new('https://example.org/') + driver = http.send(:detect_driver, 'rediss://localhost:6379', nil) + expect(driver).to eq(:redis) + end + + it 'defaults to dalli for non-redis URLs' do + http = Hawk::HTTP.new('https://example.org/') + driver = http.send(:detect_driver, 'localhost:11211', nil) + expect(driver).to eq(:dalli) + end + end + + context 'with cache configuration' do + it 'includes driver in DEFAULTS' do + expect(Hawk::HTTP::Caching::DEFAULTS[:driver]).to eq(:dalli) + end + + it 'preserves all original DEFAULTS options' do + expect(Hawk::HTTP::Caching::DEFAULTS).to include( + server: 'localhost:11211', + namespace: 'hawk', + compress: true, + expires_in: 60 + ) + end + end + end + + describe 'backward compatibility' do + it 'works without specifying driver option' do + http = Hawk::HTTP.new('https://example.org/', cache: { server: 'localhost:11211' }) + expect(http).to be_a(Hawk::HTTP) + end + + it 'maintains existing cache_configured? behavior' do + http_with_cache = Hawk::HTTP.new('https://example.org/', cache: { server: 'localhost:11211' }) + http_without_cache = Hawk::HTTP.new('https://example.org/', cache: { disabled: true }) + + expect(http_with_cache.cache_configured?).to be true + expect(http_without_cache.cache_configured?).to be false + end + + it 'maintains existing cache_options behavior' do + http = Hawk::HTTP.new('https://example.org/', cache: { server: 'localhost:11211', expires_in: 120 }) + expect(http.cache_options).to include(expires_in: 120) + end + end +end