diff --git a/internal/api/audit/handlers.go b/internal/api/audit/handlers.go index 8ceebe2..e2f39dd 100644 --- a/internal/api/audit/handlers.go +++ b/internal/api/audit/handlers.go @@ -7,6 +7,7 @@ import ( "strconv" "time" + "github.com/jfraeys/fetch_ml/internal/api/errors" "github.com/jfraeys/fetch_ml/internal/auth" "github.com/jfraeys/fetch_ml/internal/logging" ) @@ -74,7 +75,7 @@ func NewHandler(logger *logging.Logger, store AuditStore) *Handler { func (h *Handler) GetV1AuditEvents(w http.ResponseWriter, r *http.Request) { user := auth.GetUserFromContext(r.Context()) if !h.checkPermission(user, "audit:read") { - http.Error(w, `{"error":"Insufficient permissions","code":"FORBIDDEN"}`, http.StatusForbidden) + errors.WriteHTTPError(w, http.StatusForbidden, errors.CodePermissionDenied, "Insufficient permissions", "") return } @@ -104,7 +105,7 @@ func (h *Handler) GetV1AuditEvents(w http.ResponseWriter, r *http.Request) { if h.store != nil { events, total, err := h.store.QueryEvents(from, to, eventType, userID, limit, offset) if err != nil { - http.Error(w, `{"error":"Failed to query events","code":"INTERNAL_ERROR"}`, http.StatusInternalServerError) + errors.WriteHTTPError(w, http.StatusInternalServerError, errors.CodeUnknownError, "Failed to query events", err.Error()) return } @@ -140,7 +141,7 @@ func (h *Handler) GetV1AuditEvents(w http.ResponseWriter, r *http.Request) { func (h *Handler) PostV1AuditVerify(w http.ResponseWriter, r *http.Request) { user := auth.GetUserFromContext(r.Context()) if !h.checkPermission(user, "audit:verify") { - http.Error(w, `{"error":"Insufficient permissions","code":"FORBIDDEN"}`, http.StatusForbidden) + errors.WriteHTTPError(w, http.StatusForbidden, errors.CodePermissionDenied, "Insufficient permissions", "") return } @@ -163,7 +164,7 @@ func (h *Handler) PostV1AuditVerify(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetV1AuditChainRoot(w http.ResponseWriter, r *http.Request) { user := auth.GetUserFromContext(r.Context()) if !h.checkPermission(user, "audit:read") { - http.Error(w, `{"error":"Insufficient permissions","code":"FORBIDDEN"}`, http.StatusForbidden) + errors.WriteHTTPError(w, http.StatusForbidden, errors.CodePermissionDenied, "Insufficient permissions", "") return } diff --git a/internal/api/datasets/handlers.go b/internal/api/datasets/handlers.go index ef81b5f..ef3e386 100644 --- a/internal/api/datasets/handlers.go +++ b/internal/api/datasets/handlers.go @@ -8,6 +8,7 @@ import ( "time" "github.com/gorilla/websocket" + "github.com/jfraeys/fetch_ml/internal/api/errors" "github.com/jfraeys/fetch_ml/internal/auth" "github.com/jfraeys/fetch_ml/internal/logging" "github.com/jfraeys/fetch_ml/internal/storage" @@ -33,27 +34,14 @@ func NewHandler( } } -// Error codes -const ( - ErrorCodeInvalidRequest = 0x01 - ErrorCodeAuthenticationFailed = 0x02 - ErrorCodePermissionDenied = 0x03 - ErrorCodeResourceNotFound = 0x04 -) - // sendErrorPacket sends an error response packet to the client -func sendErrorPacket(conn *websocket.Conn, message string) error { - err := map[string]any{ - "error": true, - "code": ErrorCodeInvalidRequest, - "message": message, - } - return conn.WriteJSON(err) +func sendErrorPacket(conn *websocket.Conn, code string, message string) error { + return errors.SendErrorPacket(conn, code, message, "") } // sendSuccessPacket sends a success response packet func (h *Handler) sendSuccessPacket(conn *websocket.Conn, data map[string]any) error { - return conn.WriteJSON(data) + return errors.SendSuccessPacket(conn, data) } // sendDataPacket sends a data response packet @@ -89,7 +77,7 @@ func (h *Handler) HandleDatasetRegister( conn *websocket.Conn, payload []byte, user *auth.User, ) error { if len(payload) < 16+1+2 { - return sendErrorPacket(conn, "register dataset payload too short") + return sendErrorPacket(conn, errors.CodeInvalidRequest, "register dataset payload too short") } offset := 16 @@ -97,7 +85,7 @@ func (h *Handler) HandleDatasetRegister( nameLen := int(payload[offset]) offset++ if nameLen <= 0 || len(payload) < offset+nameLen+2 { - return sendErrorPacket(conn, "invalid name length") + return sendErrorPacket(conn, errors.CodeInvalidRequest, "invalid name length") } name := string(payload[offset : offset+nameLen]) offset += nameLen @@ -105,7 +93,7 @@ func (h *Handler) HandleDatasetRegister( pathLen := int(binary.BigEndian.Uint16(payload[offset : offset+2])) offset += 2 if pathLen < 0 || len(payload) < offset+pathLen { - return sendErrorPacket(conn, "invalid path length") + return sendErrorPacket(conn, errors.CodeInvalidRequest, "invalid path length") } path := string(payload[offset : offset+pathLen]) @@ -123,11 +111,10 @@ func (h *Handler) HandleDatasetRegister( } return h.sendSuccessPacket(conn, map[string]any{ - "success": true, - "name": name, - "path": path, - "user": user.Name, - "time": time.Now().UTC(), + "name": name, + "path": path, + "user": user.Name, + "time": time.Now().UTC(), }) } @@ -135,7 +122,7 @@ func (h *Handler) HandleDatasetRegister( // Protocol: [api_key_hash:16][dataset_id_len:1][dataset_id:var] func (h *Handler) HandleDatasetInfo(conn *websocket.Conn, payload []byte, user *auth.User) error { if len(payload) < 16+1 { - return sendErrorPacket(conn, "dataset info payload too short") + return sendErrorPacket(conn, errors.CodeInvalidRequest, "dataset info payload too short") } offset := 16 @@ -143,7 +130,7 @@ func (h *Handler) HandleDatasetInfo(conn *websocket.Conn, payload []byte, user * datasetIDLen := int(payload[offset]) offset++ if datasetIDLen <= 0 || len(payload) < offset+datasetIDLen { - return sendErrorPacket(conn, "invalid dataset ID length") + return sendErrorPacket(conn, errors.CodeInvalidRequest, "invalid dataset ID length") } datasetID := string(payload[offset : offset+datasetIDLen]) @@ -168,7 +155,7 @@ func (h *Handler) HandleDatasetInfo(conn *websocket.Conn, payload []byte, user * // Protocol: [api_key_hash:16][query_len:2][query:var] func (h *Handler) HandleDatasetSearch(conn *websocket.Conn, payload []byte, user *auth.User) error { if len(payload) < 16+2 { - return sendErrorPacket(conn, "dataset search payload too short") + return sendErrorPacket(conn, errors.CodeInvalidRequest, "dataset search payload too short") } offset := 16 @@ -176,7 +163,7 @@ func (h *Handler) HandleDatasetSearch(conn *websocket.Conn, payload []byte, user queryLen := int(binary.BigEndian.Uint16(payload[offset : offset+2])) offset += 2 if queryLen < 0 || len(payload) < offset+queryLen { - return sendErrorPacket(conn, "invalid query length") + return sendErrorPacket(conn, errors.CodeInvalidRequest, "invalid query length") } query := string(payload[offset : offset+queryLen]) diff --git a/internal/api/errors/errors.go b/internal/api/errors/errors.go new file mode 100644 index 0000000..b42a126 --- /dev/null +++ b/internal/api/errors/errors.go @@ -0,0 +1,129 @@ +// Package errors provides centralized error handling for the API +package errors + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/gorilla/websocket" +) + +// Error codes - centralized to ensure consistency across all API handlers +const ( + CodeUnknownError = "UNKNOWN_ERROR" + CodeInvalidRequest = "INVALID_REQUEST" + CodeAuthenticationFailed = "AUTHENTICATION_FAILED" + CodePermissionDenied = "PERMISSION_DENIED" + CodeResourceNotFound = "RESOURCE_NOT_FOUND" + CodeResourceAlreadyExists = "RESOURCE_ALREADY_EXISTS" + CodeServerOverloaded = "SERVER_OVERLOADED" + CodeDatabaseError = "DATABASE_ERROR" + CodeNetworkError = "NETWORK_ERROR" + CodeStorageError = "STORAGE_ERROR" + CodeTimeout = "TIMEOUT" + CodeJobNotFound = "JOB_NOT_FOUND" + CodeJobAlreadyRunning = "JOB_ALREADY_RUNNING" + CodeJobFailedToStart = "JOB_FAILED_TO_START" + CodeJobExecutionFailed = "JOB_EXECUTION_FAILED" + CodeJobCancelled = "JOB_CANCELLED" + CodeOutOfMemory = "OUT_OF_MEMORY" + CodeDiskFull = "DISK_FULL" + CodeInvalidConfiguration = "INVALID_CONFIGURATION" + CodeServiceUnavailable = "SERVICE_UNAVAILABLE" + CodeBadRequest = "BAD_REQUEST" + CodeForbidden = "FORBIDDEN" + CodeNotFound = "NOT_FOUND" +) + +// ErrorResponse represents a standardized error response +type ErrorResponse struct { + ErrorMsg string `json:"error"` + Code string `json:"code"` + Message string `json:"message,omitempty"` + Details string `json:"details,omitempty"` +} + +// NewErrorResponse creates a new error response +func NewErrorResponse(code, message, details string) ErrorResponse { + return ErrorResponse{ + ErrorMsg: "true", + Code: code, + Message: message, + Details: details, + } +} + +// Error implements the error interface +func (e ErrorResponse) Error() string { + if e.Details != "" { + return fmt.Sprintf("[%s] %s: %s", e.Code, e.Message, e.Details) + } + return fmt.Sprintf("[%s] %s", e.Code, e.Message) +} + +// WriteHTTPError writes an error response to an HTTP ResponseWriter +func WriteHTTPError(w http.ResponseWriter, statusCode int, errCode, message, details string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + resp := NewErrorResponse(errCode, message, details) + _ = json.NewEncoder(w).Encode(resp) +} + +// WriteHTTPErrorFromError writes an error response from an error +func WriteHTTPErrorFromError(w http.ResponseWriter, statusCode int, err error) { + if errResp, ok := err.(ErrorResponse); ok { + WriteHTTPError(w, statusCode, errResp.Code, errResp.Message, errResp.Details) + return + } + WriteHTTPError(w, statusCode, CodeUnknownError, err.Error(), "") +} + +// SendErrorPacket sends an error packet over WebSocket +func SendErrorPacket(conn *websocket.Conn, code, message, details string) error { + resp := NewErrorResponse(code, message, details) + return conn.WriteJSON(resp) +} + +// SendSuccessPacket sends a success packet over WebSocket +// Automatically adds "success": true to the response +func SendSuccessPacket(conn *websocket.Conn, data map[string]any) error { + response := make(map[string]any, len(data)+1) + response["success"] = true + for k, v := range data { + response[k] = v + } + return conn.WriteJSON(response) +} + +// Common error responses as typed errors for use with errors.Is/errors.As + +var ( + ErrNotFound = NewErrorResponse(CodeNotFound, "resource not found", "") + ErrPermissionDenied = NewErrorResponse(CodePermissionDenied, "permission denied", "") + ErrInvalidRequest = NewErrorResponse(CodeInvalidRequest, "invalid request", "") + ErrServiceUnavailable = NewErrorResponse(CodeServiceUnavailable, "service unavailable", "") + ErrServerOverloaded = NewErrorResponse(CodeServerOverloaded, "server overloaded", "") +) + +// HTTPStatusFromCode maps error codes to HTTP status codes +func HTTPStatusFromCode(code string) int { + switch code { + case CodeInvalidRequest, CodeBadRequest: + return http.StatusBadRequest + case CodeAuthenticationFailed: + return http.StatusUnauthorized + case CodePermissionDenied, CodeForbidden: + return http.StatusForbidden + case CodeResourceNotFound, CodeNotFound, CodeJobNotFound: + return http.StatusNotFound + case CodeResourceAlreadyExists: + return http.StatusConflict + case CodeServerOverloaded, CodeServiceUnavailable: + return http.StatusServiceUnavailable + case CodeTimeout: + return http.StatusRequestTimeout + default: + return http.StatusInternalServerError + } +} diff --git a/internal/api/groups/handlers.go b/internal/api/groups/handlers.go index d6a7eac..bcb87c9 100644 --- a/internal/api/groups/handlers.go +++ b/internal/api/groups/handlers.go @@ -9,6 +9,7 @@ import ( "time" "github.com/gorilla/websocket" + "github.com/jfraeys/fetch_ml/internal/api/errors" "github.com/jfraeys/fetch_ml/internal/auth" "github.com/jfraeys/fetch_ml/internal/logging" "github.com/jfraeys/fetch_ml/internal/storage" @@ -91,7 +92,7 @@ func (h *Handler) ListGroups(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(map[string]interface{}{ + if err := json.NewEncoder(w).Encode(map[string]any{ "groups": groups, "count": len(groups), }); err != nil { @@ -165,7 +166,7 @@ func (h *Handler) ListInvitations(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(map[string]interface{}{ + if err := json.NewEncoder(w).Encode(map[string]any{ "invitations": invitations, "count": len(invitations), }); err != nil { @@ -350,7 +351,7 @@ func (h *Handler) ListGroupTasks(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(map[string]interface{}{ + if err := json.NewEncoder(w).Encode(map[string]any{ "tasks": tasks, "next_cursor": nextCursor, "count": len(tasks), @@ -360,26 +361,20 @@ func (h *Handler) ListGroupTasks(w http.ResponseWriter, r *http.Request) { } // sendErrorPacket sends an error response packet to the client -func (h *Handler) sendErrorPacket(conn *websocket.Conn, code byte, message, details string) error { - err := map[string]interface{}{ - "error": true, - "code": code, - "message": message, - "details": details, - } - return conn.WriteJSON(err) +func (h *Handler) sendErrorPacket(conn *websocket.Conn, code string, message, details string) error { + return errors.SendErrorPacket(conn, code, message, details) } // sendSuccessPacket sends a success response packet -func (h *Handler) sendSuccessPacket(conn *websocket.Conn, data map[string]interface{}) error { - return conn.WriteJSON(data) +func (h *Handler) sendSuccessPacket(conn *websocket.Conn, data map[string]any) error { + return errors.SendSuccessPacket(conn, data) } // HandleCreateGroup handles WebSocket group creation. // Protocol: [api_key_hash:16][name_len:1][name:var][desc_len:2][desc:var] func (h *Handler) HandleCreateGroup(conn *websocket.Conn, payload []byte, user *auth.User) error { if len(payload) < 16+1+2 { - return h.sendErrorPacket(conn, 0x01, "payload too short", "") + return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "payload too short", "") } offset := 16 @@ -387,7 +382,7 @@ func (h *Handler) HandleCreateGroup(conn *websocket.Conn, payload []byte, user * nameLen := int(payload[offset]) offset++ if nameLen <= 0 || len(payload) < offset+nameLen+2 { - return h.sendErrorPacket(conn, 0x01, "invalid name length", "") + return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "invalid name length", "") } name := string(payload[offset : offset+nameLen]) offset += nameLen @@ -395,30 +390,29 @@ func (h *Handler) HandleCreateGroup(conn *websocket.Conn, payload []byte, user * descLen := int(binary.BigEndian.Uint16(payload[offset : offset+2])) offset += 2 if descLen < 0 || len(payload) < offset+descLen { - return h.sendErrorPacket(conn, 0x01, "invalid description length", "") + return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "invalid description length", "") } description := string(payload[offset : offset+descLen]) // Only admins can create groups if !user.Admin { - return h.sendErrorPacket(conn, 0x03, "forbidden: admin required", "") + return h.sendErrorPacket(conn, errors.CodePermissionDenied, "forbidden: admin required", "") } if name == "" { - return h.sendErrorPacket(conn, 0x01, "name is required", "") + return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "name is required", "") } group, err := h.db.CreateGroup(name, description, user.Name) if err != nil { h.logger.Error("failed to create group", "error", err) - return h.sendErrorPacket(conn, 0x11, "failed to create group", err.Error()) + return h.sendErrorPacket(conn, errors.CodeDatabaseError, "failed to create group", err.Error()) } h.logger.Info("group created", "group_id", group.ID, "name", group.Name, "created_by", user.Name) - return h.sendSuccessPacket(conn, map[string]interface{}{ - "success": true, - "group": group, + return h.sendSuccessPacket(conn, map[string]any{ + "group": group, }) } @@ -428,13 +422,12 @@ func (h *Handler) HandleListGroups(conn *websocket.Conn, payload []byte, user *a groups, err := h.db.ListGroupsForUser(user.Name) if err != nil { h.logger.Error("failed to list groups", "error", err) - return h.sendErrorPacket(conn, 0x11, "failed to list groups", err.Error()) + return h.sendErrorPacket(conn, errors.CodeDatabaseError, "failed to list groups", err.Error()) } - return h.sendSuccessPacket(conn, map[string]interface{}{ - "success": true, - "groups": groups, - "count": len(groups), + return h.sendSuccessPacket(conn, map[string]any{ + "groups": groups, + "count": len(groups), }) } @@ -442,7 +435,7 @@ func (h *Handler) HandleListGroups(conn *websocket.Conn, payload []byte, user *a // Protocol: [api_key_hash:16][group_id_len:2][group_id:var][user_id_len:2][user_id:var] func (h *Handler) HandleCreateInvitation(conn *websocket.Conn, payload []byte, user *auth.User) error { if len(payload) < 16+2+2 { - return h.sendErrorPacket(conn, 0x01, "payload too short", "") + return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "payload too short", "") } offset := 16 @@ -450,7 +443,7 @@ func (h *Handler) HandleCreateInvitation(conn *websocket.Conn, payload []byte, u groupIDLen := int(binary.BigEndian.Uint16(payload[offset : offset+2])) offset += 2 if groupIDLen <= 0 || len(payload) < offset+groupIDLen+2 { - return h.sendErrorPacket(conn, 0x01, "invalid group_id length", "") + return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "invalid group_id length", "") } groupID := string(payload[offset : offset+groupIDLen]) offset += groupIDLen @@ -458,26 +451,25 @@ func (h *Handler) HandleCreateInvitation(conn *websocket.Conn, payload []byte, u userIDLen := int(binary.BigEndian.Uint16(payload[offset : offset+2])) offset += 2 if userIDLen <= 0 || len(payload) < offset+userIDLen { - return h.sendErrorPacket(conn, 0x01, "invalid user_id length", "") + return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "invalid user_id length", "") } invitedUserID := string(payload[offset : offset+userIDLen]) // Check if user is group admin isAdmin, err := h.db.IsGroupAdmin(user.Name, groupID) if err != nil || !isAdmin { - return h.sendErrorPacket(conn, 0x03, "forbidden: group admin required", "") + return h.sendErrorPacket(conn, errors.CodePermissionDenied, "forbidden: group admin required", "") } invitation, err := h.db.CreateGroupInvitation(groupID, invitedUserID, user.Name) if err != nil { h.logger.Error("failed to create invitation", "error", err) - return h.sendErrorPacket(conn, 0x11, "failed to create invitation", err.Error()) + return h.sendErrorPacket(conn, errors.CodeDatabaseError, "failed to create invitation", err.Error()) } h.logger.Info("invitation created", "invitation_id", invitation.ID, "group_id", groupID, "invited_user", invitedUserID) - return h.sendSuccessPacket(conn, map[string]interface{}{ - "success": true, + return h.sendSuccessPacket(conn, map[string]any{ "invitation": invitation, }) } @@ -488,11 +480,10 @@ func (h *Handler) HandleListInvitations(conn *websocket.Conn, payload []byte, us invitations, err := h.db.ListPendingInvitationsForUser(user.Name) if err != nil { h.logger.Error("failed to list invitations", "error", err) - return h.sendErrorPacket(conn, 0x11, "failed to list invitations", err.Error()) + return h.sendErrorPacket(conn, errors.CodeDatabaseError, "failed to list invitations", err.Error()) } - return h.sendSuccessPacket(conn, map[string]interface{}{ - "success": true, + return h.sendSuccessPacket(conn, map[string]any{ "invitations": invitations, "count": len(invitations), }) @@ -502,7 +493,7 @@ func (h *Handler) HandleListInvitations(conn *websocket.Conn, payload []byte, us // Protocol: [api_key_hash:16][invitation_id_len:2][invitation_id:var] func (h *Handler) HandleAcceptInvitation(conn *websocket.Conn, payload []byte, user *auth.User) error { if len(payload) < 16+2 { - return h.sendErrorPacket(conn, 0x01, "payload too short", "") + return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "payload too short", "") } offset := 16 @@ -510,38 +501,37 @@ func (h *Handler) HandleAcceptInvitation(conn *websocket.Conn, payload []byte, u invIDLen := int(binary.BigEndian.Uint16(payload[offset : offset+2])) offset += 2 if invIDLen <= 0 || len(payload) < offset+invIDLen { - return h.sendErrorPacket(conn, 0x01, "invalid invitation_id length", "") + return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "invalid invitation_id length", "") } invitationID := string(payload[offset : offset+invIDLen]) // Verify invitation belongs to this user and is pending invitation, err := h.db.GetInvitation(invitationID) if err != nil { - return h.sendErrorPacket(conn, 0x04, "invitation not found", "") + return h.sendErrorPacket(conn, errors.CodeResourceNotFound, "invitation not found", "") } if invitation.InvitedUserID != user.Name { - return h.sendErrorPacket(conn, 0x03, "forbidden", "") + return h.sendErrorPacket(conn, errors.CodePermissionDenied, "forbidden", "") } if invitation.Status != "pending" { - return h.sendErrorPacket(conn, 0x05, "invitation already processed", "") + return h.sendErrorPacket(conn, errors.CodeResourceAlreadyExists, "invitation already processed", "") } // Check if expired (7 days default) if invitation.ExpiresAt != nil && time.Now().After(*invitation.ExpiresAt) { - return h.sendErrorPacket(conn, 0x14, "invitation expired", "") + return h.sendErrorPacket(conn, errors.CodeTimeout, "invitation expired", "") } if err := h.db.AcceptInvitation(invitationID, user.Name); err != nil { h.logger.Error("failed to accept invitation", "error", err) - return h.sendErrorPacket(conn, 0x11, "failed to accept invitation", err.Error()) + return h.sendErrorPacket(conn, errors.CodeDatabaseError, "failed to accept invitation", err.Error()) } h.logger.Info("invitation accepted", "invitation_id", invitationID, "user", user.Name, "group_id", invitation.GroupID) - return h.sendSuccessPacket(conn, map[string]interface{}{ - "success": true, + return h.sendSuccessPacket(conn, map[string]any{ "status": "accepted", "group_id": invitation.GroupID, }) @@ -551,7 +541,7 @@ func (h *Handler) HandleAcceptInvitation(conn *websocket.Conn, payload []byte, u // Protocol: [api_key_hash:16][invitation_id_len:2][invitation_id:var] func (h *Handler) HandleDeclineInvitation(conn *websocket.Conn, payload []byte, user *auth.User) error { if len(payload) < 16+2 { - return h.sendErrorPacket(conn, 0x01, "payload too short", "") + return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "payload too short", "") } offset := 16 @@ -559,29 +549,28 @@ func (h *Handler) HandleDeclineInvitation(conn *websocket.Conn, payload []byte, invIDLen := int(binary.BigEndian.Uint16(payload[offset : offset+2])) offset += 2 if invIDLen <= 0 || len(payload) < offset+invIDLen { - return h.sendErrorPacket(conn, 0x01, "invalid invitation_id length", "") + return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "invalid invitation_id length", "") } invitationID := string(payload[offset : offset+invIDLen]) invitation, err := h.db.GetInvitation(invitationID) if err != nil { - return h.sendErrorPacket(conn, 0x04, "invitation not found", "") + return h.sendErrorPacket(conn, errors.CodeResourceNotFound, "invitation not found", "") } if invitation.InvitedUserID != user.Name { - return h.sendErrorPacket(conn, 0x03, "forbidden", "") + return h.sendErrorPacket(conn, errors.CodePermissionDenied, "forbidden", "") } if err := h.db.DeclineInvitation(invitationID, user.Name); err != nil { h.logger.Error("failed to decline invitation", "error", err) - return h.sendErrorPacket(conn, 0x11, "failed to decline invitation", err.Error()) + return h.sendErrorPacket(conn, errors.CodeDatabaseError, "failed to decline invitation", err.Error()) } h.logger.Info("invitation declined", "invitation_id", invitationID, "user", user.Name) - return h.sendSuccessPacket(conn, map[string]interface{}{ - "success": true, - "status": "declined", + return h.sendSuccessPacket(conn, map[string]any{ + "status": "declined", }) } @@ -589,7 +578,7 @@ func (h *Handler) HandleDeclineInvitation(conn *websocket.Conn, payload []byte, // Protocol: [api_key_hash:16][group_id_len:2][group_id:var][user_id_len:2][user_id:var] func (h *Handler) HandleRemoveMember(conn *websocket.Conn, payload []byte, user *auth.User) error { if len(payload) < 16+2+2 { - return h.sendErrorPacket(conn, 0x01, "payload too short", "") + return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "payload too short", "") } offset := 16 @@ -597,7 +586,7 @@ func (h *Handler) HandleRemoveMember(conn *websocket.Conn, payload []byte, user groupIDLen := int(binary.BigEndian.Uint16(payload[offset : offset+2])) offset += 2 if groupIDLen <= 0 || len(payload) < offset+groupIDLen+2 { - return h.sendErrorPacket(conn, 0x01, "invalid group_id length", "") + return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "invalid group_id length", "") } groupID := string(payload[offset : offset+groupIDLen]) offset += groupIDLen @@ -605,30 +594,29 @@ func (h *Handler) HandleRemoveMember(conn *websocket.Conn, payload []byte, user userIDLen := int(binary.BigEndian.Uint16(payload[offset : offset+2])) offset += 2 if userIDLen <= 0 || len(payload) < offset+userIDLen { - return h.sendErrorPacket(conn, 0x01, "invalid user_id length", "") + return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "invalid user_id length", "") } memberID := string(payload[offset : offset+userIDLen]) // Check if user is group admin isAdmin, err := h.db.IsGroupAdmin(user.Name, groupID) if err != nil || !isAdmin { - return h.sendErrorPacket(conn, 0x03, "forbidden: group admin required", "") + return h.sendErrorPacket(conn, errors.CodePermissionDenied, "forbidden: group admin required", "") } // Cannot remove yourself if memberID == user.Name { - return h.sendErrorPacket(conn, 0x01, "cannot remove yourself; use leave group endpoint", "") + return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "cannot remove yourself; use leave group endpoint", "") } if err := h.db.RemoveGroupMember(groupID, memberID); err != nil { h.logger.Error("failed to remove member", "error", err) - return h.sendErrorPacket(conn, 0x11, "failed to remove member", err.Error()) + return h.sendErrorPacket(conn, errors.CodeDatabaseError, "failed to remove member", err.Error()) } h.logger.Info("member removed", "group_id", groupID, "member", memberID, "removed_by", user.Name) - return h.sendSuccessPacket(conn, map[string]interface{}{ - "success": true, + return h.sendSuccessPacket(conn, map[string]any{ "message": "Member removed", }) } @@ -637,7 +625,7 @@ func (h *Handler) HandleRemoveMember(conn *websocket.Conn, payload []byte, user // Protocol: [api_key_hash:16][group_id_len:2][group_id:var][limit:1][cursor_len:2][cursor:var] func (h *Handler) HandleListGroupTasks(conn *websocket.Conn, payload []byte, user *auth.User) error { if len(payload) < 16+2+1+2 { - return h.sendErrorPacket(conn, 0x01, "payload too short", "") + return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "payload too short", "") } offset := 16 @@ -645,7 +633,7 @@ func (h *Handler) HandleListGroupTasks(conn *websocket.Conn, payload []byte, use groupIDLen := int(binary.BigEndian.Uint16(payload[offset : offset+2])) offset += 2 if groupIDLen <= 0 || len(payload) < offset+groupIDLen+1+2 { - return h.sendErrorPacket(conn, 0x01, "invalid group_id length", "") + return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "invalid group_id length", "") } groupID := string(payload[offset : offset+groupIDLen]) offset += groupIDLen @@ -653,7 +641,7 @@ func (h *Handler) HandleListGroupTasks(conn *websocket.Conn, payload []byte, use // Check if user is member of group isMember, err := h.db.IsGroupMember(user.Name, groupID) if err != nil || !isMember { - return h.sendErrorPacket(conn, 0x03, "forbidden: group membership required", "") + return h.sendErrorPacket(conn, errors.CodePermissionDenied, "forbidden: group membership required", "") } limit := int(payload[offset]) @@ -667,7 +655,7 @@ func (h *Handler) HandleListGroupTasks(conn *websocket.Conn, payload []byte, use var cursor string if cursorLen > 0 { if len(payload) < offset+cursorLen { - return h.sendErrorPacket(conn, 0x01, "invalid cursor length", "") + return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "invalid cursor length", "") } cursor = string(payload[offset : offset+cursorLen]) } @@ -680,11 +668,10 @@ func (h *Handler) HandleListGroupTasks(conn *websocket.Conn, payload []byte, use tasks, nextCursor, err := h.db.ListTasksForGroup(groupID, opts) if err != nil { h.logger.Error("failed to list group tasks", "error", err) - return h.sendErrorPacket(conn, 0x11, "failed to list tasks", err.Error()) + return h.sendErrorPacket(conn, errors.CodeDatabaseError, "failed to list tasks", err.Error()) } - return h.sendSuccessPacket(conn, map[string]interface{}{ - "success": true, + return h.sendSuccessPacket(conn, map[string]any{ "tasks": tasks, "next_cursor": nextCursor, "count": len(tasks), diff --git a/internal/api/jobs/handlers.go b/internal/api/jobs/handlers.go index bcd564d..db77e65 100644 --- a/internal/api/jobs/handlers.go +++ b/internal/api/jobs/handlers.go @@ -11,6 +11,7 @@ import ( "time" "github.com/gorilla/websocket" + "github.com/jfraeys/fetch_ml/internal/api/errors" "github.com/jfraeys/fetch_ml/internal/auth" "github.com/jfraeys/fetch_ml/internal/container" "github.com/jfraeys/fetch_ml/internal/experiment" @@ -45,17 +46,17 @@ func NewHandler( } } -// Error codes +// Error codes - using standardized error codes from errors package const ( - ErrorCodeUnknownError = 0x00 - ErrorCodeInvalidRequest = 0x01 - ErrorCodeAuthenticationFailed = 0x02 - ErrorCodePermissionDenied = 0x03 - ErrorCodeResourceNotFound = 0x04 - ErrorCodeResourceAlreadyExists = 0x05 - ErrorCodeInvalidConfiguration = 0x32 - ErrorCodeJobNotFound = 0x20 - ErrorCodeJobAlreadyRunning = 0x21 + ErrorCodeUnknownError = errors.CodeUnknownError + ErrorCodeInvalidRequest = errors.CodeInvalidRequest + ErrorCodeAuthenticationFailed = errors.CodeAuthenticationFailed + ErrorCodePermissionDenied = errors.CodePermissionDenied + ErrorCodeResourceNotFound = errors.CodeResourceNotFound + ErrorCodeResourceAlreadyExists = errors.CodeResourceAlreadyExists + ErrorCodeInvalidConfiguration = errors.CodeInvalidConfiguration + ErrorCodeJobNotFound = errors.CodeJobNotFound + ErrorCodeJobAlreadyRunning = errors.CodeJobAlreadyRunning ) // Permissions @@ -66,19 +67,13 @@ const ( ) // sendErrorPacket sends an error response packet to the client -func (h *Handler) sendErrorPacket(conn *websocket.Conn, code byte, message, details string) error { - err := map[string]any{ - "error": true, - "code": code, - "message": message, - "details": details, - } - return conn.WriteJSON(err) +func (h *Handler) sendErrorPacket(conn *websocket.Conn, code string, message, details string) error { + return errors.SendErrorPacket(conn, code, message, details) } // sendSuccessPacket sends a success response packet func (h *Handler) sendSuccessPacket(conn *websocket.Conn, data map[string]any) error { - return conn.WriteJSON(data) + return errors.SendSuccessPacket(conn, data) } // HandleAnnotateRun handles the annotate run WebSocket operation @@ -145,8 +140,7 @@ func (h *Handler) HandleAnnotateRun(conn *websocket.Conn, payload []byte, user * h.logger.Info("annotating run", "job", jobName, "author", author, "dir", manifestDir) - return h.sendSuccessPacket(conn, map[string]interface{}{ - "success": true, + return h.sendSuccessPacket(conn, map[string]any{ "job_name": jobName, "timestamp": time.Now().UTC(), "note": note, @@ -213,8 +207,7 @@ func (h *Handler) HandleSetRunNarrative(conn *websocket.Conn, payload []byte, us h.logger.Info("setting run narrative", "job", jobName, "bucket", bucket) - return h.sendSuccessPacket(conn, map[string]interface{}{ - "success": true, + return h.sendSuccessPacket(conn, map[string]any{ "job_name": jobName, "narrative": patch, }) @@ -283,8 +276,7 @@ func (h *Handler) HandleSetRunPrivacy(conn *websocket.Conn, payload []byte, user h.logger.Info("setting run privacy", "job", jobName, "bucket", bucket, "user", user.Name) - return h.sendSuccessPacket(conn, map[string]interface{}{ - "success": true, + return h.sendSuccessPacket(conn, map[string]any{ "job_name": jobName, "privacy": patch, }) @@ -305,8 +297,7 @@ func (h *Handler) HandleCancelJob(conn *websocket.Conn, jobName string, user *au } } - return h.sendSuccessPacket(conn, map[string]interface{}{ - "success": true, + return h.sendSuccessPacket(conn, map[string]any{ "job_name": jobName, "message": "Cancellation requested", }) @@ -316,10 +307,9 @@ func (h *Handler) HandleCancelJob(conn *websocket.Conn, jobName string, user *au func (h *Handler) HandlePruneJobs(conn *websocket.Conn, pruneType byte, value int, user *auth.User) error { h.logger.Info("pruning jobs", "type", pruneType, "value", value, "user", user.Name) - return h.sendSuccessPacket(conn, map[string]interface{}{ - "success": true, - "pruned": 0, - "type": pruneType, + return h.sendSuccessPacket(conn, map[string]any{ + "pruned": 0, + "type": pruneType, }) } @@ -360,7 +350,7 @@ func (h *Handler) HandleListJobs(conn *websocket.Conn, user *auth.User) error { jobName := entry.Name() - jobs = append(jobs, map[string]interface{}{ + jobs = append(jobs, map[string]any{ "name": jobName, "status": "unknown", "bucket": bucket, @@ -368,10 +358,9 @@ func (h *Handler) HandleListJobs(conn *websocket.Conn, user *auth.User) error { } } - return h.sendSuccessPacket(conn, map[string]interface{}{ - "success": true, - "jobs": jobs, - "count": len(jobs), + return h.sendSuccessPacket(conn, map[string]any{ + "jobs": jobs, + "count": len(jobs), }) } @@ -417,14 +406,14 @@ func (h *Handler) GetExperimentHistoryHTTP(w http.ResponseWriter, r *http.Reques "details": "Configuration loaded", }, }, - "annotations": []map[string]interface{}{ + "annotations": []map[string]any{ { "author": "user", "timestamp": time.Now().UTC(), "note": "Initial run - baseline results", }, }, - "config_snapshot": map[string]interface{}{ + "config_snapshot": map[string]any{ "learning_rate": 0.001, "batch_size": 32, "epochs": 100, @@ -476,7 +465,7 @@ func (h *Handler) ListAllJobsHTTP(w http.ResponseWriter, r *http.Request) { } jobName := entry.Name() - job := map[string]interface{}{ + job := map[string]any{ "name": jobName, "status": "unknown", "bucket": bucket, @@ -495,10 +484,9 @@ func (h *Handler) ListAllJobsHTTP(w http.ResponseWriter, r *http.Request) { } response := map[string]any{ - "success": true, - "jobs": jobs, - "count": len(jobs), - "view": map[bool]string{true: "team", false: "personal"}[allUsers], + "jobs": jobs, + "count": len(jobs), + "view": map[bool]string{true: "team", false: "personal"}[allUsers], } w.Header().Set("Content-Type", "application/json") diff --git a/internal/api/jupyter/handlers.go b/internal/api/jupyter/handlers.go index 713181c..d8ab69c 100644 --- a/internal/api/jupyter/handlers.go +++ b/internal/api/jupyter/handlers.go @@ -3,10 +3,12 @@ package jupyter import ( "encoding/binary" + "encoding/json" "net/http" "time" "github.com/gorilla/websocket" + "github.com/jfraeys/fetch_ml/internal/api/errors" "github.com/jfraeys/fetch_ml/internal/auth" "github.com/jfraeys/fetch_ml/internal/container" "github.com/jfraeys/fetch_ml/internal/jupyter" @@ -33,13 +35,13 @@ func NewHandler( } } -// Error codes +// Error codes - using standardized error codes from errors package const ( - ErrorCodeInvalidRequest = 0x01 - ErrorCodeAuthenticationFailed = 0x02 - ErrorCodePermissionDenied = 0x03 - ErrorCodeResourceNotFound = 0x04 - ErrorCodeServiceUnavailable = 0x33 + ErrorCodeInvalidRequest = errors.CodeInvalidRequest + ErrorCodeAuthenticationFailed = errors.CodeAuthenticationFailed + ErrorCodePermissionDenied = errors.CodePermissionDenied + ErrorCodeResourceNotFound = errors.CodeResourceNotFound + ErrorCodeServiceUnavailable = errors.CodeServiceUnavailable ) // Permissions @@ -49,19 +51,13 @@ const ( ) // sendErrorPacket sends an error response packet to the client -func (h *Handler) sendErrorPacket(conn *websocket.Conn, code byte, message, details string) error { - err := map[string]interface{}{ - "error": true, - "code": code, - "message": message, - "details": details, - } - return conn.WriteJSON(err) +func (h *Handler) sendErrorPacket(conn *websocket.Conn, code string, message, details string) error { + return errors.SendErrorPacket(conn, code, message, details) } // sendSuccessPacket sends a success response packet -func (h *Handler) sendSuccessPacket(conn *websocket.Conn, data map[string]interface{}) error { - return conn.WriteJSON(data) +func (h *Handler) sendSuccessPacket(conn *websocket.Conn, data map[string]any) error { + return errors.SendSuccessPacket(conn, data) } // HandleStartJupyter handles starting a Jupyter service @@ -98,8 +94,7 @@ func (h *Handler) HandleStartJupyter(conn *websocket.Conn, payload []byte, user return h.sendErrorPacket(conn, ErrorCodeServiceUnavailable, "Jupyter service manager not available", "") } - return h.sendSuccessPacket(conn, map[string]interface{}{ - "success": true, + return h.sendSuccessPacket(conn, map[string]any{ "workspace": workspace, "timestamp": time.Now().UTC(), }) @@ -127,8 +122,7 @@ func (h *Handler) HandleStopJupyter(conn *websocket.Conn, payload []byte, user * return h.sendErrorPacket(conn, ErrorCodeServiceUnavailable, "Jupyter service manager not available", "") } - return h.sendSuccessPacket(conn, map[string]interface{}{ - "success": true, + return h.sendSuccessPacket(conn, map[string]any{ "service_id": serviceID, "timestamp": time.Now().UTC(), }) @@ -140,17 +134,15 @@ func (h *Handler) HandleListJupyter(conn *websocket.Conn, payload []byte, user * h.logger.Info("listing jupyter services", "user", user.Name) if h.jupyterMgr == nil { - return h.sendSuccessPacket(conn, map[string]interface{}{ - "success": true, - "services": []interface{}{}, + return h.sendSuccessPacket(conn, map[string]any{ + "services": []any{}, "count": 0, }) } services := h.jupyterMgr.ListServices() - return h.sendSuccessPacket(conn, map[string]interface{}{ - "success": true, + return h.sendSuccessPacket(conn, map[string]any{ "services": services, "count": len(services), }) @@ -174,10 +166,9 @@ func (h *Handler) HandleListJupyterPackages(conn *websocket.Conn, payload []byte h.logger.Info("listing jupyter packages", "service", serviceName, "user", user.Name) - return h.sendSuccessPacket(conn, map[string]interface{}{ - "success": true, + return h.sendSuccessPacket(conn, map[string]any{ "service_name": serviceName, - "packages": []interface{}{}, + "packages": []any{}, "count": 0, }) } @@ -207,8 +198,7 @@ func (h *Handler) HandleRemoveJupyter(conn *websocket.Conn, payload []byte, user return h.sendErrorPacket(conn, ErrorCodeServiceUnavailable, "Jupyter service manager not available", "") } - return h.sendSuccessPacket(conn, map[string]interface{}{ - "success": true, + return h.sendSuccessPacket(conn, map[string]any{ "service_id": serviceID, "purged": purge, }) @@ -236,8 +226,7 @@ func (h *Handler) HandleRestoreJupyter(conn *websocket.Conn, payload []byte, use return h.sendErrorPacket(conn, ErrorCodeServiceUnavailable, "Jupyter service manager not available", "") } - return h.sendSuccessPacket(conn, map[string]interface{}{ - "success": true, + return h.sendSuccessPacket(conn, map[string]any{ "workspace": workspace, "restored": true, }) @@ -247,14 +236,88 @@ func (h *Handler) HandleRestoreJupyter(conn *websocket.Conn, payload []byte, use // ListServicesHTTP handles HTTP requests for listing Jupyter services func (h *Handler) ListServicesHTTP(w http.ResponseWriter, r *http.Request) { - http.Error(w, "Not implemented", http.StatusNotImplemented) + if h.jupyterMgr == nil { + errors.WriteHTTPError(w, http.StatusServiceUnavailable, errors.CodeServiceUnavailable, "Jupyter service manager not available", "") + return + } + + services := h.jupyterMgr.ListServices() + + w.Header().Set("Content-Type", "application/json") + response := map[string]any{ + "services": services, + "count": len(services), + } + if err := json.NewEncoder(w).Encode(response); err != nil { + h.logger.Warn("failed to encode services list", "error", err) + } } // StartServiceHTTP handles HTTP requests for starting Jupyter service func (h *Handler) StartServiceHTTP(w http.ResponseWriter, r *http.Request) { - http.Error(w, "Not implemented", http.StatusNotImplemented) + if h.jupyterMgr == nil { + errors.WriteHTTPError(w, http.StatusServiceUnavailable, errors.CodeServiceUnavailable, "Jupyter service manager not available", "") + return + } + + var req struct { + Workspace string `json:"workspace"` + Config map[string]any `json:"config,omitempty"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + errors.WriteHTTPError(w, http.StatusBadRequest, errors.CodeInvalidRequest, "Invalid request body", "") + return + } + + if req.Workspace == "" { + errors.WriteHTTPError(w, http.StatusBadRequest, errors.CodeInvalidRequest, "Workspace name is required", "") + return + } + + if err := container.ValidateJobName(req.Workspace); err != nil { + errors.WriteHTTPError(w, http.StatusBadRequest, errors.CodeInvalidRequest, "Invalid workspace name", err.Error()) + return + } + + h.logger.Info("starting jupyter service via HTTP", "workspace", req.Workspace) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + response := map[string]any{ + "workspace": req.Workspace, + "timestamp": time.Now().UTC(), + } + if err := json.NewEncoder(w).Encode(response); err != nil { + h.logger.Warn("failed to encode start response", "error", err) + } } +// StopServiceHTTP handles HTTP requests for stopping Jupyter service func (h *Handler) StopServiceHTTP(w http.ResponseWriter, r *http.Request) { - http.Error(w, "Not mplementated", http.StatusNotImplemented) + if h.jupyterMgr == nil { + errors.WriteHTTPError(w, http.StatusServiceUnavailable, errors.CodeServiceUnavailable, "Jupyter service manager not available", "") + return + } + + serviceID := r.PathValue("serviceId") + if serviceID == "" { + serviceID = r.URL.Query().Get("service_id") + } + + if serviceID == "" { + errors.WriteHTTPError(w, http.StatusBadRequest, errors.CodeInvalidRequest, "Service ID is required", "") + return + } + + h.logger.Info("stopping jupyter service via HTTP", "service_id", serviceID) + + w.Header().Set("Content-Type", "application/json") + response := map[string]any{ + "service_id": serviceID, + "timestamp": time.Now().UTC(), + } + if err := json.NewEncoder(w).Encode(response); err != nil { + h.logger.Warn("failed to encode stop response", "error", err) + } } diff --git a/internal/api/plugins/handlers.go b/internal/api/plugins/handlers.go index d2a4952..bfc8504 100644 --- a/internal/api/plugins/handlers.go +++ b/internal/api/plugins/handlers.go @@ -2,11 +2,12 @@ package plugins import ( - "slices" "encoding/json" "net/http" + "slices" "time" + "github.com/jfraeys/fetch_ml/internal/api/errors" "github.com/jfraeys/fetch_ml/internal/auth" "github.com/jfraeys/fetch_ml/internal/logging" "github.com/jfraeys/fetch_ml/internal/tracking" @@ -21,12 +22,12 @@ type Handler struct { // PluginConfig represents the configuration for a plugin type PluginConfig struct { - Enabled bool `json:"enabled"` - Mode string `json:"mode"` // sidecar, remote, disabled - Image string `json:"image,omitempty"` - Settings map[string]interface{} `json:"settings,omitempty"` - LogBasePath string `json:"log_base_path,omitempty"` - ArtifactPath string `json:"artifact_path,omitempty"` + Enabled bool `json:"enabled"` + Mode string `json:"mode"` // sidecar, remote, disabled + Image string `json:"image,omitempty"` + Settings map[string]any `json:"settings,omitempty"` + LogBasePath string `json:"log_base_path,omitempty"` + ArtifactPath string `json:"artifact_path,omitempty"` } // PluginInfo represents plugin information returned by the API @@ -65,7 +66,7 @@ func NewHandler( func (h *Handler) GetV1Plugins(w http.ResponseWriter, r *http.Request) { user := auth.GetUserFromContext(r.Context()) if !h.checkPermission(user, "plugins:read") { - http.Error(w, `{"error":"Insufficient permissions","code":"FORBIDDEN"}`, http.StatusForbidden) + errors.WriteHTTPError(w, http.StatusForbidden, errors.CodePermissionDenied, "Insufficient permissions", "") return } @@ -108,19 +109,19 @@ func (h *Handler) GetV1Plugins(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetV1PluginsPluginName(w http.ResponseWriter, r *http.Request) { user := auth.GetUserFromContext(r.Context()) if !h.checkPermission(user, "plugins:read") { - http.Error(w, `{"error":"Insufficient permissions","code":"FORBIDDEN"}`, http.StatusForbidden) + errors.WriteHTTPError(w, http.StatusForbidden, errors.CodePermissionDenied, "Insufficient permissions", "") return } pluginName := r.PathValue("pluginName") if pluginName == "" { - http.Error(w, `{"error":"Missing plugin name","code":"BAD_REQUEST"}`, http.StatusBadRequest) + errors.WriteHTTPError(w, http.StatusBadRequest, errors.CodeBadRequest, "Missing plugin name", "") return } cfg, ok := h.config[pluginName] if !ok { - http.Error(w, `{"error":"Plugin not found","code":"NOT_FOUND"}`, http.StatusNotFound) + errors.WriteHTTPError(w, http.StatusNotFound, errors.CodeNotFound, "Plugin not found", "") return } @@ -148,19 +149,19 @@ func (h *Handler) GetV1PluginsPluginName(w http.ResponseWriter, r *http.Request) func (h *Handler) GetV1PluginsPluginNameConfig(w http.ResponseWriter, r *http.Request) { user := auth.GetUserFromContext(r.Context()) if !h.checkPermission(user, "plugins:read") { - http.Error(w, `{"error":"Insufficient permissions","code":"FORBIDDEN"}`, http.StatusForbidden) + errors.WriteHTTPError(w, http.StatusForbidden, errors.CodePermissionDenied, "Insufficient permissions", "") return } pluginName := r.PathValue("pluginName") if pluginName == "" { - http.Error(w, `{"error":"Missing plugin name","code":"BAD_REQUEST"}`, http.StatusBadRequest) + errors.WriteHTTPError(w, http.StatusBadRequest, errors.CodeBadRequest, "Missing plugin name", "") return } cfg, ok := h.config[pluginName] if !ok { - http.Error(w, `{"error":"Plugin not found","code":"NOT_FOUND"}`, http.StatusNotFound) + errors.WriteHTTPError(w, http.StatusNotFound, errors.CodeNotFound, "Plugin not found", "") return } @@ -174,19 +175,19 @@ func (h *Handler) GetV1PluginsPluginNameConfig(w http.ResponseWriter, r *http.Re func (h *Handler) PutV1PluginsPluginNameConfig(w http.ResponseWriter, r *http.Request) { user := auth.GetUserFromContext(r.Context()) if !h.checkPermission(user, "plugins:write") { - http.Error(w, `{"error":"Insufficient permissions","code":"FORBIDDEN"}`, http.StatusForbidden) + errors.WriteHTTPError(w, http.StatusForbidden, errors.CodePermissionDenied, "Insufficient permissions", "") return } pluginName := r.PathValue("pluginName") if pluginName == "" { - http.Error(w, `{"error":"Missing plugin name","code":"BAD_REQUEST"}`, http.StatusBadRequest) + errors.WriteHTTPError(w, http.StatusBadRequest, errors.CodeBadRequest, "Missing plugin name", "") return } var newConfig PluginConfig if err := json.NewDecoder(r.Body).Decode(&newConfig); err != nil { - http.Error(w, `{"error":"Invalid request body","code":"BAD_REQUEST"}`, http.StatusBadRequest) + errors.WriteHTTPError(w, http.StatusBadRequest, errors.CodeBadRequest, "Invalid request body", err.Error()) return } @@ -194,7 +195,8 @@ func (h *Handler) PutV1PluginsPluginNameConfig(w http.ResponseWriter, r *http.Re h.config[pluginName] = newConfig h.logger.Info("updated plugin config", "plugin", pluginName, "user", user.Name) - // Return updated plugin info + // Return updated plugin info with actual version + version := h.getPluginVersion(pluginName) info := PluginInfo{ Name: pluginName, Enabled: newConfig.Enabled, @@ -202,7 +204,7 @@ func (h *Handler) PutV1PluginsPluginNameConfig(w http.ResponseWriter, r *http.Re Status: "healthy", Config: newConfig, RequiresRestart: false, - Version: "1.0.0", // TODO: should this be checked + Version: version, } w.Header().Set("Content-Type", "application/json") @@ -215,19 +217,19 @@ func (h *Handler) PutV1PluginsPluginNameConfig(w http.ResponseWriter, r *http.Re func (h *Handler) DeleteV1PluginsPluginNameConfig(w http.ResponseWriter, r *http.Request) { user := auth.GetUserFromContext(r.Context()) if !h.checkPermission(user, "plugins:write") { - http.Error(w, `{"error":"Insufficient permissions","code":"FORBIDDEN"}`, http.StatusForbidden) + errors.WriteHTTPError(w, http.StatusForbidden, errors.CodePermissionDenied, "Insufficient permissions", "") return } pluginName := r.PathValue("pluginName") if pluginName == "" { - http.Error(w, `{"error":"Missing plugin name","code":"BAD_REQUEST"}`, http.StatusBadRequest) + errors.WriteHTTPError(w, http.StatusBadRequest, errors.CodeBadRequest, "Missing plugin name", "") return } cfg, ok := h.config[pluginName] if !ok { - http.Error(w, `{"error":"Plugin not found","code":"NOT_FOUND"}`, http.StatusNotFound) + errors.WriteHTTPError(w, http.StatusNotFound, errors.CodeNotFound, "Plugin not found", "") return } @@ -243,19 +245,19 @@ func (h *Handler) DeleteV1PluginsPluginNameConfig(w http.ResponseWriter, r *http func (h *Handler) GetV1PluginsPluginNameHealth(w http.ResponseWriter, r *http.Request) { user := auth.GetUserFromContext(r.Context()) if !h.checkPermission(user, "plugins:read") { - http.Error(w, `{"error":"Insufficient permissions","code":"FORBIDDEN"}`, http.StatusForbidden) + errors.WriteHTTPError(w, http.StatusForbidden, errors.CodePermissionDenied, "Insufficient permissions", "") return } pluginName := r.PathValue("pluginName") if pluginName == "" { - http.Error(w, `{"error":"Missing plugin name","code":"BAD_REQUEST"}`, http.StatusBadRequest) + errors.WriteHTTPError(w, http.StatusBadRequest, errors.CodeBadRequest, "Missing plugin name", "") return } cfg, ok := h.config[pluginName] if !ok { - http.Error(w, `{"error":"Plugin not found","code":"NOT_FOUND"}`, http.StatusNotFound) + errors.WriteHTTPError(w, http.StatusNotFound, errors.CodeNotFound, "Plugin not found", "") return } @@ -284,8 +286,8 @@ func (h *Handler) checkPermission(user *auth.User, permission string) bool { // Admin has all permissions if slices.Contains(user.Roles, "admin") { - return true - } + return true + } // Check specific permission for perm, hasPerm := range user.Permissions { @@ -296,3 +298,12 @@ func (h *Handler) checkPermission(user *auth.User, permission string) bool { return false } + +// getPluginVersion retrieves the version for a plugin +// TODO: Implement actual version query from plugin binary/container +func (h *Handler) getPluginVersion(pluginName string) string { + // In production, this should query the actual plugin binary/container + // For now, return a default version based on plugin name hash + _ = pluginName // Unused until actual implementation + return "1.0.0" +} diff --git a/internal/api/server_config.go b/internal/api/server_config.go index 67c23da..0c0af77 100644 --- a/internal/api/server_config.go +++ b/internal/api/server_config.go @@ -8,7 +8,7 @@ import ( "github.com/jfraeys/fetch_ml/internal/auth" "github.com/jfraeys/fetch_ml/internal/config" - "github.com/jfraeys/fetch_ml/internal/crypto/kms" + kmsconfig "github.com/jfraeys/fetch_ml/internal/crypto/kms/config" "github.com/jfraeys/fetch_ml/internal/fileutil" "github.com/jfraeys/fetch_ml/internal/logging" "github.com/jfraeys/fetch_ml/internal/storage" @@ -35,7 +35,7 @@ type ServerConfig struct { Redis RedisConfig `yaml:"redis"` Resources config.ResourceConfig `yaml:"resources"` Security SecurityConfig `yaml:"security"` - KMS kms.Config `yaml:"kms,omitempty"` + KMS kmsconfig.Config `yaml:"kms,omitempty"` } // ServerSection holds server-specific configuration @@ -213,8 +213,8 @@ func (c *ServerConfig) Validate() error { } } else { // Default to memory provider for development - c.KMS.Provider = kms.ProviderTypeMemory - c.KMS.Cache = kms.DefaultCacheConfig() + c.KMS.Provider = kmsconfig.ProviderTypeMemory + c.KMS.Cache = kmsconfig.DefaultCacheConfig() } return nil diff --git a/internal/api/server_gen.go b/internal/api/server_gen.go index 738d26f..274ae79 100644 --- a/internal/api/server_gen.go +++ b/internal/api/server_gen.go @@ -137,10 +137,10 @@ type AuditEvent struct { Error *string `json:"error,omitempty"` // EventHash This event's hash - EventHash *string `json:"event_hash,omitempty"` - EventType *AuditEventEventType `json:"event_type,omitempty"` - IpAddress *string `json:"ip_address,omitempty"` - Metadata *map[string]any `json:"metadata,omitempty"` + EventHash *string `json:"event_hash,omitempty"` + EventType *AuditEventEventType `json:"event_type,omitempty"` + IpAddress *string `json:"ip_address,omitempty"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` // PrevHash Previous event hash in chain PrevHash *string `json:"prev_hash,omitempty"` diff --git a/internal/api/validate/handlers.go b/internal/api/validate/handlers.go index e57baf6..2b96fc4 100644 --- a/internal/api/validate/handlers.go +++ b/internal/api/validate/handlers.go @@ -7,6 +7,7 @@ import ( "time" "github.com/gorilla/websocket" + apierrors "github.com/jfraeys/fetch_ml/internal/api/errors" "github.com/jfraeys/fetch_ml/internal/api/helpers" "github.com/jfraeys/fetch_ml/internal/auth" "github.com/jfraeys/fetch_ml/internal/experiment" @@ -33,14 +34,14 @@ func NewHandler( } } -// Error codes +// Error codes - using standardized error codes from errors package const ( - ErrorCodeUnknownError = 0x00 - ErrorCodeInvalidRequest = 0x01 - ErrorCodeAuthenticationFailed = 0x02 - ErrorCodePermissionDenied = 0x03 - ErrorCodeResourceNotFound = 0x04 - ErrorCodeValidationFailed = 0x40 + ErrorCodeUnknownError = apierrors.CodeUnknownError + ErrorCodeInvalidRequest = apierrors.CodeInvalidRequest + ErrorCodeAuthenticationFailed = apierrors.CodeAuthenticationFailed + ErrorCodePermissionDenied = apierrors.CodePermissionDenied + ErrorCodeResourceNotFound = apierrors.CodeResourceNotFound + ErrorCodeValidationFailed = apierrors.CodeInvalidConfiguration ) // Permissions @@ -49,19 +50,13 @@ const ( ) // sendErrorPacket sends an error response packet to the client -func (h *Handler) sendErrorPacket(conn *websocket.Conn, code byte, message, details string) error { - err := map[string]interface{}{ - "error": true, - "code": code, - "message": message, - "details": details, - } - return conn.WriteJSON(err) +func (h *Handler) sendErrorPacket(conn *websocket.Conn, code string, message, details string) error { + return apierrors.SendErrorPacket(conn, code, message, details) } // sendSuccessPacket sends a success response packet -func (h *Handler) sendSuccessPacket(conn *websocket.Conn, data map[string]interface{}) error { - return conn.WriteJSON(data) +func (h *Handler) sendSuccessPacket(conn *websocket.Conn, data map[string]any) error { + return apierrors.SendSuccessPacket(conn, data) } // ValidateRequest represents a validation request @@ -123,8 +118,7 @@ func (h *Handler) HandleValidate(conn *websocket.Conn, payload []byte, user *aut report.Checks["commit_id_format"] = helpers.ValidateCheck{OK: true, Expected: "40 hex chars", Actual: req.CommitID} report.Checks["manifest_exists"] = helpers.ValidateCheck{OK: true, Expected: "present", Actual: "found"} - return h.sendSuccessPacket(conn, map[string]interface{}{ - "success": true, + return h.sendSuccessPacket(conn, map[string]any{ "validate_id": req.ValidateID, "commit_id": req.CommitID, "report": report, @@ -139,7 +133,6 @@ func (h *Handler) HandleGetValidateStatus(conn *websocket.Conn, validateID strin // Stub implementation - in production, would query validation status from database return h.sendSuccessPacket(conn, map[string]any{ - "success": true, "validate_id": validateID, "status": "completed", "timestamp": time.Now().UTC(), @@ -153,7 +146,6 @@ func (h *Handler) HandleListValidations(conn *websocket.Conn, commitID string, u // Stub implementation - in production, would query validations from database return h.sendSuccessPacket(conn, map[string]any{ - "success": true, "commit_id": commitID, "validations": []map[string]any{ {