Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
90 changes: 90 additions & 0 deletions lib/hawk/http/cache_adapters.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# 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

# 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
176 changes: 176 additions & 0 deletions spec/hawk/http/cache_adapters_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe Hawk::HTTP::CacheAdapters do
describe Hawk::HTTP::CacheAdapters::DalliAdapter do
let(:server) { 'localhost:11211' }
let(:options) { { namespace: 'test', compress: true } }
let(:dalli_client) { instance_double(Dalli::Client) }
let(:adapter) { described_class.new(server, options) }

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
expect(dalli_client).to receive(:get).with('key1').and_return('value1')

Check failure on line 18 in spec/hawk/http/cache_adapters_spec.rb

View workflow job for this annotation

GitHub Actions / RuboCop

RSpec/MessageSpies: Prefer `have_received` for setting message expectations. Setup `dalli_client` as a spy using `allow` or `instance_spy`.

Check failure on line 18 in spec/hawk/http/cache_adapters_spec.rb

View workflow job for this annotation

GitHub Actions / RuboCop

RSpec/StubbedMock: Prefer `allow` over `expect` when configuring a response.
expect(adapter.get('key1')).to eq('value1')
end
end

describe '#set' do
it 'delegates to Dalli client with TTL' do
expect(dalli_client).to receive(:set).with('key1', 'value1', 60)

Check failure on line 25 in spec/hawk/http/cache_adapters_spec.rb

View workflow job for this annotation

GitHub Actions / RuboCop

RSpec/MessageSpies: Prefer `have_received` for setting message expectations. Setup `dalli_client` as a spy using `allow` or `instance_spy`.
adapter.set('key1', 'value1', 60)
end
end

describe '#delete' do
it 'delegates to Dalli client' do
expect(dalli_client).to receive(:delete).with('key1')

Check failure on line 32 in spec/hawk/http/cache_adapters_spec.rb

View workflow job for this annotation

GitHub Actions / RuboCop

RSpec/MessageSpies: Prefer `have_received` for setting message expectations. Setup `dalli_client` as a spy using `allow` or `instance_spy`.
adapter.delete('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
let(:server) { 'redis://localhost:6379' }
let(:adapter) { described_class.new(server, options) }
let(:options) { { namespace: 'hawk' } }
let(:redis_client) { instance_double(Redis) }

before do
# Stub the Redis gem loading
allow_any_instance_of(described_class).to receive(:load_redis_library)

Check failure on line 59 in spec/hawk/http/cache_adapters_spec.rb

View workflow job for this annotation

GitHub Actions / RuboCop

RSpec/AnyInstance: Avoid stubbing using `allow_any_instance_of`.

# Create a mock Redis class that accepts new with any arguments
redis_class = Class.new do
def self.new(*_args, **_kwargs)
# This will be stubbed in individual tests
end
end
stub_const('Redis', redis_class)
allow(Redis).to receive(:new).and_return(redis_client)
end

describe '#initialize' do
context 'with redis:// URL' do

Check failure on line 72 in spec/hawk/http/cache_adapters_spec.rb

View workflow job for this annotation

GitHub Actions / RuboCop

RSpec/NestedGroups: Maximum example group nesting exceeded [4/3].
it 'creates Redis client with URL' do
expect(Redis).to receive(:new).with(url: 'redis://localhost:6379')

Check failure on line 74 in spec/hawk/http/cache_adapters_spec.rb

View workflow job for this annotation

GitHub Actions / RuboCop

RSpec/MessageSpies: Prefer `have_received` for setting message expectations. Setup `Redis` as a spy using `allow` or `instance_spy`.
described_class.new('redis://localhost:6379', options)
end
end

context 'with rediss:// URL' do

Check failure on line 79 in spec/hawk/http/cache_adapters_spec.rb

View workflow job for this annotation

GitHub Actions / RuboCop

RSpec/NestedGroups: Maximum example group nesting exceeded [4/3].
it 'creates Redis client with URL' do
expect(Redis).to receive(:new).with(url: 'rediss://localhost:6379')

Check failure on line 81 in spec/hawk/http/cache_adapters_spec.rb

View workflow job for this annotation

GitHub Actions / RuboCop

RSpec/MessageSpies: Prefer `have_received` for setting message expectations. Setup `Redis` as a spy using `allow` or `instance_spy`.
described_class.new('rediss://localhost:6379', options)
end
end

context 'with host:port format' do

Check failure on line 86 in spec/hawk/http/cache_adapters_spec.rb

View workflow job for this annotation

GitHub Actions / RuboCop

RSpec/NestedGroups: Maximum example group nesting exceeded [4/3].
it 'creates Redis client with host and port' do
expect(Redis).to receive(:new).with(host: 'localhost', port: 6380)
described_class.new('localhost:6380', { namespace: 'test' })
end
end

context 'with just host' do
it 'creates Redis client with default port' do
expect(Redis).to receive(:new).with(host: 'myredis', port: 6379)
described_class.new('myredis', { namespace: 'test' })
end
end
end

describe '#get' do
it 'delegates to Redis client with namespaced key' do
expect(redis_client).to receive(:get).with('hawk:key1').and_return('value1')
expect(adapter.get('key1')).to eq('value1')
end

context 'without namespace' do
let(:options) { {} }

it 'uses key without namespace' do
expect(redis_client).to receive(:get).with('key1').and_return('value1')
expect(adapter.get('key1')).to eq('value1')
end
end
end

describe '#set' do
it 'delegates to Redis client with namespaced key and TTL' do
expect(redis_client).to receive(:set).with('hawk:key1', 'value1', ex: 60)
adapter.set('key1', 'value1', 60)
end

it 'sets without TTL when ttl is nil' do
expect(redis_client).to receive(:set).with('hawk:key1', 'value1')
adapter.set('key1', 'value1', nil)
end

it 'sets without TTL when ttl is 0' do
expect(redis_client).to receive(:set).with('hawk:key1', 'value1')
adapter.set('key1', 'value1', 0)
end

context 'without namespace' do
let(:options) { {} }

it 'uses key without namespace' do
expect(redis_client).to receive(:set).with('key1', 'value1', ex: 60)
adapter.set('key1', 'value1', 60)
end
end
end

describe '#delete' do
it 'delegates to Redis client with namespaced key' do
expect(redis_client).to receive(:del).with('hawk:key1')
adapter.delete('key1')
end

context 'without namespace' do
let(:options) { {} }

it 'uses key without namespace' do
expect(redis_client).to receive(:del).with('key1')
adapter.delete('key1')
end
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