mirror of
https://github.com/ditkrg/idempotent-request.git
synced 2026-01-22 22:06:44 +00:00
first implementation
This commit is contained in:
parent
26c1b70b07
commit
b82e271caa
@ -1,36 +1,32 @@
|
||||
# coding: utf-8
|
||||
lib = File.expand_path("../lib", __FILE__)
|
||||
lib = File.expand_path('../lib', __FILE__)
|
||||
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
||||
require "idempotent/request/version"
|
||||
require 'version'
|
||||
|
||||
Gem::Specification.new do |spec|
|
||||
spec.name = "idempotent-request"
|
||||
spec.version = Idempotent::Request::VERSION
|
||||
spec.authors = ["Dmytro Zakharov"]
|
||||
spec.email = ["dmytro@qonto.eu"]
|
||||
spec.name = 'idempotent-request'
|
||||
spec.version = IdempotentRequest::VERSION
|
||||
spec.authors = ['Dmytro Zakharov']
|
||||
spec.email = ['dmytro@qonto.eu']
|
||||
|
||||
spec.summary = %q{TODO: Write a short summary, because Rubygems requires one.}
|
||||
spec.description = %q{TODO: Write a longer description or delete this line.}
|
||||
spec.summary = %q{Write a short summary, because Rubygems requires one.}
|
||||
spec.description = %q{Write a longer description or delete this line.}
|
||||
spec.homepage = "TODO: Put your gem's website or public repo URL here."
|
||||
spec.license = "MIT"
|
||||
|
||||
# Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
|
||||
# to allow pushing to a single host or delete this section to allow pushing to any host.
|
||||
if spec.respond_to?(:metadata)
|
||||
spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'"
|
||||
else
|
||||
raise "RubyGems 2.0 or newer is required to protect against " \
|
||||
"public gem pushes."
|
||||
end
|
||||
spec.license = 'MIT'
|
||||
|
||||
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
||||
f.match(%r{^(test|spec|features)/})
|
||||
end
|
||||
spec.bindir = "exe"
|
||||
spec.bindir = 'exe'
|
||||
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
||||
spec.require_paths = ["lib"]
|
||||
spec.require_paths = ['lib']
|
||||
|
||||
spec.add_development_dependency "bundler", "~> 1.15"
|
||||
spec.add_development_dependency "rake", "~> 10.0"
|
||||
spec.add_development_dependency "rspec", "~> 3.0"
|
||||
spec.add_dependency 'rack', '~> 2.0'
|
||||
spec.add_dependency 'oj', '~> 3.0'
|
||||
|
||||
spec.add_development_dependency 'bundler', '~> 1.15'
|
||||
spec.add_development_dependency 'rake', '~> 10.0'
|
||||
spec.add_development_dependency 'rspec', '~> 3.0'
|
||||
spec.add_development_dependency 'fakeredis', '~> 0.6'
|
||||
spec.add_development_dependency 'pry', '~> 0.11'
|
||||
end
|
||||
|
||||
5
lib/idempotent-request.rb
Normal file
5
lib/idempotent-request.rb
Normal file
@ -0,0 +1,5 @@
|
||||
require 'oj'
|
||||
require 'idempotent-request/request'
|
||||
require 'idempotent-request/request_manager'
|
||||
require 'idempotent-request/redis_storage'
|
||||
require 'idempotent-request/middleware'
|
||||
39
lib/idempotent-request/middleware.rb
Normal file
39
lib/idempotent-request/middleware.rb
Normal file
@ -0,0 +1,39 @@
|
||||
module IdempotentRequest
|
||||
class Middleware
|
||||
def initialize(app, config = {})
|
||||
@app = app
|
||||
@config = config
|
||||
@decider = config[:decider]
|
||||
end
|
||||
|
||||
def call(env)
|
||||
# dup the middleware to be thread-safe
|
||||
dup.process(env)
|
||||
end
|
||||
|
||||
def process(env)
|
||||
set_request(env)
|
||||
return app.call(request.env) unless process?
|
||||
storage = RequestManager.new(request, config)
|
||||
storage.read || storage.write(*app.call(request.env))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :app, :env, :config, :request, :decider
|
||||
|
||||
def process?
|
||||
!request.key.to_s.empty? && should_be_idempotent?
|
||||
end
|
||||
|
||||
def should_be_idempotent?
|
||||
return false unless decider
|
||||
decider.new(request).should?
|
||||
end
|
||||
|
||||
def set_request(env)
|
||||
@env = env
|
||||
@request ||= Request.new(env, config)
|
||||
end
|
||||
end
|
||||
end
|
||||
29
lib/idempotent-request/redis_storage.rb
Normal file
29
lib/idempotent-request/redis_storage.rb
Normal file
@ -0,0 +1,29 @@
|
||||
module IdempotentRequest
|
||||
class RedisStorage
|
||||
attr_reader :redis, :namespace, :expire_time
|
||||
|
||||
def initialize(redis, config = {})
|
||||
@redis = redis
|
||||
@namespace = config.fetch(:namespace, 'idempotency_keys')
|
||||
@expire_time = config[:expire_time]
|
||||
end
|
||||
|
||||
def read(key)
|
||||
redis.get(namespaced_key(key))
|
||||
end
|
||||
|
||||
def write(key, payload)
|
||||
redis.setnx(namespaced_key(key), payload)
|
||||
redis.expire(namespaced_key(key), expire_time.to_i) if expire_time.to_i > 0
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def namespaced_key(idempotency_key)
|
||||
[namespace, idempotency_key.strip]
|
||||
.compact
|
||||
.join(':')
|
||||
.downcase
|
||||
end
|
||||
end
|
||||
end
|
||||
33
lib/idempotent-request/request.rb
Normal file
33
lib/idempotent-request/request.rb
Normal file
@ -0,0 +1,33 @@
|
||||
module IdempotentRequest
|
||||
class Request
|
||||
attr_reader :request
|
||||
|
||||
def initialize(env, config = {})
|
||||
@request = Rack::Request.new(env)
|
||||
@header_name = config.fetch(:header_key, 'HTTP_IDEMPOTENCY_KEY')
|
||||
end
|
||||
|
||||
def key
|
||||
request.env[header_name]
|
||||
end
|
||||
|
||||
def method_missing(method, *args)
|
||||
if request.respond_to?(method)
|
||||
request.send(method, *args)
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def header_name
|
||||
key = @header_name
|
||||
.to_s
|
||||
.upcase
|
||||
.gsub('-', '_')
|
||||
|
||||
key.start_with?('HTTP_') ? key : "HTTP_#{key}"
|
||||
end
|
||||
end
|
||||
end
|
||||
56
lib/idempotent-request/request_manager.rb
Normal file
56
lib/idempotent-request/request_manager.rb
Normal file
@ -0,0 +1,56 @@
|
||||
module IdempotentRequest
|
||||
class RequestManager
|
||||
attr_reader :request, :storage
|
||||
|
||||
def initialize(request, config)
|
||||
@request = request
|
||||
@storage = config.fetch(:storage)
|
||||
@callback = config[:callback]
|
||||
end
|
||||
|
||||
def read
|
||||
status, headers, response = parse_data(storage.read(key)).values
|
||||
|
||||
return unless status
|
||||
run_callback(:detected, key: request.key)
|
||||
[status, headers, response]
|
||||
end
|
||||
|
||||
def write(*data)
|
||||
status, headers, response = data
|
||||
response = response.body if response.respond_to?(:body)
|
||||
|
||||
return data unless status == 200
|
||||
|
||||
storage.write(key, payload(status, headers, response))
|
||||
|
||||
data
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_data(data)
|
||||
return {} if data.to_s.empty?
|
||||
|
||||
Oj.load(data)
|
||||
end
|
||||
|
||||
def payload(status, headers, response)
|
||||
Oj.dump({
|
||||
status: status,
|
||||
headers: headers.to_h,
|
||||
response: response
|
||||
})
|
||||
end
|
||||
|
||||
def run_callback(action, args)
|
||||
return unless @callback
|
||||
|
||||
@callback.new(request).send(action, args)
|
||||
end
|
||||
|
||||
def key
|
||||
request.key
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -1,7 +0,0 @@
|
||||
require "idempotent/request/version"
|
||||
|
||||
module Idempotent
|
||||
module Request
|
||||
# Your code goes here...
|
||||
end
|
||||
end
|
||||
@ -1,5 +0,0 @@
|
||||
module Idempotent
|
||||
module Request
|
||||
VERSION = "0.1.0"
|
||||
end
|
||||
end
|
||||
3
lib/version.rb
Normal file
3
lib/version.rb
Normal file
@ -0,0 +1,3 @@
|
||||
module IdempotentRequest
|
||||
VERSION = "0.1.0"
|
||||
end
|
||||
59
spec/idempotent-request/middleware_spec.rb
Normal file
59
spec/idempotent-request/middleware_spec.rb
Normal file
@ -0,0 +1,59 @@
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe IdempotentRequest::Middleware do
|
||||
let(:app) { -> (env) { [200, {}, 'body'] } }
|
||||
let(:env) do
|
||||
env_for('https://qonto.eu', method: 'POST')
|
||||
.merge!(
|
||||
'HTTP_X_QONTO_IDEMPOTENCY_KEY' => 'dont-repeat-this-request-pls'
|
||||
)
|
||||
end
|
||||
let(:storage) { @memory_storage ||= IdempotentRequest::MemoryStorage.new }
|
||||
let(:decider) do
|
||||
class_double('IdempotentRequest::Decider', new: double(should?: true))
|
||||
end
|
||||
|
||||
let(:middleware) do
|
||||
described_class.new(app,
|
||||
decider: decider,
|
||||
storage: storage,
|
||||
header_key: 'X-Qonto-Idempotency-Key'
|
||||
)
|
||||
end
|
||||
|
||||
context 'when should be idempotent' do
|
||||
it 'should be saved to storage' do
|
||||
expect_any_instance_of(IdempotentRequest::RequestManager).to receive(:read)
|
||||
expect_any_instance_of(IdempotentRequest::RequestManager).to receive(:write)
|
||||
|
||||
middleware.call(env)
|
||||
end
|
||||
|
||||
context 'when has data in storage' do
|
||||
before do
|
||||
data = [200, {}, 'body']
|
||||
allow_any_instance_of(IdempotentRequest::RequestManager).to receive(:read).and_return(data)
|
||||
end
|
||||
|
||||
it 'should read from storage' do
|
||||
expect_any_instance_of(IdempotentRequest::RequestManager).to receive(:read)
|
||||
expect_any_instance_of(IdempotentRequest::RequestManager).not_to receive(:write)
|
||||
|
||||
middleware.call(env)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when should not be idempotent' do
|
||||
let(:decider) do
|
||||
class_double('IdempotentRequest::Decider', new: double(should?: false))
|
||||
end
|
||||
|
||||
it 'should not read storage' do
|
||||
expect_any_instance_of(IdempotentRequest::RequestManager).not_to receive(:read)
|
||||
expect_any_instance_of(IdempotentRequest::RequestManager).not_to receive(:write)
|
||||
|
||||
middleware.call(env)
|
||||
end
|
||||
end
|
||||
end
|
||||
66
spec/idempotent-request/redis_storage_spec.rb
Normal file
66
spec/idempotent-request/redis_storage_spec.rb
Normal file
@ -0,0 +1,66 @@
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe IdempotentRequest::RedisStorage do
|
||||
let(:redis) { FakeRedis::Redis.new }
|
||||
let(:expire_time) { 3600 }
|
||||
let(:redis_storage) { described_class.new(redis, expire_time: expire_time) }
|
||||
|
||||
describe '#read' do
|
||||
it 'should be called' do
|
||||
expect(redis).to receive(:get)
|
||||
expect(redis_storage.read('key')).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#write' do
|
||||
let(:key) { 'key' }
|
||||
let(:payload) { {} }
|
||||
|
||||
context 'when expire time is not set' do
|
||||
let(:redis_storage) { described_class.new(redis) }
|
||||
|
||||
it 'should not set expiration' do
|
||||
expect(redis).to receive(:setnx)
|
||||
expect(redis).not_to receive(:expire)
|
||||
redis_storage.write(key, payload)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when expire time is set' do
|
||||
it 'should set expiration' do
|
||||
expect(redis).to receive(:setnx)
|
||||
expect(redis).to receive(:expire).with(String, expire_time)
|
||||
redis_storage.write(key, payload)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#namespaced_key' do
|
||||
subject { redis_storage.send(:namespaced_key, key) }
|
||||
|
||||
context 'when key contains a space' do
|
||||
let(:key) { ' REQUEST-1 ' }
|
||||
|
||||
it 'should be stripped' do
|
||||
is_expected.to eq('idempotency_keys:request-1')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when namespace is not set' do
|
||||
let(:key) { 'REQUEST-1' }
|
||||
|
||||
it 'should return with default' do
|
||||
is_expected.to eq('idempotency_keys:request-1')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when namespace is set to nil' do
|
||||
let(:redis_storage) { described_class.new(redis, namespace: nil) }
|
||||
let(:key) { 'REQUEST-1' }
|
||||
|
||||
it 'should return with default' do
|
||||
is_expected.to eq('request-1')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
112
spec/idempotent-request/request_manager_spec.rb
Normal file
112
spec/idempotent-request/request_manager_spec.rb
Normal file
@ -0,0 +1,112 @@
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe IdempotentRequest::RequestManager do
|
||||
let(:url) { 'http://qonto.eu' }
|
||||
let(:default_env) { env_for(url) }
|
||||
let(:env) { default_env }
|
||||
let(:request) { IdempotentRequest::Request.new(env) }
|
||||
let!(:memory_storage) { @memory_storage ||= IdempotentRequest::MemoryStorage.new }
|
||||
let(:request_storage) { described_class.new(request, { storage: memory_storage }) }
|
||||
|
||||
before do
|
||||
allow(request).to receive(:key).and_return('data-key')
|
||||
memory_storage.clear
|
||||
end
|
||||
|
||||
describe '#read' do
|
||||
context 'when there is no data' do
|
||||
it 'should return nil' do
|
||||
expect(request_storage.read).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when there is data' do
|
||||
let(:data) do
|
||||
[200, {}, 'body']
|
||||
end
|
||||
|
||||
let(:payload) do
|
||||
Oj.dump({
|
||||
status: data[0],
|
||||
headers: data[1],
|
||||
response: data[2]
|
||||
})
|
||||
end
|
||||
|
||||
before do
|
||||
memory_storage.write(request.key, payload)
|
||||
end
|
||||
|
||||
it 'should return data' do
|
||||
expect(request_storage.read).to eq(data)
|
||||
end
|
||||
|
||||
context 'when callback is defined' do
|
||||
let(:request_storage) { described_class.new(request, storage: memory_storage, callback: IdempotencyCallback) }
|
||||
|
||||
it 'should be called' do
|
||||
callback = double
|
||||
expect(IdempotencyCallback).to receive(:new).with(request).and_return(callback)
|
||||
expect(callback).to receive(:detected).with(key: request.key)
|
||||
expect(request_storage.read).to eq(data)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when read with different key' do
|
||||
context 'for the old key' do
|
||||
it 'should return data' do
|
||||
expect(request_storage.read).to eq(data)
|
||||
end
|
||||
end
|
||||
|
||||
context 'for the new key' do
|
||||
before do
|
||||
allow(request).to receive(:key).and_return('data-key-2')
|
||||
end
|
||||
|
||||
it 'should return nil' do
|
||||
expect(request_storage.read).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#write' do
|
||||
let(:payload) do
|
||||
Oj.dump({
|
||||
status: data[0],
|
||||
headers: data[1],
|
||||
response: data[2]
|
||||
})
|
||||
end
|
||||
|
||||
context 'when status is 200' do
|
||||
let(:data) do
|
||||
[200, {}, 'body']
|
||||
end
|
||||
|
||||
it 'should be stored' do
|
||||
request_storage.write(*data)
|
||||
expect(memory_storage.read(request.key)).to eq(payload)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when status is not 200' do
|
||||
let(:data) do
|
||||
[404, {}, 'body']
|
||||
end
|
||||
|
||||
it 'should be stored' do
|
||||
request_storage.write(*data)
|
||||
expect(memory_storage.read(request.key)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class IdempotencyCallback
|
||||
def initialize(_); end
|
||||
|
||||
def detected(_); end
|
||||
end
|
||||
end
|
||||
62
spec/idempotent-request/request_spec.rb
Normal file
62
spec/idempotent-request/request_spec.rb
Normal file
@ -0,0 +1,62 @@
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe IdempotentRequest::Request do
|
||||
let(:url) { 'https://qonto.eu' }
|
||||
let(:default_env) { env_for(url) }
|
||||
let(:env) { default_env }
|
||||
let(:request) { described_class.new(env) }
|
||||
|
||||
describe '#key' do
|
||||
context 'when is default' do
|
||||
subject { request.key }
|
||||
|
||||
context 'value is set' do
|
||||
let(:env) do
|
||||
default_env.merge!(
|
||||
'HTTP_IDEMPOTENCY_KEY' => 'test-key'
|
||||
)
|
||||
end
|
||||
|
||||
it 'should be present' do
|
||||
is_expected.to eq('test-key')
|
||||
end
|
||||
end
|
||||
|
||||
context 'value is not set' do
|
||||
it 'should be nil' do
|
||||
is_expected.to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when is custom' do
|
||||
let(:request) { described_class.new(env, header_key: 'X-Qonto-Idempotency-Key') }
|
||||
|
||||
subject { request.key }
|
||||
|
||||
context 'value is set' do
|
||||
let(:env) do
|
||||
default_env.merge!(
|
||||
'HTTP_X_QONTO_IDEMPOTENCY_KEY' => 'custom-key'
|
||||
)
|
||||
end
|
||||
|
||||
it 'should be present' do
|
||||
is_expected.to eq('custom-key')
|
||||
end
|
||||
end
|
||||
|
||||
context 'value is not set' do
|
||||
it 'should be nil' do
|
||||
is_expected.to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#method_missing' do
|
||||
it 'should forward to request' do
|
||||
expect(request.request_method).to eq('GET')
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -1,11 +0,0 @@
|
||||
require "spec_helper"
|
||||
|
||||
RSpec.describe Idempotent::Request do
|
||||
it "has a version number" do
|
||||
expect(Idempotent::Request::VERSION).not_to be nil
|
||||
end
|
||||
|
||||
it "does something useful" do
|
||||
expect(false).to eq(true)
|
||||
end
|
||||
end
|
||||
@ -1,7 +1,13 @@
|
||||
require "bundler/setup"
|
||||
require "idempotent/request"
|
||||
require 'fakeredis'
|
||||
require 'pry'
|
||||
require "idempotent-request"
|
||||
|
||||
spec = File.expand_path('../', __FILE__)
|
||||
Dir[File.join(spec, 'support/**/*.rb')].each { |f| require f }
|
||||
|
||||
RSpec.configure do |config|
|
||||
config.include IdempotentRequest::Helpers
|
||||
# Enable flags like --only-failures and --next-failure
|
||||
config.example_status_persistence_file_path = ".rspec_status"
|
||||
|
||||
|
||||
9
spec/support/helpers.rb
Normal file
9
spec/support/helpers.rb
Normal file
@ -0,0 +1,9 @@
|
||||
require 'rack'
|
||||
|
||||
module IdempotentRequest
|
||||
module Helpers
|
||||
def env_for(url, opts={})
|
||||
Rack::MockRequest.env_for(url, opts)
|
||||
end
|
||||
end
|
||||
end
|
||||
19
spec/support/memory_storage.rb
Normal file
19
spec/support/memory_storage.rb
Normal file
@ -0,0 +1,19 @@
|
||||
module IdempotentRequest
|
||||
class MemoryStorage
|
||||
def initialize
|
||||
@memory = {}
|
||||
end
|
||||
|
||||
def read(key)
|
||||
@memory[key]
|
||||
end
|
||||
|
||||
def write(key, payload)
|
||||
@memory[key] = payload
|
||||
end
|
||||
|
||||
def clear
|
||||
@memory = {}
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in New Issue
Block a user