Files
Containr/app/backend/internal/api/projects.go
T
Tomas DvorakandDevin 18f78f00a3 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>
2026-10-08 15:38:36 +02:00

404 lines
11 KiB
Go

package api
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
"context"
"database/sql"
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
OwnerID string `json:"owner_id"`
IsApproved bool `json:"is_approved"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type ProjectStats struct {
ServiceCount int `json:"service_count"`
DeploymentCount int `json:"deployment_count"`
RunningServices int `json:"running_services"`
LastDeployment *string `json:"last_deployment"`
}
type ProjectWithStats struct {
Project
Stats ProjectStats `json:"stats"`
}
type CreateProjectRequest struct {
Name string `json:"name" binding:"required,min=2"`
Description string `json:"description"`
}
type UpdateProjectRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
}
func handleGetProjects(c *gin.Context) {
userID := optionalUserUUID(c)
isAdmin := contextIsAdmin(c)
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
offset, limit := pageWindow(c, 10, 100)
search := strings.TrimSpace(c.DefaultQuery("search", ""))
searchParam := sql.NullString{}
if search != "" {
searchParam = sql.NullString{String: search, Valid: true}
}
rows, err := queries.ListProjectsWithStatsByUser(c.Request.Context(), sqlcdb.ListProjectsWithStatsByUserParams{
UserID: userID,
IsAdmin: isAdmin,
Search: searchParam,
LimitCount: int32(limit),
OffsetCount: int32(offset),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database query error"})
return
}
projects := make([]ProjectWithStats, 0, len(rows))
for _, row := range rows {
projects = append(projects, mapProjectWithStatsRow(row))
}
total, err := queries.CountProjectsByUser(c.Request.Context(), sqlcdb.CountProjectsByUserParams{
UserID: userID,
IsAdmin: isAdmin,
Search: searchParam,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database count error"})
return
}
c.JSON(http.StatusOK, gin.H{
"projects": projects,
"pagination": paginationMeta(offset, limit, int(total)),
})
}
func handleCreateProject(c *gin.Context) {
userID, ok := requireAuthenticatedUserUUID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
ctx := c.Request.Context()
var req CreateProjectRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Name = strings.TrimSpace(req.Name)
if len(req.Name) < 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "name must be at least 2 characters"})
return
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create project"})
return
}
defer tx.Rollback()
txQueries := queries.WithTx(tx)
projectRow, err := txQueries.CreateProject(ctx, sqlcdb.CreateProjectParams{
Name: req.Name,
Description: nullableText(strings.TrimSpace(req.Description)),
OwnerID: userID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create project"})
return
}
environments := []string{"production", "preview", "development"}
for _, env := range environments {
err = txQueries.InsertProjectEnvironment(ctx, sqlcdb.InsertProjectEnvironmentParams{
Name: env,
ProjectID: projectRow.ID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create environments"})
return
}
}
if err := tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create project"})
return
}
c.JSON(http.StatusCreated, mapSQLCProject(projectRow))
}
func handleGetProject(c *gin.Context) {
userID := optionalUserUUID(c)
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
projectID, ok := parseProjectIDParam(c)
if !ok {
return
}
projectRow, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{
ProjectID: projectID,
UserID: userID,
IsAdmin: contextIsAdmin(c),
})
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
c.JSON(http.StatusOK, mapSQLCProject(projectRow))
}
func handleUpdateProject(c *gin.Context) {
userID, ok := requireAuthenticatedUserUUID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
projectID, ok := parseProjectIDParam(c)
if !ok {
return
}
role, err := queries.GetProjectRoleForUser(c.Request.Context(), sqlcdb.GetProjectRoleForUserParams{
ProjectID: projectID,
UserID: userID,
IsAdmin: contextIsAdmin(c),
})
if errors.Is(err, sql.ErrNoRows) || role == "" || role == "viewer" {
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
var req UpdateProjectRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Name == nil && req.Description == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"})
return
}
nameParam := sql.NullString{}
if req.Name != nil {
trimmedName := strings.TrimSpace(*req.Name)
if len(trimmedName) < 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "name must be at least 2 characters"})
return
}
nameParam = sql.NullString{String: trimmedName, Valid: true}
}
descriptionParam := sql.NullString{}
if req.Description != nil {
descriptionParam = sql.NullString{String: strings.TrimSpace(*req.Description), Valid: true}
}
updatedRows, err := queries.UpdateProjectByID(c.Request.Context(), sqlcdb.UpdateProjectByIDParams{
Name: nameParam,
Description: descriptionParam,
ProjectID: projectID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update project"})
return
}
if updatedRows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
projectRow, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{
ProjectID: projectID,
UserID: userID,
IsAdmin: contextIsAdmin(c),
})
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
c.JSON(http.StatusOK, mapSQLCProject(projectRow))
}
func handleDeleteProject(c *gin.Context) {
userID, ok := requireAuthenticatedUserUUID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
projectID, ok := parseProjectIDParam(c)
if !ok {
return
}
ownerID, err := queries.GetProjectOwnerByID(c.Request.Context(), projectID)
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
if ownerID != userID && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Only project owners can delete projects"})
return
}
// Remove live containers and the project network before dropping rows.
// Best-effort: Docker may be unavailable; DB cleanup proceeds regardless.
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
engine := engineValue.(*deployment.DeploymentEngine)
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
_ = engine.RemoveProjectContainers(ctx, projectID.String())
_ = engine.RemoveProjectNetwork(ctx, projectID.String())
cancel()
}
deletedRows, err := queries.DeleteProjectByID(c.Request.Context(), projectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete project"})
return
}
if deletedRows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Project deleted successfully"})
}
func requireAuthenticatedUserUUID(c *gin.Context) (uuid.UUID, bool) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return uuid.Nil, false
}
parsedUserID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
return uuid.Nil, false
}
return parsedUserID, true
}
func parseProjectIDParam(c *gin.Context) (uuid.UUID, bool) {
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return uuid.Nil, false
}
return projectID, true
}
func mapProjectWithStatsRow(row sqlcdb.ListProjectsWithStatsByUserRow) ProjectWithStats {
result := ProjectWithStats{
Project: mapProjectFields(row.ID, row.Name, row.Description, row.OwnerID, row.IsApproved, row.CreatedAt, row.UpdatedAt),
Stats: ProjectStats{
ServiceCount: int(row.ServiceCount),
DeploymentCount: int(row.DeploymentCount),
RunningServices: int(row.RunningServices),
LastDeployment: nullableTimestampStringPtr(row.LastDeployment),
},
}
return result
}
func mapSQLCProject(project sqlcdb.Project) Project {
return mapProjectFields(project.ID, project.Name, project.Description, project.OwnerID, project.IsApproved, project.CreatedAt, project.UpdatedAt)
}
func mapProjectFields(id uuid.UUID, name string, description sql.NullString, ownerID uuid.UUID, isApproved bool, createdAt, updatedAt sql.NullTime) Project {
return Project{
ID: id.String(),
Name: name,
Description: nullableStringValue(description),
OwnerID: ownerID.String(),
IsApproved: isApproved,
CreatedAt: nullableTimestampString(createdAt),
UpdatedAt: nullableTimestampString(updatedAt),
}
}
func nullableText(value string) sql.NullString {
if value == "" {
return sql.NullString{}
}
return sql.NullString{String: value, Valid: true}
}
func nullableStringValue(value sql.NullString) string {
if value.Valid {
return value.String
}
return ""
}
func nullableTimestampString(value sql.NullTime) string {
if value.Valid {
return value.Time.Format(time.RFC3339)
}
return ""
}
func nullableTimestampStringPtr(value sql.NullTime) *string {
if value.Valid {
formatted := value.Time.Format(time.RFC3339)
return &formatted
}
return nil
}