rack-idempotency_key 0.1.0 → 0.2.0

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 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