Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions lib/hawk/http/cache_adapters.rb
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions lib/hawk/http/cache_adapters/dalli_adapter.rb
Original file line number Diff line number Diff line change
@@ -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
63 changes: 63 additions & 0 deletions lib/hawk/http/cache_adapters/redis_adapter.rb
Original file line number Diff line number Diff line change
@@ -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
30 changes: 25 additions & 5 deletions lib/hawk/http/caching.rb
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand Down
183 changes: 183 additions & 0 deletions spec/hawk/http/cache_adapters_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading