mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-19 00:01:14 +00:00
159 lines
3.9 KiB
Go
159 lines
3.9 KiB
Go
package middleware
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/warmbly/warmbly/internal/app/idempotency"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
)
|
|
|
|
const (
|
|
IdempotencyKeyHeader = "Idempotency-Key"
|
|
IdempotencyReplayedHeader = "X-Idempotent-Replayed"
|
|
)
|
|
|
|
// IdempotencyMiddleware implements Stripe-style retry safety for mutating API
|
|
// requests. It is opt-in per request via Idempotency-Key and scoped by
|
|
// organization, so the same key cannot collide across tenants.
|
|
func (h *Handler) IdempotencyMiddleware() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
key := strings.TrimSpace(c.GetHeader(IdempotencyKeyHeader))
|
|
if key == "" || !isIdempotentMethod(c.Request.Method) {
|
|
c.Next()
|
|
return
|
|
}
|
|
if h.IdempotencyService == nil {
|
|
errx.Handle(c, errx.New(errx.ServiceUnavailable, "idempotency service is not available"))
|
|
c.Abort()
|
|
return
|
|
}
|
|
if !validIdempotencyKey(key) {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "Idempotency-Key must be 1-255 visible ASCII characters"))
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
orgID := GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "Idempotency-Key requires an organization context"))
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
body, err := readAndRestoreBody(c)
|
|
if err != nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "failed to read request body"))
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
path := c.FullPath()
|
|
if path == "" {
|
|
path = c.Request.URL.Path
|
|
}
|
|
requestHash := hashRequest(c.Request.Method, path, c.Request.URL.RawQuery, body)
|
|
record, state, xerr := h.IdempotencyService.Begin(c.Request.Context(), *orgID, key, c.Request.Method, path, requestHash)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
switch state {
|
|
case idempotency.StateReplay:
|
|
c.Header(IdempotencyReplayedHeader, "true")
|
|
if record.ContentType != nil && *record.ContentType != "" {
|
|
c.Header("Content-Type", *record.ContentType)
|
|
}
|
|
c.Data(record.StatusCode, c.Writer.Header().Get("Content-Type"), record.ResponseBody)
|
|
c.Abort()
|
|
return
|
|
case idempotency.StateProcessing:
|
|
errx.Handle(c, errx.New(errx.Conflict, "an identical request is still processing"))
|
|
c.Abort()
|
|
return
|
|
case idempotency.StateConflict:
|
|
errx.Handle(c, errx.New(errx.Conflict, "Idempotency-Key was already used with a different request"))
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
capture := &captureResponseWriter{ResponseWriter: c.Writer}
|
|
c.Writer = capture
|
|
c.Next()
|
|
|
|
status := capture.Status()
|
|
if status == 0 {
|
|
status = http.StatusOK
|
|
}
|
|
contentType := capture.Header().Get("Content-Type")
|
|
_ = h.IdempotencyService.Complete(c.Request.Context(), record.ID, status, capture.body.Bytes(), contentType)
|
|
}
|
|
}
|
|
|
|
func isIdempotentMethod(method string) bool {
|
|
switch method {
|
|
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validIdempotencyKey(key string) bool {
|
|
if key == "" || len(key) > 255 {
|
|
return false
|
|
}
|
|
for _, r := range key {
|
|
if r < 33 || r > 126 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func readAndRestoreBody(c *gin.Context) ([]byte, error) {
|
|
if c.Request.Body == nil {
|
|
return nil, nil
|
|
}
|
|
body, err := io.ReadAll(c.Request.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c.Request.Body = io.NopCloser(bytes.NewReader(body))
|
|
return body, nil
|
|
}
|
|
|
|
func hashRequest(method, path, rawQuery string, body []byte) string {
|
|
h := sha256.New()
|
|
h.Write([]byte(method))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(path))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(rawQuery))
|
|
h.Write([]byte{0})
|
|
h.Write(body)
|
|
return hex.EncodeToString(h.Sum(nil))
|
|
}
|
|
|
|
type captureResponseWriter struct {
|
|
gin.ResponseWriter
|
|
body bytes.Buffer
|
|
}
|
|
|
|
func (w *captureResponseWriter) Write(data []byte) (int, error) {
|
|
w.body.Write(data)
|
|
return w.ResponseWriter.Write(data)
|
|
}
|
|
|
|
func (w *captureResponseWriter) WriteString(data string) (int, error) {
|
|
w.body.WriteString(data)
|
|
return w.ResponseWriter.WriteString(data)
|
|
}
|