grape-idempotency 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CHANGELOG.md +9 -0
- data/README.md +196 -0
- data/Rakefile +7 -0
- data/grape-idempotency.gemspec +31 -0
- data/lib/grape/idempotency/helpers.rb +11 -0
- data/lib/grape/idempotency/version.rb +7 -0
- data/lib/grape/idempotency.rb +127 -0
- data/lib/grape-idempotency.rb +1 -0
- metadata +125 -0
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,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,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: []
|