idempotency 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 59512a5938bf9884bd46436ff18078a74c9ed2aafb45ca0c491d1f51fa2f42d7
4
+ data.tar.gz: 4965a8b09bc2fa3d2627f79c90f4241ec192a05e7119f2003de8b55f67d394bf
5
+ SHA512:
6
+ metadata.gz: 025e20c6912c741e0994d9119d76f67d261a39c4ea8864eb0840e78dc23baf9e0b6454c0dbac31e459f5530465e79fd4823352ee0a717228d3d09386e04d889f
7
+ data.tar.gz: 55151ebed4cf89c7ac6e3444439d8c371d31b366e209fe6d17a87ac74ac1bad76921a0dc24f6502b7c5fa7be0e6679f8c22e67e836db5673249ffe7417675216
@@ -0,0 +1,18 @@
1
+ on: [push]
2
+ jobs:
3
+ tests:
4
+ runs-on: ubuntu-latest
5
+ strategy:
6
+ fail-fast: false
7
+ steps:
8
+ - name: Checkout
9
+ uses: actions/checkout@v2
10
+ - name: Set up Ruby
11
+ uses: ruby/setup-ruby@v1
12
+ with:
13
+ ruby-version: '3.1'
14
+ bundler-cache: true
15
+ - name: Run rubocop
16
+ run: bundle exec rubocop
17
+ - name: Run unit tests
18
+ run: bundle exec rspec
@@ -0,0 +1,17 @@
1
+ name: Release gem
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v[0-9]+.[0-9]+.[0-9]+'
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v3
13
+ - name: Release Gem
14
+ uses: cadwallion/publish-rubygems-action@master
15
+ env:
16
+ GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
17
+ RUBYGEMS_API_KEY: ${{secrets.RUBYGEMS_API_KEY}}
data/.gitignore ADDED
@@ -0,0 +1,11 @@
1
+ .DS_Store
2
+ .idea
3
+ *.log
4
+ tmp/
5
+
6
+ .ruby-version
7
+ .bundle
8
+ vendor
9
+ Gemfile.lock
10
+ coverage
11
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,29 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.1
3
+ NewCops: enable
4
+
5
+ Metrics/BlockLength:
6
+ Enabled: false
7
+
8
+ Metrics/MethodLength:
9
+ Max: 100
10
+
11
+ Style/ExplicitBlockArgument:
12
+ Enabled: false
13
+
14
+ Style/GuardClause:
15
+ Enabled: false
16
+
17
+ Style/StringLiterals:
18
+ Enabled: true
19
+ EnforcedStyle: single_quotes
20
+
21
+ Style/StringLiteralsInInterpolation:
22
+ Enabled: true
23
+ EnforcedStyle: double_quotes
24
+
25
+ Style/Documentation:
26
+ Enabled: false
27
+
28
+ Layout/LineLength:
29
+ Max: 120
data/CHANGELOG.md ADDED
@@ -0,0 +1,6 @@
1
+ ## [Change Log]
2
+
3
+ ## [0.1.0] - 2024-11-13
4
+
5
+ - Initial release
6
+
data/Gemfile ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ # Specify your gem's dependencies in idempotency.gemspec
6
+ gemspec
7
+
8
+ gem 'mock_redis'
9
+ gem 'rspec', '~> 3.0'
10
+
11
+ gem 'connection_pool'
12
+ gem 'hanami-controller', '~> 1.3'
13
+ gem 'pry-byebug'
14
+ gem 'rubocop', '~> 1.21'
data/Gemfile.lock ADDED
@@ -0,0 +1,101 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ idempotency (0.1.2)
5
+ base64
6
+ dry-configurable
7
+ msgpack
8
+ redis
9
+
10
+ GEM
11
+ remote: https://rubygems.org/
12
+ specs:
13
+ ast (2.4.2)
14
+ base64 (0.2.0)
15
+ byebug (11.1.3)
16
+ coderay (1.1.3)
17
+ concurrent-ruby (1.3.4)
18
+ connection_pool (2.4.1)
19
+ diff-lcs (1.5.1)
20
+ dry-configurable (1.2.0)
21
+ dry-core (~> 1.0, < 2)
22
+ zeitwerk (~> 2.6)
23
+ dry-core (1.0.2)
24
+ concurrent-ruby (~> 1.0)
25
+ logger
26
+ zeitwerk (~> 2.6)
27
+ hanami-controller (1.3.3)
28
+ hanami-utils (~> 1.3)
29
+ rack (~> 2.0)
30
+ hanami-utils (1.3.8)
31
+ concurrent-ruby (~> 1.0)
32
+ transproc (~> 1.0)
33
+ json (2.8.1)
34
+ language_server-protocol (3.17.0.3)
35
+ logger (1.6.1)
36
+ method_source (1.1.0)
37
+ mock_redis (0.46.0)
38
+ msgpack (1.7.5)
39
+ parallel (1.26.3)
40
+ parser (3.3.6.0)
41
+ ast (~> 2.4.1)
42
+ racc
43
+ pry (0.14.2)
44
+ coderay (~> 1.1)
45
+ method_source (~> 1.0)
46
+ pry-byebug (3.10.1)
47
+ byebug (~> 11.0)
48
+ pry (>= 0.13, < 0.15)
49
+ racc (1.8.1)
50
+ rack (2.2.10)
51
+ rainbow (3.1.1)
52
+ redis (5.3.0)
53
+ redis-client (>= 0.22.0)
54
+ redis-client (0.22.2)
55
+ connection_pool
56
+ regexp_parser (2.9.2)
57
+ rspec (3.13.0)
58
+ rspec-core (~> 3.13.0)
59
+ rspec-expectations (~> 3.13.0)
60
+ rspec-mocks (~> 3.13.0)
61
+ rspec-core (3.13.2)
62
+ rspec-support (~> 3.13.0)
63
+ rspec-expectations (3.13.3)
64
+ diff-lcs (>= 1.2.0, < 2.0)
65
+ rspec-support (~> 3.13.0)
66
+ rspec-mocks (3.13.2)
67
+ diff-lcs (>= 1.2.0, < 2.0)
68
+ rspec-support (~> 3.13.0)
69
+ rspec-support (3.13.1)
70
+ rubocop (1.68.0)
71
+ json (~> 2.3)
72
+ language_server-protocol (>= 3.17.0)
73
+ parallel (~> 1.10)
74
+ parser (>= 3.3.0.2)
75
+ rainbow (>= 2.2.2, < 4.0)
76
+ regexp_parser (>= 2.4, < 3.0)
77
+ rubocop-ast (>= 1.32.2, < 2.0)
78
+ ruby-progressbar (~> 1.7)
79
+ unicode-display_width (>= 2.4.0, < 3.0)
80
+ rubocop-ast (1.35.0)
81
+ parser (>= 3.3.1.0)
82
+ ruby-progressbar (1.13.0)
83
+ transproc (1.1.1)
84
+ unicode-display_width (2.6.0)
85
+ zeitwerk (2.6.18)
86
+
87
+ PLATFORMS
88
+ arm64-darwin-21
89
+ ruby
90
+
91
+ DEPENDENCIES
92
+ connection_pool
93
+ hanami-controller (~> 1.3)
94
+ idempotency!
95
+ mock_redis
96
+ pry-byebug
97
+ rspec (~> 3.0)
98
+ rubocop (~> 1.21)
99
+
100
+ BUNDLED WITH
101
+ 2.5.11
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Ascenda Loyalty Pte Ltd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # Idempotency
2
+
3
+ ## Installation
4
+
5
+ Add this line to your Gemfile:
6
+
7
+ ```ruby
8
+ gem 'idempotency'
9
+ ```
10
+
11
+ ## Configuration
12
+
13
+ ```ruby
14
+ Idempotency.configure do |config|
15
+ # Required configurations
16
+ config.redis_pool = ConnectionPool.new(size: 5) { Redis.new }
17
+ config.logger = Logger.new # Use Rails.logger or Hanami.logger based on your framework
18
+
19
+ # Optional configurations
20
+
21
+ # Handles concurrent request locks. If a request with the same idempotency key is made before the first one finishes,
22
+ # it will be blocked with a 409 status until the lock expires. Ensure this value is greater than the maximum response time.
23
+ config.default_lock_expiry = 60
24
+
25
+ # Match this config to your application's error format
26
+ config.response_body.concurrent_error = {
27
+ errors: [{ message: 'Concurrent requests occurred' }]
28
+ }
29
+
30
+ config.idempotent_methods = %w[POST PUT PATCH]
31
+ config.idempotent_statuses = (200..299).to_a
32
+ end
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ ### Rails
38
+
39
+ Add this to your controller:
40
+
41
+ ```ruby
42
+ require 'idempotency/rails'
43
+
44
+ class UserController < ApplicationController
45
+ include Idempotency::Rails
46
+
47
+ around_action :use_cache, except: %i[create]
48
+
49
+ # Configure lock_duration for specific actions
50
+ around_action :idempotency_cache, only: %i[update]
51
+
52
+ private
53
+
54
+ def idempotency_cache
55
+ use_cache(lock_duration: 360) do # Lock for 6 minutes
56
+ yield
57
+ end
58
+ end
59
+ end
60
+ ```
61
+
62
+ ### Hanami
63
+
64
+ Add this to your controller:
65
+
66
+ ```ruby
67
+ require 'idempotency/hanami'
68
+
69
+ class Api::Controllers::Users::Create
70
+ include Hanami::Action
71
+ include Idempotency::Hanami
72
+
73
+ around do |params, block|
74
+ use_cache(request_ids, lock_duration: 360) do
75
+ block.call
76
+ end
77
+ end
78
+ end
79
+ ```
80
+
81
+ ### Manual
82
+
83
+ For custom implementations or if not using Rails or Hanami:
84
+
85
+ ```ruby
86
+ status, headers, body = Idempotency.use_cache(request, request_identifiers, lock_duration: 60) do
87
+ yield
88
+ end
89
+
90
+ # Render your response
91
+ ```
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require 'rubocop/rake_task'
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/idempotency/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'idempotency'
7
+ spec.version = Idempotency::VERSION
8
+ spec.authors = ['Vu Hoang']
9
+ spec.email = 'vu.hoang@ascenda.com'
10
+
11
+ spec.summary = 'Caching requests for idempotency purpose'
12
+ spec.description = 'Caching requests for idempotency purpose'
13
+ spec.homepage = 'https://www.ascenda.com'
14
+ spec.license = 'MIT'
15
+
16
+ spec.required_ruby_version = '>= 3.1.0'
17
+
18
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
19
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
20
+ if spec.respond_to?(:metadata)
21
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
22
+
23
+ spec.metadata['homepage_uri'] = spec.homepage
24
+ spec.metadata['source_code_uri'] = 'https://www.ascenda.com'
25
+ else
26
+ raise 'RubyGems 2.0 or newer is required to protect against ' \
27
+ 'public gem pushes.'
28
+ end
29
+
30
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
31
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
32
+ end
33
+
34
+ spec.require_paths = ['lib']
35
+ spec.metadata['rubygems_mfa_required'] = 'true'
36
+
37
+ spec.add_dependency 'base64'
38
+ spec.add_dependency 'dry-configurable'
39
+ spec.add_dependency 'msgpack'
40
+ spec.add_dependency 'redis'
41
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'hanami/utils/class_attribute'
4
+ require 'hanami/utils/callbacks'
5
+
6
+ if Gem.loaded_specs['hanami'] && !Gem::Dependency.new('', '~> 1.3').match?('', Hanami::VERSION)
7
+ raise 'idempotency gem only supports Hanami version 1.3.x'
8
+ end
9
+
10
+ module Hanami
11
+ module Action
12
+ # Before and after callbacks
13
+ #
14
+ # @since 0.1.0
15
+ # @see Hanami::Action::ClassMethods#before
16
+ # @see Hanami::Action::ClassMethods#after
17
+ module Callbacks
18
+ # Override Ruby's hook for modules.
19
+ # It includes callbacks logic
20
+ #
21
+ # @param base [Class] the target action
22
+ #
23
+ # @since 0.1.0
24
+ # @api private
25
+ #
26
+ # @see http://www.ruby-doc.org/core/Module.html#method-i-included
27
+ def self.included(base)
28
+ base.class_eval do
29
+ extend ClassMethods
30
+ prepend InstanceMethods
31
+ end
32
+ end
33
+
34
+ # Callbacks API class methods
35
+ #
36
+ # @since 0.1.0
37
+ # @api private
38
+ module ClassMethods
39
+ # Override Ruby's hook for modules.
40
+ # It includes callbacks logic
41
+ #
42
+ # @param base [Class] the target action
43
+ #
44
+ # @since 0.1.0
45
+ # @api private
46
+ #
47
+ # @see http://www.ruby-doc.org/core/Module.html#method-i-extended
48
+ def self.extended(base)
49
+ base.class_eval do
50
+ include Utils::ClassAttribute
51
+
52
+ class_attribute :before_callbacks
53
+ self.before_callbacks = Utils::Callbacks::Chain.new
54
+
55
+ class_attribute :after_callbacks
56
+ self.after_callbacks = Utils::Callbacks::Chain.new
57
+
58
+ class_attribute :around_callbacks
59
+ self.around_callbacks = Utils::Callbacks::Chain.new
60
+ end
61
+ end
62
+
63
+ def append_around(&)
64
+ around_callbacks.append_around(&)
65
+ end
66
+
67
+ alias around append_around
68
+
69
+ def append_before(*callbacks, &)
70
+ before_callbacks.append(*callbacks, &)
71
+ end
72
+
73
+ alias before append_before
74
+
75
+ def append_after(*callbacks, &)
76
+ after_callbacks.append(*callbacks, &)
77
+ end
78
+
79
+ alias after append_after
80
+
81
+ def prepend_before(*callbacks, &)
82
+ before_callbacks.prepend(*callbacks, &)
83
+ end
84
+
85
+ def prepend_after(*callbacks, &)
86
+ after_callbacks.prepend(*callbacks, &)
87
+ end
88
+ end
89
+
90
+ # Callbacks API instance methods
91
+ #
92
+ # @since 0.1.0
93
+ # @api private
94
+ module InstanceMethods
95
+ # Implements the Rack/Hanami::Action protocol
96
+ #
97
+ # @since 0.1.0
98
+ # @api private
99
+ def call(params)
100
+ _run_before_callbacks(params)
101
+ _run_around_callbacks(params) { super }
102
+ _run_after_callbacks(params)
103
+ end
104
+
105
+ private
106
+
107
+ def _run_before_callbacks(params)
108
+ self.class.before_callbacks.run(self, params)
109
+ end
110
+
111
+ def _run_after_callbacks(params)
112
+ self.class.after_callbacks.run(self, params)
113
+ end
114
+
115
+ def _run_around_callbacks(params, &block)
116
+ chain = self.class.around_callbacks.chain
117
+ return block.call if chain.empty?
118
+
119
+ execute_around_chain(chain.dup, params, &block)
120
+ end
121
+
122
+ # We cannot use Hanami::Utils::Callbacks::Chain#run method
123
+ # since it always call all callbacks sequentially. Instead,
124
+ # we want to have each callback able to control to call the
125
+ # next block or not (in case it want to return early)
126
+ def execute_around_chain(chain, params, &block)
127
+ if chain.empty?
128
+ block.call
129
+ else
130
+ callback = chain.shift
131
+ callback.call(self, params) { execute_around_chain(chain, params, &block) }
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,306 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ module Utils
5
+ # Before and After callbacks
6
+ #
7
+ # @since 0.1.0
8
+ # @private
9
+ module Callbacks
10
+ # Series of callbacks to be executed
11
+ #
12
+ # @since 0.1.0
13
+ # @private
14
+ class Chain
15
+ # Returns a new chain
16
+ #
17
+ # @return [Hanami::Utils::Callbacks::Chain]
18
+ #
19
+ # @since 0.2.0
20
+ def initialize
21
+ @chain = []
22
+ end
23
+
24
+ attr_reader :chain
25
+
26
+ # Appends the given callbacks to the end of the chain.
27
+ #
28
+ # @param callbacks [Array] one or multiple callbacks to append
29
+ # @param block [Proc] an optional block to be appended
30
+ #
31
+ # @return [void]
32
+ #
33
+ # @raise [RuntimeError] if the object was previously frozen
34
+ #
35
+ # @see #prepend
36
+ # @see #run
37
+ # @see Hanami::Utils::Callbacks::Callback
38
+ # @see Hanami::Utils::Callbacks::MethodCallback
39
+ # @see Hanami::Utils::Callbacks::Chain#freeze
40
+ #
41
+ # @since 0.3.4
42
+ #
43
+ # @example
44
+ # require 'hanami/utils/callbacks'
45
+ #
46
+ # chain = Hanami::Utils::Callbacks::Chain.new
47
+ #
48
+ # # Append a Proc to be used as a callback, it will be wrapped by `Callback`
49
+ # # The optional argument(s) correspond to the one passed when invoked the chain with `run`.
50
+ # chain.append { Authenticator.authenticate! }
51
+ # chain.append { |params| ArticleRepository.new.find(params[:id]) }
52
+ #
53
+ # # Append a Symbol as a reference to a method name that will be used as a callback.
54
+ # # It will wrapped by `MethodCallback`
55
+ # # If the #notificate method accepts some argument(s) they should be passed when `run` is invoked.
56
+ # chain.append :notificate
57
+ def append(*callbacks, &block)
58
+ callables(callbacks, block).each do |c|
59
+ @chain.push(c)
60
+ end
61
+
62
+ @chain.uniq!
63
+ end
64
+
65
+ def append_around(&block)
66
+ @chain.push(AroundCallback.new(block))
67
+ end
68
+
69
+ # Prepends the given callbacks to the beginning of the chain.
70
+ #
71
+ # @param callbacks [Array] one or multiple callbacks to add
72
+ # @param block [Proc] an optional block to be added
73
+ #
74
+ # @return [void]
75
+ #
76
+ # @raise [RuntimeError] if the object was previously frozen
77
+ #
78
+ # @see #append
79
+ # @see #run
80
+ # @see Hanami::Utils::Callbacks::Callback
81
+ # @see Hanami::Utils::Callbacks::MethodCallback
82
+ # @see Hanami::Utils::Callbacks::Chain#freeze
83
+ #
84
+ # @since 0.3.4
85
+ #
86
+ # @example
87
+ # require 'hanami/utils/callbacks'
88
+ #
89
+ # chain = Hanami::Utils::Callbacks::Chain.new
90
+ #
91
+ # # Add a Proc to be used as a callback, it will be wrapped by `Callback`
92
+ # # The optional argument(s) correspond to the one passed when invoked the chain with `run`.
93
+ # chain.prepend { Authenticator.authenticate! }
94
+ # chain.prepend { |params| ArticleRepository.new.find(params[:id]) }
95
+ #
96
+ # # Add a Symbol as a reference to a method name that will be used as a callback.
97
+ # # It will wrapped by `MethodCallback`
98
+ # # If the #notificate method accepts some argument(s) they should be passed when `run` is invoked.
99
+ # chain.prepend :notificate
100
+ def prepend(*callbacks, &block)
101
+ callables(callbacks, block).each do |c|
102
+ @chain.unshift(c)
103
+ end
104
+
105
+ @chain.uniq!
106
+ end
107
+
108
+ # Runs all the callbacks in the chain.
109
+ # The only two ways to stop the execution are: `raise` or `throw`.
110
+ #
111
+ # @param context [Object] the context where we want the chain to be invoked.
112
+ # @param args [Array] the arguments that we want to pass to each single callback.
113
+ #
114
+ # @since 0.1.0
115
+ #
116
+ # @example
117
+ # require 'hanami/utils/callbacks'
118
+ #
119
+ # class Action
120
+ # private
121
+ # def authenticate!
122
+ # end
123
+ #
124
+ # def set_article(params)
125
+ # end
126
+ # end
127
+ #
128
+ # action = Action.new
129
+ # params = Hash[id: 23]
130
+ #
131
+ # chain = Hanami::Utils::Callbacks::Chain.new
132
+ # chain.append :authenticate!, :set_article
133
+ #
134
+ # chain.run(action, params)
135
+ #
136
+ # # `params` will only be passed as #set_article argument, because it has an arity greater than zero
137
+ #
138
+ #
139
+ #
140
+ # chain = Hanami::Utils::Callbacks::Chain.new
141
+ #
142
+ # chain.append do
143
+ # # some authentication logic
144
+ # end
145
+ #
146
+ # chain.append do |params|
147
+ # # some other logic that requires `params`
148
+ # end
149
+ #
150
+ # chain.run(action, params)
151
+ #
152
+ # Those callbacks will be invoked within the context of `action`.
153
+ def run(context, *args)
154
+ @chain.each do |callback|
155
+ callback.call(context, *args)
156
+ end
157
+ end
158
+
159
+ # It freezes the object by preventing further modifications.
160
+ #
161
+ # @since 0.2.0
162
+ #
163
+ # @see http://ruby-doc.org/core/Object.html#method-i-freeze
164
+ #
165
+ # @example
166
+ # require 'hanami/utils/callbacks'
167
+ #
168
+ # chain = Hanami::Utils::Callbacks::Chain.new
169
+ # chain.freeze
170
+ #
171
+ # chain.frozen? # => true
172
+ #
173
+ # chain.append :authenticate! # => RuntimeError
174
+ def freeze
175
+ super
176
+ @chain.freeze
177
+ end
178
+
179
+ private
180
+
181
+ # @api private
182
+ def callables(callbacks, block)
183
+ callbacks.push(block) if block
184
+ callbacks.map { |c| Factory.fabricate(c) }
185
+ end
186
+ end
187
+
188
+ # Callback factory
189
+ #
190
+ # @since 0.1.0
191
+ # @api private
192
+ class Factory
193
+ # Instantiates a `Callback` according to if it responds to #call.
194
+ #
195
+ # @param callback [Object] the object that needs to be wrapped
196
+ #
197
+ # @return [Callback, MethodCallback]
198
+ #
199
+ # @since 0.1.0
200
+ #
201
+ # @example
202
+ # require 'hanami/utils/callbacks'
203
+ #
204
+ # callable = Proc.new{} # it responds to #call
205
+ # method = :upcase # it doesn't responds to #call
206
+ #
207
+ # Hanami::Utils::Callbacks::Factory.fabricate(callable).class
208
+ # # => Hanami::Utils::Callbacks::Callback
209
+ #
210
+ # Hanami::Utils::Callbacks::Factory.fabricate(method).class
211
+ # # => Hanami::Utils::Callbacks::MethodCallback
212
+ def self.fabricate(callback)
213
+ if callback.respond_to?(:call)
214
+ Callback.new(callback)
215
+ else
216
+ MethodCallback.new(callback)
217
+ end
218
+ end
219
+ end
220
+
221
+ # Proc callback
222
+ # It wraps an object that responds to #call
223
+ #
224
+ # @since 0.1.0
225
+ # @api private
226
+ class Callback
227
+ # @api private
228
+ attr_reader :callback
229
+
230
+ # Initialize by wrapping the given callback
231
+ #
232
+ # @param callback [Object] the original callback that needs to be wrapped
233
+ #
234
+ # @return [Callback] self
235
+ #
236
+ # @since 0.1.0
237
+ # @api private
238
+ def initialize(callback)
239
+ @callback = callback
240
+ end
241
+
242
+ # Executes the callback within the given context and passing the given arguments.
243
+ #
244
+ # @param context [Object] the context within we want to execute the callback.
245
+ # @param args [Array] an array of arguments that will be available within the execution.
246
+ #
247
+ # @return [void, Object] It may return a value, it depends on the callback.
248
+ #
249
+ # @since 0.1.0
250
+ # @api private
251
+ #
252
+ # @see Hanami::Utils::Callbacks::Chain#run
253
+ def call(context, *args)
254
+ context.instance_exec(*args, &callback)
255
+ end
256
+ end
257
+
258
+ class AroundCallback < Callback
259
+ def call(context, *args, &block)
260
+ context.instance_exec(*args, block, &callback)
261
+ end
262
+ end
263
+
264
+ # Method callback
265
+ #
266
+ # It wraps a symbol or a string representing a method name that is
267
+ # implemented by the context within it will be called.
268
+ #
269
+ # @since 0.1.0
270
+ # @api private
271
+ class MethodCallback < Callback
272
+ # Executes the callback within the given context and eventually passing the given arguments.
273
+ # Those arguments will be passed according to the arity of the target method.
274
+ #
275
+ # @param context [Object] the context within we want to execute the callback.
276
+ # @param args [Array] an array of arguments that will be available within the execution.
277
+ #
278
+ # @return [void, Object] It may return a value, it depends on the callback.
279
+ #
280
+ # @since 0.1.0
281
+ # @api private
282
+ #
283
+ # @see Hanami::Utils::Callbacks::Chain#run
284
+ def call(context, *args)
285
+ method = context.method(callback)
286
+
287
+ if method.parameters.any?
288
+ method.call(*args)
289
+ else
290
+ method.call
291
+ end
292
+ end
293
+
294
+ # @api private
295
+ def hash
296
+ callback.hash
297
+ end
298
+
299
+ # @api private
300
+ def eql?(other)
301
+ hash == other.hash
302
+ end
303
+ end
304
+ end
305
+ end
306
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'redis'
5
+ require 'msgpack'
6
+
7
+ class Idempotency
8
+ class Cache
9
+ class LockConflict < StandardError; end
10
+
11
+ DEFAULT_CACHE_EXPIRY = 86_400 # seconds = 1 hour
12
+
13
+ COMPARE_AND_DEL_SCRIPT = <<-SCRIPT
14
+ local value = ARGV[1]
15
+ local cached_value = redis.call('GET', KEYS[1])
16
+
17
+ if( value == cached_value )
18
+ then
19
+ redis.call('DEL', KEYS[1])
20
+ return value
21
+ end
22
+
23
+ return cached_value
24
+ SCRIPT
25
+ COMPARE_AND_DEL_SCRIPT_SHA = Digest::SHA1.hexdigest(COMPARE_AND_DEL_SCRIPT)
26
+
27
+ def initialize(config: Idempotency.config)
28
+ @logger = config.logger
29
+ @redis_pool = config.redis_pool
30
+ end
31
+
32
+ def get(fingerprint)
33
+ key = response_cache_key(fingerprint)
34
+
35
+ cached_response = with_redis do |r|
36
+ r.get(key)
37
+ end
38
+
39
+ deserialize(cached_response) if cached_response
40
+ end
41
+
42
+ def set(fingerprint, response_status, response_headers, response_body)
43
+ key = response_cache_key(fingerprint)
44
+
45
+ with_redis do |r|
46
+ r.set(key, serialize(response_status, response_headers, response_body))
47
+ end
48
+ end
49
+
50
+ def with_lock(fingerprint, duration)
51
+ acquired_lock = lock(fingerprint, duration)
52
+ yield
53
+ ensure
54
+ release_lock(fingerprint, acquired_lock) if acquired_lock
55
+ end
56
+
57
+ def lock(fingerprint, duration)
58
+ random_value = SecureRandom.hex
59
+ key = lock_key(fingerprint)
60
+
61
+ lock_acquired = with_redis do |r|
62
+ r.set(key, random_value, nx: true, ex: duration || Idempotency.config.default_lock_expiry)
63
+ end
64
+
65
+ raise LockConflict unless lock_acquired
66
+
67
+ random_value
68
+ end
69
+
70
+ def release_lock(fingerprint, acquired_lock)
71
+ with_redis do |r|
72
+ lock_released = r.evalsha(COMPARE_AND_DEL_SCRIPT_SHA, keys: [lock_key(fingerprint)], argv: [acquired_lock])
73
+ raise LockConflict if lock_released != acquired_lock
74
+ rescue Redis::CommandError => e
75
+ if e.message.include?('NOSCRIPT')
76
+ # The Redis server has never seen this script before. Needs to run only once in the entire lifetime
77
+ # of the Redis server, until the script changes - in which case it will be loaded under a different SHA
78
+ r.script(:load, COMPARE_AND_DEL_SCRIPT)
79
+ retry
80
+ else
81
+ raise e
82
+ end
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def with_redis(&)
89
+ redis_pool.with(&)
90
+ rescue Redis::ConnectionError, Redis::CannotConnectError => e
91
+ logger.error(e.message)
92
+ nil
93
+ end
94
+
95
+ def response_cache_key(fingerprint)
96
+ "idempotency:cached_response:#{fingerprint}"
97
+ end
98
+
99
+ def lock_key(fingerprint)
100
+ "idempotency:lock:#{fingerprint}"
101
+ end
102
+
103
+ def serialize(response_status, response_headers, response_body)
104
+ cache_data = [response_status, response_headers, response_body]
105
+ MessagePack.pack(cache_data)
106
+ end
107
+
108
+ def deserialize(cached_response)
109
+ MessagePack.unpack(cached_response)
110
+ end
111
+
112
+ attr_reader :redis_pool, :logger
113
+ end
114
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Idempotency
4
+ class Constants
5
+ RACK_HEADER_KEY = 'HTTP_IDEMPOTENCY_KEY'
6
+ HEADER_KEY = 'Idempotency-Key'
7
+ end
8
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../idempotency'
4
+
5
+ class Idempotency
6
+ module Hanami
7
+ def use_cache(request_identifiers = [], lock_duration: nil)
8
+ response_status, response_headers, response_body = Idempotency.use_cache(
9
+ request, request_identifiers, lock_duration:
10
+ ) do
11
+ yield
12
+
13
+ response
14
+ end
15
+
16
+ set_response(response_status, response_headers, response_body)
17
+ end
18
+
19
+ private
20
+
21
+ def set_response(status, headers, body)
22
+ self.status = status
23
+ self.body = body
24
+ self.headers.merge!(headers)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../idempotency'
4
+
5
+ class Idempotency
6
+ module Rails
7
+ def use_cache(request_identifiers = [], lock_duration: nil)
8
+ response_status, response_headers, response_body = Idempotency.use_cache(
9
+ request, request_identifiers, lock_duration:
10
+ ) do
11
+ yield
12
+
13
+ [response.status, response.headers, response.body]
14
+ end
15
+
16
+ set_response(response_status, response_headers, response_body)
17
+ end
18
+
19
+ private
20
+
21
+ def set_response(status, headers, body)
22
+ response.status = status
23
+ response.body = body
24
+ headers.each do |key, value|
25
+ response.set_header(key, value)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Idempotency
4
+ VERSION = '0.1.2'
5
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-configurable'
4
+ require 'json'
5
+ require 'base64'
6
+ require_relative 'idempotency/cache'
7
+ require_relative 'idempotency/constants'
8
+
9
+ class Idempotency
10
+ extend Dry::Configurable
11
+
12
+ setting :redis_pool
13
+ setting :logger
14
+ setting :default_lock_expiry, default: 300 # 5 minutes
15
+ setting :idempotent_methods, default: %w[POST PUT PATCH DELETE]
16
+ setting :idempotent_statuses, default: (200..299).to_a + (400..499).to_a
17
+
18
+ setting :response_body do
19
+ setting :concurrent_error, default: {
20
+ errors: [{ message: 'Request conflicts with another likely concurrent request.' }]
21
+ }.to_json
22
+ end
23
+
24
+ def initialize(config: Idempotency.config, cache: Cache.new(config:))
25
+ @config = config
26
+ @cache = cache
27
+ end
28
+
29
+ def self.use_cache(request, request_identifiers, lock_duration: nil, &blk)
30
+ new.use_cache(request, request_identifiers, lock_duration:, &blk)
31
+ end
32
+
33
+ def use_cache(request, request_identifiers, lock_duration:) # rubocop:disable Metrics/AbcSize
34
+ return yield unless cache_request?(request)
35
+
36
+ request_headers = request.env
37
+ idempotency_key = unquote(request_headers[Constants::RACK_HEADER_KEY] || SecureRandom.hex)
38
+
39
+ fingerprint = calculate_fingerprint(request, idempotency_key, request_identifiers)
40
+
41
+ cached_response = cache.get(fingerprint)
42
+
43
+ if (cached_status, cached_headers, cached_body = cached_response)
44
+ cached_headers.merge!(Constants::HEADER_KEY => idempotency_key)
45
+ return [cached_status, cached_headers, cached_body]
46
+ end
47
+
48
+ lock_duration ||= config.default_lock_expiry
49
+ response_status, response_headers, response_body = cache.with_lock(fingerprint, lock_duration) do
50
+ yield
51
+ end
52
+
53
+ if cache_response?(response_status)
54
+ cache.set(fingerprint, response_status, response_headers, response_body)
55
+ response_headers.merge!({ Constants::HEADER_KEY => idempotency_key })
56
+ end
57
+
58
+ [response_status, response_headers, response_body]
59
+ rescue Idempotency::Cache::LockConflict
60
+ [409, {}, config.response_body.concurrent_error]
61
+ end
62
+
63
+ private
64
+
65
+ attr_reader :config, :cache
66
+
67
+ def calculate_fingerprint(request, idempotency_key, request_identifiers)
68
+ d = Digest::SHA256.new
69
+ d << idempotency_key
70
+ d << request.path
71
+ d << request.request_method
72
+
73
+ request_identifiers.each do |identifier|
74
+ d << identifier
75
+ end
76
+
77
+ Base64.strict_encode64(d.digest)
78
+ end
79
+
80
+ def cache_request?(request)
81
+ config.idempotent_methods.include?(request.request_method)
82
+ end
83
+
84
+ def cache_response?(response_status)
85
+ config.idempotent_statuses.include?(response_status)
86
+ end
87
+
88
+ def unquote(str)
89
+ double_quote = '"'
90
+ if str.start_with?(double_quote) && str.end_with?(double_quote)
91
+ str[1..-2]
92
+ else
93
+ str
94
+ end
95
+ end
96
+ end
metadata ADDED
@@ -0,0 +1,122 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: idempotency
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.2
5
+ platform: ruby
6
+ authors:
7
+ - Vu Hoang
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-01-07 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: base64
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: dry-configurable
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: msgpack
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: redis
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ description: Caching requests for idempotency purpose
70
+ email: vu.hoang@ascenda.com
71
+ executables: []
72
+ extensions: []
73
+ extra_rdoc_files: []
74
+ files:
75
+ - ".github/workflows/ci.yml"
76
+ - ".github/workflows/release.yml"
77
+ - ".gitignore"
78
+ - ".rspec"
79
+ - ".rubocop.yml"
80
+ - CHANGELOG.md
81
+ - Gemfile
82
+ - Gemfile.lock
83
+ - LICENSE
84
+ - README.md
85
+ - Rakefile
86
+ - idempotency.gemspec
87
+ - lib/hanami/action/callbacks.rb
88
+ - lib/hanami/utils/callbacks.rb
89
+ - lib/idempotency.rb
90
+ - lib/idempotency/cache.rb
91
+ - lib/idempotency/constants.rb
92
+ - lib/idempotency/hanami.rb
93
+ - lib/idempotency/rails.rb
94
+ - lib/idempotency/version.rb
95
+ homepage: https://www.ascenda.com
96
+ licenses:
97
+ - MIT
98
+ metadata:
99
+ allowed_push_host: https://rubygems.org
100
+ homepage_uri: https://www.ascenda.com
101
+ source_code_uri: https://www.ascenda.com
102
+ rubygems_mfa_required: 'true'
103
+ post_install_message:
104
+ rdoc_options: []
105
+ require_paths:
106
+ - lib
107
+ required_ruby_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: 3.1.0
112
+ required_rubygems_version: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ requirements: []
118
+ rubygems_version: 3.5.23
119
+ signing_key:
120
+ specification_version: 4
121
+ summary: Caching requests for idempotency purpose
122
+ test_files: []