grape-idempotency 0.1.2 → 1.0.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: 615bae812ada064af7880ab0553e2608a7f3386ce8c54f437d6b5048e565f256
4
- data.tar.gz: 587cc1a7c3a679e347a26a6971c68ece90c836abdade401aa412abedcc5a0f0d
3
+ metadata.gz: 756cff52967b35d996913c3d682847a4cd362c2cbd4a95d8bf766c2f037d86ce
4
+ data.tar.gz: bf87694a06895bfd09749676574a078f71b698f59c23211a438112b22f332f06
5
5
  SHA512:
6
- metadata.gz: 44b64241b9f09c5a252a91eba6747da4f77cc590163410ed0396f49ac64016f86e8225c2730ed2cf9545764f3fc3098585e49493fe712e241dda2b7ee674140e
7
- data.tar.gz: 3bd18355c4d7053e1d77b283e45c41f670c8a06048f8a37e3f3b1344a51dec704b659e1170e4e2f3fc85c4afd5431993e7272109fe9ac1acca2d112850528ce4
6
+ metadata.gz: bdaaa014a0a39fdbba3f850bdddbd27db5fca791f543609c4a847615eaeeac52d0dfb16e642d1310915bbdada5b46267291a1e43fec70299a027c5a9848285b2
7
+ data.tar.gz: 2030a6016fcbaf64667b22a2be43af70239381e6d3311571e45c172d439bb67222ca57f2566becd847eb867d8ae9750ddcef70e1dd2444492191e2924f27e7cd
data/CHANGELOG.md CHANGED
@@ -4,25 +4,44 @@ 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.2] - 2023-01-06
7
+ ## [1.0.0] - 2023-11-23
8
+
9
+ ### Changed
10
+
11
+ * [#11](https://github.com/jcagarcia/grape-idempotency/pull/11): Changing error response formats - [@Flip120](https://github.com/Flip120).
12
+ * [#16](https://github.com/jcagarcia/grape-idempotency/pull/16): Changing error response code to 422 for conflict - [@Flip120](https://github.com/Flip120).
13
+
14
+ ### Feature
15
+
16
+ * [#11](https://github.com/jcagarcia/grape-idempotency/pull/11): Return 409 conflict when a request is still being processed - [@Flip120](https://github.com/Flip120).
17
+ * [#15](https://github.com/jcagarcia/grape-idempotency/pull/15): Allow to mark the idempotent header as required - [@jcagarcia](https://github.com/jcagarcia).
18
+ * [#17](https://github.com/jcagarcia/grape-idempotency/pull/17): Allow to configure logger - [@jcagarcia](https://github.com/jcagarcia).
19
+
20
+ ## [0.1.3] - 2023-11-07
8
21
 
9
22
  ### Fix
10
23
 
11
- - Return correct original response when the endpoint returns a hash in the body
24
+ * [#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).
25
+ - [#9](https://github.com/jcagarcia/grape-idempotency/pull/9): Conflict response had invalid format. - [@jcagarcia](https://github.com/jcagarcia).
26
+
27
+ ## [0.1.2] - 2023-11-06
28
+
29
+ ### Fix
12
30
 
31
+ * [#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).
13
32
 
14
- ## [0.1.1] - 2023-01-06
33
+ ## [0.1.1] - 2023-11-06
15
34
 
16
35
  ### Fix
17
36
 
18
- - Return `409 - Conflict` response if idempotency key is provided for same query and body parameters BUT different endpoints.
19
- - Use `nx: true` when storing the original request in the Redis storage for only setting the key if it does not already exist.
37
+ * [#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).
38
+ * [#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).
20
39
 
21
40
  ### Changed
22
41
 
23
- - Include `idempotency-key` in the response headers
24
- - 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.
42
+ * [#4](https://github.com/jcagarcia/grape-idempotency/pull/4): Include `idempotency-key` in the response headers - [@jcagarcia](https://github.com/jcagarcia).
43
+ * 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.
25
44
 
26
- ## [0.1.0] - 2023-01-03
45
+ ## [0.1.0] - 2023-11-03
27
46
 
28
- - Initial version
47
+ * [#1](https://github.com/jcagarcia/grape-idempotency/pull/1): Initial version - [@jcagarcia](https://github.com/jcagarcia).
data/README.md CHANGED
@@ -5,11 +5,14 @@
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-)
13
16
  - [Configuration](#configuration-)
14
17
  - [Changelog](#changelog)
15
18
  - [Contributing](#contributing)
@@ -63,7 +66,7 @@ That's all! 🚀
63
66
 
64
67
  ## How it works 🤔
65
68
 
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.
69
+ 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
70
 
68
71
  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
72
 
@@ -71,12 +74,45 @@ To execute an idempotent request, simply request your user to include an extra `
71
74
 
72
75
  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
76
 
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.
77
+ 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.
78
+ 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
79
 
76
80
  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
81
 
78
82
  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
83
 
84
+ ## Making idempotency key header mandatory ⚠️
85
+
86
+ 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
+ ```ruby
89
+ require 'grape'
90
+ require 'grape-idempotency'
91
+
92
+ class API < Grape::API
93
+ post '/payments' do
94
+ idempotent(required: true) do
95
+ status 201
96
+ Payment.create!({
97
+ amount: params[:amount]
98
+ })
99
+ end
100
+ end
101
+ end
102
+ end
103
+ ```
104
+
105
+ 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:
106
+
107
+ ```json
108
+ {
109
+ "title": "Idempotency-Key is missing",
110
+ "detail": "This operation is idempotent and it requires correct usage of Idempotency Key.",
111
+ }
112
+ ```
113
+
114
+ 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
+
80
116
  ## Configuration 🪚
81
117
 
82
118
  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 +160,40 @@ end
124
160
 
125
161
  In the case above, you request your consumers to use the `X-Trace-Id: <trace-id>` header when requesting your API.
126
162
 
163
+ ### logger, logger_level and logger_prefix
164
+
165
+ 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.
166
+
167
+
168
+ 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:
169
+
170
+ ```ruby
171
+ Grape::Idempotency.configure do |c|
172
+ c.logger = Infrastructure::MyLogger.new
173
+ c.logger_level = :debug
174
+ c.logger_prefix = '[my-own-prefix]'
175
+ end
176
+ ```
177
+
178
+ An example of the logged information when changing the level of the log to `DEBUG` and customizing the `logger_prefix`:
179
+
180
+ ```shell
181
+ I, [2023-11-23T22:41:39.148163 #1] DEBUG -- : [my-own-prefix] Performing endpoint "/payments" with idempotency.
182
+ I, [2023-11-23T22:41:39.148176 #1] DEBUG -- : [my-own-prefix] Idempotency key is NOT mandatory for this endpoint.
183
+ 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"
184
+ I, [2023-11-23T22:41:39.148210 #1] DEBUG -- : [my-own-prefix] Previous request information has NOT been found for the provided idempotency key.
185
+ I, [2023-11-23T22:41:39.148248 #1] DEBUG -- : [my-own-prefix] Request stored as processing.
186
+ I, [2023-11-23T22:41:39.148261 #1] DEBUG -- : [my-own-prefix] Performing the provided block.
187
+ I, [2023-11-23T22:41:39.148268 #1] DEBUG -- : [my-own-prefix] Block has been performed.
188
+ I, [2023-11-23T22:41:39.148287 #1] DEBUG -- : [my-own-prefix] Storing response.
189
+ I, [2023-11-23T22:41:39.148317 #1] DEBUG -- : [my-own-prefix] Response stored.
190
+ I, [2023-11-23T22:41:39.148473 #1] DEBUG -- : [my-own-prefix] Performing endpoint "/payments" with idempotency.
191
+ I, [2023-11-23T22:41:39.148486 #1] DEBUG -- : [my-own-prefix] Idempotency key is NOT mandatory for this endpoint.
192
+ 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"
193
+ 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\"}"}
194
+ I, [2023-11-23T22:41:39.148537 #1] DEBUG -- : [my-own-prefix] Returning the response from the original request.
195
+ ```
196
+
127
197
  ### conflict_error_response
128
198
 
129
199
  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 +201,8 @@ When providing a `Idempotency-Key: <key>` header, this gem compares incoming par
131
201
  ```json
132
202
  {
133
203
 
134
- "error": "You are using the same idempotent key for two different requests"
204
+ "title": "Idempotency-Key is already used",
205
+ "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
206
  }
136
207
  ```
137
208
 
@@ -151,6 +222,61 @@ end
151
222
 
152
223
  In the configuration above, the error is following the [RFC-7807](https://datatracker.ietf.org/doc/html/rfc7807) format.
153
224
 
225
+ ### processing_response
226
+
227
+ 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:
228
+
229
+ ```json
230
+ {
231
+
232
+ "title": "A request is outstanding for this Idempotency-Key",
233
+ "detail": "A request with the same idempotent key for the same operation is being processed or is outstanding."
234
+ }
235
+ ```
236
+
237
+ 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.
238
+
239
+ ```ruby
240
+ Grape::Idempotency.configure do |c|
241
+ c.storage = @storage
242
+ c.processing_response = {
243
+ "type": "about:blank",
244
+ "status": 409,
245
+ "title": "A request is still being processed",
246
+ "detail": "A request with the same idempotent key is being procesed"
247
+ }
248
+ end
249
+ ```
250
+
251
+ In the configuration above, the error is following the [RFC-7807](https://datatracker.ietf.org/doc/html/rfc7807) format.
252
+
253
+ ### mandatory_header_response
254
+
255
+ 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:
256
+
257
+ ```json
258
+ {
259
+ "title": "Idempotency-Key is missing",
260
+ "detail": "This operation is idempotent and it requires correct usage of Idempotency Key.",
261
+ }
262
+ ```
263
+
264
+ 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.
265
+
266
+ ```ruby
267
+ Grape::Idempotency.configure do |c|
268
+ c.storage = @storage
269
+ c.mandatory_header_response = {
270
+ "type": "about:blank",
271
+ "status": 400,
272
+ "title": "Idempotency-Key is missing",
273
+ "detail": "Please, provide a valid idempotent key in the headers for performing this operation"
274
+ }
275
+ end
276
+ ```
277
+
278
+ In the configuration above, the error is following the [RFC-7807](https://datatracker.ietf.org/doc/html/rfc7807) format.
279
+
154
280
  ## Changelog
155
281
 
156
282
  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 +319,5 @@ Open issues on the GitHub issue tracker with clear information.
193
319
 
194
320
  ### Contributors
195
321
 
196
- * Juan Carlos García - Creator - https://github.com/jcagarcia
322
+ * Juan Carlos García - Creator - https://github.com/jcagarcia
323
+ * Carlos Cabanero - Contributor - https://github.com/Flip120
@@ -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
@@ -0,0 +1,40 @@
1
+ require 'grape/middleware/base'
2
+
3
+ module Grape
4
+ module Middleware
5
+ class Error < Base
6
+ def run_rescue_handler(handler, error, endpoint=nil)
7
+ if handler.instance_of?(Symbol)
8
+ raise NoMethodError, "undefined method '#{handler}'" unless respond_to?(handler)
9
+
10
+ handler = public_method(handler)
11
+ end
12
+
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
18
+
19
+ if response.is_a?(Rack::Response)
20
+ update_idempotency_error_with(error, response)
21
+ response
22
+ else
23
+ run_rescue_handler(:default_rescue_handler, Grape::Exceptions::InvalidResponse.new)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def update_idempotency_error_with(error, response)
30
+ begin
31
+ body = JSON.parse(response.body.join)
32
+ rescue JSON::ParserError
33
+ body = response.body.join
34
+ end
35
+
36
+ Grape::Idempotency.update_error_with_rescue_from_result(error, response.status, body)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Grape
4
4
  module Idempotency
5
- VERSION = '0.1.2'
5
+ VERSION = '1.0.0'
6
6
  end
7
7
  end
@@ -1,6 +1,8 @@
1
1
  require 'grape'
2
+ require 'logger'
2
3
  require 'securerandom'
3
4
  require 'grape/idempotency/version'
5
+ require 'grape/idempotency/middleware/error'
4
6
 
5
7
  module Grape
6
8
  module Idempotency
@@ -20,48 +22,107 @@ module Grape
20
22
  @configuration = clean_configuration
21
23
  end
22
24
 
23
- def idempotent(grape, &block)
25
+ def idempotent(grape, required: false, &block)
24
26
  validate_config!
27
+ log(:debug, "Performing endpoint \"#{grape.request.path}\" with idempotency.")
28
+ log(:debug, "Idempotency key is #{!required ? 'NOT' : ''} mandatory for this endpoint.")
25
29
 
26
30
  idempotency_key = get_idempotency_key(grape.request.headers)
27
- return block.call unless idempotency_key
31
+ log(:debug, "Idempotency key received in request header \"#{configuration.idempotency_key_header.downcase}\" => \"#{idempotency_key}\"") if idempotency_key
32
+
33
+ grape.error!(configuration.mandatory_header_response, 400) if required && !idempotency_key
34
+ return block.call if !idempotency_key
28
35
 
29
36
  cached_request = get_from_cache(idempotency_key)
37
+ log(:debug, "Request has been found for the provided idempotency key => #{cached_request}") if cached_request
30
38
  if cached_request && (cached_request["params"] != grape.request.params || cached_request["path"] != grape.request.path)
31
- grape.status 409
32
- return configuration.conflict_error_response.to_json
39
+ log(:debug, "Request has conflicts. Same params? => #{cached_request["params"] != grape.request.params}. Same path? => #{cached_request["path"] != grape.request.path}")
40
+ log(:debug, "Returning conflict error response.")
41
+ grape.error!(configuration.conflict_error_response, 422)
42
+ elsif cached_request && cached_request["processing"] == true
43
+ log(:debug, "Returning processing error response.")
44
+ grape.error!(configuration.processing_response, 409)
33
45
  elsif cached_request
46
+ log(:debug, "Returning the response from the original request.")
34
47
  grape.status cached_request["status"]
35
48
  grape.header(ORIGINAL_REQUEST_HEADER, cached_request["original_request"])
36
49
  grape.header(configuration.idempotency_key_header, idempotency_key)
37
50
  return cached_request["response"]
38
51
  end
39
52
 
53
+ log(:debug, "Previous request information has NOT been found for the provided idempotency key.")
54
+
55
+ original_request_id = get_request_id(grape.request.headers)
56
+ success = store_processing_request(idempotency_key, grape.request.path, grape.request.params, original_request_id)
57
+ if !success
58
+ log(:error, "Request NOT stored as processing. Concurrent requests for the same idempotency key appeared.")
59
+ grape.error!(configuration.processing_response, 409)
60
+ else
61
+ log(:debug, "Request stored as processing.")
62
+ end
63
+
64
+ log(:debug, "Performing the provided block.")
65
+
40
66
  response = catch(:error) do
41
67
  block.call
42
68
  end
43
69
 
44
- response = response[:message].to_json if is_an_error?(response)
70
+ log(:debug, "Block has been performed.")
71
+
72
+ if is_an_error?(response)
73
+ log(:debug, "An error response was returned by the performed block. => #{response}")
74
+ response = response[:message]
75
+ end
45
76
 
46
- original_request_id = get_request_id(grape.request.headers)
47
77
  grape.header(ORIGINAL_REQUEST_HEADER, original_request_id)
48
78
  grape.body response
79
+ rescue => e
80
+ log(:debug, "An unexpected error was raised when performing the block.")
81
+ if !cached_request && !response
82
+ validate_config!
83
+ log(:debug, "Storing error response.")
84
+ original_request_id = get_request_id(grape.request.headers)
85
+ stored_key = store_error_request(idempotency_key, grape.request.path, grape.request.params, grape.status, original_request_id, e)
86
+ log(:debug, "Error response stored.")
87
+ grape.header(ORIGINAL_REQUEST_HEADER, original_request_id)
88
+ grape.header(configuration.idempotency_key_header, stored_key)
89
+ end
90
+ log(:debug, "Re-raising the error.")
91
+ raise
49
92
  ensure
50
- validate_config!
51
- unless cached_request
52
- stored_key = store_in_cache(idempotency_key, grape.request.path, grape.request.params, grape.status, original_request_id, response)
93
+ if !cached_request && response
94
+ validate_config!
95
+ log(:debug, "Storing response.")
96
+ stored_key = store_request_response(idempotency_key, grape.request.path, grape.request.params, grape.status, original_request_id, response)
97
+ log(:debug, "Response stored.")
53
98
  grape.header(configuration.idempotency_key_header, stored_key)
54
99
  end
55
100
  end
56
101
 
102
+ def update_error_with_rescue_from_result(error, status, response)
103
+ validate_config!
104
+
105
+ stored_error = get_error_request_for(error)
106
+ return unless stored_error
107
+
108
+ request_with_unmanaged_error = stored_error[:request]
109
+ idempotency_key = stored_error[:idempotency_key]
110
+ path = request_with_unmanaged_error["path"]
111
+ params = request_with_unmanaged_error["params"]
112
+ original_request_id = request_with_unmanaged_error["original_request"]
113
+
114
+ store_request_response(idempotency_key, path, params, status, original_request_id, response)
115
+ storage.del(stored_error[:error_key])
116
+ end
117
+
57
118
  private
58
119
 
59
120
  def validate_config!
60
- storage = configuration.storage
121
+ raise Configuration::Error.new("A Redis instance must be configured as cache storage") unless valid_storage?
122
+ end
61
123
 
62
- if storage.nil? || !storage.respond_to?(:set)
63
- raise Configuration::Error.new("A Redis instance must be configured as cache storage")
64
- end
124
+ def valid_storage?
125
+ configuration.storage && configuration.storage.respond_to?(:set)
65
126
  end
66
127
 
67
128
  def get_idempotency_key(headers)
@@ -87,22 +148,65 @@ module Grape
87
148
  JSON.parse(value)
88
149
  end
89
150
 
90
- def store_in_cache(idempotency_key, path, params, status, request_id, response)
151
+ def store_processing_request(idempotency_key, path, params, request_id)
152
+ body = {
153
+ path: path,
154
+ params: params,
155
+ original_request: request_id,
156
+ processing: true
157
+ }
158
+
159
+ storage.set(key(idempotency_key), body.to_json, ex: configuration.expires_in, nx: true)
160
+ end
161
+
162
+ def store_request_response(idempotency_key, path, params, status, request_id, response)
91
163
  body = {
92
164
  path: path,
93
165
  params: params,
94
166
  status: status,
95
167
  original_request: request_id,
96
168
  response: response
169
+ }
170
+
171
+ storage.set(key(idempotency_key), body.to_json, ex: configuration.expires_in, nx: false)
172
+
173
+ idempotency_key
174
+ end
175
+
176
+ def store_error_request(idempotency_key, path, params, status, request_id, error)
177
+ body = {
178
+ path: path,
179
+ params: params,
180
+ status: status,
181
+ original_request: request_id,
182
+ error: {
183
+ class_name: error.class.to_s,
184
+ message: error.message
185
+ }
97
186
  }.to_json
98
187
 
99
- result = storage.set(key(idempotency_key), body, ex: configuration.expires_in, nx: true)
188
+ storage.set(error_key(idempotency_key), body, ex: 30, nx: false)
100
189
 
101
- if !result
102
- return store_in_cache(random_idempotency_key, path, params, status, request_id, response)
103
- else
104
- return idempotency_key
105
- end
190
+ idempotency_key
191
+ end
192
+
193
+ def get_error_request_for(error)
194
+ error_keys = storage.keys("#{error_key_prefix}*")
195
+ return if error_keys.empty?
196
+
197
+ error_keys.map do |key|
198
+ request_with_error = JSON.parse(storage.get(key))
199
+ error_class_name = request_with_error["error"]["class_name"]
200
+ error_message = request_with_error["error"]["message"]
201
+
202
+ if error_class_name == error.class.to_s && error_message == error.message
203
+ {
204
+ error_key: key,
205
+ request: request_with_error,
206
+ idempotency_key: key.gsub(error_key_prefix, '')
207
+ }
208
+ end
209
+ end.first
106
210
  end
107
211
 
108
212
  def is_an_error?(response)
@@ -110,40 +214,67 @@ module Grape
110
214
  end
111
215
 
112
216
  def key(idempotency_key)
113
- "grape:idempotency:#{idempotency_key}"
217
+ "#{gem_prefix}#{idempotency_key}"
114
218
  end
115
219
 
116
- def random_idempotency_key
117
- tentative_key = SecureRandom.uuid
118
- already_existing_key = storage.get(key(tentative_key))
119
- if already_existing_key
120
- return random_idempotency_key
121
- else
122
- return tentative_key
123
- end
220
+ def error_key(idempotency_key)
221
+ "#{error_key_prefix}#{idempotency_key}"
222
+ end
223
+
224
+ def error_key_prefix
225
+ "#{gem_prefix}error:"
226
+ end
227
+
228
+ def gem_prefix
229
+ "grape:idempotency:"
124
230
  end
125
231
 
126
232
  def storage
127
233
  configuration.storage
128
234
  end
129
235
 
236
+ def log(level, msg)
237
+ logger.send(level, "#{configuration.logger_prefix} #{msg}")
238
+ end
239
+
240
+ def logger
241
+ return @logger if @logger
242
+
243
+ @logger = configuration.logger
244
+ @logger.level = configuration.logger_level
245
+ @logger
246
+ end
247
+
130
248
  def configuration
131
249
  @configuration ||= Configuration.new
132
250
  end
133
251
  end
134
252
 
135
253
  class Configuration
136
- attr_accessor :storage, :expires_in, :idempotency_key_header, :request_id_header, :conflict_error_response
254
+ 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
137
256
 
138
257
  class Error < StandardError; end
139
258
 
140
259
  def initialize
141
260
  @storage = nil
261
+ @logger = Logger.new(STDOUT)
262
+ @logger_level = Logger::INFO
263
+ @logger_prefix = "[grape-idempotency]"
142
264
  @expires_in = 216_000
143
265
  @idempotency_key_header = "idempotency-key"
144
266
  @request_id_header = "x-request-id"
145
- @conflict_error_response = {
146
- "error" => "You are using the same idempotent key for two different requests"
267
+ @conflict_error_response = {
268
+ "title" => "Idempotency-Key is already used",
269
+ "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."
270
+ }
271
+ @processing_response = {
272
+ "title" => "A request is outstanding for this Idempotency-Key",
273
+ "detail" => "A request with the same idempotent key for the same operation is being processed or is outstanding."
274
+ }
275
+ @mandatory_header_response = {
276
+ "title" => "Idempotency-Key is missing",
277
+ "detail" => "This operation is idempotent and it requires correct usage of Idempotency Key."
147
278
  }
148
279
  end
149
280
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: grape-idempotency
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 1.0.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-06 00:00:00.000000000 Z
11
+ date: 2023-11-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: grape
@@ -98,6 +98,7 @@ files:
98
98
  - lib/grape-idempotency.rb
99
99
  - lib/grape/idempotency.rb
100
100
  - lib/grape/idempotency/helpers.rb
101
+ - lib/grape/idempotency/middleware/error.rb
101
102
  - lib/grape/idempotency/version.rb
102
103
  homepage: https://github.com/jcagarcia/grape-idempotency
103
104
  licenses: