rack-idempotency_key 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9358732d434938ba1444636f644cb3d8a161e95aa913e2c60e62f77b74cc635c
4
- data.tar.gz: 9862a6bcb70d4bb24bcb85ae6eaf07265116ecdddcdede65ba6b8effcbb2155a
3
+ metadata.gz: e373df67d62807148650baff69ae771992a755e3188e1ed7cc073ab1a96f4752
4
+ data.tar.gz: d03656ab0464a9daa8773964de916fbc8f65cabed93449a79b39eedd854f044d
5
5
  SHA512:
6
- metadata.gz: 78e77d1c68c3df1f39edd5df9e2a53e169e6dd4057fcc8cb1f01ec659d16e33a7c59c290596828ab98159ee2e90f8168986f9aaf668f99b547bc4bced6bf4949
7
- data.tar.gz: 3c5c345ccd361084b39d86b7ec4458240aaa9b02f399e53bad9e4fe06296ed7d4718ddd262c60adb00096f285a8540ecc493606a703a1257332ecccb349067aa
6
+ metadata.gz: 99c992fa0e6d742aac0d4b310034df7e329baf13dbb890b5adde181e433bbafccf2baa4d5d53ac9a3b99382d49592b094a67ccd8f624a5cd89006365a500890e
7
+ data.tar.gz: 4d2ed1c9827cca08f177ccc09d5af6e717213d705ccf95d712772d91f0be21ab769f13b0dc373e09762d30d7b5a0538a766fd4b5d4320789aecf5901617eda2d
@@ -0,0 +1,35 @@
1
+ name: Conventional Commits PR title
2
+
3
+ on:
4
+ pull_request:
5
+ types:
6
+ - opened
7
+ - edited
8
+ - reopened
9
+ - synchronize
10
+
11
+ concurrency:
12
+ group: ${{github.workflow}}-${{github.ref}}
13
+ cancel-in-progress: true
14
+
15
+ jobs:
16
+ conventional-pr-title:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: amannn/action-semantic-pull-request@v5
20
+ env:
21
+ GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
22
+ with:
23
+ types: |
24
+ build
25
+ ci
26
+ chore
27
+ docs
28
+ feat
29
+ fix
30
+ perf
31
+ revert
32
+ refactor
33
+ style
34
+ test
35
+ requireScope: false
@@ -6,15 +6,16 @@ on:
6
6
  - opened
7
7
  - reopened
8
8
  - synchronize
9
+ workflow_call:
9
10
 
10
11
  jobs:
11
12
  lint:
12
13
  runs-on: ubuntu-latest
13
14
  steps:
14
- - uses: actions/checkout@v2
15
+ - uses: actions/checkout@v4
15
16
  - uses: ruby/setup-ruby@v1
16
17
  with:
17
18
  bundler-cache: true
18
- ruby-version: 2.5.0
19
+ ruby-version: 2.6
19
20
  - run: bundle install
20
21
  - run: bundle exec rubocop
@@ -6,7 +6,19 @@ on:
6
6
  - main
7
7
 
8
8
  jobs:
9
+ lint:
10
+ uses: ./.github/workflows/lint.yml
11
+ test:
12
+ uses: ./.github/workflows/test.yml
13
+ secrets:
14
+ CC_TEST_REPORTER_ID: ${{secrets.CC_TEST_REPORTER_ID}}
9
15
  release-please:
16
+ needs:
17
+ - lint
18
+ - test
19
+ permissions:
20
+ contents: write
21
+ pull-requests: write
10
22
  runs-on: ubuntu-latest
11
23
  steps:
12
24
  - uses: google-github-actions/release-please-action@v3
@@ -16,12 +28,26 @@ jobs:
16
28
  release-type: ruby
17
29
  token: ${{secrets.GITHUB_TOKEN}}
18
30
  version-file: "lib/rack/idempotency_key/version.rb"
19
- - uses: actions/checkout@v3
31
+ changelog-types: >
32
+ [
33
+ { "type": "build", "section": "Build System", "hidden": false },
34
+ { "type": "ci", "section": "Continuous Integration", "hidden": false },
35
+ { "type": "chore", "section": "Miscellaneous Chores", "hidden": false },
36
+ { "type": "docs", "section": "Documentation", "hidden": false },
37
+ { "type": "feat", "section": "Features", "hidden": false },
38
+ { "type": "fix", "section": "Bug Fixes", "hidden": false },
39
+ { "type": "perf", "section": "Performance Improvements", "hidden": false },
40
+ { "type": "revert", "section": "Reverts", "hidden": false },
41
+ { "type": "refactor", "section": "Code Refactoring", "hidden": false },
42
+ { "type": "style", "section": "Styles", "hidden": false },
43
+ { "type": "test", "section": "Tests", "hidden": false }
44
+ ]
45
+ - uses: actions/checkout@v4
20
46
  if: ${{steps.release.outputs.release_created}}
21
47
  - uses: ruby/setup-ruby@v1
22
48
  with:
23
49
  bundler-cache: true
24
- ruby-version: 2.7.0
50
+ ruby-version: 2.6
25
51
  if: ${{steps.release.outputs.release_created}}
26
52
  - run: bundle install
27
53
  if: ${{steps.release.outputs.release_created}}
@@ -6,6 +6,10 @@ on:
6
6
  - opened
7
7
  - reopened
8
8
  - synchronize
9
+ workflow_call:
10
+ secrets:
11
+ CC_TEST_REPORTER_ID:
12
+ required: true
9
13
 
10
14
  jobs:
11
15
  test:
@@ -14,12 +18,28 @@ jobs:
14
18
  strategy:
15
19
  fail-fast: false
16
20
  matrix:
17
- ruby: [2.5, 2.6, 2.7, '3.0', 3.1, head]
21
+ ruby: [2.6, 2.7, '3.0', 3.1, 3.2, 3.3, 3.4, head]
18
22
  steps:
19
- - uses: actions/checkout@v2
23
+ - uses: actions/checkout@v4
20
24
  - uses: ruby/setup-ruby@v1
21
25
  with:
22
26
  bundler-cache: true
23
27
  ruby-version: ${{ matrix.ruby }}
24
28
  - run: bundle install
25
29
  - run: bundle exec rake
30
+ coverage:
31
+ runs-on: ubuntu-latest
32
+ steps:
33
+ - uses: actions/checkout@v4
34
+ - uses: ruby/setup-ruby@v1
35
+ with:
36
+ bundler-cache: true
37
+ ruby-version: 2.6
38
+ - run: bundle install
39
+ - name: Test & publish code coverage
40
+ uses: paambaati/codeclimate-action@v6.0.0
41
+ env:
42
+ CC_TEST_REPORTER_ID: ${{secrets.CC_TEST_REPORTER_ID}}
43
+ with:
44
+ coverageCommand: bundle exec rake
45
+ debug: true
data/.gitignore CHANGED
@@ -1,10 +1,13 @@
1
1
  *.rbc
2
2
  *.swp
3
+ **/.DS_Store
3
4
  /_yardoc/
4
5
  /.bundle/
5
6
  /.byebug_history
7
+ /.DS_Store
6
8
  /.yardoc
7
9
  /.ruby-version
10
+ /.tool-versions
8
11
  /coverage/
9
12
  /doc/
10
13
  /dump.rdb
data/.rubocop.yml CHANGED
@@ -5,7 +5,7 @@ require:
5
5
  - rubocop-rspec
6
6
 
7
7
  AllCops:
8
- TargetRubyVersion: 2.5
8
+ TargetRubyVersion: 2.6
9
9
 
10
10
  Bundler/OrderedGems:
11
11
  Enabled: false
data/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0](https://github.com/matteoredz/rack-idempotency_key/compare/v0.1.1...v0.2.0) (2025-01-30)
4
+
5
+
6
+ ### Continuous Integration
7
+
8
+ * add code climate coverage sync ([#6](https://github.com/matteoredz/rack-idempotency_key/issues/6)) ([4196edf](https://github.com/matteoredz/rack-idempotency_key/commit/4196edf2194668084d15ca3414ab3dd3d01551d4))
9
+ * trigger lint and test before release ([#8](https://github.com/matteoredz/rack-idempotency_key/issues/8)) ([033aedd](https://github.com/matteoredz/rack-idempotency_key/commit/033aedd29ff24308ebdf6f4ea0d162374dd399af))
10
+ * update gh workflows ([#5](https://github.com/matteoredz/rack-idempotency_key/issues/5)) ([84aa493](https://github.com/matteoredz/rack-idempotency_key/commit/84aa49341cf74e057efb48e2e68b5901d9843226))
11
+
12
+
13
+ ### Miscellaneous Chores
14
+
15
+ * update Rakefile to use rspec ([#7](https://github.com/matteoredz/rack-idempotency_key/issues/7)) ([eb866ee](https://github.com/matteoredz/rack-idempotency_key/commit/eb866eea6d5ba9dac599a9c3b2cd239b0471b853))
16
+
17
+
18
+ ### Documentation
19
+
20
+ * add quality badges ([#18](https://github.com/matteoredz/rack-idempotency_key/issues/18)) ([f8bcc96](https://github.com/matteoredz/rack-idempotency_key/commit/f8bcc96421b6b167a578728ff0d1eeb9c5eccff3))
21
+ * add warning to readme ([#11](https://github.com/matteoredz/rack-idempotency_key/issues/11)) ([e0333c8](https://github.com/matteoredz/rack-idempotency_key/commit/e0333c813aaf2969bebad9ea72056a9d36e9489f))
22
+
23
+
24
+ ### Features
25
+
26
+ * add request hashing ([#16](https://github.com/matteoredz/rack-idempotency_key/issues/16)) ([f0169fe](https://github.com/matteoredz/rack-idempotency_key/commit/f0169feb088c09c24d68c13a4f640fb41204e4e4))
27
+ * allow redis store to receive a connection pool ([#13](https://github.com/matteoredz/rack-idempotency_key/issues/13)) ([adafc66](https://github.com/matteoredz/rack-idempotency_key/commit/adafc66cee442cc7521c559f2b4fc46651f9c0f9))
28
+ * properly handle concurrent requests from Memory and Redis stores ([#15](https://github.com/matteoredz/rack-idempotency_key/issues/15)) ([d7fbbcc](https://github.com/matteoredz/rack-idempotency_key/commit/d7fbbccce3211e4ff2ce0b0d4ac0e7df4bbd5a10))
29
+
30
+
31
+ ### Bug Fixes
32
+
33
+ * **docs:** document the correct default expiration time ([#17](https://github.com/matteoredz/rack-idempotency_key/issues/17)) ([ac0ea44](https://github.com/matteoredz/rack-idempotency_key/commit/ac0ea44ad5a7ecd450a2b8be11a192a19b7222cb))
34
+
35
+
36
+ ### Code Refactoring
37
+
38
+ * better organise the idempotent request public interface ([#10](https://github.com/matteoredz/rack-idempotency_key/issues/10)) ([0f07fb2](https://github.com/matteoredz/rack-idempotency_key/commit/0f07fb2f55c62b0f08f2b8b96e040b494db48a6e))
39
+ * change default store duration to 5 minutes ([#14](https://github.com/matteoredz/rack-idempotency_key/issues/14)) ([6fa2537](https://github.com/matteoredz/rack-idempotency_key/commit/6fa2537cd43d32b159e82064a938d9195f42ab7b))
40
+ * remove configurable routes ([#12](https://github.com/matteoredz/rack-idempotency_key/issues/12)) ([2463b55](https://github.com/matteoredz/rack-idempotency_key/commit/2463b555188b5c4fee436ff6f96f3144a66c16fa))
41
+
42
+ ## [0.1.1](https://github.com/matteoredz/rack-idempotency_key/compare/v0.1.0...v0.1.1) (2023-03-31)
43
+
44
+
45
+ ### Bug Fixes
46
+
47
+ * check size of path segments against each configured route ([#3](https://github.com/matteoredz/rack-idempotency_key/issues/3)) ([248d6ca](https://github.com/matteoredz/rack-idempotency_key/commit/248d6cafbcb875781b0a3673db8561d31db464f7))
48
+
3
49
  ## 0.1.0 (2023-01-20)
4
50
 
5
51
 
data/README.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # `Rack::IdempotencyKey`
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/rack-idempotency_key.svg)](https://badge.fury.io/rb/rack-idempotency_key) [![Maintainability](https://api.codeclimate.com/v1/badges/26b3ad3d3af3b2377037/maintainability)](https://codeclimate.com/github/matteoredz/rack-idempotency_key/maintainability) [![Test Coverage](https://api.codeclimate.com/v1/badges/26b3ad3d3af3b2377037/test_coverage)](https://codeclimate.com/github/matteoredz/rack-idempotency_key/test_coverage)
4
+
5
+ > [!WARNING]
6
+ > This gem is in its pre-1.0 release phase, which means it may contain bugs and the API is subject to change.
7
+ > Proceed with caution and use it at your own risk.
8
+
3
9
  A Rack Middleware implementing the idempotency design principle using the `Idempotency-Key` HTTP header. A cached response, generated by an idempotent request, can be recognized by checking for the presence of the `Idempotent-Replayed` response header.
4
10
 
5
11
  ## What is idempotency?
@@ -13,7 +19,7 @@ To be idempotent, only the state of the server is considered. The response retur
13
19
  ## Under the hood
14
20
 
15
21
  - A valid idempotent request is cached on the server, using the `store` of choice
16
- - A cached response expires out of the system after `24 hours`
22
+ - A cached response expires out of the system after `5 minutes` by default
17
23
  - A response with a `400` (BadRequest) HTTP status code isn't cached
18
24
 
19
25
  ## Installation
@@ -47,11 +53,7 @@ module MyApp
47
53
 
48
54
  config.middleware.use(
49
55
  Rack::IdempotencyKey,
50
- store: Rack::IdempotencyKey::MemoryStore.new,
51
- routes: [
52
- { path: "/posts", method: "POST" },
53
- { path: "/posts/*", method: "PATCH" }
54
- ]
56
+ store: Rack::IdempotencyKey::MemoryStore.new
55
57
  )
56
58
  end
57
59
  end
@@ -68,8 +70,8 @@ This one is the default store. It caches the response in memory.
68
70
  ```ruby
69
71
  Rack::IdempotencyKey::MemoryStore.new
70
72
 
71
- # Explicitly set the key's expiration, in seconds. The default is 86_400 (24 hours)
72
- Rack::IdempotencyKey::MemoryStore.new(expires_in: 43_200)
73
+ # Explicitly set the key's expiration, in seconds. The default is 300 (5 minutes)
74
+ Rack::IdempotencyKey::MemoryStore.new(expires_in: 300)
73
75
  ```
74
76
 
75
77
  ### RedisStore
@@ -79,27 +81,57 @@ This one is the suggested store to use in production. It relies on the [redis ge
79
81
  ```ruby
80
82
  Rack::IdempotencyKey::RedisStore.new(Redis.current)
81
83
 
82
- # Explicitly set the key's expiration, in seconds. The default is 86_400 (24 hours)
83
- Rack::IdempotencyKey::RedisStore.new(Redis.current, expires_in: 43_200)
84
+ # Explicitly set the key's expiration, in seconds. The default is 300 (5 minutes)
85
+ Rack::IdempotencyKey::RedisStore.new(Redis.current, expires_in: 300)
84
86
  ```
85
87
 
86
- Every key written to Redis will get prefixed with `idempotency_key` to avoid conflicts on shared instances.
88
+ If you're using a [Connection Pool](https://github.com/mperham/connection_pool), you can pass it instead of the single instance:
89
+
90
+ ```ruby
91
+ redis_pool = ConnectionPool.new(size: 5, timeout: 5) { Redis.new }
92
+ Rack::IdempotencyKey::RedisStore.new(redis_pool)
93
+ ```
87
94
 
88
95
  ### Custom Store
89
96
 
97
+ > [!IMPORTANT]
98
+ > Ensure proper concurrency handling when implementing a custom store to prevent race conditions and data inconsistencies.
99
+
90
100
  Any object that conforms to the following interface can be used as a custom Store:
91
101
 
92
102
  ```ruby
93
- # @param [String] key
103
+ # Gets the value by key from the store.
104
+ #
105
+ # @param key [String] The cache key
106
+ #
107
+ # @raise [Rack::IdempotencyKey::StoreError]
108
+ # When the underlying store doesn't work as expected.
94
109
  #
95
110
  # @return [Array]
96
111
  def get(key)
97
112
 
98
- # @param [String] key
99
- # @param [Array] value
113
+ # Sets the value by key to the store.
114
+ #
115
+ # @param key [String] The cache key
116
+ # @param value [Array] The cache value
117
+ #
118
+ # @raise [Rack::IdempotencyKey::ConflictError]
119
+ # When a concurrent request tries to update an already-cached request.
120
+ # @raise [Rack::IdempotencyKey::StoreError]
121
+ # When the underlying store doesn't work as expected.
100
122
  #
101
123
  # @return [Array]
102
124
  def set(key, value)
125
+
126
+ # Unsets the key/value pair from the store.
127
+ #
128
+ # @param key [String] The cache key
129
+ #
130
+ # @raise [Rack::IdempotencyKey::StoreError]
131
+ # When the underlying store doesn't work as expected.
132
+ #
133
+ # @return [Array]
134
+ def unset(key)
103
135
  ```
104
136
 
105
137
  The Array returned must conform to the [Rack Specification](https://github.com/rack/rack/blob/main/SPEC.rdoc), as follows:
@@ -112,21 +144,6 @@ The Array returned must conform to the [Rack Specification](https://github.com/r
112
144
  ]
113
145
  ```
114
146
 
115
- ## Idempotent Routes
116
-
117
- To declare the routes where you want to enable idempotency, you only need to pass a `route` keyword parameter when the Middleware gets mounted.
118
-
119
- Each route entry must be compliant with what follows:
120
-
121
- ```ruby
122
- routes: [
123
- { path: "/posts", method: "POST" },
124
- { path: "/posts/*", method: "PATCH" }
125
- ]
126
- ```
127
-
128
- The `*` char is a placeholder representing a named parameter that will get converted to an any-chars regex.
129
-
130
147
  ## Development
131
148
 
132
149
  After checking out the repo, run `bin/setup` to install dependencies.
data/Rakefile CHANGED
@@ -1,12 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bundler/gem_tasks"
4
- require "rake/testtask"
4
+ require "rspec/core/rake_task"
5
5
 
6
- Rake::TestTask.new(:test) do |t|
7
- t.libs << "test"
8
- t.libs << "lib"
9
- t.test_files = FileList["test/**/*_test.rb"]
10
- end
6
+ RSpec::Core::RakeTask.new(:spec)
11
7
 
12
- task default: :test
8
+ task default: :spec
@@ -10,7 +10,7 @@ Gem::Specification.new do |spec|
10
10
  spec.email = ["mttrss5@gmail.com"]
11
11
  spec.summary = "A Rack Middleware implementing the idempotency principle"
12
12
  spec.homepage = "https://github.com/matteoredz/rack-idempotency_key"
13
- spec.required_ruby_version = Gem::Requirement.new(">= 2.5.0")
13
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.6.0")
14
14
  spec.metadata["homepage_uri"] = spec.homepage
15
15
  spec.metadata["source_code_uri"] = "https://github.com/matteoredz/rack-idempotency_key"
16
16
  spec.metadata["changelog_uri"] = "https://github.com/matteoredz/rack-idempotency_key/CHANGELOG.md"
@@ -24,4 +24,6 @@ Gem::Specification.new do |spec|
24
24
  spec.bindir = "exe"
25
25
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
26
26
  spec.require_paths = ["lib"]
27
+
28
+ spec.add_runtime_dependency "redis", "~> 5.2"
27
29
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ class IdempotencyKey
5
+ # Base error class for all IdempotencyKey errors
6
+ Error = Class.new(StandardError)
7
+
8
+ # Error raised when a conflicting idempotent request is detected
9
+ class ConflictError < Error
10
+ DEFAULT_MESSAGE = "This request is already being processed. Please retry later."
11
+
12
+ def initialize(msg = DEFAULT_MESSAGE)
13
+ super(msg)
14
+ end
15
+ end
16
+
17
+ # Error raised for general store failures
18
+ StoreError = Class.new(Error)
19
+ end
20
+ end
@@ -3,34 +3,47 @@
3
3
  module Rack
4
4
  class IdempotencyKey
5
5
  class MemoryStore
6
- def initialize(expires_in: 86_400)
6
+ DEFAULT_EXPIRATION = 300 # 5 minutes in seconds
7
+
8
+ def initialize(expires_in: DEFAULT_EXPIRATION)
7
9
  @store = {}
8
10
  @expires_in = expires_in
11
+ @mutex = Mutex.new
9
12
  end
10
13
 
11
14
  def get(key)
12
- value = store[key]
13
- return if value.nil?
15
+ mutex.synchronize do
16
+ value = store[key]
17
+ return if value.nil?
14
18
 
15
- if expired?(value[:added_at])
16
- store.delete(key)
17
- return
18
- end
19
+ if expired?(value[:expires_at])
20
+ store.delete(key)
21
+ return
22
+ end
19
23
 
20
- value[:value]
24
+ value[:value]
25
+ end
21
26
  end
22
27
 
23
- def set(key, value)
24
- store[key] ||= { value: value, added_at: Time.now.utc }
28
+ def set(key, value, ttl: expires_in)
29
+ mutex.synchronize do
30
+ store[key] ||= { value: value, expires_at: Time.now.utc + ttl }
31
+ raise Rack::IdempotencyKey::ConflictError if store[key][:value] != value
32
+ end
33
+
25
34
  get(key)
26
35
  end
27
36
 
37
+ def unset(key)
38
+ mutex.synchronize { store.delete(key) }
39
+ end
40
+
28
41
  private
29
42
 
30
- attr_reader :store, :expires_in
43
+ attr_reader :store, :expires_in, :mutex
31
44
 
32
- def expired?(added_at)
33
- Time.now.utc - added_at > expires_in
45
+ def expired?(expires_at)
46
+ Time.now.utc > expires_at
34
47
  end
35
48
  end
36
49
  end
@@ -1,31 +1,67 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "redis"
4
+
3
5
  module Rack
4
6
  class IdempotencyKey
5
7
  class RedisStore
6
- KEY_NAMESPACE = "idempotency_key"
8
+ DEFAULT_EXPIRATION = 300 # 5 minutes in seconds
7
9
 
8
- def initialize(store, expires_in: 86_400)
10
+ def initialize(store, expires_in: DEFAULT_EXPIRATION)
9
11
  @store = store
10
12
  @expires_in = expires_in
11
13
  end
12
14
 
13
15
  def get(key)
14
- value = store.get(namespaced_key(key))
16
+ value = with_redis { |redis| redis.get(key) }
15
17
  JSON.parse(value) unless value.nil?
18
+ rescue Redis::BaseError => e
19
+ raise Rack::IdempotencyKey::StoreError, "#{self.class}: #{e.message}"
16
20
  end
17
21
 
18
- def set(key, value)
19
- store.set(namespaced_key(key), value, nx: true, ex: expires_in)
22
+ def set(key, value, ttl: expires_in)
23
+ with_redis do |redis|
24
+ result = redis.set(key, value, nx: true, ex: ttl)
25
+ raise Rack::IdempotencyKey::ConflictError unless result
26
+ end
27
+
20
28
  get(key)
29
+ rescue Redis::BaseError => e
30
+ raise Rack::IdempotencyKey::StoreError, "#{self.class}: #{e.message}"
31
+ end
32
+
33
+ def unset(key)
34
+ with_redis { |redis| redis.del(key) }
35
+ rescue Redis::BaseError => e
36
+ raise Rack::IdempotencyKey::StoreError, "#{self.class}: #{e.message}"
21
37
  end
22
38
 
23
39
  private
24
40
 
25
41
  attr_reader :store, :expires_in
26
42
 
27
- def namespaced_key(key)
28
- "#{KEY_NAMESPACE}:#{key.split.join}"
43
+ # Executes the given block with a Redis connection, supporting both direct
44
+ # Redis instances and connection pools (https://github.com/mperham/connection_pool).
45
+ #
46
+ # If a `ConnectionPool` is detected (by responding to `with`), it will yield a Redis
47
+ # connection from the pool. Otherwise, it will yield the direct Redis instance.
48
+ #
49
+ # @yieldparam redis [Redis] A Redis connection instance.
50
+ # @return [Object] The result of the block execution.
51
+ #
52
+ # @example Using a direct Redis instance
53
+ # store = RedisStore.new(Redis.new)
54
+ # store.with_redis { |redis| redis.set("key", "value") }
55
+ #
56
+ # @example Using a Redis connection pool
57
+ # store = RedisStore.new(ConnectionPool.new(size: 5, timeout: 5) { Redis.new })
58
+ # store.with_redis { |redis| redis.set("key", "value") }
59
+ def with_redis(&block)
60
+ if store.respond_to?(:with)
61
+ store.with(&block)
62
+ else
63
+ yield store
64
+ end
29
65
  end
30
66
  end
31
67
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/idempotency_key/request_hash"
4
+
5
+ module Rack
6
+ class IdempotencyKey
7
+ class Request
8
+ DEFAULT_LOCK_TTL = 60 # seconds
9
+
10
+ # @param request [Rack::Request]
11
+ # @param store [Store]
12
+ def initialize(request, store)
13
+ @request = request
14
+ @request_id = Rack::IdempotencyKey::RequestHash.new(request).id
15
+ @store = store
16
+ end
17
+
18
+ # Checks if the `Idempotency-Key` header is present, if the HTTP request method is allowed.
19
+ #
20
+ # @return [Boolean]
21
+ def allowed?
22
+ idempotency_key? && allowed_method?
23
+ end
24
+
25
+ def cached_response!
26
+ store.get(cache_key).tap do |response|
27
+ response[1]["Idempotent-Replayed"] = true unless response.nil?
28
+ end
29
+ end
30
+
31
+ def locked!
32
+ store.set(lock_key, 1, ttl: DEFAULT_LOCK_TTL)
33
+
34
+ begin
35
+ yield
36
+ ensure
37
+ store.unset(lock_key)
38
+ end
39
+ end
40
+
41
+ def cache!(response)
42
+ status, = response
43
+ store.set(cache_key, response) if status != 400
44
+ end
45
+
46
+ # Checks if the HTTP request method is non-idempotent by design.
47
+ #
48
+ # @return [Boolean]
49
+ def allowed_method?
50
+ %w[POST PATCH CONNECT].include? request.request_method
51
+ end
52
+
53
+ # Checks if the given request has the Idempotency-Key header
54
+ #
55
+ # @return [Boolean]
56
+ def idempotency_key?
57
+ request.has_header? "HTTP_IDEMPOTENCY_KEY"
58
+ end
59
+
60
+ # Fetches the Idempotency-Key header value from the request headers
61
+ #
62
+ # @return [String, nil]
63
+ def idempotency_key
64
+ request.get_header "HTTP_IDEMPOTENCY_KEY"
65
+ end
66
+
67
+ private
68
+
69
+ attr_reader :request, :request_id, :store
70
+
71
+ def cache_key
72
+ "idempotency_key:#{request_id}"
73
+ end
74
+
75
+ def lock_key
76
+ "#{cache_key}_lock"
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Rack
6
+ class IdempotencyKey
7
+ class RequestHash
8
+ def initialize(rack_request)
9
+ @rack_request = rack_request
10
+ end
11
+
12
+ # Generates a unique SHA-256 hash to identify the request.
13
+ #
14
+ # The hash is constructed using the request method, full path, idempotency key,
15
+ # authorization header, and the request body (if available and rewindable).
16
+ #
17
+ # This ensures that requests with identical content produce the same fingerprint,
18
+ # supporting idempotency while preventing accidental collisions between different clients.
19
+ #
20
+ # @return [String] A SHA-256 hexadecimal digest representing the request fingerprint.
21
+ def id
22
+ digest = Digest::SHA256.new
23
+ digest.update(rack_request.request_method)
24
+ digest.update(rack_request.fullpath)
25
+ digest.update(idempotency_key_header)
26
+ digest.update(authorization_header)
27
+ update_with_request_body_chunks(digest)
28
+ digest.hexdigest
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :rack_request
34
+
35
+ # Retrieves the `Idempotency-Key` header from the request. If the header is missing,
36
+ # it defaults to `"no-idempotency-key"` to ensure that the request can still be processed
37
+ # while clearly indicating the absence of an explicit idempotency key.
38
+ def idempotency_key_header
39
+ rack_request.get_header("HTTP_IDEMPOTENCY_KEY") || "no-idempotency-key"
40
+ end
41
+
42
+ # Retrieves the Authorization header from the request. If the header is missing,
43
+ # defaults to "no-authorization" to indicate the absence of credentials.
44
+ def authorization_header
45
+ rack_request.get_header("HTTP_AUTHORIZATION") || "no-authorization"
46
+ end
47
+
48
+ # Updates the given digest with the request body content. If the body is non-rewindable
49
+ # (e.g., a streaming request), it appends a predefined "streaming-body" marker instead.
50
+ # Otherwise, it reads the body in 8KB chunks for efficient hashing and ensures the stream
51
+ # is rewound after processing.
52
+ #
53
+ # @param digest [Digest::SHA256]
54
+ def update_with_request_body_chunks(digest)
55
+ return digest.update("streaming-body") unless rack_request.body.respond_to?(:rewind)
56
+
57
+ begin
58
+ while (chunk = rack_request.body.read(8192))
59
+ digest.update(chunk)
60
+ end
61
+ ensure
62
+ rack_request.body.rewind
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rack
4
4
  class IdempotencyKey
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -1,42 +1,44 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rack/idempotency_key/version"
4
+ require "rack/idempotency_key/error"
4
5
 
5
6
  # Stores
6
7
  require "rack/idempotency_key/memory_store"
7
8
  require "rack/idempotency_key/redis_store"
8
9
 
9
10
  # Collaborators
10
- require "rack/idempotency_key/idempotent_request"
11
+ require "rack/idempotency_key/request"
11
12
 
12
13
  module Rack
13
14
  class IdempotencyKey
14
- Error = Class.new(StandardError)
15
-
16
- def initialize(app, routes: [], store: MemoryStore.new)
17
- @app = app
18
- @routes = routes
19
- @store = store
15
+ def initialize(app, store: MemoryStore.new)
16
+ @app = app
17
+ @store = store
20
18
  end
21
19
 
22
20
  def call(env)
23
- request = IdempotentRequest.new(Rack::Request.new(env), routes)
21
+ request = Request.new(Rack::Request.new(env), store)
24
22
  return app.call(env) unless request.allowed?
25
23
 
26
- cached_response = store.get(request.idempotency_key)
27
-
28
- if cached_response
29
- cached_response[1]["Idempotent-Replayed"] = true
30
- return cached_response
31
- end
32
-
33
- app.call(env).tap do |response|
34
- store.set(request.idempotency_key, response) if response[0] != 400
35
- end
24
+ handle_request!(request, env)
25
+ rescue ConflictError => e
26
+ [409, { "Content-Type" => "text/plain" }, [e.message]]
27
+ rescue StoreError => e
28
+ [503, { "Content-Type" => "text/plain" }, [e.message]]
36
29
  end
37
30
 
38
31
  private
39
32
 
40
- attr_reader :app, :store, :routes
33
+ attr_reader :app, :store
34
+
35
+ def handle_request!(request, env)
36
+ request.locked! do
37
+ cached_response = request.cached_response!
38
+ return cached_response unless cached_response.nil?
39
+
40
+ app.call(env).tap { |response| request.cache!(response) }
41
+ end
42
+ end
41
43
  end
42
44
  end
metadata CHANGED
@@ -1,16 +1,30 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rack-idempotency_key
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matteo Rossi
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-01-20 00:00:00.000000000 Z
12
- dependencies: []
13
- description:
11
+ date: 2025-01-30 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: redis
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.2'
27
+ description:
14
28
  email:
15
29
  - mttrss5@gmail.com
16
30
  executables: []
@@ -18,13 +32,13 @@ extensions: []
18
32
  extra_rdoc_files: []
19
33
  files:
20
34
  - ".github/CODEOWNERS"
35
+ - ".github/workflows/conventional-pr-title.yml"
21
36
  - ".github/workflows/lint.yml"
22
37
  - ".github/workflows/release.yml"
23
38
  - ".github/workflows/test.yml"
24
39
  - ".gitignore"
25
40
  - ".rspec"
26
41
  - ".rubocop.yml"
27
- - ".travis.yml"
28
42
  - CHANGELOG.md
29
43
  - CODE_OF_CONDUCT.md
30
44
  - Gemfile
@@ -35,9 +49,11 @@ files:
35
49
  - bin/setup
36
50
  - idempotency_key.gemspec
37
51
  - lib/rack/idempotency_key.rb
38
- - lib/rack/idempotency_key/idempotent_request.rb
52
+ - lib/rack/idempotency_key/error.rb
39
53
  - lib/rack/idempotency_key/memory_store.rb
40
54
  - lib/rack/idempotency_key/redis_store.rb
55
+ - lib/rack/idempotency_key/request.rb
56
+ - lib/rack/idempotency_key/request_hash.rb
41
57
  - lib/rack/idempotency_key/version.rb
42
58
  homepage: https://github.com/matteoredz/rack-idempotency_key
43
59
  licenses:
@@ -46,7 +62,7 @@ metadata:
46
62
  homepage_uri: https://github.com/matteoredz/rack-idempotency_key
47
63
  source_code_uri: https://github.com/matteoredz/rack-idempotency_key
48
64
  changelog_uri: https://github.com/matteoredz/rack-idempotency_key/CHANGELOG.md
49
- post_install_message:
65
+ post_install_message:
50
66
  rdoc_options: []
51
67
  require_paths:
52
68
  - lib
@@ -54,15 +70,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
54
70
  requirements:
55
71
  - - ">="
56
72
  - !ruby/object:Gem::Version
57
- version: 2.5.0
73
+ version: 2.6.0
58
74
  required_rubygems_version: !ruby/object:Gem::Requirement
59
75
  requirements:
60
76
  - - ">="
61
77
  - !ruby/object:Gem::Version
62
78
  version: '0'
63
79
  requirements: []
64
- rubygems_version: 3.1.2
65
- signing_key:
80
+ rubygems_version: 3.0.3.1
81
+ signing_key:
66
82
  specification_version: 4
67
83
  summary: A Rack Middleware implementing the idempotency principle
68
84
  test_files: []
data/.travis.yml DELETED
@@ -1,6 +0,0 @@
1
- ---
2
- language: ruby
3
- cache: bundler
4
- rvm:
5
- - 2.7.0
6
- before_install: gem install bundler -v 2.1.4
@@ -1,80 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Rack
4
- class IdempotencyKey
5
- class IdempotentRequest
6
- # @param [Rack::Request] request
7
- # @param [Array] routes
8
- def initialize(request, routes = [])
9
- @request = request
10
- @routes = routes
11
- end
12
-
13
- # Check if the `Idempotency-Key` header is present, if the HTTP request method is
14
- # allowed and if there is any matching route whitelisted in the `routes` array.
15
- #
16
- # @return [Boolean]
17
- def allowed?
18
- idempotency_key? && allowed_method? && any_matching_route?
19
- end
20
-
21
- # Check if the HTTP request method is non-idempotent by design.
22
- #
23
- # @return [Boolean]
24
- def allowed_method?
25
- %w[POST PATCH CONNECT].include? request.request_method
26
- end
27
-
28
- # Check if there is any matching route from the `routes` input array against
29
- # the currently requested path.
30
- #
31
- # @return [Boolean]
32
- def any_matching_route?
33
- routes.any? { |route| matching_route?(route[:path]) && matching_method?(route[:method]) }
34
- end
35
-
36
- # Check if the given request has the Idempotency-Key header
37
- #
38
- # @return [Boolean]
39
- def idempotency_key?
40
- request.has_header? "HTTP_IDEMPOTENCY_KEY"
41
- end
42
-
43
- # Fetches the Idempotency-Key header value from the request headers
44
- #
45
- # @return [String, nil]
46
- def idempotency_key
47
- request.get_header "HTTP_IDEMPOTENCY_KEY"
48
- end
49
-
50
- private
51
-
52
- attr_reader :request, :routes
53
-
54
- def matching_route?(route_path)
55
- same_segments? segments(route_path)
56
- end
57
-
58
- def matching_method?(route_method)
59
- request.request_method.casecmp(route_method).zero?
60
- end
61
-
62
- def path_segments
63
- @path_segments ||= segments(request.path_info)
64
- end
65
-
66
- def segments(path)
67
- path.split("/").reject(&:empty?)
68
- end
69
-
70
- def same_segments?(route_segments)
71
- path_segments.each_with_index do |path_segment, index|
72
- route_segment = Regexp.new route_segments[index].gsub("*", '\w+'), Regexp::IGNORECASE
73
- return false unless path_segment.match?(route_segment)
74
- end
75
-
76
- true
77
- end
78
- end
79
- end
80
- end