rack-idempotency_key 0.1.1 → 0.2.1

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