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 +4 -4
- data/.github/workflows/conventional-pr-title.yml +35 -0
- data/.github/workflows/lint.yml +3 -2
- data/.github/workflows/release.yml +28 -2
- data/.github/workflows/test.yml +23 -3
- data/.gitignore +4 -0
- data/.rubocop.yml +6 -2
- data/Appraisals +9 -0
- data/CHANGELOG.md +51 -0
- data/Gemfile +4 -3
- data/README.md +49 -39
- data/gemfiles/rack_2.gemfile +18 -0
- data/gemfiles/rack_3.gemfile +18 -0
- data/idempotency_key.gemspec +1 -1
- data/lib/rack/idempotency_key/error.rb +20 -0
- data/lib/rack/idempotency_key/memory_store.rb +26 -13
- data/lib/rack/idempotency_key/redis_store.rb +103 -7
- data/lib/rack/idempotency_key/request.rb +80 -0
- data/lib/rack/idempotency_key/request_hash.rb +67 -0
- data/lib/rack/idempotency_key/version.rb +1 -1
- data/lib/rack/idempotency_key.rb +28 -20
- metadata +15 -11
- data/.travis.yml +0 -6
- data/Rakefile +0 -12
- data/lib/rack/idempotency_key/idempotent_request.rb +0 -81
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 157af590e24f6928fc61715b006c746296b9a9b0192d42bf14975b1722117dc7
|
4
|
+
data.tar.gz: 41f8668647cf15a010732444f5c8071f93dc1434e9be5e0edbd3a45c403854bb
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
data/.github/workflows/lint.yml
CHANGED
@@ -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@
|
15
|
+
- uses: actions/checkout@v4
|
15
16
|
- uses: ruby/setup-ruby@v1
|
16
17
|
with:
|
17
18
|
bundler-cache: true
|
18
|
-
ruby-version: 2.
|
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
|
-
|
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.
|
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}}
|
data/.github/workflows/test.yml
CHANGED
@@ -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.
|
21
|
+
ruby: [2.6, 2.7, '3.0', 3.1, 3.2, 3.3, 3.4, head]
|
18
22
|
steps:
|
19
|
-
- uses: actions/checkout@
|
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
|
-
-
|
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.
|
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
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 "
|
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
|
+
[](https://badge.fury.io/rb/rack-idempotency_key) [](https://codeclimate.com/github/matteoredz/rack-idempotency_key/maintainability) [](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 `
|
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
|
73
|
-
Rack::IdempotencyKey::MemoryStore.new(expires_in:
|
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
|
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.
|
82
|
+
Rack::IdempotencyKey::RedisStore.new(Redis.new)
|
82
83
|
|
83
|
-
# Explicitly set the key's expiration, in seconds. The default is
|
84
|
-
Rack::IdempotencyKey::RedisStore.new(Redis.
|
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
|
-
|
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
|
-
#
|
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
|
-
#
|
100
|
-
#
|
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 `
|
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: "../"
|
data/idempotency_key.gemspec
CHANGED
@@ -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.
|
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
|
-
|
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
|
-
|
13
|
-
|
15
|
+
mutex.synchronize do
|
16
|
+
value = store[key]
|
17
|
+
return if value.nil?
|
14
18
|
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
+
if expired?(value[:expires_at])
|
20
|
+
store.delete(key)
|
21
|
+
return
|
22
|
+
end
|
19
23
|
|
20
|
-
|
24
|
+
value[:value]
|
25
|
+
end
|
21
26
|
end
|
22
27
|
|
23
|
-
def set(key, value)
|
24
|
-
|
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?(
|
33
|
-
Time.now.utc
|
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
|
-
|
21
|
+
DEFAULT_EXPIRATION = 300 # 5 minutes in seconds
|
7
22
|
|
8
|
-
|
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 =
|
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
|
-
|
19
|
-
|
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
|
-
|
28
|
-
|
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
|
data/lib/rack/idempotency_key.rb
CHANGED
@@ -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
|
-
|
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/
|
17
|
+
require "rack/idempotency_key/request"
|
11
18
|
|
12
19
|
module Rack
|
13
20
|
class IdempotencyKey
|
14
|
-
|
15
|
-
|
16
|
-
|
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 =
|
27
|
+
request = Request.new(Rack::Request.new(env), store)
|
24
28
|
return app.call(env) unless request.allowed?
|
25
29
|
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
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
|
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.
|
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:
|
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
|
-
-
|
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/
|
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.
|
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
|
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
data/Rakefile
DELETED
@@ -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
|