grape-idempotency 0.1.3 → 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 +40 -12
- data/README.md +179 -4
- data/grape-idempotency.gemspec +2 -1
- data/lib/grape/idempotency/helpers.rb +2 -2
- data/lib/grape/idempotency/middleware/error.rb +6 -2
- data/lib/grape/idempotency/version.rb +1 -1
- data/lib/grape/idempotency.rb +136 -52
- 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,31 +4,59 @@ 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
|
-
## [
|
7
|
+
## [1.1.0] - (Next)
|
8
8
|
|
9
9
|
### Fix
|
10
10
|
|
11
|
-
|
12
|
-
- Conflict response had invalid format.
|
11
|
+
* Your contribution here.
|
13
12
|
|
14
|
-
|
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
|
+
|
22
|
+
## [1.0.0] - 2023-11-23
|
23
|
+
|
24
|
+
### Changed
|
25
|
+
|
26
|
+
* [#11](https://github.com/jcagarcia/grape-idempotency/pull/11): Changing error response formats - [@Flip120](https://github.com/Flip120).
|
27
|
+
* [#16](https://github.com/jcagarcia/grape-idempotency/pull/16): Changing error response code to 422 for conflict - [@Flip120](https://github.com/Flip120).
|
28
|
+
|
29
|
+
### Feature
|
30
|
+
|
31
|
+
* [#11](https://github.com/jcagarcia/grape-idempotency/pull/11): Return 409 conflict when a request is still being processed - [@Flip120](https://github.com/Flip120).
|
32
|
+
* [#15](https://github.com/jcagarcia/grape-idempotency/pull/15): Allow to mark the idempotent header as required - [@jcagarcia](https://github.com/jcagarcia).
|
33
|
+
* [#17](https://github.com/jcagarcia/grape-idempotency/pull/17): Allow to configure logger - [@jcagarcia](https://github.com/jcagarcia).
|
34
|
+
|
35
|
+
## [0.1.3] - 2023-11-07
|
36
|
+
|
37
|
+
### Fix
|
38
|
+
|
39
|
+
* [#9](https://github.com/jcagarcia/grape-idempotency/pull/9): Second calls were returning `null` when the first response was generated inside a `rescue_from`. - [@jcagarcia](https://github.com/jcagarcia).
|
40
|
+
- [#9](https://github.com/jcagarcia/grape-idempotency/pull/9): Conflict response had invalid format. - [@jcagarcia](https://github.com/jcagarcia).
|
41
|
+
|
42
|
+
## [0.1.2] - 2023-11-06
|
15
43
|
|
16
44
|
### Fix
|
17
45
|
|
18
|
-
- Return correct original response when the endpoint returns a hash in the body
|
46
|
+
* [#5](https://github.com/jcagarcia/grape-idempotency/pull/5): Return correct original response when the endpoint returns a hash in the body - [@jcagarcia](https://github.com/jcagarcia).
|
19
47
|
|
20
|
-
## [0.1.1] - 2023-
|
48
|
+
## [0.1.1] - 2023-11-06
|
21
49
|
|
22
50
|
### Fix
|
23
51
|
|
24
|
-
- Return `409 - Conflict` response if idempotency key is provided for same query and body parameters BUT different endpoints.
|
25
|
-
- Use `nx: true` when storing the original request in the Redis storage for only setting the key if it does not already exist.
|
52
|
+
* [#4](https://github.com/jcagarcia/grape-idempotency/pull/4): Return `409 - Conflict` response if idempotency key is provided for same query and body parameters BUT different endpoints. - [@jcagarcia](https://github.com/jcagarcia).
|
53
|
+
* [#4](https://github.com/jcagarcia/grape-idempotency/pull/4): Use `nx: true` when storing the original request in the Redis storage for only setting the key if it does not already exist. - [@jcagarcia](https://github.com/jcagarcia).
|
26
54
|
|
27
55
|
### Changed
|
28
56
|
|
29
|
-
- Include `idempotency-key` in the response headers
|
30
|
-
|
57
|
+
* [#4](https://github.com/jcagarcia/grape-idempotency/pull/4): Include `idempotency-key` in the response headers - [@jcagarcia](https://github.com/jcagarcia).
|
58
|
+
* In the case of a concurrency error when storing the request into the redis storage (because now `nx: true`), a new idempotency key will be generated, so the consumer can check the new one seeing the headers.
|
31
59
|
|
32
|
-
## [0.1.0] - 2023-
|
60
|
+
## [0.1.0] - 2023-11-03
|
33
61
|
|
34
|
-
- Initial version
|
62
|
+
* [#1](https://github.com/jcagarcia/grape-idempotency/pull/1): Initial version - [@jcagarcia](https://github.com/jcagarcia).
|
data/README.md
CHANGED
@@ -5,11 +5,15 @@
|
|
5
5
|
|
6
6
|
Gem for supporting idempotency in your [Grape](https://github.com/ruby-grape/grape) APIs.
|
7
7
|
|
8
|
+
Implementation based on the Active Internet-Draft [draft-ietf-httpapi-idempotency-key-header-04](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/04/)
|
9
|
+
|
8
10
|
Topics covered in this README:
|
9
11
|
|
10
12
|
- [Installation](#installation-)
|
11
13
|
- [Basic Usage](#basic-usage-)
|
12
14
|
- [How it works](#how-it-works-)
|
15
|
+
- [Making idempotency key header mandatory](#making-idempotency-key-header-mandatory-)
|
16
|
+
- [Redis Storage Connectivity Issue](#redis-storage-connectivity-issue)
|
13
17
|
- [Configuration](#configuration-)
|
14
18
|
- [Changelog](#changelog)
|
15
19
|
- [Contributing](#contributing)
|
@@ -63,7 +67,7 @@ That's all! 🚀
|
|
63
67
|
|
64
68
|
## How it works 🤔
|
65
69
|
|
66
|
-
Once you've set up the gem and enclosed your endpoint code within the `idempotent` method, your endpoint will exhibit idempotent behavior, but this will only occur if the consumer of the endpoint includes an idempotency key in their request.
|
70
|
+
Once you've set up the gem and enclosed your endpoint code within the `idempotent` method, your endpoint will exhibit idempotent behavior, but this will only occur if the consumer of the endpoint includes an idempotency key in their request. (If you want to make the idempotency key header mandatory for your endpoint, check [How to make idempotency key header mandatory](#making-idempotency-key-header-mandatory-) section)
|
67
71
|
|
68
72
|
This key allows your consumer to make the same request again in case of a connection error, without the risk of creating a duplicate object or executing the update twice.
|
69
73
|
|
@@ -71,12 +75,76 @@ To execute an idempotent request, simply request your user to include an extra `
|
|
71
75
|
|
72
76
|
This gem operates by storing the initial request's status code and response body, regardless of whether the request succeeded or failed, using a specific idempotency key. Subsequent requests with the same key will consistently yield the same result, even if there were 500 errors.
|
73
77
|
|
74
|
-
Keys are automatically removed from the system if they are at least 24 hours old, and a new request is generated when a key is reused after the original has been removed. The idempotency layer compares incoming parameters to those of the original request and returns a `
|
78
|
+
Keys are automatically removed from the system if they are at least 24 hours old, and a new request is generated when a key is reused after the original has been removed. The idempotency layer compares incoming parameters to those of the original request and returns a `422 - Unprocessable Entity` status code if they don't match, preventing accidental misuse.
|
79
|
+
If a request is received while another one with the same idempotency key is still being processed the idempotency layer returns a `409 - Conflict` status
|
75
80
|
|
76
81
|
Results are only saved if an API endpoint begins its execution. If incoming parameters fail validation or if the request conflicts with another one executing concurrently, no idempotent result is stored because no API endpoint has initiated execution. In such cases, retrying these requests is safe.
|
77
82
|
|
78
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.
|
79
84
|
|
85
|
+
### Making idempotency key header mandatory ⚠️
|
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`:
|
88
|
+
|
89
|
+
```ruby
|
90
|
+
require 'grape'
|
91
|
+
require 'grape-idempotency'
|
92
|
+
|
93
|
+
class API < Grape::API
|
94
|
+
post '/payments' do
|
95
|
+
idempotent(required: true) do
|
96
|
+
status 201
|
97
|
+
Payment.create!({
|
98
|
+
amount: params[:amount]
|
99
|
+
})
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
```
|
105
|
+
|
106
|
+
If the Idempotency-Key request header is missing for a idempotent operation requiring this header, the gem will reply with an HTTP 400 status code with the following body:
|
107
|
+
|
108
|
+
```json
|
109
|
+
{
|
110
|
+
"title": "Idempotency-Key is missing",
|
111
|
+
"detail": "This operation is idempotent and it requires correct usage of Idempotency Key.",
|
112
|
+
}
|
113
|
+
```
|
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.
|
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
|
+
|
80
148
|
## Configuration 🪚
|
81
149
|
|
82
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.
|
@@ -124,6 +192,56 @@ end
|
|
124
192
|
|
125
193
|
In the case above, you request your consumers to use the `X-Trace-Id: <trace-id>` header when requesting your API.
|
126
194
|
|
195
|
+
### logger, logger_level and logger_prefix
|
196
|
+
|
197
|
+
By default, the logger used by the gem is configured like `Logger.new(STDOUT)` and `INFO` level. As this gem does not log any message with `INFO` level, only `ERROR` messages will be logged.
|
198
|
+
|
199
|
+
|
200
|
+
If you want to provide your own logger, you want to change the level to `DEBUG` or you want to provide your own prefix, you can configure the gem like:
|
201
|
+
|
202
|
+
```ruby
|
203
|
+
Grape::Idempotency.configure do |c|
|
204
|
+
c.storage = @storage
|
205
|
+
c.logger = Infrastructure::MyLogger.new
|
206
|
+
c.logger_level = :debug
|
207
|
+
c.logger_prefix = '[my-own-prefix]'
|
208
|
+
end
|
209
|
+
```
|
210
|
+
|
211
|
+
An example of the logged information when changing the level of the log to `DEBUG` and customizing the `logger_prefix`:
|
212
|
+
|
213
|
+
```shell
|
214
|
+
I, [2023-11-23T22:41:39.148163 #1] DEBUG -- : [my-own-prefix] Performing endpoint "/payments" with idempotency.
|
215
|
+
I, [2023-11-23T22:41:39.148176 #1] DEBUG -- : [my-own-prefix] Idempotency key is NOT mandatory for this endpoint.
|
216
|
+
I, [2023-11-23T22:41:39.148192 #1] DEBUG -- : [my-own-prefix] Idempotency key received in request header "x-custom-idempotency-key" => "fd77c9d6-b7da-4966-aac8-40ee258f24aa"
|
217
|
+
I, [2023-11-23T22:41:39.148210 #1] DEBUG -- : [my-own-prefix] Previous request information has NOT been found for the provided idempotency key.
|
218
|
+
I, [2023-11-23T22:41:39.148248 #1] DEBUG -- : [my-own-prefix] Request stored as processing.
|
219
|
+
I, [2023-11-23T22:41:39.148261 #1] DEBUG -- : [my-own-prefix] Performing the provided block.
|
220
|
+
I, [2023-11-23T22:41:39.148268 #1] DEBUG -- : [my-own-prefix] Block has been performed.
|
221
|
+
I, [2023-11-23T22:41:39.148287 #1] DEBUG -- : [my-own-prefix] Storing response.
|
222
|
+
I, [2023-11-23T22:41:39.148317 #1] DEBUG -- : [my-own-prefix] Response stored.
|
223
|
+
I, [2023-11-23T22:41:39.148473 #1] DEBUG -- : [my-own-prefix] Performing endpoint "/payments" with idempotency.
|
224
|
+
I, [2023-11-23T22:41:39.148486 #1] DEBUG -- : [my-own-prefix] Idempotency key is NOT mandatory for this endpoint.
|
225
|
+
I, [2023-11-23T22:41:39.148502 #1] DEBUG -- : [my-own-prefix] Idempotency key received in request header "x-custom-idempotency-key" => "fd77c9d6-b7da-4966-aac8-40ee258f24aa"
|
226
|
+
I, [2023-11-23T22:41:39.148523 #1] DEBUG -- : [my-own-prefix] Request has been found for the provided idempotency key => {"path"=>"/payments", "params"=>{"locale"=>"undefined", "{\"amount\":10000}"=>nil}, "status"=>500, "original_request"=>"wadus", "response"=>"{\"error\":\"Internal Server Error\"}"}
|
227
|
+
I, [2023-11-23T22:41:39.148537 #1] DEBUG -- : [my-own-prefix] Returning the response from the original request.
|
228
|
+
```
|
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
|
+
|
127
245
|
### conflict_error_response
|
128
246
|
|
129
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:
|
@@ -131,7 +249,8 @@ When providing a `Idempotency-Key: <key>` header, this gem compares incoming par
|
|
131
249
|
```json
|
132
250
|
{
|
133
251
|
|
134
|
-
"
|
252
|
+
"title": "Idempotency-Key is already used",
|
253
|
+
"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."
|
135
254
|
}
|
136
255
|
```
|
137
256
|
|
@@ -151,6 +270,61 @@ end
|
|
151
270
|
|
152
271
|
In the configuration above, the error is following the [RFC-7807](https://datatracker.ietf.org/doc/html/rfc7807) format.
|
153
272
|
|
273
|
+
### processing_response
|
274
|
+
|
275
|
+
When a request with a `Idempotency-Key: <key>` header is performed while a previous one still on going with the same idempotency value, this gem returns a `409 - Conflict` status. The response body returned by the gem looks like:
|
276
|
+
|
277
|
+
```json
|
278
|
+
{
|
279
|
+
|
280
|
+
"title": "A request is outstanding for this Idempotency-Key",
|
281
|
+
"detail": "A request with the same idempotent key for the same operation is being processed or is outstanding."
|
282
|
+
}
|
283
|
+
```
|
284
|
+
|
285
|
+
You have the option to specify the desired response body to be returned to your users when this error occurs. This allows you to align the error format with the one used in your application.
|
286
|
+
|
287
|
+
```ruby
|
288
|
+
Grape::Idempotency.configure do |c|
|
289
|
+
c.storage = @storage
|
290
|
+
c.processing_response = {
|
291
|
+
"type": "about:blank",
|
292
|
+
"status": 409,
|
293
|
+
"title": "A request is still being processed",
|
294
|
+
"detail": "A request with the same idempotent key is being procesed"
|
295
|
+
}
|
296
|
+
end
|
297
|
+
```
|
298
|
+
|
299
|
+
In the configuration above, the error is following the [RFC-7807](https://datatracker.ietf.org/doc/html/rfc7807) format.
|
300
|
+
|
301
|
+
### mandatory_header_response
|
302
|
+
|
303
|
+
If the Idempotency-Key request header is missing for a idempotent operation requiring this header, the gem will reply with an HTTP 400 status code with the following body:
|
304
|
+
|
305
|
+
```json
|
306
|
+
{
|
307
|
+
"title": "Idempotency-Key is missing",
|
308
|
+
"detail": "This operation is idempotent and it requires correct usage of Idempotency Key.",
|
309
|
+
}
|
310
|
+
```
|
311
|
+
|
312
|
+
You have the option to specify the desired response body to be returned to your users when this error occurs. This allows you to align the error format with the one used in your application.
|
313
|
+
|
314
|
+
```ruby
|
315
|
+
Grape::Idempotency.configure do |c|
|
316
|
+
c.storage = @storage
|
317
|
+
c.mandatory_header_response = {
|
318
|
+
"type": "about:blank",
|
319
|
+
"status": 400,
|
320
|
+
"title": "Idempotency-Key is missing",
|
321
|
+
"detail": "Please, provide a valid idempotent key in the headers for performing this operation"
|
322
|
+
}
|
323
|
+
end
|
324
|
+
```
|
325
|
+
|
326
|
+
In the configuration above, the error is following the [RFC-7807](https://datatracker.ietf.org/doc/html/rfc7807) format.
|
327
|
+
|
154
328
|
## Changelog
|
155
329
|
|
156
330
|
If you're interested in seeing the changes and bug fixes between each version of `grape-idempotency`, read the [Changelog](https://github.com/jcagarcia/grape-idempotency/blob/main/CHANGELOG.md).
|
@@ -193,4 +367,5 @@ Open issues on the GitHub issue tracker with clear information.
|
|
193
367
|
|
194
368
|
### Contributors
|
195
369
|
|
196
|
-
*
|
370
|
+
* Juan Carlos García - Creator - https://github.com/jcagarcia
|
371
|
+
* Carlos Cabanero - Contributor - https://github.com/Flip120
|
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'
|
@@ -3,14 +3,18 @@ require 'grape/middleware/base'
|
|
3
3
|
module Grape
|
4
4
|
module Middleware
|
5
5
|
class Error < Base
|
6
|
-
def run_rescue_handler(handler, error)
|
6
|
+
def run_rescue_handler(handler, error, endpoint=nil)
|
7
7
|
if handler.instance_of?(Symbol)
|
8
8
|
raise NoMethodError, "undefined method '#{handler}'" unless respond_to?(handler)
|
9
9
|
|
10
10
|
handler = public_method(handler)
|
11
11
|
end
|
12
12
|
|
13
|
-
|
13
|
+
if endpoint
|
14
|
+
response = handler.arity.zero? ? endpoint.instance_exec(&handler) : endpoint.instance_exec(error, &handler)
|
15
|
+
else
|
16
|
+
response = handler.arity.zero? ? instance_exec(&handler) : instance_exec(error, &handler)
|
17
|
+
end
|
14
18
|
|
15
19
|
if response.is_a?(Rack::Response)
|
16
20
|
update_idempotency_error_with(error, response)
|
data/lib/grape/idempotency.rb
CHANGED
@@ -1,4 +1,6 @@
|
|
1
1
|
require 'grape'
|
2
|
+
require 'redis'
|
3
|
+
require 'logger'
|
2
4
|
require 'securerandom'
|
3
5
|
require 'grape/idempotency/version'
|
4
6
|
require 'grape/idempotency/middleware/error'
|
@@ -21,46 +23,86 @@ module Grape
|
|
21
23
|
@configuration = clean_configuration
|
22
24
|
end
|
23
25
|
|
24
|
-
def idempotent(grape, &block)
|
26
|
+
def idempotent(grape, required: false, &block)
|
25
27
|
validate_config!
|
28
|
+
log(:debug, "Performing endpoint \"#{grape.request.path}\" with idempotency.")
|
29
|
+
log(:debug, "Idempotency key is #{!required ? 'NOT' : ''} mandatory for this endpoint.")
|
26
30
|
|
27
31
|
idempotency_key = get_idempotency_key(grape.request.headers)
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
32
|
+
log(:debug, "Idempotency key received in request header \"#{configuration.idempotency_key_header.downcase}\" => \"#{idempotency_key}\"") if idempotency_key
|
33
|
+
|
34
|
+
grape.error!(configuration.mandatory_header_response, 400) if required && !idempotency_key
|
35
|
+
return block.call if !idempotency_key
|
36
|
+
|
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}")
|
41
|
+
log(:debug, "Returning conflict error response.")
|
42
|
+
grape.error!(configuration.conflict_error_response, 422)
|
43
|
+
elsif stored_request && stored_request["processing"] == true
|
44
|
+
log(:debug, "Returning processing error response.")
|
45
|
+
grape.error!(configuration.processing_response, 409)
|
46
|
+
elsif stored_request
|
47
|
+
log(:debug, "Returning the response from the original request.")
|
48
|
+
grape.status stored_request["status"]
|
49
|
+
grape.header(ORIGINAL_REQUEST_HEADER, stored_request["original_request"])
|
37
50
|
grape.header(configuration.idempotency_key_header, idempotency_key)
|
38
|
-
return
|
51
|
+
return stored_request["response"]
|
39
52
|
end
|
40
53
|
|
54
|
+
log(:debug, "Previous request information has NOT been found for the provided idempotency key.")
|
55
|
+
|
56
|
+
original_request_id = get_request_id(grape.request.headers)
|
57
|
+
success = store_processing_request(idempotency_key, grape.request.path, grape.request.params, original_request_id)
|
58
|
+
if !success
|
59
|
+
log(:error, "Request NOT stored as processing. Concurrent requests for the same idempotency key appeared.")
|
60
|
+
grape.error!(configuration.processing_response, 409)
|
61
|
+
else
|
62
|
+
log(:debug, "Request stored as processing.")
|
63
|
+
end
|
64
|
+
|
65
|
+
log(:debug, "Performing the provided block.")
|
66
|
+
|
41
67
|
response = catch(:error) do
|
42
68
|
block.call
|
43
69
|
end
|
44
70
|
|
45
|
-
|
71
|
+
log(:debug, "Block has been performed.")
|
72
|
+
|
73
|
+
if is_an_error?(response)
|
74
|
+
log(:debug, "An error response was returned by the performed block. => #{response}")
|
75
|
+
response = response[:message]
|
76
|
+
end
|
46
77
|
|
47
|
-
original_request_id = get_request_id(grape.request.headers)
|
48
78
|
grape.header(ORIGINAL_REQUEST_HEADER, original_request_id)
|
49
79
|
grape.body response
|
80
|
+
rescue Redis::BaseError => e
|
81
|
+
raise
|
50
82
|
rescue => e
|
51
|
-
|
83
|
+
log(:debug, "An unexpected error was raised when performing the block.")
|
84
|
+
if !stored_request && !response
|
52
85
|
validate_config!
|
86
|
+
log(:debug, "Storing error response.")
|
53
87
|
original_request_id = get_request_id(grape.request.headers)
|
54
88
|
stored_key = store_error_request(idempotency_key, grape.request.path, grape.request.params, grape.status, original_request_id, e)
|
55
|
-
|
56
|
-
|
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
|
57
94
|
end
|
95
|
+
log(:debug, "Re-raising the error.")
|
58
96
|
raise
|
59
97
|
ensure
|
60
|
-
if !
|
98
|
+
if !stored_request && response
|
61
99
|
validate_config!
|
62
|
-
|
63
|
-
grape.
|
100
|
+
log(:debug, "Storing response.")
|
101
|
+
stored_key = store_request_response(idempotency_key, grape.request.path, grape.request.params, grape.status, original_request_id, response)
|
102
|
+
if stored_key
|
103
|
+
log(:debug, "Response stored.")
|
104
|
+
grape.header(configuration.idempotency_key_header, stored_key)
|
105
|
+
end
|
64
106
|
end
|
65
107
|
end
|
66
108
|
|
@@ -76,18 +118,24 @@ module Grape
|
|
76
118
|
params = request_with_unmanaged_error["params"]
|
77
119
|
original_request_id = request_with_unmanaged_error["original_request"]
|
78
120
|
|
79
|
-
|
121
|
+
store_request_response(idempotency_key, path, params, status, original_request_id, response)
|
80
122
|
storage.del(stored_error[:error_key])
|
123
|
+
rescue Redis::BaseError => e
|
124
|
+
log(:error, "Storage error => #{e.message} - #{e}")
|
125
|
+
nil
|
81
126
|
end
|
82
127
|
|
83
128
|
private
|
84
129
|
|
85
130
|
def validate_config!
|
86
|
-
storage
|
131
|
+
raise Configuration::Error.new("A Redis instance must be configured as cache storage") unless valid_storage?
|
132
|
+
end
|
87
133
|
|
88
|
-
|
89
|
-
|
90
|
-
|
134
|
+
def valid_storage?
|
135
|
+
configuration.storage &&
|
136
|
+
configuration.storage.respond_to?(:get) &&
|
137
|
+
configuration.storage.respond_to?(:set) &&
|
138
|
+
configuration.storage.respond_to?(:del)
|
91
139
|
end
|
92
140
|
|
93
141
|
def get_idempotency_key(headers)
|
@@ -106,29 +154,46 @@ module Grape
|
|
106
154
|
request_id || "req_#{SecureRandom.hex}"
|
107
155
|
end
|
108
156
|
|
109
|
-
def
|
157
|
+
def get_from_storage(idempotency_key)
|
110
158
|
value = storage.get(key(idempotency_key))
|
111
159
|
return unless value
|
112
160
|
|
113
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
|
114
166
|
end
|
115
167
|
|
116
|
-
def
|
168
|
+
def store_processing_request(idempotency_key, path, params, request_id)
|
169
|
+
body = {
|
170
|
+
path: path,
|
171
|
+
params: params,
|
172
|
+
original_request: request_id,
|
173
|
+
processing: true
|
174
|
+
}
|
175
|
+
|
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
|
180
|
+
end
|
181
|
+
|
182
|
+
def store_request_response(idempotency_key, path, params, status, request_id, response)
|
117
183
|
body = {
|
118
184
|
path: path,
|
119
185
|
params: params,
|
120
186
|
status: status,
|
121
187
|
original_request: request_id,
|
122
188
|
response: response
|
123
|
-
}
|
189
|
+
}
|
124
190
|
|
125
|
-
|
191
|
+
storage.set(key(idempotency_key), body.to_json, ex: configuration.expires_in, nx: false)
|
126
192
|
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
end
|
193
|
+
idempotency_key
|
194
|
+
rescue Redis::BaseError => e
|
195
|
+
log(:error, "Storage error => #{e.message} - #{e}")
|
196
|
+
idempotency_key
|
132
197
|
end
|
133
198
|
|
134
199
|
def store_error_request(idempotency_key, path, params, status, request_id, error)
|
@@ -143,13 +208,12 @@ module Grape
|
|
143
208
|
}
|
144
209
|
}.to_json
|
145
210
|
|
146
|
-
|
211
|
+
storage.set(error_key(idempotency_key), body, ex: 30, nx: false)
|
147
212
|
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
end
|
213
|
+
idempotency_key
|
214
|
+
rescue Redis::BaseError => e
|
215
|
+
log(:error, "Storage error => #{e.message} - #{e}")
|
216
|
+
idempotency_key
|
153
217
|
end
|
154
218
|
|
155
219
|
def get_error_request_for(error)
|
@@ -169,6 +233,9 @@ module Grape
|
|
169
233
|
}
|
170
234
|
end
|
171
235
|
end.first
|
236
|
+
rescue Redis::BaseError => e
|
237
|
+
log(:error, "Storage error => #{e.message} - #{e}")
|
238
|
+
nil
|
172
239
|
end
|
173
240
|
|
174
241
|
def is_an_error?(response)
|
@@ -191,37 +258,54 @@ module Grape
|
|
191
258
|
"grape:idempotency:"
|
192
259
|
end
|
193
260
|
|
194
|
-
def random_idempotency_key
|
195
|
-
tentative_key = SecureRandom.uuid
|
196
|
-
already_existing_key = storage.get(key(tentative_key))
|
197
|
-
if already_existing_key
|
198
|
-
return random_idempotency_key
|
199
|
-
else
|
200
|
-
return tentative_key
|
201
|
-
end
|
202
|
-
end
|
203
|
-
|
204
261
|
def storage
|
205
262
|
configuration.storage
|
206
263
|
end
|
207
264
|
|
265
|
+
def log(level, msg)
|
266
|
+
logger.send(level, "#{configuration.logger_prefix} #{msg}")
|
267
|
+
end
|
268
|
+
|
269
|
+
def logger
|
270
|
+
return @logger if @logger
|
271
|
+
|
272
|
+
@logger = configuration.logger
|
273
|
+
@logger.level = configuration.logger_level
|
274
|
+
@logger
|
275
|
+
end
|
276
|
+
|
208
277
|
def configuration
|
209
278
|
@configuration ||= Configuration.new
|
210
279
|
end
|
211
280
|
end
|
212
281
|
|
213
282
|
class Configuration
|
214
|
-
attr_accessor :storage, :
|
283
|
+
attr_accessor :storage, :logger, :logger_level, :logger_prefix, :expires_in, :idempotency_key_header,
|
284
|
+
:request_id_header, :conflict_error_response, :processing_response, :mandatory_header_response,
|
285
|
+
:manage_redis_exceptions
|
215
286
|
|
216
287
|
class Error < StandardError; end
|
217
288
|
|
218
289
|
def initialize
|
219
290
|
@storage = nil
|
291
|
+
@logger = Logger.new(STDOUT)
|
292
|
+
@logger_level = Logger::INFO
|
293
|
+
@logger_prefix = "[grape-idempotency]"
|
220
294
|
@expires_in = 216_000
|
221
295
|
@idempotency_key_header = "idempotency-key"
|
222
296
|
@request_id_header = "x-request-id"
|
223
|
-
@
|
224
|
-
|
297
|
+
@manage_redis_exceptions = false
|
298
|
+
@conflict_error_response = {
|
299
|
+
"title" => "Idempotency-Key is already used",
|
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."
|
301
|
+
}
|
302
|
+
@processing_response = {
|
303
|
+
"title" => "A request is outstanding for this Idempotency-Key",
|
304
|
+
"detail" => "A request with the same idempotent key for the same operation is being processed or is outstanding."
|
305
|
+
}
|
306
|
+
@mandatory_header_response = {
|
307
|
+
"title" => "Idempotency-Key is missing",
|
308
|
+
"detail" => "This operation is idempotent and it requires correct usage of Idempotency Key."
|
225
309
|
}
|
226
310
|
end
|
227
311
|
end
|
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:
|
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
|