mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-29 00:01:22 +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
@@ -6,7 +6,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -74,14 +73,21 @@ func handleGetDeployments(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
offset, limit := pageWindow(c, 50, 100)
|
||||
|
||||
var total int
|
||||
_ = db.(*database.DB).QueryRow(
|
||||
`SELECT COUNT(*) FROM deployments WHERE service_id = $1`, serviceID,
|
||||
).Scan(&total)
|
||||
|
||||
rows, err := db.(*database.DB).Query(
|
||||
`SELECT id, service_id, commit_hash, status, image_name, image_tag,
|
||||
build_log, runtime_log, error, started_at, completed_at, created_at, updated_at
|
||||
FROM deployments
|
||||
WHERE service_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50`,
|
||||
serviceID,
|
||||
LIMIT $2 OFFSET $3`,
|
||||
serviceID, limit, offset,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
|
||||
@@ -104,7 +110,10 @@ func handleGetDeployments(c *gin.Context) {
|
||||
deployments = append(deployments, d)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"deployments": deployments})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"deployments": deployments,
|
||||
"pagination": paginationMeta(offset, limit, total),
|
||||
})
|
||||
}
|
||||
|
||||
// RecentDeployment is a deployment row joined with its service and project names.
|
||||
@@ -132,11 +141,19 @@ func handleGetRecentDeployments(c *gin.Context) {
|
||||
userID := optionalUserUUID(c)
|
||||
isAdmin := contextIsAdmin(c)
|
||||
|
||||
limit := 10
|
||||
if v := c.Query("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 50 {
|
||||
limit = n
|
||||
}
|
||||
offset, limit := pageWindow(c, 10, 50)
|
||||
|
||||
var total int
|
||||
if err := db.(*database.DB).QueryRow(
|
||||
`SELECT COUNT(*)
|
||||
FROM deployments d
|
||||
JOIN services s ON s.id = d.service_id
|
||||
JOIN projects p ON p.id = s.project_id
|
||||
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
|
||||
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)`,
|
||||
userID, isAdmin,
|
||||
).Scan(&total); err != nil {
|
||||
total = 0
|
||||
}
|
||||
|
||||
rows, err := db.(*database.DB).Query(
|
||||
@@ -148,8 +165,8 @@ func handleGetRecentDeployments(c *gin.Context) {
|
||||
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
|
||||
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)
|
||||
ORDER BY d.created_at DESC
|
||||
LIMIT $3`,
|
||||
userID, isAdmin, limit,
|
||||
LIMIT $3 OFFSET $4`,
|
||||
userID, isAdmin, limit, offset,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
|
||||
@@ -168,7 +185,10 @@ func handleGetRecentDeployments(c *gin.Context) {
|
||||
deployments = append(deployments, d)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"deployments": deployments})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"deployments": deployments,
|
||||
"pagination": paginationMeta(offset, limit, total),
|
||||
})
|
||||
}
|
||||
|
||||
func handleCreateDeployment(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Stable machine-readable error codes for agent clients. The "error" field
|
||||
// stays human-readable; "code" is the contract the CLI/MCP rely on.
|
||||
const (
|
||||
CodeUnauthorized = "UNAUTHENTICATED"
|
||||
CodeInvalidToken = "INVALID_TOKEN"
|
||||
CodeForbidden = "FORBIDDEN"
|
||||
CodeAdminRequired = "ADMIN_REQUIRED"
|
||||
CodeScopeInsufficient = "SCOPE_INSUFFICIENT"
|
||||
CodeNotFound = "NOT_FOUND"
|
||||
CodeValidation = "VALIDATION"
|
||||
CodeConflict = "CONFLICT"
|
||||
CodeInternal = "INTERNAL"
|
||||
CodeUnavailable = "DEPENDENCY_UNAVAILABLE"
|
||||
CodeIdempotencyRunning = "IDEMPOTENCY_IN_PROGRESS"
|
||||
)
|
||||
|
||||
// respondError emits the standard error envelope. Existing handlers return
|
||||
// bare {"error": msg} — this adds the stable code for new and
|
||||
// agent-facing paths.
|
||||
func respondError(c *gin.Context, status int, code, message string) {
|
||||
c.JSON(status, gin.H{"error": message, "code": code})
|
||||
c.Abort()
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// pageWindow parses pagination from either ?page=&limit= (legacy) or
|
||||
// ?cursor=&limit= (agent-friendly). The cursor is an opaque base64 offset —
|
||||
// cheap to implement over the existing offset queries while the public
|
||||
// contract stays cursor-shaped for clients.
|
||||
func pageWindow(c *gin.Context, defaultLimit, maxLimit int) (offset, limit int) {
|
||||
limit = defaultLimit
|
||||
if v := c.Query("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= maxLimit {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
if cur := c.Query("cursor"); cur != "" {
|
||||
if raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(cur, "o_")); err == nil {
|
||||
if n, err := strconv.Atoi(string(raw)); err == nil && n >= 0 {
|
||||
return n, limit
|
||||
}
|
||||
}
|
||||
return 0, limit // invalid cursor → first page, fail soft
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
return (page - 1) * limit, limit
|
||||
}
|
||||
|
||||
// paginationMeta renders the standard envelope: legacy page fields plus
|
||||
// next_cursor when another page exists.
|
||||
func paginationMeta(offset, limit, total int) gin.H {
|
||||
page := offset/limit + 1
|
||||
meta := gin.H{
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"pages": (total + limit - 1) / limit,
|
||||
}
|
||||
if offset+limit < total {
|
||||
meta["next_cursor"] = "o_" + base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprint(offset+limit)))
|
||||
}
|
||||
return meta
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func ctxWithQuery(q string) *gin.Context {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("GET", "/x?"+q, nil)
|
||||
return c
|
||||
}
|
||||
|
||||
func TestPageWindowDefaults(t *testing.T) {
|
||||
offset, limit := pageWindow(ctxWithQuery(""), 10, 100)
|
||||
if offset != 0 || limit != 10 {
|
||||
t.Fatalf("expected 0/10, got %d/%d", offset, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageWindowLegacyPage(t *testing.T) {
|
||||
offset, limit := pageWindow(ctxWithQuery("page=3&limit=20"), 10, 100)
|
||||
if offset != 40 || limit != 20 {
|
||||
t.Fatalf("expected 40/20, got %d/%d", offset, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageWindowCursor(t *testing.T) {
|
||||
// cursor for offset 50 produced by paginationMeta
|
||||
meta := paginationMeta(0, 50, 200)
|
||||
cursor := meta["next_cursor"].(string)
|
||||
offset, limit := pageWindow(ctxWithQuery("cursor="+cursor+"&limit=50"), 10, 100)
|
||||
if offset != 50 || limit != 50 {
|
||||
t.Fatalf("expected 50/50, got %d/%d", offset, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageWindowInvalidCursorFailsSoft(t *testing.T) {
|
||||
offset, _ := pageWindow(ctxWithQuery("cursor=garbage"), 10, 100)
|
||||
if offset != 0 {
|
||||
t.Fatalf("expected offset 0 on invalid cursor, got %d", offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginationMetaNextCursorOnlyWhenMore(t *testing.T) {
|
||||
if _, ok := paginationMeta(0, 10, 10)["next_cursor"]; ok {
|
||||
t.Fatal("no next page should omit next_cursor")
|
||||
}
|
||||
if _, ok := paginationMeta(0, 10, 25)["next_cursor"]; !ok {
|
||||
t.Fatal("expected next_cursor when more pages exist")
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -55,18 +54,9 @@ func handleGetProjects(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
queries := sqlcdb.New(db.DB)
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
offset, limit := pageWindow(c, 10, 100)
|
||||
search := strings.TrimSpace(c.DefaultQuery("search", ""))
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if limit > 100 || limit < 1 {
|
||||
limit = 10
|
||||
}
|
||||
offset := (page - 1) * limit
|
||||
|
||||
searchParam := sql.NullString{}
|
||||
if search != "" {
|
||||
searchParam = sql.NullString{String: search, Valid: true}
|
||||
@@ -99,15 +89,9 @@ func handleGetProjects(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
totalCount := int(total)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"projects": projects,
|
||||
"pagination": gin.H{
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": totalCount,
|
||||
"pages": (totalCount + limit - 1) / limit,
|
||||
},
|
||||
"projects": projects,
|
||||
"pagination": paginationMeta(offset, limit, int(total)),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -256,6 +256,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
// enforce ownership; platform admins additionally pass every check.
|
||||
authed := v1.Group("/")
|
||||
authed.Use(middleware.Auth(cfg.JWTSecret))
|
||||
authed.Use(middleware.Idempotency())
|
||||
{
|
||||
authed.GET("/user/profile", handleGetProfile)
|
||||
authed.PUT("/user/profile", handleUpdateProfile)
|
||||
@@ -357,6 +358,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
// Admin routes — platform-wide controls and cross-user management.
|
||||
admin := v1.Group("/")
|
||||
admin.Use(middleware.Auth(cfg.JWTSecret))
|
||||
admin.Use(middleware.Idempotency())
|
||||
admin.Use(middleware.RequireAdmin())
|
||||
{
|
||||
admin.GET("/admin/overview", handleAdminOverview)
|
||||
|
||||
@@ -72,36 +72,36 @@ func handleCreateUserToken(c *gin.Context) {
|
||||
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
if req.Scope == "" {
|
||||
req.Scope = "write"
|
||||
}
|
||||
if !userTokenScopes[req.Scope] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be one of: read, write, admin"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be one of: read, write, admin", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
// A token can never outrank its owner: non-admin users cannot mint
|
||||
// admin-scope tokens even for themselves.
|
||||
if req.Scope == "admin" && !c.GetBool("is_admin") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "admin scope requires a platform admin account"})
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "admin scope requires a platform admin account", "code": "FORBIDDEN"})
|
||||
return
|
||||
}
|
||||
if req.ExpiresInDays < 0 || req.ExpiresInDays > 3650 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "expires_in_days must be between 0 (never) and 3650"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "expires_in_days must be between 0 (never) and 3650", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
|
||||
token, prefix, hash, err := generateUserToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token", "code": "INTERNAL"})
|
||||
return
|
||||
}
|
||||
|
||||
ownerUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ func handleCreateUserToken(c *gin.Context) {
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store token"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store token", "code": "INTERNAL"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -140,13 +140,13 @@ func handleListUserTokens(c *gin.Context) {
|
||||
|
||||
ownerUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := queries.ListUserTokens(c.Request.Context(), ownerUUID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tokens"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tokens", "code": "INTERNAL"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -167,12 +167,12 @@ func handleDeleteUserToken(c *gin.Context) {
|
||||
|
||||
ownerUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context", "code": "INVALID_TOKEN"})
|
||||
return
|
||||
}
|
||||
tokenUUID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid token ID"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid token ID", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -181,11 +181,11 @@ func handleDeleteUserToken(c *gin.Context) {
|
||||
UserID: ownerUUID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke token"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke token", "code": "INTERNAL"})
|
||||
return
|
||||
}
|
||||
if affected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Token not found"})
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Token not found", "code": "NOT_FOUND"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user