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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7f9a4d3d3a6e885bfa07afd8f0d51dfbfe930853d3919934016e9173f9d418a0
4
- data.tar.gz: 81e8817b09d72d35bb71746a3451e66aed12af38888b169abc0297bb2c3f7481
3
+ metadata.gz: 115100d8cd1141c7c801a1501ede2db9eff3419a5b2974de93c4b79e7c812fd2
4
+ data.tar.gz: 567472c4ab8ad2aae2561d7af0e808c803a025caae34f75492c2b889b6468d7e
5
5
  SHA512:
6
- metadata.gz: 20a9654e3b1086a7dfa77379129f7bc46d18df4eadfa85ee13e7027dd91ffc6f68b83843f0d41212695287b472ebcb6e7c9b68ac322bb46f8ddadbdb9294a6ad
7
- data.tar.gz: 60fc291bbc9558e19b2816614f0584812e08b6846b17b68af7395d15665439cad756034ff4f64d59592f56626d4fa0d5381f8dbcfc91b91294e5d223c8d4a8da
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
- ## [0.1.3] - 2023-01-07
7
+ ## [1.1.0] - (Next)
8
8
 
9
9
  ### Fix
10
10
 
11
- - Second calls were returning `null` when the first response was generated inside a `rescue_from`.
12
- - Conflict response had invalid format.
11
+ * Your contribution here.
13
12
 
14
- ## [0.1.2] - 2023-01-06
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-01-06
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
- - 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.
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-01-03
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 `409 - Conflict` status code if they don't match, preventing accidental misuse.
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
- "error": "You are using the same idempotent key for two different requests"
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
- * Juan Carlos García - Creator - https://github.com/jcagarcia
370
+ * Juan Carlos García - Creator - https://github.com/jcagarcia
371
+ * Carlos Cabanero - Contributor - https://github.com/Flip120
@@ -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', '~> 1'
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'
@@ -1,8 +1,8 @@
1
1
  module Grape
2
2
  module Idempotency
3
3
  module Helpers
4
- def idempotent(&block)
5
- Grape::Idempotency.idempotent(self) do
4
+ def idempotent(required: false, &block)
5
+ Grape::Idempotency.idempotent(self, required: required) do
6
6
  block.call
7
7
  end
8
8
  end
@@ -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
- response = handler.arity.zero? ? instance_exec(&handler) : instance_exec(error, &handler)
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)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Grape
4
4
  module Idempotency
5
- VERSION = '0.1.3'
5
+ VERSION = '1.1.0'
6
6
  end
7
7
  end
@@ -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
- return block.call unless idempotency_key
29
-
30
- cached_request = get_from_cache(idempotency_key)
31
- if cached_request && (cached_request["params"] != grape.request.params || cached_request["path"] != grape.request.path)
32
- grape.status 409
33
- return configuration.conflict_error_response
34
- elsif cached_request
35
- grape.status cached_request["status"]
36
- grape.header(ORIGINAL_REQUEST_HEADER, cached_request["original_request"])
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 cached_request["response"]
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
- response = response[:message] if is_an_error?(response)
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
- if !cached_request && !response
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
- grape.header(ORIGINAL_REQUEST_HEADER, original_request_id)
56
- grape.header(configuration.idempotency_key_header, stored_key)
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 !cached_request && response
98
+ if !stored_request && response
61
99
  validate_config!
62
- stored_key = store_request(idempotency_key, grape.request.path, grape.request.params, grape.status, original_request_id, response)
63
- grape.header(configuration.idempotency_key_header, stored_key)
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
- store_request(idempotency_key, path, params, status, original_request_id, response)
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 = configuration.storage
131
+ raise Configuration::Error.new("A Redis instance must be configured as cache storage") unless valid_storage?
132
+ end
87
133
 
88
- if storage.nil? || !storage.respond_to?(:set)
89
- raise Configuration::Error.new("A Redis instance must be configured as cache storage")
90
- end
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 get_from_cache(idempotency_key)
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 store_request(idempotency_key, path, params, status, request_id, response)
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
- }.to_json
189
+ }
124
190
 
125
- result = storage.set(key(idempotency_key), body, ex: configuration.expires_in, nx: true)
191
+ storage.set(key(idempotency_key), body.to_json, ex: configuration.expires_in, nx: false)
126
192
 
127
- if !result
128
- return store_request(random_idempotency_key, path, params, status, request_id, response)
129
- else
130
- return idempotency_key
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
- result = storage.set(error_key(idempotency_key), body, ex: 30, nx: true)
211
+ storage.set(error_key(idempotency_key), body, ex: 30, nx: false)
147
212
 
148
- if !result
149
- return store_error_request(random_idempotency_key, path, params, status, request_id, error)
150
- else
151
- return idempotency_key
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, :expires_in, :idempotency_key_header, :request_id_header, :conflict_error_response
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
- @conflict_error_response = {
224
- "error" => "You are using the same idempotent key for two different requests"
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: 0.1.3
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-07 00:00:00.000000000 Z
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