grape-idempotency 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e458d533c1b108959144d42615dcfbd9b85f741811a3150e0fd64a103469a734
4
+ data.tar.gz: 55a8de15e2332ff805edd7992dab11ff4bb14576b980ee71e7f699d83fcc6f11
5
+ SHA512:
6
+ metadata.gz: 720d6987f1007f27656a769ef7cc945d04fbbe2355dac226a2dd38c801d935f674ab11c80409a6c850817ce8f641ecf2fc9d3ed5b7dc6fbe66ee61e664843ded
7
+ data.tar.gz: a4fe4ca95116844195429b7d54908aee357c59076095749420cce607dd87da005039e1096a436d85b82e7eaa58944c158910a41ec44780238915bbd22b9ab9cd
data/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+ All changes to `grape-idempotency` will be documented in this file.
3
+
4
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.1.0] - 2023-01-03
8
+
9
+ - Initial version
data/README.md ADDED
@@ -0,0 +1,196 @@
1
+ # grape-idempotency πŸ‡πŸ”
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/grape-idempotency.svg)](https://badge.fury.io/rb/grape-idempotency)
4
+
5
+ Gem for supporting idempotency in your [Grape](https://github.com/ruby-grape/grape) APIs.
6
+
7
+
8
+ Topics covered in this README:
9
+
10
+ - [Installation](#installation-)
11
+ - [Basic Usage](#basic-usage-)
12
+ - [How it works](#how-it-works-)
13
+ - [Configuration](#configuration-)
14
+ - [Changelog](#changelog)
15
+ - [Contributing](#contributing)
16
+
17
+
18
+ ## Installation πŸ§—
19
+
20
+ Ruby 2.6 or newer is required.
21
+ [Grape](https://github.com/ruby-grape/grape) 1 or newer is required.
22
+
23
+ `grape-idempotency` is available as a gem, to install it run:
24
+
25
+ ```bash
26
+ gem install grape-idempotency
27
+ ```
28
+
29
+ ## Basic Usage πŸ“–
30
+
31
+ Configure the `Grape::Idempotency` class with a `Redis` instance.
32
+
33
+ ```ruby
34
+ require 'redis'
35
+ require 'grape-idempotency'
36
+
37
+ redis = Redis.new(host: 'localhost', port: 6379)
38
+ Grape::Idempotency.configure do |c|
39
+ c.storage = redis
40
+ end
41
+ ```
42
+
43
+ Now you can wrap your code inside the `idempotent` method:
44
+
45
+ ```ruby
46
+ require 'grape'
47
+ require 'grape-idempotency'
48
+
49
+ class API < Grape::API
50
+ post '/payments' do
51
+ idempotent do
52
+ status 201
53
+ Payment.create!({
54
+ amount: params[:amount]
55
+ })
56
+ end
57
+ end
58
+ end
59
+ end
60
+ ```
61
+
62
+ That's all! πŸš€
63
+
64
+ ## How it works πŸ€”
65
+
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.
67
+
68
+ 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
+
70
+ To execute an idempotent request, simply request your user to include an extra `Idempotency-Key: <key>` header as part of his request.
71
+
72
+ 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
+
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.
75
+
76
+ 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
+
78
+ Additionally, this gem automatically appends the `Original-Request` header to your API's response, enabling you to trace back to the initial request that generated that specific response.
79
+
80
+ ## Configuration πŸͺš
81
+
82
+ 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.
83
+
84
+ ### expires_in
85
+
86
+ As we have mentioned in the [How it works](#how-it-works-) section, keys are automatically removed from the system if they are at least 24 hours old. However, a 24-hour timeframe may not be suitable for your project. To accommodate your specific needs, you can adjust this duration by using the `expires_in` parameter for configuration:
87
+
88
+ ```ruby
89
+ Grape::Idempotency.configure do |c|
90
+ c.storage = @storage
91
+ c.expires_in = 1800
92
+ end
93
+ ```
94
+
95
+ So with the cofiguration above, the keys will expire in 30 minutes.
96
+
97
+ ### idempotency_key_header
98
+
99
+ As outlined in the [How it works](#how-it-works-) section, in order to perform an idempotent request, you need to instruct your users to include an additional `Idempotency-Key: <key>` header with their request. However, if this header format doesn't align with your project requirements, you have the flexibility to configure the specific header that the gem will examine to determine idempotent behavior:
100
+
101
+ ```ruby
102
+ Grape::Idempotency.configure do |c|
103
+ c.storage = @storage
104
+ c.idempotency_key_header = "x-custom-idempotency-key"
105
+ end
106
+ ```
107
+
108
+ Given the previous configuration, the gem will examine the `X-Custom-Idempotency-Key: <key>` for determine the idempotent behavior.
109
+
110
+ ### request_id_header
111
+
112
+ By default, this gem stores a random hex value as identifier when storing the original request and returns it in all the subsequent requests that use the same idempotency-key as `Original-Request` header in the response.
113
+
114
+ This value can be also provided by your consumer using the `X-Request-Id: <request-id>` header when performing the request to your API.
115
+
116
+ However, if you prefer to use a different format for getting the request identifier, you can configure the header to check using the `request_id_header` parameter:
117
+
118
+ ```ruby
119
+ Grape::Idempotency.configure do |c|
120
+ c.storage = @storage
121
+ c.request_id_header = "x-trace-id"
122
+ end
123
+ ```
124
+
125
+ In the case above, you request your consumers to use the `X-Trace-Id: <trace-id>` header when requesting your API.
126
+
127
+ ### conflict_error_response
128
+
129
+ 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:
130
+
131
+ ```json
132
+ {
133
+
134
+ "error": "You are using the same idempotent key for two different requests"
135
+ }
136
+ ```
137
+
138
+ 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.
139
+
140
+ ```ruby
141
+ Grape::Idempotency.configure do |c|
142
+ c.storage = @storage
143
+ c.conflict_error_response = {
144
+ "type": "about:blank",
145
+ "status": 409,
146
+ "title": "Conflict",
147
+ "detail": "You are using the same idempotent key for two different requests"
148
+ }
149
+ end
150
+ ```
151
+
152
+ In the configuration above, the error is following the [RFC-7807](https://datatracker.ietf.org/doc/html/rfc7807) format.
153
+
154
+ ## Changelog
155
+
156
+ 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).
157
+
158
+ ## Contributing
159
+
160
+ We welcome and appreciate contributions from the open-source community. Before you get started, please take a moment to review the guidelines below.
161
+
162
+ ### How to Contribute
163
+
164
+ 1. Fork the repository.
165
+ 2. Clone the repository to your local machine.
166
+ 3. Create a new branch for your contribution.
167
+ 4. Make your changes and ensure they meet project standards.
168
+ 5. Commit your changes with clear messages.
169
+ 6. Push your branch to your GitHub repository.
170
+ 7. Open a pull request in our repository.
171
+ 8. Participate in code review and address feedback.
172
+ 9. Once approved, your changes will be merged.
173
+
174
+ ### Development
175
+
176
+ This project is dockerized, so be sure you have docker installed in your machine.
177
+
178
+ Once you clone the repository, you can use the `Make` commands to build the project.
179
+
180
+ ```shell
181
+ make build
182
+ ```
183
+
184
+ You can pass the tests running:
185
+
186
+ ```shell
187
+ make test
188
+ ```
189
+
190
+ ### Issue Tracker
191
+
192
+ Open issues on the GitHub issue tracker with clear information.
193
+
194
+ ### Contributors
195
+
196
+ * Juan Carlos GarcΓ­a - Creator - https://github.com/jcagarcia
data/Rakefile ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/core/rake_task'
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+
7
+ task default: :spec
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.push File.expand_path('lib', __dir__)
4
+ require 'grape/idempotency/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'grape-idempotency'
8
+ spec.version = Grape::Idempotency::VERSION
9
+ spec.authors = ['Juan Carlos GarcΓ­a']
10
+ spec.email = ['jugade92@gmail.com']
11
+ spec.description = 'Add idempotency support to your Grape APIs for safely retrying requests without accidentally performing the same operation twice. When creating or updating an object, use an idempotency key. Then, if a connection error occurs, you can safely repeat the request without risk of creating a second object or performing the update twice.'
12
+ spec.summary = 'Gem for supporting idempotency in your Grape APIs'
13
+ spec.homepage = 'https://github.com/jcagarcia/grape-idempotency'
14
+ spec.license = 'MIT'
15
+
16
+ files = Dir["lib/**/*.rb"]
17
+ rootfiles = ["CHANGELOG.md", "grape-idempotency.gemspec", "Rakefile", "README.md"]
18
+
19
+ spec.files = rootfiles + files
20
+ spec.executables = []
21
+ spec.require_paths = ["lib"]
22
+
23
+ spec.required_ruby_version = '>= 2.6'
24
+
25
+ spec.add_runtime_dependency 'grape', '>= 1'
26
+
27
+ spec.add_development_dependency 'bundler', '>= 2.4'
28
+ spec.add_development_dependency 'rspec', '~> 3.12'
29
+ spec.add_development_dependency 'rack-test'
30
+ spec.add_development_dependency 'mock_redis', '~> 0.38'
31
+ end
@@ -0,0 +1,11 @@
1
+ module Grape
2
+ module Idempotency
3
+ module Helpers
4
+ def idempotent(&block)
5
+ Grape::Idempotency.idempotent(self) do
6
+ block.call
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ module Idempotency
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,127 @@
1
+ require 'grape'
2
+ require 'securerandom'
3
+ require 'grape/idempotency/version'
4
+
5
+ module Grape
6
+ module Idempotency
7
+ autoload :Helpers, 'grape/idempotency/helpers'
8
+ Grape::Endpoint.send :include, Grape::Idempotency::Helpers
9
+
10
+ class << self
11
+ ORIGINAL_REQUEST_HEADER = "Original-Request".freeze
12
+
13
+ def configure(&block)
14
+ yield(configuration)
15
+ end
16
+
17
+ def restore_configuration
18
+ clean_configuration = Configuration.new
19
+ clean_configuration.storage = storage
20
+ @configuration = clean_configuration
21
+ end
22
+
23
+ def idempotent(grape, &block)
24
+ validate_config!
25
+
26
+ idempotency_key = get_idempotency_key(grape.request.headers)
27
+ return block.call unless idempotency_key
28
+
29
+ cached_request = get_from_cache(idempotency_key)
30
+ if cached_request && cached_request["params"] != grape.request.params
31
+ grape.status 409
32
+ return configuration.conflict_error_response.to_json
33
+ elsif cached_request
34
+ grape.status cached_request["status"]
35
+ grape.header(ORIGINAL_REQUEST_HEADER, cached_request["original_request"])
36
+ return cached_request["response"]
37
+ end
38
+
39
+ response = catch(:error) do
40
+ block.call
41
+ end
42
+
43
+ if response.is_a?(Hash)
44
+ response = response[:message].to_json
45
+ end
46
+
47
+ original_request_id = get_request_id(grape.request.headers)
48
+ grape.header(ORIGINAL_REQUEST_HEADER, original_request_id)
49
+ response
50
+ ensure
51
+ validate_config!
52
+ store_in_cache(idempotency_key, grape.request.params, grape.status, original_request_id, response) unless cached_request
53
+ end
54
+
55
+ private
56
+
57
+ def validate_config!
58
+ storage = configuration.storage
59
+
60
+ if storage.nil? || !storage.respond_to?(:set)
61
+ raise Configuration::Error.new("A Redis instance must be configured as cache storage")
62
+ end
63
+ end
64
+
65
+ def get_idempotency_key(headers)
66
+ idempotency_key = nil
67
+ headers.each do |key, value|
68
+ idempotency_key = value if key.downcase == configuration.idempotency_key_header.downcase
69
+ end
70
+ idempotency_key
71
+ end
72
+
73
+ def get_request_id(headers)
74
+ request_id = nil
75
+ headers.each do |key, value|
76
+ request_id = value if key.downcase == configuration.request_id_header.downcase
77
+ end
78
+ request_id || "req_#{SecureRandom.hex}"
79
+ end
80
+
81
+ def get_from_cache(idempotency_key)
82
+ value = storage.get(key(idempotency_key))
83
+ return unless value
84
+
85
+ JSON.parse(value)
86
+ end
87
+
88
+ def store_in_cache(idempotency_key, params, status, request_id, response)
89
+ body = {
90
+ params: params,
91
+ status: status,
92
+ original_request: request_id,
93
+ response: response
94
+ }.to_json
95
+ storage.set(key(idempotency_key), body, ex: configuration.expires_in)
96
+ end
97
+
98
+ def key(idempotency_key)
99
+ "grape:idempotency:#{idempotency_key}"
100
+ end
101
+
102
+ def storage
103
+ configuration.storage
104
+ end
105
+
106
+ def configuration
107
+ @configuration ||= Configuration.new
108
+ end
109
+ end
110
+
111
+ class Configuration
112
+ attr_accessor :storage, :expires_in, :idempotency_key_header, :request_id_header, :conflict_error_response
113
+
114
+ class Error < StandardError; end
115
+
116
+ def initialize
117
+ @storage = nil
118
+ @expires_in = 216_000
119
+ @idempotency_key_header = "idempotency-key"
120
+ @request_id_header = "x-request-id"
121
+ @conflict_error_response = {
122
+ "error" => "You are using the same idempotent key for two different requests"
123
+ }
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1 @@
1
+ require 'grape/idempotency'
metadata ADDED
@@ -0,0 +1,125 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: grape-idempotency
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Juan Carlos GarcΓ­a
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-11-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: grape
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '2.4'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '2.4'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.12'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.12'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rack-test
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: mock_redis
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.38'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.38'
83
+ description: Add idempotency support to your Grape APIs for safely retrying requests
84
+ without accidentally performing the same operation twice. When creating or updating
85
+ an object, use an idempotency key. Then, if a connection error occurs, you can safely
86
+ repeat the request without risk of creating a second object or performing the update
87
+ twice.
88
+ email:
89
+ - jugade92@gmail.com
90
+ executables: []
91
+ extensions: []
92
+ extra_rdoc_files: []
93
+ files:
94
+ - CHANGELOG.md
95
+ - README.md
96
+ - Rakefile
97
+ - grape-idempotency.gemspec
98
+ - lib/grape-idempotency.rb
99
+ - lib/grape/idempotency.rb
100
+ - lib/grape/idempotency/helpers.rb
101
+ - lib/grape/idempotency/version.rb
102
+ homepage: https://github.com/jcagarcia/grape-idempotency
103
+ licenses:
104
+ - MIT
105
+ metadata: {}
106
+ post_install_message:
107
+ rdoc_options: []
108
+ require_paths:
109
+ - lib
110
+ required_ruby_version: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ version: '2.6'
115
+ required_rubygems_version: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: '0'
120
+ requirements: []
121
+ rubygems_version: 3.4.10
122
+ signing_key:
123
+ specification_version: 4
124
+ summary: Gem for supporting idempotency in your Grape APIs
125
+ test_files: []