grape-idempotency 1.0.0 → 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +50 -2
- data/grape-idempotency.gemspec +2 -1
- data/lib/grape/idempotency/version.rb +1 -1
- data/lib/grape/idempotency.rb +50 -19
- metadata +18 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 115100d8cd1141c7c801a1501ede2db9eff3419a5b2974de93c4b79e7c812fd2
|
4
|
+
data.tar.gz: 567472c4ab8ad2aae2561d7af0e808c803a025caae34f75492c2b889b6468d7e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: fde07abe226552ba88086f20fcece9778ef13ee04c17c27376aea2d13386955944caea65ae28ff1d2abdda72bc501600a9afb107229e6da47b4dd02a56b2e5f5
|
7
|
+
data.tar.gz: 3a9a9fcd8f30d5f1deab9da2c719c54c1a0cbecafb5ce3b1196301bc60983bd3271153f4b02fed795d49d4188255507e0a8445d1fdd37cad1bbe0a0851b32e48
|
data/CHANGELOG.md
CHANGED
@@ -4,6 +4,21 @@ All changes to `grape-idempotency` will be documented in this file.
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
6
6
|
|
7
|
+
## [1.1.0] - (Next)
|
8
|
+
|
9
|
+
### Fix
|
10
|
+
|
11
|
+
* Your contribution here.
|
12
|
+
|
13
|
+
### Changed
|
14
|
+
|
15
|
+
* Your contribution here.
|
16
|
+
|
17
|
+
### Feature
|
18
|
+
|
19
|
+
* [#20](https://github.com/jcagarcia/grape-idempotency/pull/20): Manage `Redis` exceptions by default and allow the gem's consumer to manage them by itself - [@jcagarcia](https://github.com/jcagarcia).
|
20
|
+
* Your contribution here.
|
21
|
+
|
7
22
|
## [1.0.0] - 2023-11-23
|
8
23
|
|
9
24
|
### Changed
|
data/README.md
CHANGED
@@ -12,7 +12,8 @@ Topics covered in this README:
|
|
12
12
|
- [Installation](#installation-)
|
13
13
|
- [Basic Usage](#basic-usage-)
|
14
14
|
- [How it works](#how-it-works-)
|
15
|
-
- [Making idempotency key header mandatory](#making-idempotency-key-header-mandatory-)
|
15
|
+
- [Making idempotency key header mandatory](#making-idempotency-key-header-mandatory-)
|
16
|
+
- [Redis Storage Connectivity Issue](#redis-storage-connectivity-issue)
|
16
17
|
- [Configuration](#configuration-)
|
17
18
|
- [Changelog](#changelog)
|
18
19
|
- [Contributing](#contributing)
|
@@ -81,7 +82,7 @@ Results are only saved if an API endpoint begins its execution. If incoming para
|
|
81
82
|
|
82
83
|
Additionally, this gem automatically appends the `Original-Request` header and the `Idempotency-Key` header to your API's response, enabling you to trace back to the initial request that generated that specific response.
|
83
84
|
|
84
|
-
|
85
|
+
### Making idempotency key header mandatory ⚠️
|
85
86
|
|
86
87
|
For some endpoints, you want to enforce your consumers to provide idempotency key. So, when wrapping the code inside the `idempotent` method, you can mark it as `required`:
|
87
88
|
|
@@ -113,6 +114,37 @@ If the Idempotency-Key request header is missing for a idempotent operation requ
|
|
113
114
|
|
114
115
|
If you want to change the error message returned in this scenario, check [How to configure idempotency key missing error message](#mandatory_header_response) section.
|
115
116
|
|
117
|
+
### Redis Storage Connectivity Issue
|
118
|
+
|
119
|
+
By default, `Redis` exceptions are not handled by the `grape-idempotency` gem.
|
120
|
+
|
121
|
+
Therefore, if an exception arises while attempting to read, write or delete data from the `Redis` storage, the gem will re-raise the identical exception to your application. Thus, you will be responsible for handling it within your own code, such as:
|
122
|
+
|
123
|
+
```ruby
|
124
|
+
require 'grape'
|
125
|
+
require 'grape-idempotency'
|
126
|
+
|
127
|
+
class API < Grape::API
|
128
|
+
post '/payments' do
|
129
|
+
begin
|
130
|
+
idempotent do
|
131
|
+
status 201
|
132
|
+
Payment.create!({
|
133
|
+
amount: params[:amount]
|
134
|
+
})
|
135
|
+
end
|
136
|
+
rescue Redis::BaseError => e
|
137
|
+
error!("Redis error! Idempotency is very important here and we cannot continue.", 500)
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
```
|
143
|
+
|
144
|
+
If you want to avoid this functionality, and you want the gem handles the potential `Redis` exceptions, you have the option to configure the gem for handling these `Redis` exceptions. Please refer to the [manage_redis_exceptions](#manage_redis_exceptions) configuration property.
|
145
|
+
|
146
|
+
🚨 WARNING: If a `Redis` exception appears AFTER performing the wrapped code, nothing will be re-raised. The process will continue working and the response will be returned to the consumer of your API. However, a `409 Conflict` response can be returned to your consumer if it retried the same call with the same idempotency key. This is because the gem was not able to associate the response of the original request to the original idempotency key because those connectivity issues.
|
147
|
+
|
116
148
|
## Configuration 🪚
|
117
149
|
|
118
150
|
In addition to the storage aspect, you have the option to supply additional configuration details to tailor the gem to the specific requirements of your project.
|
@@ -169,6 +201,7 @@ If you want to provide your own logger, you want to change the level to `DEBUG`
|
|
169
201
|
|
170
202
|
```ruby
|
171
203
|
Grape::Idempotency.configure do |c|
|
204
|
+
c.storage = @storage
|
172
205
|
c.logger = Infrastructure::MyLogger.new
|
173
206
|
c.logger_level = :debug
|
174
207
|
c.logger_prefix = '[my-own-prefix]'
|
@@ -194,6 +227,21 @@ I, [2023-11-23T22:41:39.148523 #1] DEBUG -- : [my-own-prefix] Request has been
|
|
194
227
|
I, [2023-11-23T22:41:39.148537 #1] DEBUG -- : [my-own-prefix] Returning the response from the original request.
|
195
228
|
```
|
196
229
|
|
230
|
+
### manage_redis_exceptions
|
231
|
+
|
232
|
+
By default, the `grape-idempotency` gem is configured to re-raise `Redis` exceptions.
|
233
|
+
|
234
|
+
If you want to delegate the `Redis` exception management into the gem, you can configure it using the `manage_redis_exceptions` configuration property.
|
235
|
+
|
236
|
+
```ruby
|
237
|
+
Grape::Idempotency.configure do |c|
|
238
|
+
c.storage = @storage
|
239
|
+
c.manage_redis_exceptions = true
|
240
|
+
end
|
241
|
+
```
|
242
|
+
|
243
|
+
However, this approach carries a certain level of risk. In the case that `Redis` experiences an outage, the idempotent functionality will be lost, the endpoint will behave as no idempotent, and this issue may go unnoticed.
|
244
|
+
|
197
245
|
### conflict_error_response
|
198
246
|
|
199
247
|
When providing a `Idempotency-Key: <key>` header, this gem compares incoming parameters to those of the original request (if exists) and returns a `409 - Conflict` status code if they don't match, preventing accidental misuse. The response body returned by the gem looks like:
|
data/grape-idempotency.gemspec
CHANGED
@@ -22,7 +22,8 @@ Gem::Specification.new do |spec|
|
|
22
22
|
|
23
23
|
spec.required_ruby_version = '>= 2.6'
|
24
24
|
|
25
|
-
spec.add_runtime_dependency 'grape', '
|
25
|
+
spec.add_runtime_dependency 'grape', '>= 1'
|
26
|
+
spec.add_runtime_dependency 'redis', '>= 4'
|
26
27
|
|
27
28
|
spec.add_development_dependency 'bundler'
|
28
29
|
spec.add_development_dependency 'rspec'
|
data/lib/grape/idempotency.rb
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
require 'grape'
|
2
|
+
require 'redis'
|
2
3
|
require 'logger'
|
3
4
|
require 'securerandom'
|
4
5
|
require 'grape/idempotency/version'
|
@@ -33,21 +34,21 @@ module Grape
|
|
33
34
|
grape.error!(configuration.mandatory_header_response, 400) if required && !idempotency_key
|
34
35
|
return block.call if !idempotency_key
|
35
36
|
|
36
|
-
|
37
|
-
log(:debug, "Request has been found for the provided idempotency key => #{
|
38
|
-
if
|
39
|
-
log(:debug, "Request has conflicts. Same params? => #{
|
37
|
+
stored_request = get_from_storage(idempotency_key)
|
38
|
+
log(:debug, "Request has been found for the provided idempotency key => #{stored_request}") if stored_request
|
39
|
+
if stored_request && (stored_request["params"] != grape.request.params || stored_request["path"] != grape.request.path)
|
40
|
+
log(:debug, "Request has conflicts. Same params? => #{stored_request["params"] != grape.request.params}. Same path? => #{stored_request["path"] != grape.request.path}")
|
40
41
|
log(:debug, "Returning conflict error response.")
|
41
42
|
grape.error!(configuration.conflict_error_response, 422)
|
42
|
-
elsif
|
43
|
+
elsif stored_request && stored_request["processing"] == true
|
43
44
|
log(:debug, "Returning processing error response.")
|
44
45
|
grape.error!(configuration.processing_response, 409)
|
45
|
-
elsif
|
46
|
+
elsif stored_request
|
46
47
|
log(:debug, "Returning the response from the original request.")
|
47
|
-
grape.status
|
48
|
-
grape.header(ORIGINAL_REQUEST_HEADER,
|
48
|
+
grape.status stored_request["status"]
|
49
|
+
grape.header(ORIGINAL_REQUEST_HEADER, stored_request["original_request"])
|
49
50
|
grape.header(configuration.idempotency_key_header, idempotency_key)
|
50
|
-
return
|
51
|
+
return stored_request["response"]
|
51
52
|
end
|
52
53
|
|
53
54
|
log(:debug, "Previous request information has NOT been found for the provided idempotency key.")
|
@@ -76,26 +77,32 @@ module Grape
|
|
76
77
|
|
77
78
|
grape.header(ORIGINAL_REQUEST_HEADER, original_request_id)
|
78
79
|
grape.body response
|
80
|
+
rescue Redis::BaseError => e
|
81
|
+
raise
|
79
82
|
rescue => e
|
80
83
|
log(:debug, "An unexpected error was raised when performing the block.")
|
81
|
-
if !
|
84
|
+
if !stored_request && !response
|
82
85
|
validate_config!
|
83
86
|
log(:debug, "Storing error response.")
|
84
87
|
original_request_id = get_request_id(grape.request.headers)
|
85
88
|
stored_key = store_error_request(idempotency_key, grape.request.path, grape.request.params, grape.status, original_request_id, e)
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
+
if stored_key
|
90
|
+
log(:debug, "Error response stored.")
|
91
|
+
grape.header(ORIGINAL_REQUEST_HEADER, original_request_id)
|
92
|
+
grape.header(configuration.idempotency_key_header, stored_key)
|
93
|
+
end
|
89
94
|
end
|
90
95
|
log(:debug, "Re-raising the error.")
|
91
96
|
raise
|
92
97
|
ensure
|
93
|
-
if !
|
98
|
+
if !stored_request && response
|
94
99
|
validate_config!
|
95
100
|
log(:debug, "Storing response.")
|
96
101
|
stored_key = store_request_response(idempotency_key, grape.request.path, grape.request.params, grape.status, original_request_id, response)
|
97
|
-
|
98
|
-
|
102
|
+
if stored_key
|
103
|
+
log(:debug, "Response stored.")
|
104
|
+
grape.header(configuration.idempotency_key_header, stored_key)
|
105
|
+
end
|
99
106
|
end
|
100
107
|
end
|
101
108
|
|
@@ -113,6 +120,9 @@ module Grape
|
|
113
120
|
|
114
121
|
store_request_response(idempotency_key, path, params, status, original_request_id, response)
|
115
122
|
storage.del(stored_error[:error_key])
|
123
|
+
rescue Redis::BaseError => e
|
124
|
+
log(:error, "Storage error => #{e.message} - #{e}")
|
125
|
+
nil
|
116
126
|
end
|
117
127
|
|
118
128
|
private
|
@@ -122,7 +132,10 @@ module Grape
|
|
122
132
|
end
|
123
133
|
|
124
134
|
def valid_storage?
|
125
|
-
configuration.storage &&
|
135
|
+
configuration.storage &&
|
136
|
+
configuration.storage.respond_to?(:get) &&
|
137
|
+
configuration.storage.respond_to?(:set) &&
|
138
|
+
configuration.storage.respond_to?(:del)
|
126
139
|
end
|
127
140
|
|
128
141
|
def get_idempotency_key(headers)
|
@@ -141,11 +154,15 @@ module Grape
|
|
141
154
|
request_id || "req_#{SecureRandom.hex}"
|
142
155
|
end
|
143
156
|
|
144
|
-
def
|
157
|
+
def get_from_storage(idempotency_key)
|
145
158
|
value = storage.get(key(idempotency_key))
|
146
159
|
return unless value
|
147
160
|
|
148
161
|
JSON.parse(value)
|
162
|
+
rescue Redis::BaseError => e
|
163
|
+
log(:error, "Storage error => #{e.message} - #{e}")
|
164
|
+
return if configuration.manage_redis_exceptions
|
165
|
+
raise
|
149
166
|
end
|
150
167
|
|
151
168
|
def store_processing_request(idempotency_key, path, params, request_id)
|
@@ -157,6 +174,9 @@ module Grape
|
|
157
174
|
}
|
158
175
|
|
159
176
|
storage.set(key(idempotency_key), body.to_json, ex: configuration.expires_in, nx: true)
|
177
|
+
rescue Redis::BaseError => e
|
178
|
+
return true if configuration.manage_redis_exceptions
|
179
|
+
raise
|
160
180
|
end
|
161
181
|
|
162
182
|
def store_request_response(idempotency_key, path, params, status, request_id, response)
|
@@ -170,6 +190,9 @@ module Grape
|
|
170
190
|
|
171
191
|
storage.set(key(idempotency_key), body.to_json, ex: configuration.expires_in, nx: false)
|
172
192
|
|
193
|
+
idempotency_key
|
194
|
+
rescue Redis::BaseError => e
|
195
|
+
log(:error, "Storage error => #{e.message} - #{e}")
|
173
196
|
idempotency_key
|
174
197
|
end
|
175
198
|
|
@@ -187,6 +210,9 @@ module Grape
|
|
187
210
|
|
188
211
|
storage.set(error_key(idempotency_key), body, ex: 30, nx: false)
|
189
212
|
|
213
|
+
idempotency_key
|
214
|
+
rescue Redis::BaseError => e
|
215
|
+
log(:error, "Storage error => #{e.message} - #{e}")
|
190
216
|
idempotency_key
|
191
217
|
end
|
192
218
|
|
@@ -207,6 +233,9 @@ module Grape
|
|
207
233
|
}
|
208
234
|
end
|
209
235
|
end.first
|
236
|
+
rescue Redis::BaseError => e
|
237
|
+
log(:error, "Storage error => #{e.message} - #{e}")
|
238
|
+
nil
|
210
239
|
end
|
211
240
|
|
212
241
|
def is_an_error?(response)
|
@@ -252,7 +281,8 @@ module Grape
|
|
252
281
|
|
253
282
|
class Configuration
|
254
283
|
attr_accessor :storage, :logger, :logger_level, :logger_prefix, :expires_in, :idempotency_key_header,
|
255
|
-
:request_id_header, :conflict_error_response, :processing_response, :mandatory_header_response
|
284
|
+
:request_id_header, :conflict_error_response, :processing_response, :mandatory_header_response,
|
285
|
+
:manage_redis_exceptions
|
256
286
|
|
257
287
|
class Error < StandardError; end
|
258
288
|
|
@@ -264,6 +294,7 @@ module Grape
|
|
264
294
|
@expires_in = 216_000
|
265
295
|
@idempotency_key_header = "idempotency-key"
|
266
296
|
@request_id_header = "x-request-id"
|
297
|
+
@manage_redis_exceptions = false
|
267
298
|
@conflict_error_response = {
|
268
299
|
"title" => "Idempotency-Key is already used",
|
269
300
|
"detail" => "This operation is idempotent and it requires correct usage of Idempotency Key. Idempotency Key MUST not be reused across different payloads of this operation."
|
metadata
CHANGED
@@ -1,29 +1,43 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: grape-idempotency
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Juan Carlos García
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-
|
11
|
+
date: 2023-12-12 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: grape
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
16
16
|
requirements:
|
17
|
-
- - "
|
17
|
+
- - ">="
|
18
18
|
- !ruby/object:Gem::Version
|
19
19
|
version: '1'
|
20
20
|
type: :runtime
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
23
23
|
requirements:
|
24
|
-
- - "
|
24
|
+
- - ">="
|
25
25
|
- !ruby/object:Gem::Version
|
26
26
|
version: '1'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: redis
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '4'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '4'
|
27
41
|
- !ruby/object:Gem::Dependency
|
28
42
|
name: bundler
|
29
43
|
requirement: !ruby/object:Gem::Requirement
|