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