rack-idempotency_key 0.1.1 → 0.2.1

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: 52baa32ae332842d9bcdf5d7ae8a2d3e4f4597c619313dee041213051bbeec43
4
- data.tar.gz: a83d88e91d0814bcbb4f3808fc1eb8c03a09294e44537f32238c15649c95afb7
3
+ metadata.gz: 157af590e24f6928fc61715b006c746296b9a9b0192d42bf14975b1722117dc7
4
+ data.tar.gz: 41f8668647cf15a010732444f5c8071f93dc1434e9be5e0edbd3a45c403854bb
5
5
  SHA512:
6
- metadata.gz: 4632d51f766b18bf459a126a3fb8a759cdd7bdad116c0395ef0ed8df8d874076a247f7d01372c55a30f394de2674c866bd5396d583de2694f2dd4b7faee177b6
7
- data.tar.gz: f2be7f25e92145490bd328dae4d36a1714fa24b74e4ca9affa038fea9b052ddedfe2e3e2867eac70be554dc4a16f490b07ab18c4d4ef51d682388eb685662674
6
+ metadata.gz: e3b80b32393b26f2ef685948a39b95c0acbcf549a2b6d3bc08b3e654faacd866aa707d5d412e5a80b76d388dbfe417778d484fdbc1bda7c773ed6236d7adbe8f
7
+ data.tar.gz: c9b2b228020e64ce63c4aa0f19a9216a84812a48be30be15701d0eda01fd5d9e46d1c5d841b19cf3848e9168332bdcc907d1906ccfa96acc0d679b7f96f31409
@@ -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 }}
28
+ - run: bundle exec appraisal install
29
+ - run: bundle exec appraisal rspec
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
24
38
  - run: bundle install
25
- - run: bundle exec rake
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 rspec
45
+ debug: true
data/.gitignore CHANGED
@@ -1,14 +1,18 @@
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
11
14
  /Gemfile.lock
15
+ /gemfiles/*.lock
12
16
  /pkg/
13
17
  /rack-idempotency_key-*.gem
14
18
  /spec/reports/
data/.rubocop.yml CHANGED
@@ -1,11 +1,10 @@
1
1
  ---
2
2
  require:
3
3
  - rubocop-performance
4
- - rubocop-rake
5
4
  - rubocop-rspec
6
5
 
7
6
  AllCops:
8
- TargetRubyVersion: 2.5
7
+ TargetRubyVersion: 2.6
9
8
 
10
9
  Bundler/OrderedGems:
11
10
  Enabled: false
@@ -45,6 +44,11 @@ RSpec/MultipleMemoizedHelpers:
45
44
  Style/Documentation:
46
45
  Enabled: false
47
46
 
47
+ Style/FrozenStringLiteralComment:
48
+ Enabled: true
49
+ Exclude:
50
+ - 'gemfiles/*'
51
+
48
52
  Style/StringLiterals:
49
53
  Enabled: true
50
54
  EnforcedStyle: double_quotes
data/Appraisals ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ appraise "rack-2" do
4
+ gem "rack", "~> 2.0"
5
+ end
6
+
7
+ appraise "rack-3" do
8
+ gem "rack", "~> 3.0"
9
+ end
data/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.1](https://github.com/matteoredz/rack-idempotency_key/compare/v0.2.0...v0.2.1) (2025-01-31)
4
+
5
+
6
+ ### Continuous Integration
7
+
8
+ * add rack 2 and 3 via appraisal gem ([#21](https://github.com/matteoredz/rack-idempotency_key/issues/21)) ([b603427](https://github.com/matteoredz/rack-idempotency_key/commit/b603427915422dabf658dcaad5a2af5199f64d44))
9
+
10
+
11
+ ### Code Refactoring
12
+
13
+ * remove direct dependency on redis-rb ([#19](https://github.com/matteoredz/rack-idempotency_key/issues/19)) ([08d7a48](https://github.com/matteoredz/rack-idempotency_key/commit/08d7a488876c2029c8f8b80ce54ff1ad0086532e))
14
+
15
+ ## [0.2.0](https://github.com/matteoredz/rack-idempotency_key/compare/v0.1.1...v0.2.0) (2025-01-30)
16
+
17
+
18
+ ### Continuous Integration
19
+
20
+ * 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))
21
+ * 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))
22
+ * update gh workflows ([#5](https://github.com/matteoredz/rack-idempotency_key/issues/5)) ([84aa493](https://github.com/matteoredz/rack-idempotency_key/commit/84aa49341cf74e057efb48e2e68b5901d9843226))
23
+
24
+
25
+ ### Miscellaneous Chores
26
+
27
+ * 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))
28
+
29
+
30
+ ### Documentation
31
+
32
+ * add quality badges ([#18](https://github.com/matteoredz/rack-idempotency_key/issues/18)) ([f8bcc96](https://github.com/matteoredz/rack-idempotency_key/commit/f8bcc96421b6b167a578728ff0d1eeb9c5eccff3))
33
+ * add warning to readme ([#11](https://github.com/matteoredz/rack-idempotency_key/issues/11)) ([e0333c8](https://github.com/matteoredz/rack-idempotency_key/commit/e0333c813aaf2969bebad9ea72056a9d36e9489f))
34
+
35
+
36
+ ### Features
37
+
38
+ * add request hashing ([#16](https://github.com/matteoredz/rack-idempotency_key/issues/16)) ([f0169fe](https://github.com/matteoredz/rack-idempotency_key/commit/f0169feb088c09c24d68c13a4f640fb41204e4e4))
39
+ * 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))
40
+ * 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))
41
+
42
+
43
+ ### Bug Fixes
44
+
45
+ * **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))
46
+
47
+
48
+ ### Code Refactoring
49
+
50
+ * 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))
51
+ * 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))
52
+ * remove configurable routes ([#12](https://github.com/matteoredz/rack-idempotency_key/issues/12)) ([2463b55](https://github.com/matteoredz/rack-idempotency_key/commit/2463b555188b5c4fee436ff6f96f3144a66c16fa))
53
+
3
54
  ## [0.1.1](https://github.com/matteoredz/rack-idempotency_key/compare/v0.1.0...v0.1.1) (2023-03-31)
4
55
 
5
56
 
data/Gemfile CHANGED
@@ -4,14 +4,15 @@ source "https://rubygems.org"
4
4
 
5
5
  gemspec
6
6
 
7
+ gem "appraisal", "~> 2.5"
7
8
  gem "byebug"
8
9
  gem "mock_redis"
10
+ gem "rack", "~> 3.1", ">= 3.1.9"
9
11
  gem "rack-test"
10
- gem "rake"
11
- gem "rspec"
12
+ gem "redis", "~> 5.3"
13
+ gem "rspec", "~> 3.13"
12
14
  gem "rubocop"
13
15
  gem "rubocop-performance"
14
- gem "rubocop-rake"
15
16
  gem "rubocop-rspec"
16
17
  gem "simplecov"
17
18
  gem "timecop"
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,12 +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
- { path: "/posts/*/comments", method: "POST" }
55
- ]
56
+ store: Rack::IdempotencyKey::MemoryStore.new
56
57
  )
57
58
  end
58
59
  end
@@ -69,38 +70,68 @@ This one is the default store. It caches the response in memory.
69
70
  ```ruby
70
71
  Rack::IdempotencyKey::MemoryStore.new
71
72
 
72
- # Explicitly set the key's expiration, in seconds. The default is 86_400 (24 hours)
73
- 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)
74
75
  ```
75
76
 
76
77
  ### RedisStore
77
78
 
78
- This one is the suggested store to use in production. It relies on the [redis gem](https://github.com/redis/redis-rb).
79
+ This one is the suggested store to use in production. It relies on the [redis-rb](https://github.com/redis/redis-rb) gem, so make sure you're bundling it with your application.
79
80
 
80
81
  ```ruby
81
- Rack::IdempotencyKey::RedisStore.new(Redis.current)
82
+ Rack::IdempotencyKey::RedisStore.new(Redis.new)
82
83
 
83
- # Explicitly set the key's expiration, in seconds. The default is 86_400 (24 hours)
84
- 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.new, expires_in: 300)
85
86
  ```
86
87
 
87
- 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
+ ```
88
94
 
89
95
  ### Custom Store
90
96
 
97
+ > [!IMPORTANT]
98
+ > Ensure proper concurrency handling when implementing a custom store to prevent race conditions and data inconsistencies.
99
+
91
100
  Any object that conforms to the following interface can be used as a custom Store:
92
101
 
93
102
  ```ruby
94
- # @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.
95
109
  #
96
110
  # @return [Array]
97
111
  def get(key)
98
112
 
99
- # @param [String] key
100
- # @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.
101
122
  #
102
123
  # @return [Array]
103
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)
104
135
  ```
105
136
 
106
137
  The Array returned must conform to the [Rack Specification](https://github.com/rack/rack/blob/main/SPEC.rdoc), as follows:
@@ -113,33 +144,12 @@ The Array returned must conform to the [Rack Specification](https://github.com/r
113
144
  ]
114
145
  ```
115
146
 
116
- ## Idempotent Routes
117
-
118
- To declare the routes where you want to enable idempotency, you only need to pass a `route` keyword parameter when the Middleware gets mounted.
119
-
120
- Each route entry must be compliant with what follows:
121
-
122
- ```ruby
123
- routes: [
124
- { path: "/posts", method: "POST" },
125
- { path: "/posts/*", method: "PATCH" },
126
- { path: "/posts/*/comments", method: "POST" }
127
- ]
128
- ```
129
-
130
- The `*` char is a placeholder representing a named parameter that will get converted to an any-chars regex.
131
-
132
147
  ## Development
133
148
 
134
149
  After checking out the repo, run `bin/setup` to install dependencies.
135
- Then, run `rake test` to run the tests.
150
+ Then, run `bundle exec rspec` to run the tests.
136
151
  You can also run `bin/console` for an interactive prompt that will allow you to experiment.
137
152
 
138
- To install this gem onto your local machine, run `bundle exec rake install`.
139
- To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`,
140
- which will create a git tag for the version, push git commits and tags,
141
- and push the `.gem` file to [rubygems.org](https://rubygems.org).
142
-
143
153
  ## Contributing
144
154
 
145
155
  Bug reports and pull requests are welcome on GitHub at https://github.com/matteoredz/rack-idempotency_key.
@@ -0,0 +1,18 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal", "~> 2.5"
6
+ gem "byebug"
7
+ gem "mock_redis"
8
+ gem "rack", "~> 2.0"
9
+ gem "rack-test"
10
+ gem "redis", "~> 5.3"
11
+ gem "rspec", "~> 3.13"
12
+ gem "rubocop"
13
+ gem "rubocop-performance"
14
+ gem "rubocop-rspec"
15
+ gem "simplecov"
16
+ gem "timecop"
17
+
18
+ gemspec path: "../"
@@ -0,0 +1,18 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal", "~> 2.5"
6
+ gem "byebug"
7
+ gem "mock_redis"
8
+ gem "rack", "~> 3.0"
9
+ gem "rack-test"
10
+ gem "redis", "~> 5.3"
11
+ gem "rspec", "~> 3.13"
12
+ gem "rubocop"
13
+ gem "rubocop-performance"
14
+ gem "rubocop-rspec"
15
+ gem "simplecov"
16
+ gem "timecop"
17
+
18
+ gemspec path: "../"
@@ -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"
@@ -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,127 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "redis"
4
+
3
5
  module Rack
4
6
  class IdempotencyKey
7
+ # Redis-based store for handling idempotency keys.
8
+ #
9
+ # This class provides methods to store, retrieve, and delete idempotency keys
10
+ # in a Redis database, ensuring that the same request is not processed multiple
11
+ # times. It supports both direct Redis instances and connection pools.
12
+ #
13
+ # @example Using a direct Redis instance
14
+ # redis = Redis.new
15
+ # store = Rack::IdempotencyKey::RedisStore.new(redis)
16
+ #
17
+ # @example Using a Redis connection pool
18
+ # redis_pool = ConnectionPool.new(size: 5, timeout: 5) { Redis.new }
19
+ # store = Rack::IdempotencyKey::RedisStore.new(redis_pool)
5
20
  class RedisStore
6
- KEY_NAMESPACE = "idempotency_key"
21
+ DEFAULT_EXPIRATION = 300 # 5 minutes in seconds
7
22
 
8
- def initialize(store, expires_in: 86_400)
23
+ # Initializes a new RedisStore instance.
24
+ #
25
+ # @param store [Redis, ConnectionPool] A Redis instance or a connection pool.
26
+ # @param expires_in [Integer] The default expiration time for stored values, in seconds.
27
+ #
28
+ # @example
29
+ # redis = Redis.new
30
+ # store = Rack::IdempotencyKey::RedisStore.new(redis, expires_in: 600)
31
+ def initialize(store, expires_in: DEFAULT_EXPIRATION)
9
32
  @store = store
10
33
  @expires_in = expires_in
11
34
  end
12
35
 
36
+ # Retrieves a value from Redis by key.
37
+ #
38
+ # The stored value is expected to be JSON-encoded and is automatically parsed.
39
+ # If the key does not exist, `nil` is returned.
40
+ #
41
+ # @param key [String] The Redis key to retrieve.
42
+ # @return [Object, nil] The parsed JSON value, or `nil` if the key does not exist.
43
+ #
44
+ # @raise [Rack::IdempotencyKey::StoreError] If a Redis-related error occurs.
45
+ #
46
+ # @example Retrieve a value from Redis
47
+ # store.get("key") # => "value"
13
48
  def get(key)
14
- value = store.get(namespaced_key(key))
49
+ value = with_redis { |redis| redis.get(key) }
15
50
  JSON.parse(value) unless value.nil?
51
+ rescue Redis::BaseError => e
52
+ raise Rack::IdempotencyKey::StoreError, "#{self.class}: #{e.message}"
16
53
  end
17
54
 
18
- def set(key, value)
19
- store.set(namespaced_key(key), value, nx: true, ex: expires_in)
55
+ # Stores a value in Redis with an optional time-to-live (TTL).
56
+ #
57
+ # This method ensures that the key is only set if it does not already exist (`NX` flag).
58
+ # If the key is already present, a `ConflictError` is raised.
59
+ #
60
+ # @param key [String] The Redis key to set.
61
+ # @param value [String] The value to store.
62
+ # @param ttl [Integer] The expiration time in seconds (defaults to `expires_in`).
63
+ #
64
+ # @return [Object, nil] The stored value retrieved from Redis.
65
+ #
66
+ # @raise [Rack::IdempotencyKey::ConflictError] If the key already exists and is locked.
67
+ # @raise [Rack::IdempotencyKey::StoreError] If a Redis-related error occurs.
68
+ #
69
+ # @example Store a new idempotency key
70
+ # store.set("key", "value", ttl: 600)
71
+ def set(key, value, ttl: expires_in)
72
+ with_redis do |redis|
73
+ result = redis.set(key, value, nx: true, ex: ttl)
74
+ raise Rack::IdempotencyKey::ConflictError unless result
75
+ end
76
+
20
77
  get(key)
78
+ rescue Redis::BaseError => e
79
+ raise Rack::IdempotencyKey::StoreError, "#{self.class}: #{e.message}"
80
+ end
81
+
82
+ # Deletes a key from Redis.
83
+ #
84
+ # This method removes the idempotency key from Redis, allowing the same request
85
+ # to be processed again in the future.
86
+ #
87
+ # @param key [String] The Redis key to delete.
88
+ #
89
+ # @raise [Rack::IdempotencyKey::StoreError] If a Redis-related error occurs.
90
+ #
91
+ # @example Remove an idempotency key
92
+ # store.unset("key")
93
+ def unset(key)
94
+ with_redis { |redis| redis.del(key) }
95
+ rescue Redis::BaseError => e
96
+ raise Rack::IdempotencyKey::StoreError, "#{self.class}: #{e.message}"
21
97
  end
22
98
 
23
99
  private
24
100
 
25
101
  attr_reader :store, :expires_in
26
102
 
27
- def namespaced_key(key)
28
- "#{KEY_NAMESPACE}:#{key.split.join}"
103
+ # Executes the given block with a Redis connection, supporting both direct
104
+ # Redis instances and connection pools (https://github.com/mperham/connection_pool).
105
+ #
106
+ # If a `ConnectionPool` is detected (by responding to `with`), it will yield a Redis
107
+ # connection from the pool. Otherwise, it will yield the direct Redis instance.
108
+ #
109
+ # @yieldparam redis [Redis] A Redis connection instance.
110
+ # @return [Object] The result of the block execution.
111
+ #
112
+ # @example Using a direct Redis instance
113
+ # store = RedisStore.new(Redis.new)
114
+ # store.with_redis { |redis| redis.set("key", "value") }
115
+ #
116
+ # @example Using a Redis connection pool
117
+ # store = RedisStore.new(ConnectionPool.new(size: 5, timeout: 5) { Redis.new })
118
+ # store.with_redis { |redis| redis.set("key", "value") }
119
+ def with_redis(&block)
120
+ if store.respond_to?(:with)
121
+ store.with(&block)
122
+ else
123
+ yield store
124
+ end
29
125
  end
30
126
  end
31
127
  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.1"
5
+ VERSION = "0.2.1"
6
6
  end
7
7
  end
@@ -1,42 +1,50 @@
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
- require "rack/idempotency_key/redis_store"
8
+
9
+ begin
10
+ require "rack/idempotency_key/redis_store"
11
+ rescue LoadError => e
12
+ warn "RedisStore was not required: #{e.message}"
13
+ warn "* Add 'redis' to your bundle to use this store."
14
+ end
8
15
 
9
16
  # Collaborators
10
- require "rack/idempotency_key/idempotent_request"
17
+ require "rack/idempotency_key/request"
11
18
 
12
19
  module Rack
13
20
  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
21
+ def initialize(app, store: MemoryStore.new)
22
+ @app = app
23
+ @store = store
20
24
  end
21
25
 
22
26
  def call(env)
23
- request = IdempotentRequest.new(Rack::Request.new(env), routes)
27
+ request = Request.new(Rack::Request.new(env), store)
24
28
  return app.call(env) unless request.allowed?
25
29
 
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
30
+ handle_request!(request, env)
31
+ rescue ConflictError => e
32
+ [409, { "Content-Type" => "text/plain" }, [e.message]]
33
+ rescue StoreError => e
34
+ [503, { "Content-Type" => "text/plain" }, [e.message]]
36
35
  end
37
36
 
38
37
  private
39
38
 
40
- attr_reader :app, :store, :routes
39
+ attr_reader :app, :store
40
+
41
+ def handle_request!(request, env)
42
+ request.locked! do
43
+ cached_response = request.cached_response!
44
+ return cached_response unless cached_response.nil?
45
+
46
+ app.call(env).tap { |response| request.cache!(response) }
47
+ end
48
+ end
41
49
  end
42
50
  end
metadata CHANGED
@@ -1,16 +1,16 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rack-idempotency_key
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.1
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-03-31 00:00:00.000000000 Z
11
+ date: 2025-01-31 00:00:00.000000000 Z
12
12
  dependencies: []
13
- description:
13
+ description:
14
14
  email:
15
15
  - mttrss5@gmail.com
16
16
  executables: []
@@ -18,26 +18,30 @@ extensions: []
18
18
  extra_rdoc_files: []
19
19
  files:
20
20
  - ".github/CODEOWNERS"
21
+ - ".github/workflows/conventional-pr-title.yml"
21
22
  - ".github/workflows/lint.yml"
22
23
  - ".github/workflows/release.yml"
23
24
  - ".github/workflows/test.yml"
24
25
  - ".gitignore"
25
26
  - ".rspec"
26
27
  - ".rubocop.yml"
27
- - ".travis.yml"
28
+ - Appraisals
28
29
  - CHANGELOG.md
29
30
  - CODE_OF_CONDUCT.md
30
31
  - Gemfile
31
32
  - README.md
32
- - Rakefile
33
33
  - bin/console
34
34
  - bin/release
35
35
  - bin/setup
36
+ - gemfiles/rack_2.gemfile
37
+ - gemfiles/rack_3.gemfile
36
38
  - idempotency_key.gemspec
37
39
  - lib/rack/idempotency_key.rb
38
- - lib/rack/idempotency_key/idempotent_request.rb
40
+ - lib/rack/idempotency_key/error.rb
39
41
  - lib/rack/idempotency_key/memory_store.rb
40
42
  - lib/rack/idempotency_key/redis_store.rb
43
+ - lib/rack/idempotency_key/request.rb
44
+ - lib/rack/idempotency_key/request_hash.rb
41
45
  - lib/rack/idempotency_key/version.rb
42
46
  homepage: https://github.com/matteoredz/rack-idempotency_key
43
47
  licenses:
@@ -46,7 +50,7 @@ metadata:
46
50
  homepage_uri: https://github.com/matteoredz/rack-idempotency_key
47
51
  source_code_uri: https://github.com/matteoredz/rack-idempotency_key
48
52
  changelog_uri: https://github.com/matteoredz/rack-idempotency_key/CHANGELOG.md
49
- post_install_message:
53
+ post_install_message:
50
54
  rdoc_options: []
51
55
  require_paths:
52
56
  - lib
@@ -54,15 +58,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
54
58
  requirements:
55
59
  - - ">="
56
60
  - !ruby/object:Gem::Version
57
- version: 2.5.0
61
+ version: 2.6.0
58
62
  required_rubygems_version: !ruby/object:Gem::Requirement
59
63
  requirements:
60
64
  - - ">="
61
65
  - !ruby/object:Gem::Version
62
66
  version: '0'
63
67
  requirements: []
64
- rubygems_version: 3.1.2
65
- signing_key:
68
+ rubygems_version: 3.0.3.1
69
+ signing_key:
66
70
  specification_version: 4
67
71
  summary: A Rack Middleware implementing the idempotency principle
68
72
  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
data/Rakefile DELETED
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/gem_tasks"
4
- require "rake/testtask"
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
11
-
12
- task default: :test
@@ -1,81 +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
- route_segments = segments route_path
56
- path_segments.size == route_segments.size && same_segments?(route_segments)
57
- end
58
-
59
- def matching_method?(route_method)
60
- request.request_method.casecmp(route_method).zero?
61
- end
62
-
63
- def path_segments
64
- @path_segments ||= segments(request.path_info)
65
- end
66
-
67
- def segments(path)
68
- path.split("/").reject(&:empty?)
69
- end
70
-
71
- def same_segments?(route_segments)
72
- path_segments.each_with_index do |path_segment, index|
73
- route_segment = Regexp.new route_segments[index].gsub("*", '\w+'), Regexp::IGNORECASE
74
- return false unless path_segment.match?(route_segment)
75
- end
76
-
77
- true
78
- end
79
- end
80
- end
81
- end