Files
Primora/apps/backend/internal/handlers/http.go
T
2026-04-10 12:03:31 +02:00

1180 lines
31 KiB
Go

package handlers
import (
"errors"
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/tdvorak/primora/apps/backend/internal/middleware"
"github.com/tdvorak/primora/apps/backend/internal/observability"
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
"github.com/tdvorak/primora/apps/backend/internal/services"
)
type HTTPHandler struct {
Platform *services.PlatformService
Validate *validator.Validate
Metrics *observability.Metrics
Readiness func(*gin.Context) map[string]any
}
func (h *HTTPHandler) Register(router *gin.Engine) {
router.GET("/api/v1/health/liveness", h.liveness)
router.GET("/api/v1/health/readiness", h.readiness)
router.GET("/api/v1/openapi.yaml", h.openapi)
api := router.Group("/api/v1")
api.GET("/me", h.me)
api.POST("/bootstrap", h.bootstrap)
api.GET("/organizations", h.organizations)
api.POST("/organizations", h.createOrganization)
api.PATCH("/organizations/:organizationID", h.updateOrganization)
api.DELETE("/organizations/:organizationID", h.deleteOrganization)
api.GET("/organizations/:organizationID/members", h.organizationMembers)
api.PATCH("/organizations/:organizationID/members/:userID", h.updateOrganizationMemberRole)
api.DELETE("/organizations/:organizationID/members/:userID", h.removeOrganizationMember)
api.GET("/organizations/:organizationID/projects", h.projects)
api.POST("/organizations/:organizationID/projects", h.createProject)
api.GET("/organizations/:organizationID/invitations", h.invitations)
api.POST("/organizations/:organizationID/invitations", h.createInvitation)
api.DELETE("/organizations/:organizationID/invitations/:invitationID", h.revokeInvitation)
api.POST("/invitations/accept", h.acceptInvitation)
api.GET("/projects/:projectID/overview", h.projectOverview)
api.GET("/projects/:projectID/api-keys", h.apiKeys)
api.POST("/projects/:projectID/api-keys", h.createAPIKey)
api.DELETE("/projects/:projectID/api-keys/:apiKeyID", h.revokeAPIKey)
api.PATCH("/projects/:projectID", h.updateProject)
api.DELETE("/projects/:projectID", h.deleteProject)
api.GET("/projects/:projectID/members", h.projectMembers)
api.PATCH("/projects/:projectID/members/:userID", h.updateProjectMemberRole)
api.DELETE("/projects/:projectID/members/:userID", h.removeProjectMember)
api.GET("/projects/:projectID/buckets", h.buckets)
api.POST("/projects/:projectID/buckets", h.createBucket)
api.PATCH("/buckets/:bucketID", h.updateBucket)
api.DELETE("/buckets/:bucketID", h.deleteBucket)
api.GET("/projects/:projectID/audit-logs", h.auditLogs)
api.GET("/projects/:projectID/collections", h.listCollections)
api.POST("/projects/:projectID/collections", h.createCollection)
api.GET("/projects/:projectID/collections/:collectionID", h.getCollection)
api.PATCH("/projects/:projectID/collections/:collectionID", h.updateCollection)
api.DELETE("/projects/:projectID/collections/:collectionID", h.deleteCollection)
api.GET("/collections/:collectionID/documents", h.listDocuments)
api.POST("/collections/:collectionID/documents", h.createDocument)
api.GET("/collections/:collectionID/documents/:documentID", h.getDocument)
api.PATCH("/collections/:collectionID/documents/:documentID", h.updateDocument)
api.DELETE("/collections/:collectionID/documents/:documentID", h.deleteDocument)
api.GET("/buckets/:bucketID/objects", h.listObjects)
api.POST("/buckets/:bucketID/objects", h.uploadObject)
api.POST("/buckets/:bucketID/object-copies", h.copyObject)
api.GET("/buckets/:bucketID/objects/*objectKey", h.downloadObject)
api.PATCH("/buckets/:bucketID/objects/*objectKey", h.updateObject)
api.DELETE("/buckets/:bucketID/objects/*objectKey", h.deleteObject)
}
func (h *HTTPHandler) liveness(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
func (h *HTTPHandler) readiness(c *gin.Context) {
c.JSON(http.StatusOK, h.Readiness(c))
}
func (h *HTTPHandler) openapi(c *gin.Context) {
c.File("openapi/openapi.yaml")
}
func (h *HTTPHandler) me(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
result, err := h.Platform.Me(c.Request.Context(), actor)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) bootstrap(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
var body services.BootstrapInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.Bootstrap(c.Request.Context(), actor, body, middleware.RequestIDFromContext(c))
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusCreated, result)
}
func (h *HTTPHandler) organizations(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
result, err := h.Platform.ListOrganizations(c.Request.Context(), actor)
if err != nil {
h.handleError(c, err)
return
}
items := make([]organizationMembershipResponse, 0, len(result))
for _, row := range result {
items = append(items, toOrganizationMembershipResponse(row))
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (h *HTTPHandler) createOrganization(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
var body services.CreateOrganizationInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.CreateOrganization(c.Request.Context(), actor, body, middleware.RequestIDFromContext(c))
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusCreated, toOrganizationMembershipResponseFromCore(result, "owner"))
}
func (h *HTTPHandler) deleteOrganization(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
organizationID, ok := parseUUIDParam(c, "organizationID")
if !ok {
return
}
if err := h.Platform.DeleteOrganization(c.Request.Context(), actor, organizationID, middleware.RequestIDFromContext(c)); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "organization not found")
return
}
h.handleError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) updateOrganization(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
organizationID, ok := parseUUIDParam(c, "organizationID")
if !ok {
return
}
var body services.UpdateOrganizationInput
if !h.bindAndValidate(c, &body) {
return
}
if _, err := h.Platform.UpdateOrganization(c.Request.Context(), actor, organizationID, body, middleware.RequestIDFromContext(c)); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "organization not found")
return
}
h.handleError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) projects(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
organizationID, ok := parseUUIDParam(c, "organizationID")
if !ok {
return
}
query := strings.TrimSpace(c.Query("q"))
result, err := h.Platform.ListProjects(c.Request.Context(), actor, organizationID, query)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": toProjectListResponse(result)})
}
func (h *HTTPHandler) organizationMembers(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
organizationID, ok := parseUUIDParam(c, "organizationID")
if !ok {
return
}
result, err := h.Platform.ListOrganizationMembers(c.Request.Context(), actor, organizationID)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": result})
}
func (h *HTTPHandler) updateOrganizationMemberRole(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
organizationID, ok := parseUUIDParam(c, "organizationID")
if !ok {
return
}
userID, ok := parseUUIDParam(c, "userID")
if !ok {
return
}
var body services.UpdateOrganizationMemberRoleInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.UpdateOrganizationMemberRole(c.Request.Context(), actor, organizationID, userID, body, middleware.RequestIDFromContext(c))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "organization member not found")
return
}
if strings.Contains(err.Error(), "retain at least one") {
apperrors.Abort(c, http.StatusConflict, "role_conflict", err.Error())
return
}
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) removeOrganizationMember(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
organizationID, ok := parseUUIDParam(c, "organizationID")
if !ok {
return
}
userID, ok := parseUUIDParam(c, "userID")
if !ok {
return
}
if err := h.Platform.RemoveOrganizationMember(c.Request.Context(), actor, organizationID, userID, middleware.RequestIDFromContext(c)); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "organization member not found")
return
}
if strings.Contains(err.Error(), "retain at least one") {
apperrors.Abort(c, http.StatusConflict, "role_conflict", err.Error())
return
}
h.handleError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) createProject(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
organizationID, ok := parseUUIDParam(c, "organizationID")
if !ok {
return
}
var body services.CreateProjectInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.CreateProject(c.Request.Context(), actor, organizationID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusCreated, toProjectFromCore(result))
}
func (h *HTTPHandler) deleteProject(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
projectID, ok := parseUUIDParam(c, "projectID")
if !ok {
return
}
if err := h.Platform.DeleteProject(c.Request.Context(), actor, projectID, middleware.RequestIDFromContext(c)); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "project not found")
return
}
h.handleError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) updateProject(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
projectID, ok := parseUUIDParam(c, "projectID")
if !ok {
return
}
var body services.UpdateProjectInput
if !h.bindAndValidate(c, &body) {
return
}
if _, err := h.Platform.UpdateProject(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c)); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "project not found")
return
}
h.handleError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) createInvitation(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
organizationID, ok := parseUUIDParam(c, "organizationID")
if !ok {
return
}
var body services.CreateInvitationInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.CreateInvitation(c.Request.Context(), actor, organizationID, body, middleware.RequestIDFromContext(c))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "project not found")
return
}
h.handleError(c, err)
return
}
c.JSON(http.StatusCreated, result)
}
func (h *HTTPHandler) invitations(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
organizationID, ok := parseUUIDParam(c, "organizationID")
if !ok {
return
}
result, err := h.Platform.ListInvitations(c.Request.Context(), actor, organizationID)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": result})
}
func (h *HTTPHandler) revokeInvitation(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
organizationID, ok := parseUUIDParam(c, "organizationID")
if !ok {
return
}
invitationID, ok := parseUUIDParam(c, "invitationID")
if !ok {
return
}
if err := h.Platform.RevokeInvitation(c.Request.Context(), actor, organizationID, invitationID, middleware.RequestIDFromContext(c)); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "invitation not found")
return
}
h.handleError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) acceptInvitation(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
var body services.AcceptInvitationInput
if !h.bindAndValidate(c, &body) {
return
}
if err := h.Platform.AcceptInvitation(c.Request.Context(), actor, body, middleware.RequestIDFromContext(c)); err != nil {
h.handleError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) projectOverview(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
projectID, ok := parseUUIDParam(c, "projectID")
if !ok {
return
}
result, err := h.Platform.GetProjectOverview(c.Request.Context(), actor, projectID)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) apiKeys(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
projectID, ok := parseUUIDParam(c, "projectID")
if !ok {
return
}
result, err := h.Platform.ListAPIKeys(c.Request.Context(), actor, projectID)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": toAPIKeyListResponse(result)})
}
func (h *HTTPHandler) createAPIKey(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
projectID, ok := parseUUIDParam(c, "projectID")
if !ok {
return
}
var body services.CreateAPIKeyInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.CreateAPIKey(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusCreated, result)
}
func (h *HTTPHandler) revokeAPIKey(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
projectID, ok := parseUUIDParam(c, "projectID")
if !ok {
return
}
apiKeyID, ok := parseUUIDParam(c, "apiKeyID")
if !ok {
return
}
if err := h.Platform.RevokeAPIKey(c.Request.Context(), actor, projectID, apiKeyID, middleware.RequestIDFromContext(c)); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "api key not found")
return
}
h.handleError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) projectMembers(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
projectID, ok := parseUUIDParam(c, "projectID")
if !ok {
return
}
result, err := h.Platform.ListProjectMembers(c.Request.Context(), actor, projectID)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": result})
}
func (h *HTTPHandler) updateProjectMemberRole(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
projectID, ok := parseUUIDParam(c, "projectID")
if !ok {
return
}
userID, ok := parseUUIDParam(c, "userID")
if !ok {
return
}
var body services.UpdateProjectMemberRoleInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.UpdateProjectMemberRole(c.Request.Context(), actor, projectID, userID, body, middleware.RequestIDFromContext(c))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "project member not found")
return
}
if strings.Contains(err.Error(), "retain at least one") {
apperrors.Abort(c, http.StatusConflict, "role_conflict", err.Error())
return
}
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) removeProjectMember(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
projectID, ok := parseUUIDParam(c, "projectID")
if !ok {
return
}
userID, ok := parseUUIDParam(c, "userID")
if !ok {
return
}
if err := h.Platform.RemoveProjectMember(c.Request.Context(), actor, projectID, userID, middleware.RequestIDFromContext(c)); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "project member not found")
return
}
if strings.Contains(err.Error(), "retain at least one") {
apperrors.Abort(c, http.StatusConflict, "role_conflict", err.Error())
return
}
h.handleError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) buckets(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
projectID, ok := parseUUIDParam(c, "projectID")
if !ok {
return
}
query := strings.TrimSpace(c.Query("q"))
result, err := h.Platform.ListBuckets(c.Request.Context(), actor, projectID, query)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": toBucketListResponse(result)})
}
func (h *HTTPHandler) createBucket(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
projectID, ok := parseUUIDParam(c, "projectID")
if !ok {
return
}
var body services.CreateBucketInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.CreateBucket(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusCreated, toBucketResponse(result))
}
func (h *HTTPHandler) updateBucket(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
bucketID, ok := parseUUIDParam(c, "bucketID")
if !ok {
return
}
var body services.UpdateBucketInput
if !h.bindAndValidate(c, &body) {
return
}
if _, err := h.Platform.UpdateBucket(c.Request.Context(), actor, bucketID, body, middleware.RequestIDFromContext(c)); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "bucket not found")
return
}
h.handleError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) deleteBucket(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
bucketID, ok := parseUUIDParam(c, "bucketID")
if !ok {
return
}
if err := h.Platform.DeleteBucket(c.Request.Context(), actor, bucketID, middleware.RequestIDFromContext(c)); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "bucket not found")
return
}
h.handleError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) listObjects(c *gin.Context) {
bucketID, ok := parseUUIDParam(c, "bucketID")
if !ok {
return
}
limit, ok := parsePaginationQuery(c, "limit", 50, 1, 200)
if !ok {
return
}
offset, ok := parsePaginationQuery(c, "offset", 0, 0, 100000)
if !ok {
return
}
query := strings.TrimSpace(c.Query("q"))
actor, _ := middleware.ActorFromContext(c)
result, err := h.Platform.ListObjects(c.Request.Context(), actor, bucketID, query, limit, offset)
if err != nil {
h.handleError(c, err)
return
}
total, err := h.Platform.CountObjects(c.Request.Context(), actor, bucketID, query)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"items": toObjectListResponse(result),
"total": total,
"limit": limit,
"offset": offset,
"has_more": int64(offset)+int64(len(result)) < total,
})
}
func (h *HTTPHandler) uploadObject(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
bucketID, ok := parseUUIDParam(c, "bucketID")
if !ok {
return
}
fileHeader, err := c.FormFile("file")
if err != nil {
apperrors.Abort(c, http.StatusBadRequest, "invalid_file", "multipart file field `file` is required")
return
}
objectKey := c.PostForm("objectKey")
if objectKey == "" {
objectKey = fileHeader.Filename
}
file, err := fileHeader.Open()
if err != nil {
h.handleError(c, err)
return
}
defer file.Close()
result, err := h.Platform.UploadObject(c.Request.Context(), actor, bucketID, strings.TrimPrefix(objectKey, "/"), fileHeader.Header.Get("Content-Type"), file, middleware.RequestIDFromContext(c))
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusCreated, toObjectResponse(result))
}
func (h *HTTPHandler) copyObject(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
bucketID, ok := parseUUIDParam(c, "bucketID")
if !ok {
return
}
var body services.CopyObjectInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.CopyObject(c.Request.Context(), actor, bucketID, body, middleware.RequestIDFromContext(c))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "bucket or object not found")
return
}
h.handleError(c, err)
return
}
c.JSON(http.StatusCreated, toObjectResponse(result))
}
func (h *HTTPHandler) downloadObject(c *gin.Context) {
bucketID, ok := parseUUIDParam(c, "bucketID")
if !ok {
return
}
objectKey := strings.TrimPrefix(c.Param("objectKey"), "/")
actor, _ := middleware.ActorFromContext(c)
object, reader, err := h.Platform.GetObject(c.Request.Context(), actor, bucketID, objectKey)
if err != nil {
h.handleError(c, err)
return
}
defer reader.Close()
c.Header("Content-Type", object.ContentType)
c.Header("Content-Length", strconv.FormatInt(object.SizeBytes, 10))
c.Header("ETag", object.ChecksumSha256)
_, _ = io.Copy(c.Writer, reader)
}
func (h *HTTPHandler) updateObject(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
bucketID, ok := parseUUIDParam(c, "bucketID")
if !ok {
return
}
objectKey := strings.TrimPrefix(c.Param("objectKey"), "/")
var body services.UpdateObjectInput
if !h.bindAndValidate(c, &body) {
return
}
if _, err := h.Platform.UpdateObject(c.Request.Context(), actor, bucketID, objectKey, body, middleware.RequestIDFromContext(c)); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "bucket or object not found")
return
}
h.handleError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) deleteObject(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
bucketID, ok := parseUUIDParam(c, "bucketID")
if !ok {
return
}
if err := h.Platform.DeleteObject(c.Request.Context(), actor, bucketID, strings.TrimPrefix(c.Param("objectKey"), "/"), middleware.RequestIDFromContext(c)); err != nil {
h.handleError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) auditLogs(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
projectID, ok := parseUUIDParam(c, "projectID")
if !ok {
return
}
limit, ok := parsePaginationQuery(c, "limit", 50, 1, 200)
if !ok {
return
}
offset, ok := parsePaginationQuery(c, "offset", 0, 0, 100000)
if !ok {
return
}
query := strings.TrimSpace(c.Query("q"))
action := strings.TrimSpace(c.Query("action"))
result, err := h.Platform.ListAuditLogs(c.Request.Context(), actor, projectID, query, action, limit, offset)
if err != nil {
h.handleError(c, err)
return
}
total, err := h.Platform.CountAuditLogs(c.Request.Context(), actor, projectID, query, action)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"items": toAuditLogListResponse(result),
"total": total,
"limit": limit,
"offset": offset,
"has_more": int64(offset)+int64(len(result)) < total,
})
}
func (h *HTTPHandler) listCollections(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
projectID, ok := parseUUIDParam(c, "projectID")
if !ok {
return
}
result, err := h.Platform.ListCollections(c.Request.Context(), actor, projectID)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": toCollectionListResponse(result)})
}
func (h *HTTPHandler) createCollection(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
projectID, ok := parseUUIDParam(c, "projectID")
if !ok {
return
}
var body services.CreateCollectionInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.CreateCollection(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusCreated, toCollectionResponse(result))
}
func (h *HTTPHandler) getCollection(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
projectID, ok := parseUUIDParam(c, "projectID")
if !ok {
return
}
collectionID, ok := parseUUIDParam(c, "collectionID")
if !ok {
return
}
result, err := h.Platform.GetCollection(c.Request.Context(), actor, projectID, collectionID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "collection not found")
return
}
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, toCollectionResponse(result))
}
func (h *HTTPHandler) updateCollection(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
projectID, ok := parseUUIDParam(c, "projectID")
if !ok {
return
}
collectionID, ok := parseUUIDParam(c, "collectionID")
if !ok {
return
}
var body services.UpdateCollectionInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.UpdateCollection(c.Request.Context(), actor, projectID, collectionID, body, middleware.RequestIDFromContext(c))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "collection not found")
return
}
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, toCollectionResponse(result))
}
func (h *HTTPHandler) deleteCollection(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
projectID, ok := parseUUIDParam(c, "projectID")
if !ok {
return
}
collectionID, ok := parseUUIDParam(c, "collectionID")
if !ok {
return
}
if err := h.Platform.DeleteCollection(c.Request.Context(), actor, projectID, collectionID, middleware.RequestIDFromContext(c)); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "collection not found")
return
}
h.handleError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) listDocuments(c *gin.Context) {
collectionID, ok := parseUUIDParam(c, "collectionID")
if !ok {
return
}
limit, ok := parsePaginationQuery(c, "limit", 50, 1, 200)
if !ok {
return
}
offset, ok := parsePaginationQuery(c, "offset", 0, 0, 100000)
if !ok {
return
}
actor, _ := middleware.ActorFromContext(c)
result, err := h.Platform.ListDocuments(c.Request.Context(), actor, collectionID, limit, offset)
if err != nil {
h.handleError(c, err)
return
}
total, err := h.Platform.CountDocuments(c.Request.Context(), actor, collectionID)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"items": toDocumentListResponse(result),
"total": total,
"limit": limit,
"offset": offset,
"has_more": int64(offset)+int64(len(result)) < total,
})
}
func (h *HTTPHandler) createDocument(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
collectionID, ok := parseUUIDParam(c, "collectionID")
if !ok {
return
}
var body services.CreateDocumentInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.CreateDocument(c.Request.Context(), actor, collectionID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusCreated, toDocumentResponse(result))
}
func (h *HTTPHandler) getDocument(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
collectionID, ok := parseUUIDParam(c, "collectionID")
if !ok {
return
}
documentID, ok := parseUUIDParam(c, "documentID")
if !ok {
return
}
result, err := h.Platform.GetDocument(c.Request.Context(), actor, collectionID, documentID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "document not found")
return
}
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, toDocumentResponse(result))
}
func (h *HTTPHandler) updateDocument(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
collectionID, ok := parseUUIDParam(c, "collectionID")
if !ok {
return
}
documentID, ok := parseUUIDParam(c, "documentID")
if !ok {
return
}
var body services.UpdateDocumentInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.UpdateDocument(c.Request.Context(), actor, collectionID, documentID, body, middleware.RequestIDFromContext(c))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "document not found")
return
}
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, toDocumentResponse(result))
}
func (h *HTTPHandler) deleteDocument(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
collectionID, ok := parseUUIDParam(c, "collectionID")
if !ok {
return
}
documentID, ok := parseUUIDParam(c, "documentID")
if !ok {
return
}
if err := h.Platform.DeleteDocument(c.Request.Context(), actor, collectionID, documentID, middleware.RequestIDFromContext(c)); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "document not found")
return
}
h.handleError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func parsePaginationQuery(c *gin.Context, name string, defaultValue, minValue, maxValue int32) (int32, bool) {
raw := strings.TrimSpace(c.Query(name))
if raw == "" {
return defaultValue, true
}
value, err := strconv.Atoi(raw)
if err != nil {
apperrors.Abort(c, http.StatusBadRequest, "invalid_query", "invalid "+name)
return 0, false
}
intValue := int32(value)
if intValue < minValue || intValue > maxValue {
apperrors.Abort(c, http.StatusBadRequest, "invalid_query", name+" out of range")
return 0, false
}
return intValue, true
}
func (h *HTTPHandler) bindAndValidate(c *gin.Context, target any) bool {
if err := c.ShouldBindJSON(target); err != nil {
apperrors.Abort(c, http.StatusBadRequest, "invalid_json", err.Error())
return false
}
if err := h.Validate.Struct(target); err != nil {
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", err.Error())
return false
}
return true
}
func (h *HTTPHandler) handleError(c *gin.Context, err error) {
switch {
case errors.Is(err, io.EOF):
apperrors.Abort(c, http.StatusBadRequest, "invalid_body", "request body is empty")
default:
status := http.StatusInternalServerError
message := strings.ToLower(err.Error())
if strings.Contains(message, "insufficient") || strings.Contains(message, "access denied") {
status = http.StatusForbidden
}
if strings.Contains(message, "already") || strings.Contains(message, "exists") || strings.Contains(message, "duplicate key") || strings.Contains(message, "unique") {
status = http.StatusConflict
}
if strings.Contains(message, "expired") || strings.Contains(message, "invalid") {
status = http.StatusBadRequest
}
apperrors.Abort(c, status, "request_failed", err.Error())
}
}
func parseUUIDParam(c *gin.Context, name string) (uuid.UUID, bool) {
value := c.Param(name)
id, err := uuid.Parse(value)
if err != nil {
apperrors.Abort(c, http.StatusBadRequest, "invalid_id", "invalid "+name)
return uuid.Nil, false
}
return id, true
}