idempotency 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: []