Skip to content

Commit

Permalink
feature(#14): Allow to make idempotency key header mandatory (#15)
Browse files Browse the repository at this point in the history
* feature(#14): Allow to make idempotency key header mandatory

* Updating README link
  • Loading branch information
jcagarcia authored Nov 21, 2023
1 parent 4b25062 commit 4485d7d
Show file tree
Hide file tree
Showing 5 changed files with 125 additions and 23 deletions.
33 changes: 25 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,48 @@ All changes to `grape-idempotency` will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.0.0] - (Next)

### Fix

* Your contribution here.

### Changed

* [#11](https://github.com/jcagarcia/grape-idempotency/pull/11): Changing error response formats - [@Flip120](https://github.com/Flip120).
* Your contribution here.

### Feature

* [#15](https://github.com/jcagarcia/grape-idempotency/pull/15): Allow to mark the idempotent header as required - [@jcagarcia](https://github.com/jcagarcia).
* [#11](https://github.com/jcagarcia/grape-idempotency/pull/11): Return 409 conflict when a request is still being processed - [@Flip120](https://github.com/Flip120).
* Your contribution here.

## [0.1.3] - 2023-01-07

### Fix

- Second calls were returning `null` when the first response was generated inside a `rescue_from`.
- Conflict response had invalid format.
* [#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).
- [#9](https://github.com/jcagarcia/grape-idempotency/pull/9): Conflict response had invalid format. - [@jcagarcia](https://github.com/jcagarcia).

## [0.1.2] - 2023-01-06

### Fix

- Return correct original response when the endpoint returns a hash in the body
* [#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).

## [0.1.1] - 2023-01-06

### Fix

- Return `409 - Conflict` response if idempotency key is provided for same query and body parameters BUT different endpoints.
- Use `nx: true` when storing the original request in the Redis storage for only setting the key if it does not already exist.
* [#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).
* [#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).

### Changed

- Include `idempotency-key` in the response headers
- 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.
* [#4](https://github.com/jcagarcia/grape-idempotency/pull/4): Include `idempotency-key` in the response headers - [@jcagarcia](https://github.com/jcagarcia).
* 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.

## [0.1.0] - 2023-01-03

- Initial version
* [#1](https://github.com/jcagarcia/grape-idempotency/pull/1): Initial version - [@jcagarcia](https://github.com/jcagarcia).
69 changes: 66 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Topics covered in this README:
- [Installation](#installation-)
- [Basic Usage](#basic-usage-)
- [How it works](#how-it-works-)
- [Making idempotency key header mandatory](#making-idempotency-key-header-mandatory-)
- [Configuration](#configuration-)
- [Changelog](#changelog)
- [Contributing](#contributing)
Expand Down Expand Up @@ -65,7 +66,7 @@ That's all! 🚀

## How it works 🤔

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.
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)

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.

Expand All @@ -80,6 +81,38 @@ Results are only saved if an API endpoint begins its execution. If incoming para

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.

## Making idempotency key header mandatory ⚠️

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`:

```ruby
require 'grape'
require 'grape-idempotency'

class API < Grape::API
post '/payments' do
idempotent(required: true) do
status 201
Payment.create!({
amount: params[:amount]
})
end
end
end
end
```

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:

```json
{
"title": "Idempotency-Key is missing",
"detail": "This operation is idempotent and it requires correct usage of Idempotency Key.",
}
```

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.

## Configuration 🪚

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.
Expand Down Expand Up @@ -153,9 +186,11 @@ Grape::Idempotency.configure do |c|
end
```

In the configuration above, the error is following the [RFC-7807](https://datatracker.ietf.org/doc/html/rfc7807) format.

### processing_response

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. Thre response body returned by the gem looks like:
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:

```json
{
Expand All @@ -181,6 +216,33 @@ end

In the configuration above, the error is following the [RFC-7807](https://datatracker.ietf.org/doc/html/rfc7807) format.

### mandatory_header_response

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:

```json
{
"title": "Idempotency-Key is missing",
"detail": "This operation is idempotent and it requires correct usage of Idempotency Key.",
}
```

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.

```ruby
Grape::Idempotency.configure do |c|
c.storage = @storage
c.mandatory_header_response = {
"type": "about:blank",
"status": 400,
"title": "Idempotency-Key is missing",
"detail": "Please, provide a valid idempotent key in the headers for performing this operation"
}
end
```

In the configuration above, the error is following the [RFC-7807](https://datatracker.ietf.org/doc/html/rfc7807) format.

## Changelog

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).
Expand Down Expand Up @@ -223,4 +285,5 @@ Open issues on the GitHub issue tracker with clear information.

### Contributors

* Juan Carlos García - Creator - https://github.com/jcagarcia
* Juan Carlos García - Creator - https://github.com/jcagarcia
* Carlos Cabanero - Contributor - https://github.com/Flip120
18 changes: 11 additions & 7 deletions lib/grape/idempotency.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,19 @@ def restore_configuration
@configuration = clean_configuration
end

def idempotent(grape, &block)
def idempotent(grape, required: false, &block)
validate_config!

idempotency_key = get_idempotency_key(grape.request.headers)
return block.call unless idempotency_key

grape.error!(configuration.mandatory_header_response, 400) if required && !idempotency_key
return block.call if !idempotency_key

cached_request = get_from_cache(idempotency_key)
if cached_request && (cached_request["params"] != grape.request.params || cached_request["path"] != grape.request.path)
grape.status 409
return configuration.conflict_error_response
grape.error!(configuration.conflict_error_response, 409)
elsif cached_request && cached_request["processing"] == true
grape.status 409
return configuration.processing_response.to_json
grape.error!(configuration.processing_response, 409)
elsif cached_request
grape.status cached_request["status"]
grape.header(ORIGINAL_REQUEST_HEADER, cached_request["original_request"])
Expand Down Expand Up @@ -213,7 +213,7 @@ def configuration
end

class Configuration
attr_accessor :storage, :expires_in, :idempotency_key_header, :request_id_header, :conflict_error_response, :processing_response
attr_accessor :storage, :expires_in, :idempotency_key_header, :request_id_header, :conflict_error_response, :processing_response, :mandatory_header_response

class Error < StandardError; end

Expand All @@ -230,6 +230,10 @@ def initialize
"title" => "A request is outstanding for this Idempotency-Key",
"detail" => "A request with the same idempotent key for the same operation is being processed or is outstanding."
}
@mandatory_header_response = {
"title" => "Idempotency-Key is missing",
"detail" => "This operation is idempotent and it requires correct usage of Idempotency Key."
}
end
end
end
Expand Down
4 changes: 2 additions & 2 deletions lib/grape/idempotency/helpers.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
module Grape
module Idempotency
module Helpers
def idempotent(&block)
Grape::Idempotency.idempotent(self) do
def idempotent(required: false, &block)
Grape::Idempotency.idempotent(self, required: required) do
block.call
end
end
Expand Down
24 changes: 21 additions & 3 deletions spec/idempotent_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@
header "idempotency-key", idempotency_key
post 'payments?locale=en', { amount: 800_00 }.to_json
expect(last_response.status).to eq(409)
expect(last_response.body).to eq("{\"title\"=>\"Idempotency-Key is already used\", \"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.\"}")
expect(last_response.body).to eq("{\"title\":\"Idempotency-Key is already used\",\"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.\"}")
end
end

Expand Down Expand Up @@ -192,7 +192,7 @@
header "idempotency-key", idempotency_key
post 'refunds?locale=es', { amount: 100_00 }.to_json
expect(last_response.status).to eq(409)
expect(last_response.body).to eq("{\"title\"=>\"Idempotency-Key is already used\", \"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.\"}")
expect(last_response.body).to eq("{\"title\":\"Idempotency-Key is already used\",\"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.\"}")
end
end
end
Expand Down Expand Up @@ -340,6 +340,24 @@
post 'payments', { amount: 100_00 }.to_json
expect(last_response.body).to eq({ amount_to: 2 }.to_json)
end

context 'BUT the idempotency key header was mandatory' do
it 'returns 400 bad request response' do
app.post('/payments') do
idempotent(required: true) do
status 201
{ amount_to: SecureRandom.random_number }.to_json
end
end

post 'payments', { amount: 100_00 }.to_json
expect(last_response.body).to eq({
title: "Idempotency-Key is missing",
detail: "This operation is idempotent and it requires correct usage of Idempotency Key."
}.to_json)
expect(last_response.status).to eq(400)
end
end
end
end
end
Expand Down Expand Up @@ -513,7 +531,7 @@
header "idempotency-key", idempotency_key
post 'payments?locale=en', { amount: 800_00 }.to_json
expect(last_response.status).to eq(409)
expect(last_response.body).to eq("{:error=>\"An error wadus with conflict\", :status=>409, :message=>\"You are using the same idempotency key for two different requests\"}")
expect(last_response.body).to eq("{\"error\":\"An error wadus with conflict\",\"status\":409,\"message\":\"You are using the same idempotency key for two different requests\"}")
end
end

Expand Down

0 comments on commit 4485d7d

Please sign in to comment.