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 +7 -0
- data/.github/workflows/ci.yml +18 -0
- data/.github/workflows/release.yml +17 -0
- data/.gitignore +11 -0
- data/.rspec +2 -0
- data/.rubocop.yml +29 -0
- data/CHANGELOG.md +6 -0
- data/Gemfile +14 -0
- data/Gemfile.lock +101 -0
- data/LICENSE +21 -0
- data/README.md +91 -0
- data/Rakefile +12 -0
- data/idempotency.gemspec +41 -0
- data/lib/hanami/action/callbacks.rb +137 -0
- data/lib/hanami/utils/callbacks.rb +306 -0
- data/lib/idempotency/cache.rb +114 -0
- data/lib/idempotency/constants.rb +8 -0
- data/lib/idempotency/hanami.rb +27 -0
- data/lib/idempotency/rails.rb +29 -0
- data/lib/idempotency/version.rb +5 -0
- data/lib/idempotency.rb +96 -0
- metadata +122 -0
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
data/.rspec
ADDED
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
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
data/idempotency.gemspec
ADDED
@@ -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,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
|
data/lib/idempotency.rb
ADDED
@@ -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: []
|