Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ This separation enables different optimization strategies:

### Idempotency Keys
Idempotency ensures that multiple identical requests have the same effect as a single request. This is crucial for handling network failures and retries in distributed systems. Implementation:
- Each command accepts an optional `idempotency_key` in the request
- Every mutating endpoint (create, update, **and delete**) accepts an optional key. The conventional `Idempotency-Key` HTTP header is preferred; an `idempotency_key` field in the JSON body is still honored as a fallback, and the header wins when both are sent
- The key is **reserved atomically** (`INSERT ... ON CONFLICT DO NOTHING`), so two concurrent requests with the same key can never both execute — no check-then-write race
- A completed request returns its cached response; a still-running one returns an "in progress" error so the client retries later
- Reusing a key with a **different payload** is rejected instead of silently returning the wrong cached response
Expand Down
25 changes: 24 additions & 1 deletion api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ info:
title: go-ddd Marketplace API
description: |
Example REST API of the go-ddd template. Sellers manage products.
Write endpoints accept an optional `idempotency_key` making retries safe.
Write endpoints (create, update, delete) are idempotent: send an
`Idempotency-Key` header to make retries safe. A completed request with the
same key replays its cached response; the same key with a different payload
is rejected. The legacy `idempotency_key` body field is still honored as a
fallback.
version: "1.0.0"
license:
name: MIT
Expand Down Expand Up @@ -43,6 +47,8 @@ paths:
post:
summary: Create a seller
operationId: createSeller
parameters:
- $ref: "#/components/parameters/IdempotencyKey"
requestBody:
required: true
content:
Expand Down Expand Up @@ -71,6 +77,8 @@ paths:
put:
summary: Update a seller
operationId: updateSeller
parameters:
- $ref: "#/components/parameters/IdempotencyKey"
requestBody:
required: true
content:
Expand Down Expand Up @@ -106,6 +114,7 @@ paths:
operationId: deleteSeller
parameters:
- $ref: "#/components/parameters/Id"
- $ref: "#/components/parameters/IdempotencyKey"
responses:
"204":
description: Seller deleted
Expand All @@ -115,6 +124,8 @@ paths:
post:
summary: Create a product
operationId: createProduct
parameters:
- $ref: "#/components/parameters/IdempotencyKey"
requestBody:
required: true
content:
Expand Down Expand Up @@ -160,6 +171,7 @@ paths:
operationId: updateProduct
parameters:
- $ref: "#/components/parameters/Id"
- $ref: "#/components/parameters/IdempotencyKey"
requestBody:
required: true
content:
Expand All @@ -180,6 +192,7 @@ paths:
operationId: deleteProduct
parameters:
- $ref: "#/components/parameters/Id"
- $ref: "#/components/parameters/IdempotencyKey"
responses:
"204":
description: Product deleted
Expand All @@ -194,6 +207,16 @@ components:
schema:
type: string
format: uuid
IdempotencyKey:
name: Idempotency-Key
in: header
required: false
description: >-
Unique key that makes this request safe to retry. Repeating the request
with the same key replays the original response instead of executing
again.
schema:
type: string
responses:
BadRequest:
description: Malformed request
Expand Down
18 changes: 18 additions & 0 deletions internal/interface/api/rest/idempotency.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package rest

import "github.com/labstack/echo/v4"

// idempotencyHeader is the conventional header clients use to make a mutating
// request safe to retry. See https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/
const idempotencyHeader = "Idempotency-Key"

// idempotencyKey resolves the idempotency key for a request. The Idempotency-Key
// header is the preferred transport; bodyKey (the request's optional
// "idempotency_key" field) is a backward-compatible fallback for callers that
// send the key in the JSON body. The header wins when both are present.
func idempotencyKey(c echo.Context, bodyKey string) string {
if header := c.Request().Header.Get(idempotencyHeader); header != "" {
return header
}
return bodyKey
}
34 changes: 34 additions & 0 deletions internal/interface/api/rest/idempotency_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package rest

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
)

func newContext(t *testing.T, header string) echo.Context {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/", nil)
if header != "" {
req.Header.Set(idempotencyHeader, header)
}
return echo.New().NewContext(req, httptest.NewRecorder())
}

func TestIdempotencyKey_HeaderTakesPrecedence(t *testing.T) {
c := newContext(t, "from-header")
assert.Equal(t, "from-header", idempotencyKey(c, "from-body"))
}

func TestIdempotencyKey_FallsBackToBody(t *testing.T) {
c := newContext(t, "")
assert.Equal(t, "from-body", idempotencyKey(c, "from-body"))
}

func TestIdempotencyKey_EmptyWhenNeitherPresent(t *testing.T) {
c := newContext(t, "")
assert.Empty(t, idempotencyKey(c, ""))
}
7 changes: 6 additions & 1 deletion internal/interface/api/rest/product_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ func (pc *ProductController) CreateProductController(c echo.Context) error {
"error": "Invalid product Id format",
})
}
productCommand.IdempotencyKey = idempotencyKey(c, productCommand.IdempotencyKey)

result, err := pc.service.CreateProduct(c.Request().Context(), productCommand)
if err != nil {
Expand Down Expand Up @@ -116,6 +117,7 @@ func (pc *ProductController) UpdateProductController(c echo.Context) error {
"error": "Invalid seller Id format",
})
}
productCommand.IdempotencyKey = idempotencyKey(c, productCommand.IdempotencyKey)

result, err := pc.service.UpdateProduct(c.Request().Context(), productCommand)
if err != nil {
Expand All @@ -135,7 +137,10 @@ func (pc *ProductController) DeleteProductController(c echo.Context) error {
})
}

_, err = pc.service.DeleteProduct(c.Request().Context(), &command.DeleteProductCommand{Id: id})
_, err = pc.service.DeleteProduct(c.Request().Context(), &command.DeleteProductCommand{
IdempotencyKey: idempotencyKey(c, ""),
Id: id,
})
if err != nil {
return writeCommandError(c, err, "Failed to delete product")
}
Expand Down
7 changes: 6 additions & 1 deletion internal/interface/api/rest/seller_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ func (sc *SellerController) CreateSellerController(c echo.Context) error {
"error": "Invalid seller Id format",
})
}
sellerCommand.IdempotencyKey = idempotencyKey(c, sellerCommand.IdempotencyKey)

commandResult, err := sc.service.CreateSeller(c.Request().Context(), sellerCommand)
if err != nil {
Expand Down Expand Up @@ -110,6 +111,7 @@ func (sc *SellerController) PutSellerController(c echo.Context) error {
"error": "Invalid seller Id format",
})
}
updateSellerCommand.IdempotencyKey = idempotencyKey(c, updateSellerCommand.IdempotencyKey)

commandResult, err := sc.service.UpdateSeller(c.Request().Context(), updateSellerCommand)
if err != nil {
Expand All @@ -129,7 +131,10 @@ func (sc *SellerController) DeleteSellerController(c echo.Context) error {
})
}

_, err = sc.service.DeleteSeller(c.Request().Context(), &command.DeleteSellerCommand{Id: id})
_, err = sc.service.DeleteSeller(c.Request().Context(), &command.DeleteSellerCommand{
IdempotencyKey: idempotencyKey(c, ""),
Id: id,
})
if err != nil {
return writeCommandError(c, err, "Failed to delete seller")
}
Expand Down
Loading