mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(api): error codes, idempotency keys, cursor pagination
- Stable machine-readable error codes alongside the error message (UNAUTHENTICATED, INVALID_TOKEN, ADMIN_REQUIRED, SCOPE_INSUFFICIENT, IDEMPOTENCY_IN_PROGRESS); CLI renders "CODE: message". - Idempotency middleware on authed/admin groups: POST requests with an Idempotency-Key header replay the first response for 24h (Redis-backed, caller-scoped); concurrent duplicates get 409. X-Idempotent-Replay header marks replays. - Shared pagination helpers: ?cursor= (opaque, preferred) alongside legacy ?page= on projects and deployments lists; pagination meta includes next_cursor; invalid cursors fail soft to page 1. - CLI --idempotency-key flag for safe scripted retries. - OpenAPI updated: error envelope, cursor params, idempotency contract. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
485e41a817
commit
18f78f00a3
15 files changed
+454
-56
No files matched your search
@@ -0,0 +1,132 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-redis/redis/v8"
|
||||
)
|
||||
|
||||
// Idempotency replays POST responses for a repeated Idempotency-Key header
|
||||
// so agents can safely retry mutations. The first request wins; concurrent
|
||||
// duplicates get 409 IDEMPOTENCY_IN_PROGRESS; completed responses replay
|
||||
// verbatim for 24h. Keys are scoped to the authenticated identity.
|
||||
func Idempotency() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.Method != http.MethodPost {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
key := c.GetHeader("Idempotency-Key")
|
||||
if key == "" || len(key) > 128 {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
dbValue, exists := c.Get("redis")
|
||||
if !exists {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
rdb, ok := dbValue.(*database.Redis)
|
||||
if !ok || rdb == nil || rdb.Client == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Scope by caller identity so a stolen key can't replay across users.
|
||||
actor := c.GetString("user_id")
|
||||
if actor == "" {
|
||||
actor = c.GetHeader("Authorization")
|
||||
}
|
||||
sum := sha256.Sum256([]byte(actor + ":" + c.Request.URL.Path + ":" + key))
|
||||
redisKey := "idem:" + hex.EncodeToString(sum[:16])
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stored, err := rdb.Client.Get(ctx, redisKey).Result()
|
||||
switch {
|
||||
case err == nil:
|
||||
if stored == "processing" {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "a request with this idempotency key is still in progress",
|
||||
"code": "IDEMPOTENCY_IN_PROGRESS",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
var rec idemRecord
|
||||
if json.Unmarshal([]byte(stored), &rec) == nil {
|
||||
c.Header("X-Idempotent-Replay", "true")
|
||||
c.Data(rec.Status, rec.ContentType, rec.Body)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
case err != redis.Nil:
|
||||
// Redis hiccup — degrade to pass-through rather than failing the request.
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Claim the key for the duration of the request.
|
||||
if set, serr := rdb.Client.SetNX(ctx, redisKey, "processing", 2*time.Minute).Result(); serr != nil || !set {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "a request with this idempotency key is still in progress",
|
||||
"code": "IDEMPOTENCY_IN_PROGRESS",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
recorder := &responseRecorder{ResponseWriter: c.Writer, status: http.StatusOK, body: &bytes.Buffer{}}
|
||||
c.Writer = recorder
|
||||
c.Next()
|
||||
|
||||
// Cache terminal responses only — 5xx is retryable and must not be replayed.
|
||||
if recorder.status < 500 && recorder.body.Len() <= 1<<20 {
|
||||
rec := idemRecord{
|
||||
Status: recorder.status,
|
||||
ContentType: recorder.Header().Get("Content-Type"),
|
||||
Body: recorder.body.Bytes(),
|
||||
}
|
||||
if rec.ContentType == "" {
|
||||
rec.ContentType = "application/json; charset=utf-8"
|
||||
}
|
||||
if raw, merr := json.Marshal(rec); merr == nil {
|
||||
_ = rdb.Client.Set(context.Background(), redisKey, raw, 24*time.Hour).Err()
|
||||
}
|
||||
} else {
|
||||
_ = rdb.Client.Del(context.Background(), redisKey).Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type idemRecord struct {
|
||||
Status int `json:"s"`
|
||||
ContentType string `json:"ct"`
|
||||
Body []byte `json:"b"`
|
||||
}
|
||||
|
||||
type responseRecorder struct {
|
||||
gin.ResponseWriter
|
||||
status int
|
||||
body *bytes.Buffer
|
||||
}
|
||||
|
||||
func (r *responseRecorder) WriteHeader(code int) {
|
||||
r.status = code
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (r *responseRecorder) Write(b []byte) (int, error) {
|
||||
r.body.Write(b)
|
||||
return r.ResponseWriter.Write(b)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestIdempotencyPassThroughWithoutKey(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
called := 0
|
||||
router := gin.New()
|
||||
router.Use(Idempotency())
|
||||
router.POST("/x", func(c *gin.Context) { called++; c.JSON(200, gin.H{"ok": true}) })
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/x", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if called != 1 || rec.Code != 200 {
|
||||
t.Fatalf("expected pass-through, called=%d code=%d", called, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdempotencyPassThroughWithoutRedis(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
called := 0
|
||||
router := gin.New()
|
||||
router.Use(Idempotency())
|
||||
router.POST("/x", func(c *gin.Context) { called++; c.JSON(200, gin.H{"ok": true}) })
|
||||
|
||||
// Key present but no redis in context — must not block the request.
|
||||
req := httptest.NewRequest(http.MethodPost, "/x", nil)
|
||||
req.Header.Set("Idempotency-Key", "abc-123")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if called != 1 || rec.Code != 200 {
|
||||
t.Fatalf("expected pass-through without redis, called=%d code=%d", called, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdempotencySkipsNonPOST(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
called := 0
|
||||
router := gin.New()
|
||||
router.Use(Idempotency())
|
||||
router.GET("/x", func(c *gin.Context) { called++; c.String(200, "ok") })
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||
req.Header.Set("Idempotency-Key", "abc")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if called != 1 {
|
||||
t.Fatalf("GET should bypass idempotency, called=%d", called)
|
||||
}
|
||||
}
|
||||
@@ -212,15 +212,15 @@ func Auth(jwtSecret string) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
if tokenErr != "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": tokenErr})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": tokenErr, "code": "INVALID_TOKEN"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if hasToken {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token", "code": "INVALID_TOKEN"})
|
||||
} else {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required", "code": "UNAUTHENTICATED"})
|
||||
}
|
||||
c.Abort()
|
||||
}
|
||||
@@ -245,12 +245,12 @@ func OptionalAuth(jwtSecret string) gin.HandlerFunc {
|
||||
func RequireAdmin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if _, exists := c.Get("user_id"); !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required", "code": "UNAUTHENTICATED"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !c.GetBool("is_admin") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required", "code": "ADMIN_REQUIRED"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user