rack-idempotency_key 0.1.0 → 0.2.0
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 +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
|