From e413e955c5ead71c6dcde494d68bf9ffd2b8c636 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Tue, 3 Dec 2024 21:50:16 +0100 Subject: [PATCH] Update api docs --- server/internal/git/client.go | 25 ++- server/internal/handlers/admin_handlers.go | 100 ++++++------ server/internal/handlers/auth_handlers.go | 46 +++--- .../auth_handlers_integration_test.go | 2 +- server/internal/handlers/file_handlers.go | 142 ++++++++++-------- .../file_handlers_integration_test.go | 4 +- server/internal/handlers/git_handlers.go | 47 +++--- .../handlers/git_handlers_integration_test.go | 4 +- server/internal/handlers/handlers.go | 13 +- server/internal/handlers/mock_git_test.go | 7 +- server/internal/handlers/static_handler.go | 2 +- server/internal/handlers/user_handlers.go | 70 ++++----- .../user_handlers_integration_test.go | 2 +- .../internal/handlers/workspace_handlers.go | 102 +++++++------ .../workspace_handlers_integration_test.go | 2 +- server/internal/storage/git.go | 14 +- server/internal/storage/git_test.go | 10 +- 17 files changed, 331 insertions(+), 261 deletions(-) diff --git a/server/internal/git/client.go b/server/internal/git/client.go index 0b9cc9b..24e09c1 100644 --- a/server/internal/git/client.go +++ b/server/internal/git/client.go @@ -8,6 +8,7 @@ import ( "time" "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/transport/http" ) @@ -26,11 +27,19 @@ type Config struct { type Client interface { Clone() error Pull() error - Commit(message string) error + Commit(message string) (CommitHash, error) Push() error EnsureRepo() error } +// CommitHash represents a Git commit hash +type CommitHash plumbing.Hash + +// String returns the string representation of the CommitHash +func (h CommitHash) String() string { + return plumbing.Hash(h).String() +} + // client implements the Client interface type client struct { Config @@ -101,22 +110,22 @@ func (c *client) Pull() error { } // Commit commits the changes in the repository with the given message -func (c *client) Commit(message string) error { +func (c *client) Commit(message string) (CommitHash, error) { if c.repo == nil { - return fmt.Errorf("repository not initialized") + return CommitHash(plumbing.ZeroHash), fmt.Errorf("repository not initialized") } w, err := c.repo.Worktree() if err != nil { - return fmt.Errorf("failed to get worktree: %w", err) + return CommitHash(plumbing.ZeroHash), fmt.Errorf("failed to get worktree: %w", err) } _, err = w.Add(".") if err != nil { - return fmt.Errorf("failed to add changes: %w", err) + return CommitHash(plumbing.ZeroHash), fmt.Errorf("failed to add changes: %w", err) } - _, err = w.Commit(message, &git.CommitOptions{ + hash, err := w.Commit(message, &git.CommitOptions{ Author: &object.Signature{ Name: c.CommitName, Email: c.CommitEmail, @@ -124,10 +133,10 @@ func (c *client) Commit(message string) error { }, }) if err != nil { - return fmt.Errorf("failed to commit changes: %w", err) + return CommitHash(plumbing.ZeroHash), fmt.Errorf("failed to commit changes: %w", err) } - return nil + return CommitHash(hash), nil } // Push pushes the changes to the remote repository diff --git a/server/internal/handlers/admin_handlers.go b/server/internal/handlers/admin_handlers.go index 3d1a2db..a776204 100644 --- a/server/internal/handlers/admin_handlers.go +++ b/server/internal/handlers/admin_handlers.go @@ -55,13 +55,13 @@ type SystemStats struct { // @ID adminListUsers // @Produce json // @Success 200 {array} models.User -// @Failure 500 {string} "Failed to list users" +// @Failure 500 {object} ErrorResponse "Failed to list users" // @Router /admin/users [get] func (h *Handler) AdminListUsers() http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { users, err := h.DB.GetAllUsers() if err != nil { - http.Error(w, "Failed to list users", http.StatusInternalServerError) + respondError(w, "Failed to list users", http.StatusInternalServerError) return } @@ -79,45 +79,45 @@ func (h *Handler) AdminListUsers() http.HandlerFunc { // @Produce json // @Param user body CreateUserRequest true "User details" // @Success 200 {object} models.User -// @Failure 400 {string} "Invalid request body" -// @Failure 400 {string} "Email, password, and role are required" -// @Failure 400 {string} "Password must be at least 8 characters" -// @Failure 409 {string} "Email already exists" -// @Failure 500 {string} "Failed to hash password" -// @Failure 500 {string} "Failed to create user" -// @Failure 500 {string} "Failed to initialize user workspace" +// @Failure 400 {object} ErrorResponse "Invalid request body" +// @Failure 400 {object} ErrorResponse "Email, password, and role are required" +// @Failure 400 {object} ErrorResponse "Password must be at least 8 characters" +// @Failure 409 {object} ErrorResponse "Email already exists" +// @Failure 500 {object} ErrorResponse "Failed to hash password" +// @Failure 500 {object} ErrorResponse "Failed to create user" +// @Failure 500 {object} ErrorResponse "Failed to initialize user workspace" // @Router /admin/users [post] func (h *Handler) AdminCreateUser() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req CreateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + respondError(w, "Invalid request body", http.StatusBadRequest) return } // Validate request if req.Email == "" || req.Password == "" || req.Role == "" { - http.Error(w, "Email, password, and role are required", http.StatusBadRequest) + respondError(w, "Email, password, and role are required", http.StatusBadRequest) return } // Check if email already exists existingUser, err := h.DB.GetUserByEmail(req.Email) if err == nil && existingUser != nil { - http.Error(w, "Email already exists", http.StatusConflict) + respondError(w, "Email already exists", http.StatusConflict) return } // Check if password is long enough if len(req.Password) < 8 { - http.Error(w, "Password must be at least 8 characters", http.StatusBadRequest) + respondError(w, "Password must be at least 8 characters", http.StatusBadRequest) return } // Hash password hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { - http.Error(w, "Failed to hash password", http.StatusInternalServerError) + respondError(w, "Failed to hash password", http.StatusInternalServerError) return } @@ -131,13 +131,13 @@ func (h *Handler) AdminCreateUser() http.HandlerFunc { insertedUser, err := h.DB.CreateUser(user) if err != nil { - http.Error(w, "Failed to create user", http.StatusInternalServerError) + respondError(w, "Failed to create user", http.StatusInternalServerError) return } // Initialize user workspace if err := h.Storage.InitializeUserWorkspace(insertedUser.ID, insertedUser.LastWorkspaceID); err != nil { - http.Error(w, "Failed to initialize user workspace", http.StatusInternalServerError) + respondError(w, "Failed to initialize user workspace", http.StatusInternalServerError) return } @@ -154,20 +154,20 @@ func (h *Handler) AdminCreateUser() http.HandlerFunc { // @Produce json // @Param userId path int true "User ID" // @Success 200 {object} models.User -// @Failure 400 {string} "Invalid user ID" -// @Failure 404 {string} "User not found" +// @Failure 400 {object} ErrorResponse "Invalid user ID" +// @Failure 404 {object} ErrorResponse "User not found" // @Router /admin/users/{userId} [get] func (h *Handler) AdminGetUser() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID, err := strconv.Atoi(chi.URLParam(r, "userId")) if err != nil { - http.Error(w, "Invalid user ID", http.StatusBadRequest) + respondError(w, "Invalid user ID", http.StatusBadRequest) return } user, err := h.DB.GetUserByID(userID) if err != nil { - http.Error(w, "User not found", http.StatusNotFound) + respondError(w, "User not found", http.StatusNotFound) return } @@ -186,30 +186,30 @@ func (h *Handler) AdminGetUser() http.HandlerFunc { // @Param userId path int true "User ID" // @Param user body UpdateUserRequest true "User details" // @Success 200 {object} models.User -// @Failure 400 {string} "Invalid user ID" -// @Failure 400 {string} "Invalid request body" -// @Failure 404 {string} "User not found" -// @Failure 500 {string} "Failed to hash password" -// @Failure 500 {string} "Failed to update user" +// @Failure 400 {object} ErrorResponse "Invalid user ID" +// @Failure 400 {object} ErrorResponse "Invalid request body" +// @Failure 404 {object} ErrorResponse "User not found" +// @Failure 500 {object} ErrorResponse "Failed to hash password" +// @Failure 500 {object} ErrorResponse "Failed to update user" // @Router /admin/users/{userId} [put] func (h *Handler) AdminUpdateUser() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID, err := strconv.Atoi(chi.URLParam(r, "userId")) if err != nil { - http.Error(w, "Invalid user ID", http.StatusBadRequest) + respondError(w, "Invalid user ID", http.StatusBadRequest) return } // Get existing user user, err := h.DB.GetUserByID(userID) if err != nil { - http.Error(w, "User not found", http.StatusNotFound) + respondError(w, "User not found", http.StatusNotFound) return } var req UpdateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + respondError(w, "Invalid request body", http.StatusBadRequest) return } @@ -226,14 +226,14 @@ func (h *Handler) AdminUpdateUser() http.HandlerFunc { if req.Password != "" { hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { - http.Error(w, "Failed to hash password", http.StatusInternalServerError) + respondError(w, "Failed to hash password", http.StatusInternalServerError) return } user.PasswordHash = string(hashedPassword) } if err := h.DB.UpdateUser(user); err != nil { - http.Error(w, "Failed to update user", http.StatusInternalServerError) + respondError(w, "Failed to update user", http.StatusInternalServerError) return } @@ -249,11 +249,11 @@ func (h *Handler) AdminUpdateUser() http.HandlerFunc { // @ID adminDeleteUser // @Param userId path int true "User ID" // @Success 204 "No Content" -// @Failure 400 {string} "Invalid user ID" -// @Failure 400 {string} "Cannot delete your own account" -// @Failure 403 {string} "Cannot delete other admin users" -// @Failure 404 {string} "User not found" -// @Failure 500 {string} "Failed to delete user" +// @Failure 400 {object} ErrorResponse "Invalid user ID" +// @Failure 400 {object} ErrorResponse "Cannot delete your own account" +// @Failure 403 {object} ErrorResponse "Cannot delete other admin users" +// @Failure 404 {object} ErrorResponse "User not found" +// @Failure 500 {object} ErrorResponse "Failed to delete user" // @Router /admin/users/{userId} [delete] func (h *Handler) AdminDeleteUser() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -264,31 +264,31 @@ func (h *Handler) AdminDeleteUser() http.HandlerFunc { userID, err := strconv.Atoi(chi.URLParam(r, "userId")) if err != nil { - http.Error(w, "Invalid user ID", http.StatusBadRequest) + respondError(w, "Invalid user ID", http.StatusBadRequest) return } // Prevent admin from deleting themselves if userID == ctx.UserID { - http.Error(w, "Cannot delete your own account", http.StatusBadRequest) + respondError(w, "Cannot delete your own account", http.StatusBadRequest) return } // Get user before deletion to check role user, err := h.DB.GetUserByID(userID) if err != nil { - http.Error(w, "User not found", http.StatusNotFound) + respondError(w, "User not found", http.StatusNotFound) return } // Prevent deletion of other admin users if user.Role == models.RoleAdmin && ctx.UserID != userID { - http.Error(w, "Cannot delete other admin users", http.StatusForbidden) + respondError(w, "Cannot delete other admin users", http.StatusForbidden) return } if err := h.DB.DeleteUser(userID); err != nil { - http.Error(w, "Failed to delete user", http.StatusInternalServerError) + respondError(w, "Failed to delete user", http.StatusInternalServerError) return } @@ -304,15 +304,15 @@ func (h *Handler) AdminDeleteUser() http.HandlerFunc { // @ID adminListWorkspaces // @Produce json // @Success 200 {array} WorkspaceStats -// @Failure 500 {string} "Failed to list workspaces" -// @Failure 500 {string} "Failed to get user" -// @Failure 500 {string} "Failed to get file stats" +// @Failure 500 {object} ErrorResponse "Failed to list workspaces" +// @Failure 500 {object} ErrorResponse "Failed to get user" +// @Failure 500 {object} ErrorResponse "Failed to get file stats" // @Router /admin/workspaces [get] func (h *Handler) AdminListWorkspaces() http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { workspaces, err := h.DB.GetAllWorkspaces() if err != nil { - http.Error(w, "Failed to list workspaces", http.StatusInternalServerError) + respondError(w, "Failed to list workspaces", http.StatusInternalServerError) return } @@ -324,7 +324,7 @@ func (h *Handler) AdminListWorkspaces() http.HandlerFunc { user, err := h.DB.GetUserByID(ws.UserID) if err != nil { - http.Error(w, "Failed to get user", http.StatusInternalServerError) + respondError(w, "Failed to get user", http.StatusInternalServerError) return } @@ -336,7 +336,7 @@ func (h *Handler) AdminListWorkspaces() http.HandlerFunc { fileStats, err := h.Storage.GetFileStats(ws.UserID, ws.ID) if err != nil { - http.Error(w, "Failed to get file stats", http.StatusInternalServerError) + respondError(w, "Failed to get file stats", http.StatusInternalServerError) return } @@ -357,20 +357,20 @@ func (h *Handler) AdminListWorkspaces() http.HandlerFunc { // @ID adminGetSystemStats // @Produce json // @Success 200 {object} SystemStats -// @Failure 500 {string} "Failed to get user stats" -// @Failure 500 {string} "Failed to get file stats" +// @Failure 500 {object} ErrorResponse "Failed to get user stats" +// @Failure 500 {object} ErrorResponse "Failed to get file stats" // @Router /admin/stats [get] func (h *Handler) AdminGetSystemStats() http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { userStats, err := h.DB.GetSystemStats() if err != nil { - http.Error(w, "Failed to get user stats", http.StatusInternalServerError) + respondError(w, "Failed to get user stats", http.StatusInternalServerError) return } fileStats, err := h.Storage.GetTotalFileStats() if err != nil { - http.Error(w, "Failed to get file stats", http.StatusInternalServerError) + respondError(w, "Failed to get file stats", http.StatusInternalServerError) return } diff --git a/server/internal/handlers/auth_handlers.go b/server/internal/handlers/auth_handlers.go index d039623..68b637f 100644 --- a/server/internal/handlers/auth_handlers.go +++ b/server/internal/handlers/auth_handlers.go @@ -43,43 +43,43 @@ type RefreshResponse struct { // @Produce json // @Param body body LoginRequest true "Login request" // @Success 200 {object} LoginResponse -// @Failure 400 {string} string "Invalid request body" -// @Failure 400 {string} string "Email and password are required" -// @Failure 401 {string} string "Invalid credentials" -// @Failure 500 {string} string "Failed to create session" +// @Failure 400 {object} ErrorResponse "Invalid request body" +// @Failure 400 {object} ErrorResponse "Email and password are required" +// @Failure 401 {object} ErrorResponse "Invalid credentials" +// @Failure 500 {object} ErrorResponse "Failed to create session" // @Router /auth/login [post] func (h *Handler) Login(authService *auth.SessionService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req LoginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + respondError(w, "Invalid request body", http.StatusBadRequest) return } // Validate request if req.Email == "" || req.Password == "" { - http.Error(w, "Email and password are required", http.StatusBadRequest) + respondError(w, "Email and password are required", http.StatusBadRequest) return } // Get user from database user, err := h.DB.GetUserByEmail(req.Email) if err != nil { - http.Error(w, "Invalid credentials", http.StatusUnauthorized) + respondError(w, "Invalid credentials", http.StatusUnauthorized) return } // Verify password err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) if err != nil { - http.Error(w, "Invalid credentials", http.StatusUnauthorized) + respondError(w, "Invalid credentials", http.StatusUnauthorized) return } // Create session and generate tokens session, accessToken, err := authService.CreateSession(user.ID, string(user.Role)) if err != nil { - http.Error(w, "Failed to create session", http.StatusInternalServerError) + respondError(w, "Failed to create session", http.StatusInternalServerError) return } @@ -101,25 +101,25 @@ func (h *Handler) Login(authService *auth.SessionService) http.HandlerFunc { // @Tags auth // @ID logout // @Security BearerAuth -// @Success 200 {string} string "OK" -// @Failure 400 {string} string "Session ID required" -// @Failure 500 {string} string "Failed to logout" +// @Success 204 "No Content" +// @Failure 400 {object} ErrorResponse "Session ID required" +// @Failure 500 {object} ErrorResponse "Failed to logout" // @Router /auth/logout [post] func (h *Handler) Logout(authService *auth.SessionService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { sessionID := r.Header.Get("X-Session-ID") if sessionID == "" { - http.Error(w, "Session ID required", http.StatusBadRequest) + respondError(w, "Session ID required", http.StatusBadRequest) return } err := authService.InvalidateSession(sessionID) if err != nil { - http.Error(w, "Failed to logout", http.StatusInternalServerError) + respondError(w, "Failed to logout", http.StatusInternalServerError) return } - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusNoContent) } } @@ -132,27 +132,27 @@ func (h *Handler) Logout(authService *auth.SessionService) http.HandlerFunc { // @Produce json // @Param body body RefreshRequest true "Refresh request" // @Success 200 {object} RefreshResponse -// @Failure 400 {string} string "Invalid request body" -// @Failure 400 {string} string "Refresh token required" -// @Failure 401 {string} string "Invalid refresh token" +// @Failure 400 {object} ErrorResponse "Invalid request body" +// @Failure 400 {object} ErrorResponse "Refresh token required" +// @Failure 401 {object} ErrorResponse "Invalid refresh token" // @Router /auth/refresh [post] func (h *Handler) RefreshToken(authService *auth.SessionService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req RefreshRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + respondError(w, "Invalid request body", http.StatusBadRequest) return } if req.RefreshToken == "" { - http.Error(w, "Refresh token required", http.StatusBadRequest) + respondError(w, "Refresh token required", http.StatusBadRequest) return } // Generate new access token accessToken, err := authService.RefreshSession(req.RefreshToken) if err != nil { - http.Error(w, "Invalid refresh token", http.StatusUnauthorized) + respondError(w, "Invalid refresh token", http.StatusUnauthorized) return } @@ -172,7 +172,7 @@ func (h *Handler) RefreshToken(authService *auth.SessionService) http.HandlerFun // @Security BearerAuth // @Produce json // @Success 200 {object} models.User -// @Failure 404 {string} string "User not found" +// @Failure 404 {object} ErrorResponse "User not found" // @Router /auth/me [get] func (h *Handler) GetCurrentUser() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -184,7 +184,7 @@ func (h *Handler) GetCurrentUser() http.HandlerFunc { // Get user from database user, err := h.DB.GetUserByID(ctx.UserID) if err != nil { - http.Error(w, "User not found", http.StatusNotFound) + respondError(w, "User not found", http.StatusNotFound) return } diff --git a/server/internal/handlers/auth_handlers_integration_test.go b/server/internal/handlers/auth_handlers_integration_test.go index d917e32..6db4153 100644 --- a/server/internal/handlers/auth_handlers_integration_test.go +++ b/server/internal/handlers/auth_handlers_integration_test.go @@ -188,7 +188,7 @@ func TestAuthHandlers_Integration(t *testing.T) { "X-Session-ID": loginResp.Session.ID, } rr = h.makeRequest(t, http.MethodPost, "/api/v1/auth/logout", nil, loginResp.AccessToken, headers) - require.Equal(t, http.StatusOK, rr.Code) + require.Equal(t, http.StatusNoContent, rr.Code) // Try to use the refresh token - should fail refreshReq := handlers.RefreshRequest{ diff --git a/server/internal/handlers/file_handlers.go b/server/internal/handlers/file_handlers.go index b0b52f1..5e6ad27 100644 --- a/server/internal/handlers/file_handlers.go +++ b/server/internal/handlers/file_handlers.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "os" + "time" "novamd/internal/context" "novamd/internal/storage" @@ -12,6 +13,28 @@ import ( "github.com/go-chi/chi/v5" ) +// LookupResponse represents a response to a file lookup request +type LookupResponse struct { + Paths []string `json:"paths"` +} + +// SaveFileResponse represents a response to a save file request +type SaveFileResponse struct { + FilePath string `json:"filePath"` + Size int64 `json:"size"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// LastOpenedFileResponse represents a response to a last opened file request +type LastOpenedFileResponse struct { + LastOpenedFilePath string `json:"lastOpenedFilePath"` +} + +// UpdateLastOpenedFileRequest represents a request to update the last opened file +type UpdateLastOpenedFileRequest struct { + FilePath string `json:"filePath"` +} + // ListFiles godoc // @Summary List files // @Description Lists all files in the user's workspace @@ -20,8 +43,8 @@ import ( // @Security BearerAuth // @Produce json // @Param workspace_name path string true "Workspace name" -// @Success 200 {array} string -// @Failure 500 {string} string "Failed to list files" +// @Success 200 {array} storage.FileNode +// @Failure 500 {object} ErrorResponse "Failed to list files" // @Router /workspaces/{workspace_name}/files [get] func (h *Handler) ListFiles() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -32,7 +55,7 @@ func (h *Handler) ListFiles() http.HandlerFunc { files, err := h.Storage.ListFilesRecursively(ctx.UserID, ctx.Workspace.ID) if err != nil { - http.Error(w, "Failed to list files", http.StatusInternalServerError) + respondError(w, "Failed to list files", http.StatusInternalServerError) return } @@ -49,9 +72,9 @@ func (h *Handler) ListFiles() http.HandlerFunc { // @Produce json // @Param workspace_name path string true "Workspace name" // @Param filename query string true "File name" -// @Success 200 {object} map[string][]string -// @Failure 400 {string} string "Filename is required" -// @Failure 404 {string} string "File not found" +// @Success 200 {object} LookupResponse +// @Failure 400 {object} ErrorResponse "Filename is required" +// @Failure 404 {object} ErrorResponse "File not found" // @Router /workspaces/{workspace_name}/files/lookup [get] func (h *Handler) LookupFileByName() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -62,17 +85,17 @@ func (h *Handler) LookupFileByName() http.HandlerFunc { filename := r.URL.Query().Get("filename") if filename == "" { - http.Error(w, "Filename is required", http.StatusBadRequest) + respondError(w, "Filename is required", http.StatusBadRequest) return } filePaths, err := h.Storage.FindFileByName(ctx.UserID, ctx.Workspace.ID, filename) if err != nil { - http.Error(w, "File not found", http.StatusNotFound) + respondError(w, "File not found", http.StatusNotFound) return } - respondJSON(w, map[string][]string{"paths": filePaths}) + respondJSON(w, &LookupResponse{Paths: filePaths}) } } @@ -86,10 +109,10 @@ func (h *Handler) LookupFileByName() http.HandlerFunc { // @Param workspace_name path string true "Workspace name" // @Param file_path path string true "File path" // @Success 200 {string} "File content" -// @Failure 400 {string} string "Invalid file path" -// @Failure 404 {string} string "File not found" -// @Failure 500 {string} string "Failed to read file" -// @Failure 500 {string} string "Failed to write response" +// @Failure 400 {object} ErrorResponse "Invalid file path" +// @Failure 404 {object} ErrorResponse "File not found" +// @Failure 500 {object} ErrorResponse "Failed to read file" +// @Failure 500 {object} ErrorResponse "Failed to write response" // @Router /workspaces/{workspace_name}/files/* [get] func (h *Handler) GetFileContent() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -103,23 +126,23 @@ func (h *Handler) GetFileContent() http.HandlerFunc { if err != nil { if storage.IsPathValidationError(err) { - http.Error(w, "Invalid file path", http.StatusBadRequest) + respondError(w, "Invalid file path", http.StatusBadRequest) return } if os.IsNotExist(err) { - http.Error(w, "File not found", http.StatusNotFound) + respondError(w, "File not found", http.StatusNotFound) return } - http.Error(w, "Failed to read file", http.StatusInternalServerError) + respondError(w, "Failed to read file", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/plain") _, err = w.Write(content) if err != nil { - http.Error(w, "Failed to write response", http.StatusInternalServerError) + respondError(w, "Failed to write response", http.StatusInternalServerError) return } } @@ -135,10 +158,10 @@ func (h *Handler) GetFileContent() http.HandlerFunc { // @Produce json // @Param workspace_name path string true "Workspace name" // @Param file_path path string true "File path" -// @Success 200 {string} "File saved successfully" -// @Failure 400 {string} string "Failed to read request body" -// @Failure 400 {string} string "Invalid file path" -// @Failure 500 {string} string "Failed to save file" +// @Success 200 {object} SaveFileResponse +// @Failure 400 {object} ErrorResponse "Failed to read request body" +// @Failure 400 {object} ErrorResponse "Invalid file path" +// @Failure 500 {object} ErrorResponse "Failed to save file" // @Router /workspaces/{workspace_name}/files/* [post] func (h *Handler) SaveFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -150,22 +173,29 @@ func (h *Handler) SaveFile() http.HandlerFunc { filePath := chi.URLParam(r, "*") content, err := io.ReadAll(r.Body) if err != nil { - http.Error(w, "Failed to read request body", http.StatusBadRequest) + respondError(w, "Failed to read request body", http.StatusBadRequest) return } err = h.Storage.SaveFile(ctx.UserID, ctx.Workspace.ID, filePath, content) if err != nil { if storage.IsPathValidationError(err) { - http.Error(w, "Invalid file path", http.StatusBadRequest) + respondError(w, "Invalid file path", http.StatusBadRequest) return } - http.Error(w, "Failed to save file", http.StatusInternalServerError) + respondError(w, "Failed to save file", http.StatusInternalServerError) return } - respondJSON(w, map[string]string{"message": "File saved successfully"}) + response := SaveFileResponse{ + FilePath: filePath, + Size: int64(len(content)), + UpdatedAt: time.Now().UTC(), + } + + w.WriteHeader(http.StatusOK) + respondJSON(w, response) } } @@ -178,11 +208,11 @@ func (h *Handler) SaveFile() http.HandlerFunc { // @Produce string // @Param workspace_name path string true "Workspace name" // @Param file_path path string true "File path" -// @Success 200 {string} "File deleted successfully" -// @Failure 400 {string} string "Invalid file path" -// @Failure 404 {string} string "File not found" -// @Failure 500 {string} string "Failed to delete file" -// @Failure 500 {string} string "Failed to write response" +// @Success 204 "No Content - File deleted successfully" +// @Failure 400 {object} ErrorResponse "Invalid file path" +// @Failure 404 {object} ErrorResponse "File not found" +// @Failure 500 {object} ErrorResponse "Failed to delete file" +// @Failure 500 {object} ErrorResponse "Failed to write response" // @Router /workspaces/{workspace_name}/files/* [delete] func (h *Handler) DeleteFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -195,25 +225,20 @@ func (h *Handler) DeleteFile() http.HandlerFunc { err := h.Storage.DeleteFile(ctx.UserID, ctx.Workspace.ID, filePath) if err != nil { if storage.IsPathValidationError(err) { - http.Error(w, "Invalid file path", http.StatusBadRequest) + respondError(w, "Invalid file path", http.StatusBadRequest) return } if os.IsNotExist(err) { - http.Error(w, "File not found", http.StatusNotFound) + respondError(w, "File not found", http.StatusNotFound) return } - http.Error(w, "Failed to delete file", http.StatusInternalServerError) + respondError(w, "Failed to delete file", http.StatusInternalServerError) return } - w.WriteHeader(http.StatusOK) - _, err = w.Write([]byte("File deleted successfully")) - if err != nil { - http.Error(w, "Failed to write response", http.StatusInternalServerError) - return - } + w.WriteHeader(http.StatusNoContent) } } @@ -225,9 +250,9 @@ func (h *Handler) DeleteFile() http.HandlerFunc { // @Security BearerAuth // @Produce json // @Param workspace_name path string true "Workspace name" -// @Success 200 {object} map[string]string -// @Failure 400 {string} string "Invalid file path" -// @Failure 500 {string} string "Failed to get last opened file" +// @Success 200 {object} LastOpenedFileResponse +// @Failure 400 {object} ErrorResponse "Invalid file path" +// @Failure 500 {object} ErrorResponse "Failed to get last opened file" // @Router /workspaces/{workspace_name}/files/last [get] func (h *Handler) GetLastOpenedFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -238,16 +263,16 @@ func (h *Handler) GetLastOpenedFile() http.HandlerFunc { filePath, err := h.DB.GetLastOpenedFile(ctx.Workspace.ID) if err != nil { - http.Error(w, "Failed to get last opened file", http.StatusInternalServerError) + respondError(w, "Failed to get last opened file", http.StatusInternalServerError) return } if _, err := h.Storage.ValidatePath(ctx.UserID, ctx.Workspace.ID, filePath); err != nil { - http.Error(w, "Invalid file path", http.StatusBadRequest) + respondError(w, "Invalid file path", http.StatusBadRequest) return } - respondJSON(w, map[string]string{"lastOpenedFilePath": filePath}) + respondJSON(w, &LastOpenedFileResponse{LastOpenedFilePath: filePath}) } } @@ -260,11 +285,12 @@ func (h *Handler) GetLastOpenedFile() http.HandlerFunc { // @Accept json // @Produce json // @Param workspace_name path string true "Workspace name" -// @Success 200 {object} map[string]string -// @Failure 400 {string} string "Invalid request body" -// @Failure 400 {string} string "Invalid file path" -// @Failure 404 {string} string "File not found" -// @Failure 500 {string} string "Failed to update file" +// @Param body body UpdateLastOpenedFileRequest true "Update last opened file request" +// @Success 204 "No Content - Last opened file updated successfully" +// @Failure 400 {object} ErrorResponse "Invalid request body" +// @Failure 400 {object} ErrorResponse "Invalid file path" +// @Failure 404 {object} ErrorResponse "File not found" +// @Failure 500 {object} ErrorResponse "Failed to update file" // @Router /workspaces/{workspace_name}/files/last [put] func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -273,12 +299,10 @@ func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc { return } - var requestBody struct { - FilePath string `json:"filePath"` - } + var requestBody UpdateLastOpenedFileRequest if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + respondError(w, "Invalid request body", http.StatusBadRequest) return } @@ -287,25 +311,25 @@ func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc { _, err := h.Storage.GetFileContent(ctx.UserID, ctx.Workspace.ID, requestBody.FilePath) if err != nil { if storage.IsPathValidationError(err) { - http.Error(w, "Invalid file path", http.StatusBadRequest) + respondError(w, "Invalid file path", http.StatusBadRequest) return } if os.IsNotExist(err) { - http.Error(w, "File not found", http.StatusNotFound) + respondError(w, "File not found", http.StatusNotFound) return } - http.Error(w, "Failed to update last opened file", http.StatusInternalServerError) + respondError(w, "Failed to update last opened file", http.StatusInternalServerError) return } } if err := h.DB.UpdateLastOpenedFile(ctx.Workspace.ID, requestBody.FilePath); err != nil { - http.Error(w, "Failed to update last opened file", http.StatusInternalServerError) + respondError(w, "Failed to update last opened file", http.StatusInternalServerError) return } - respondJSON(w, map[string]string{"message": "Last opened file updated successfully"}) + w.WriteHeader(http.StatusNoContent) } } diff --git a/server/internal/handlers/file_handlers_integration_test.go b/server/internal/handlers/file_handlers_integration_test.go index 70327d6..516c508 100644 --- a/server/internal/handlers/file_handlers_integration_test.go +++ b/server/internal/handlers/file_handlers_integration_test.go @@ -145,7 +145,7 @@ func TestFileHandlers_Integration(t *testing.T) { // Delete file rr = h.makeRequest(t, http.MethodDelete, baseURL+"/"+filePath, nil, h.RegularToken, nil) - require.Equal(t, http.StatusOK, rr.Code) + require.Equal(t, http.StatusNoContent, rr.Code) // Verify file is gone rr = h.makeRequest(t, http.MethodGet, baseURL+"/"+filePath, nil, h.RegularToken, nil) @@ -171,7 +171,7 @@ func TestFileHandlers_Integration(t *testing.T) { FilePath: "docs/readme.md", } rr = h.makeRequest(t, http.MethodPut, baseURL+"/last", updateReq, h.RegularToken, nil) - require.Equal(t, http.StatusOK, rr.Code) + require.Equal(t, http.StatusNoContent, rr.Code) // Verify update rr = h.makeRequest(t, http.MethodGet, baseURL+"/last", nil, h.RegularToken, nil) diff --git a/server/internal/handlers/git_handlers.go b/server/internal/handlers/git_handlers.go index 433a242..1ab45e0 100644 --- a/server/internal/handlers/git_handlers.go +++ b/server/internal/handlers/git_handlers.go @@ -7,6 +7,21 @@ import ( "novamd/internal/context" ) +// CommitRequest represents a request to commit changes +type CommitRequest struct { + Message string `json:"message" example:"Initial commit"` +} + +// CommitResponse represents a response to a commit request +type CommitResponse struct { + CommitHash string `json:"commitHash" example:"a1b2c3d4"` +} + +// PullResponse represents a response to a pull http request +type PullResponse struct { + Message string `json:"message" example:"Pulled changes from remote"` +} + // StageCommitAndPush godoc // @Summary Stage, commit, and push changes // @Description Stages, commits, and pushes changes to the remote repository @@ -15,11 +30,11 @@ import ( // @Security BearerAuth // @Produce json // @Param workspace_name path string true "Workspace name" -// @Param body body string true "Commit message" -// @Success 200 {object} map[string]string -// @Failure 400 {string} string "Invalid request body" -// @Failure 400 {string} string "Commit message is required" -// @Failure 500 {string} string "Failed to stage, commit, and push changes" +// @Param body body CommitRequest true "Commit request" +// @Success 200 {object} CommitResponse +// @Failure 400 {object} ErrorResponse "Invalid request body" +// @Failure 400 {object} ErrorResponse "Commit message is required" +// @Failure 500 {object} ErrorResponse "Failed to stage, commit, and push changes" // @Router /workspaces/{workspace_name}/git/commit [post] func (h *Handler) StageCommitAndPush() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -28,27 +43,25 @@ func (h *Handler) StageCommitAndPush() http.HandlerFunc { return } - var requestBody struct { - Message string `json:"message"` - } + var requestBody CommitRequest if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + respondError(w, "Invalid request body", http.StatusBadRequest) return } if requestBody.Message == "" { - http.Error(w, "Commit message is required", http.StatusBadRequest) + respondError(w, "Commit message is required", http.StatusBadRequest) return } - err := h.Storage.StageCommitAndPush(ctx.UserID, ctx.Workspace.ID, requestBody.Message) + hash, err := h.Storage.StageCommitAndPush(ctx.UserID, ctx.Workspace.ID, requestBody.Message) if err != nil { - http.Error(w, "Failed to stage, commit, and push changes: "+err.Error(), http.StatusInternalServerError) + respondError(w, "Failed to stage, commit, and push changes: "+err.Error(), http.StatusInternalServerError) return } - respondJSON(w, map[string]string{"message": "Changes staged, committed, and pushed successfully"}) + respondJSON(w, CommitResponse{CommitHash: hash.String()}) } } @@ -60,8 +73,8 @@ func (h *Handler) StageCommitAndPush() http.HandlerFunc { // @Security BearerAuth // @Produce json // @Param workspace_name path string true "Workspace name" -// @Success 200 {object} map[string]string -// @Failure 500 {string} string "Failed to pull changes" +// @Success 200 {object} PullResponse +// @Failure 500 {object} ErrorResponse "Failed to pull changes" // @Router /workspaces/{workspace_name}/git/pull [post] func (h *Handler) PullChanges() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -72,10 +85,10 @@ func (h *Handler) PullChanges() http.HandlerFunc { err := h.Storage.Pull(ctx.UserID, ctx.Workspace.ID) if err != nil { - http.Error(w, "Failed to pull changes: "+err.Error(), http.StatusInternalServerError) + respondError(w, "Failed to pull changes: "+err.Error(), http.StatusInternalServerError) return } - respondJSON(w, map[string]string{"message": "Pulled changes from remote"}) + respondJSON(w, PullResponse{Message: "Successfully pulled changes from remote"}) } } diff --git a/server/internal/handlers/git_handlers_integration_test.go b/server/internal/handlers/git_handlers_integration_test.go index 6d26039..c7706c8 100644 --- a/server/internal/handlers/git_handlers_integration_test.go +++ b/server/internal/handlers/git_handlers_integration_test.go @@ -56,7 +56,7 @@ func TestGitHandlers_Integration(t *testing.T) { var response map[string]string err := json.NewDecoder(rr.Body).Decode(&response) require.NoError(t, err) - assert.Contains(t, response["message"], "successfully") + require.Contains(t, response, "commitHash") // Verify mock was called correctly assert.Equal(t, 1, h.MockGit.GetCommitCount(), "Commit should be called once") @@ -100,7 +100,7 @@ func TestGitHandlers_Integration(t *testing.T) { var response map[string]string err := json.NewDecoder(rr.Body).Decode(&response) require.NoError(t, err) - assert.Contains(t, response["message"], "Pulled changes") + assert.Contains(t, response["message"], "Successfully pulled changes") assert.Equal(t, 1, h.MockGit.GetPullCount(), "Pull should be called once") }) diff --git a/server/internal/handlers/handlers.go b/server/internal/handlers/handlers.go index 7af3611..da20999 100644 --- a/server/internal/handlers/handlers.go +++ b/server/internal/handlers/handlers.go @@ -7,6 +7,11 @@ import ( "novamd/internal/storage" ) +// ErrorResponse is a generic error response +type ErrorResponse struct { + Message string `json:"message"` +} + // Handler provides common functionality for all handlers type Handler struct { DB db.Database @@ -25,6 +30,12 @@ func NewHandler(db db.Database, s storage.Manager) *Handler { func respondJSON(w http.ResponseWriter, data interface{}) { w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(data); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) + respondError(w, "Failed to encode response", http.StatusInternalServerError) } } + +// respondError is a helper to send error responses +func respondError(w http.ResponseWriter, message string, code int) { + w.WriteHeader(code) + respondJSON(w, ErrorResponse{Message: message}) +} diff --git a/server/internal/handlers/mock_git_test.go b/server/internal/handlers/mock_git_test.go index 23259f5..88a710b 100644 --- a/server/internal/handlers/mock_git_test.go +++ b/server/internal/handlers/mock_git_test.go @@ -4,6 +4,7 @@ package handlers_test import ( "fmt" + "novamd/internal/git" ) // MockGitClient implements the git.Client interface for testing @@ -51,13 +52,13 @@ func (m *MockGitClient) Pull() error { } // Commit implements git.Client -func (m *MockGitClient) Commit(message string) error { +func (m *MockGitClient) Commit(message string) (git.CommitHash, error) { if m.error != nil { - return m.error + return git.CommitHash{}, m.error } m.commitCount++ m.lastCommitMsg = message - return nil + return git.CommitHash{}, nil } // Push implements git.Client diff --git a/server/internal/handlers/static_handler.go b/server/internal/handlers/static_handler.go index 8360b3c..752e7b9 100644 --- a/server/internal/handlers/static_handler.go +++ b/server/internal/handlers/static_handler.go @@ -28,7 +28,7 @@ func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Security check to prevent directory traversal if !strings.HasPrefix(cleanPath, h.staticPath) { - http.Error(w, "Invalid path", http.StatusBadRequest) + respondError(w, "Invalid path", http.StatusBadRequest) return } diff --git a/server/internal/handlers/user_handlers.go b/server/internal/handlers/user_handlers.go index 3e042df..58d4403 100644 --- a/server/internal/handlers/user_handlers.go +++ b/server/internal/handlers/user_handlers.go @@ -32,15 +32,15 @@ type DeleteAccountRequest struct { // @Produce json // @Param body body UpdateProfileRequest true "Profile update request" // @Success 200 {object} models.User -// @Failure 400 {string} string "Invalid request body" -// @Failure 400 {string} string "Current password is required to change password" -// @Failure 400 {string} string "New password must be at least 8 characters long" -// @Failure 400 {string} string "Current password is required to change email" -// @Failure 401 {string} string "Current password is incorrect" -// @Failure 404 {string} string "User not found" -// @Failure 409 {string} string "Email already in use" -// @Failure 500 {string} string "Failed to process new password" -// @Failure 500 {string} string "Failed to update profile" +// @Failure 400 {object} ErrorResponse "Invalid request body" +// @Failure 400 {object} ErrorResponse "Current password is required to change password" +// @Failure 400 {object} ErrorResponse "New password must be at least 8 characters long" +// @Failure 400 {object} ErrorResponse "Current password is required to change email" +// @Failure 401 {object} ErrorResponse "Current password is incorrect" +// @Failure 404 {object} ErrorResponse "User not found" +// @Failure 409 {object} ErrorResponse "Email already in use" +// @Failure 500 {object} ErrorResponse "Failed to process new password" +// @Failure 500 {object} ErrorResponse "Failed to update profile" // @Router /profile [put] func (h *Handler) UpdateProfile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -51,14 +51,14 @@ func (h *Handler) UpdateProfile() http.HandlerFunc { var req UpdateProfileRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + respondError(w, "Invalid request body", http.StatusBadRequest) return } // Get current user user, err := h.DB.GetUserByID(ctx.UserID) if err != nil { - http.Error(w, "User not found", http.StatusNotFound) + respondError(w, "User not found", http.StatusNotFound) return } @@ -66,26 +66,26 @@ func (h *Handler) UpdateProfile() http.HandlerFunc { if req.NewPassword != "" { // Current password must be provided to change password if req.CurrentPassword == "" { - http.Error(w, "Current password is required to change password", http.StatusBadRequest) + respondError(w, "Current password is required to change password", http.StatusBadRequest) return } // Verify current password if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword)); err != nil { - http.Error(w, "Current password is incorrect", http.StatusUnauthorized) + respondError(w, "Current password is incorrect", http.StatusUnauthorized) return } // Validate new password if len(req.NewPassword) < 8 { - http.Error(w, "New password must be at least 8 characters long", http.StatusBadRequest) + respondError(w, "New password must be at least 8 characters long", http.StatusBadRequest) return } // Hash new password hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) if err != nil { - http.Error(w, "Failed to process new password", http.StatusInternalServerError) + respondError(w, "Failed to process new password", http.StatusInternalServerError) return } user.PasswordHash = string(hashedPassword) @@ -95,14 +95,14 @@ func (h *Handler) UpdateProfile() http.HandlerFunc { if req.Email != "" && req.Email != user.Email { // Check if email change requires password verification if req.CurrentPassword == "" { - http.Error(w, "Current password is required to change email", http.StatusBadRequest) + respondError(w, "Current password is required to change email", http.StatusBadRequest) return } // Verify current password if not already verified for password change if req.NewPassword == "" { if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword)); err != nil { - http.Error(w, "Current password is incorrect", http.StatusUnauthorized) + respondError(w, "Current password is incorrect", http.StatusUnauthorized) return } } @@ -110,7 +110,7 @@ func (h *Handler) UpdateProfile() http.HandlerFunc { // Check if new email is already in use existingUser, err := h.DB.GetUserByEmail(req.Email) if err == nil && existingUser.ID != user.ID { - http.Error(w, "Email already in use", http.StatusConflict) + respondError(w, "Email already in use", http.StatusConflict) return } user.Email = req.Email @@ -123,7 +123,7 @@ func (h *Handler) UpdateProfile() http.HandlerFunc { // Update user in database if err := h.DB.UpdateUser(user); err != nil { - http.Error(w, "Failed to update profile", http.StatusInternalServerError) + respondError(w, "Failed to update profile", http.StatusInternalServerError) return } @@ -141,13 +141,13 @@ func (h *Handler) UpdateProfile() http.HandlerFunc { // @Accept json // @Produce json // @Param body body DeleteAccountRequest true "Account deletion request" -// @Success 200 {object} map[string]string -// @Failure 400 {string} string "Invalid request body" -// @Failure 401 {string} string "Password is incorrect" -// @Failure 403 {string} string "Cannot delete the last admin account" -// @Failure 404 {string} string "User not found" -// @Failure 500 {string} string "Failed to verify admin status" -// @Failure 500 {string} string "Failed to delete account" +// @Success 204 "No Content - Account deleted successfully" +// @Failure 400 {object} ErrorResponse "Invalid request body" +// @Failure 401 {object} ErrorResponse "Password is incorrect" +// @Failure 403 {object} ErrorResponse "Cannot delete the last admin account" +// @Failure 404 {object} ErrorResponse "User not found" +// @Failure 500 {object} ErrorResponse "Failed to verify admin status" +// @Failure 500 {object} ErrorResponse "Failed to delete account" // @Router /profile [delete] func (h *Handler) DeleteAccount() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -158,20 +158,20 @@ func (h *Handler) DeleteAccount() http.HandlerFunc { var req DeleteAccountRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + respondError(w, "Invalid request body", http.StatusBadRequest) return } // Get current user user, err := h.DB.GetUserByID(ctx.UserID) if err != nil { - http.Error(w, "User not found", http.StatusNotFound) + respondError(w, "User not found", http.StatusNotFound) return } // Verify password if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { - http.Error(w, "Password is incorrect", http.StatusUnauthorized) + respondError(w, "Password is incorrect", http.StatusUnauthorized) return } @@ -180,11 +180,11 @@ func (h *Handler) DeleteAccount() http.HandlerFunc { // Count number of admin users adminCount, err := h.DB.CountAdminUsers() if err != nil { - http.Error(w, "Failed to verify admin status", http.StatusInternalServerError) + respondError(w, "Failed to verify admin status", http.StatusInternalServerError) return } if adminCount <= 1 { - http.Error(w, "Cannot delete the last admin account", http.StatusForbidden) + respondError(w, "Cannot delete the last admin account", http.StatusForbidden) return } } @@ -192,24 +192,24 @@ func (h *Handler) DeleteAccount() http.HandlerFunc { // Get user's workspaces for cleanup workspaces, err := h.DB.GetWorkspacesByUserID(ctx.UserID) if err != nil { - http.Error(w, "Failed to get user workspaces", http.StatusInternalServerError) + respondError(w, "Failed to get user workspaces", http.StatusInternalServerError) return } // Delete workspace directories for _, workspace := range workspaces { if err := h.Storage.DeleteUserWorkspace(ctx.UserID, workspace.ID); err != nil { - http.Error(w, "Failed to delete workspace files", http.StatusInternalServerError) + respondError(w, "Failed to delete workspace files", http.StatusInternalServerError) return } } // Delete user from database (this will cascade delete workspaces and sessions) if err := h.DB.DeleteUser(ctx.UserID); err != nil { - http.Error(w, "Failed to delete account", http.StatusInternalServerError) + respondError(w, "Failed to delete account", http.StatusInternalServerError) return } - respondJSON(w, map[string]string{"message": "Account deleted successfully"}) + w.WriteHeader(http.StatusNoContent) } } diff --git a/server/internal/handlers/user_handlers_integration_test.go b/server/internal/handlers/user_handlers_integration_test.go index 1722273..9c41772 100644 --- a/server/internal/handlers/user_handlers_integration_test.go +++ b/server/internal/handlers/user_handlers_integration_test.go @@ -191,7 +191,7 @@ func TestUserHandlers_Integration(t *testing.T) { } rr := h.makeRequest(t, http.MethodDelete, "/api/v1/profile", deleteReq, userToken, nil) - require.Equal(t, http.StatusOK, rr.Code) + require.Equal(t, http.StatusNoContent, rr.Code) // Verify user is deleted rr = h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", loginReq, "", nil) diff --git a/server/internal/handlers/workspace_handlers.go b/server/internal/handlers/workspace_handlers.go index 4e65bdc..9cfb61f 100644 --- a/server/internal/handlers/workspace_handlers.go +++ b/server/internal/handlers/workspace_handlers.go @@ -9,6 +9,16 @@ import ( "novamd/internal/models" ) +// DeleteWorkspaceResponse contains the name of the next workspace after deleting the current one +type DeleteWorkspaceResponse struct { + NextWorkspaceName string `json:"nextWorkspaceName"` +} + +// GetLastWorkspaceNameResponse contains the name of the last opened workspace +type GetLastWorkspaceNameResponse struct { + LastWorkspaceName string `json:"lastWorkspaceName"` +} + // ListWorkspaces godoc // @Summary List workspaces // @Description Lists all workspaces for the current user @@ -17,7 +27,7 @@ import ( // @Security BearerAuth // @Produce json // @Success 200 {array} models.Workspace -// @Failure 500 {string} string "Failed to list workspaces" +// @Failure 500 {object} ErrorResponse "Failed to list workspaces" // @Router /workspaces [get] func (h *Handler) ListWorkspaces() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -28,7 +38,7 @@ func (h *Handler) ListWorkspaces() http.HandlerFunc { workspaces, err := h.DB.GetWorkspacesByUserID(ctx.UserID) if err != nil { - http.Error(w, "Failed to list workspaces", http.StatusInternalServerError) + respondError(w, "Failed to list workspaces", http.StatusInternalServerError) return } @@ -46,11 +56,11 @@ func (h *Handler) ListWorkspaces() http.HandlerFunc { // @Produce json // @Param body body models.Workspace true "Workspace" // @Success 200 {object} models.Workspace -// @Failure 400 {string} string "Invalid request body" -// @Failure 400 {string} string "Invalid workspace" -// @Failure 500 {string} string "Failed to create workspace" -// @Failure 500 {string} string "Failed to initialize workspace directory" -// @Failure 500 {string} string "Failed to setup git repo" +// @Failure 400 {object} ErrorResponse "Invalid request body" +// @Failure 400 {object} ErrorResponse "Invalid workspace" +// @Failure 500 {object} ErrorResponse "Failed to create workspace" +// @Failure 500 {object} ErrorResponse "Failed to initialize workspace directory" +// @Failure 500 {object} ErrorResponse "Failed to setup git repo" // @Router /workspaces [post] func (h *Handler) CreateWorkspace() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -61,23 +71,23 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc { var workspace models.Workspace if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + respondError(w, "Invalid request body", http.StatusBadRequest) return } if err := workspace.ValidateGitSettings(); err != nil { - http.Error(w, "Invalid workspace", http.StatusBadRequest) + respondError(w, "Invalid workspace", http.StatusBadRequest) return } workspace.UserID = ctx.UserID if err := h.DB.CreateWorkspace(&workspace); err != nil { - http.Error(w, "Failed to create workspace", http.StatusInternalServerError) + respondError(w, "Failed to create workspace", http.StatusInternalServerError) return } if err := h.Storage.InitializeUserWorkspace(workspace.UserID, workspace.ID); err != nil { - http.Error(w, "Failed to initialize workspace directory", http.StatusInternalServerError) + respondError(w, "Failed to initialize workspace directory", http.StatusInternalServerError) return } @@ -91,7 +101,7 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc { workspace.GitCommitName, workspace.GitCommitEmail, ); err != nil { - http.Error(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError) + respondError(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError) return } } @@ -109,7 +119,7 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc { // @Produce json // @Param workspace_name path string true "Workspace name" // @Success 200 {object} models.Workspace -// @Failure 500 {string} string "Failed to get workspace" +// @Failure 500 {object} ErrorResponse "Internal server error" // @Router /workspaces/{workspace_name} [get] func (h *Handler) GetWorkspace() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -151,9 +161,9 @@ func gitSettingsChanged(new, old *models.Workspace) bool { // @Param workspace_name path string true "Workspace name" // @Param body body models.Workspace true "Workspace" // @Success 200 {object} models.Workspace -// @Failure 400 {string} string "Invalid request body" -// @Failure 500 {string} string "Failed to update workspace" -// @Failure 500 {string} string "Failed to setup git repo" +// @Failure 400 {object} ErrorResponse "Invalid request body" +// @Failure 500 {object} ErrorResponse "Failed to update workspace" +// @Failure 500 {object} ErrorResponse "Failed to setup git repo" // @Router /workspaces/{workspace_name} [put] func (h *Handler) UpdateWorkspace() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -164,7 +174,7 @@ func (h *Handler) UpdateWorkspace() http.HandlerFunc { var workspace models.Workspace if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + respondError(w, "Invalid request body", http.StatusBadRequest) return } @@ -174,7 +184,7 @@ func (h *Handler) UpdateWorkspace() http.HandlerFunc { // Validate the workspace if err := workspace.Validate(); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + respondError(w, err.Error(), http.StatusBadRequest) return } @@ -190,7 +200,7 @@ func (h *Handler) UpdateWorkspace() http.HandlerFunc { workspace.GitCommitName, workspace.GitCommitEmail, ); err != nil { - http.Error(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError) + respondError(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError) return } @@ -200,7 +210,7 @@ func (h *Handler) UpdateWorkspace() http.HandlerFunc { } if err := h.DB.UpdateWorkspace(&workspace); err != nil { - http.Error(w, "Failed to update workspace", http.StatusInternalServerError) + respondError(w, "Failed to update workspace", http.StatusInternalServerError) return } @@ -216,14 +226,14 @@ func (h *Handler) UpdateWorkspace() http.HandlerFunc { // @Security BearerAuth // @Produce json // @Param workspace_name path string true "Workspace name" -// @Success 200 {object} map[string]string -// @Failure 400 {string} string "Cannot delete the last workspace" -// @Failure 500 {string} string "Failed to get workspaces" -// @Failure 500 {string} string "Failed to start transaction" -// @Failure 500 {string} string "Failed to update last workspace" -// @Failure 500 {string} string "Failed to delete workspace" -// @Failure 500 {string} string "Failed to rollback transaction" -// @Failure 500 {string} string "Failed to commit transaction" +// @Success 200 {object} DeleteWorkspaceResponse +// @Failure 400 {object} ErrorResponse "Cannot delete the last workspace" +// @Failure 500 {object} ErrorResponse "Failed to get workspaces" +// @Failure 500 {object} ErrorResponse "Failed to start transaction" +// @Failure 500 {object} ErrorResponse "Failed to update last workspace" +// @Failure 500 {object} ErrorResponse "Failed to delete workspace" +// @Failure 500 {object} ErrorResponse "Failed to rollback transaction" +// @Failure 500 {object} ErrorResponse "Failed to commit transaction" // @Router /workspaces/{workspace_name} [delete] func (h *Handler) DeleteWorkspace() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -235,12 +245,12 @@ func (h *Handler) DeleteWorkspace() http.HandlerFunc { // Check if this is the user's last workspace workspaces, err := h.DB.GetWorkspacesByUserID(ctx.UserID) if err != nil { - http.Error(w, "Failed to get workspaces", http.StatusInternalServerError) + respondError(w, "Failed to get workspaces", http.StatusInternalServerError) return } if len(workspaces) <= 1 { - http.Error(w, "Cannot delete the last workspace", http.StatusBadRequest) + respondError(w, "Cannot delete the last workspace", http.StatusBadRequest) return } @@ -258,37 +268,37 @@ func (h *Handler) DeleteWorkspace() http.HandlerFunc { // Start transaction tx, err := h.DB.Begin() if err != nil { - http.Error(w, "Failed to start transaction", http.StatusInternalServerError) + respondError(w, "Failed to start transaction", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(); err != nil && err != sql.ErrTxDone { - http.Error(w, "Failed to rollback transaction", http.StatusInternalServerError) + respondError(w, "Failed to rollback transaction", http.StatusInternalServerError) } }() // Update last workspace ID first err = h.DB.UpdateLastWorkspaceTx(tx, ctx.UserID, nextWorkspaceID) if err != nil { - http.Error(w, "Failed to update last workspace", http.StatusInternalServerError) + respondError(w, "Failed to update last workspace", http.StatusInternalServerError) return } // Delete the workspace err = h.DB.DeleteWorkspaceTx(tx, ctx.Workspace.ID) if err != nil { - http.Error(w, "Failed to delete workspace", http.StatusInternalServerError) + respondError(w, "Failed to delete workspace", http.StatusInternalServerError) return } // Commit transaction if err = tx.Commit(); err != nil { - http.Error(w, "Failed to commit transaction", http.StatusInternalServerError) + respondError(w, "Failed to commit transaction", http.StatusInternalServerError) return } // Return the next workspace ID in the response so frontend knows where to redirect - respondJSON(w, map[string]string{"nextWorkspaceName": nextWorkspaceName}) + respondJSON(w, &DeleteWorkspaceResponse{NextWorkspaceName: nextWorkspaceName}) } } @@ -299,8 +309,8 @@ func (h *Handler) DeleteWorkspace() http.HandlerFunc { // @ID getLastWorkspaceName // @Security BearerAuth // @Produce json -// @Success 200 {object} map[string]string -// @Failure 500 {string} string "Failed to get last workspace" +// @Success 200 {object} LastWorkspaceNameResponse +// @Failure 500 {object} ErrorResponse "Failed to get last workspace" // @Router /workspaces/last [get] func (h *Handler) GetLastWorkspaceName() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -311,11 +321,11 @@ func (h *Handler) GetLastWorkspaceName() http.HandlerFunc { workspaceName, err := h.DB.GetLastWorkspaceName(ctx.UserID) if err != nil { - http.Error(w, "Failed to get last workspace", http.StatusInternalServerError) + respondError(w, "Failed to get last workspace", http.StatusInternalServerError) return } - respondJSON(w, map[string]string{"lastWorkspaceName": workspaceName}) + respondJSON(w, &GetLastWorkspaceNameResponse{LastWorkspaceName: workspaceName}) } } @@ -327,9 +337,9 @@ func (h *Handler) GetLastWorkspaceName() http.HandlerFunc { // @Security BearerAuth // @Accept json // @Produce json -// @Success 200 {object} map[string]string -// @Failure 400 {string} string "Invalid request body" -// @Failure 500 {string} string "Failed to update last workspace" +// @Success 204 "No Content - Last workspace updated successfully" +// @Failure 400 {object} ErrorResponse "Invalid request body" +// @Failure 500 {object} ErrorResponse "Failed to update last workspace" // @Router /workspaces/last [put] func (h *Handler) UpdateLastWorkspaceName() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -343,15 +353,15 @@ func (h *Handler) UpdateLastWorkspaceName() http.HandlerFunc { } if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + respondError(w, "Invalid request body", http.StatusBadRequest) return } if err := h.DB.UpdateLastWorkspace(ctx.UserID, requestBody.WorkspaceName); err != nil { - http.Error(w, "Failed to update last workspace", http.StatusInternalServerError) + respondError(w, "Failed to update last workspace", http.StatusInternalServerError) return } - respondJSON(w, map[string]string{"message": "Last workspace updated successfully"}) + w.WriteHeader(http.StatusNoContent) } } diff --git a/server/internal/handlers/workspace_handlers_integration_test.go b/server/internal/handlers/workspace_handlers_integration_test.go index 0d44b73..e07efc0 100644 --- a/server/internal/handlers/workspace_handlers_integration_test.go +++ b/server/internal/handlers/workspace_handlers_integration_test.go @@ -226,7 +226,7 @@ func TestWorkspaceHandlers_Integration(t *testing.T) { } rr := h.makeRequest(t, http.MethodPut, "/api/v1/workspaces/last", req, h.RegularToken, nil) - require.Equal(t, http.StatusOK, rr.Code) + require.Equal(t, http.StatusNoContent, rr.Code) // Verify the update rr = h.makeRequest(t, http.MethodGet, "/api/v1/workspaces/last", nil, h.RegularToken, nil) diff --git a/server/internal/storage/git.go b/server/internal/storage/git.go index 13a32d0..a4d5d78 100644 --- a/server/internal/storage/git.go +++ b/server/internal/storage/git.go @@ -9,7 +9,7 @@ import ( type RepositoryManager interface { SetupGitRepo(userID, workspaceID int, gitURL, gitUser, gitToken, commitName, commitEmail string) error DisableGitRepo(userID, workspaceID int) - StageCommitAndPush(userID, workspaceID int, message string) error + StageCommitAndPush(userID, workspaceID int, message string) (git.CommitHash, error) Pull(userID, workspaceID int) error } @@ -36,17 +36,19 @@ func (s *Service) DisableGitRepo(userID, workspaceID int) { // StageCommitAndPush stages, commit with the message, and pushes the changes to the Git repository. // The git repository belongs to the given userID and is associated with the given workspaceID. -func (s *Service) StageCommitAndPush(userID, workspaceID int, message string) error { +func (s *Service) StageCommitAndPush(userID, workspaceID int, message string) (git.CommitHash, error) { repo, ok := s.getGitRepo(userID, workspaceID) if !ok { - return fmt.Errorf("git settings not configured for this workspace") + return git.CommitHash{}, fmt.Errorf("git settings not configured for this workspace") } - if err := repo.Commit(message); err != nil { - return err + hash, err := repo.Commit(message) + if err != nil { + return git.CommitHash{}, err } - return repo.Push() + err = repo.Push() + return hash, err } // Pull pulls the changes from the remote Git repository. diff --git a/server/internal/storage/git_test.go b/server/internal/storage/git_test.go index a849ff5..f852616 100644 --- a/server/internal/storage/git_test.go +++ b/server/internal/storage/git_test.go @@ -29,10 +29,10 @@ func (m *MockGitClient) Pull() error { return m.ReturnError } -func (m *MockGitClient) Commit(message string) error { +func (m *MockGitClient) Commit(message string) (git.CommitHash, error) { m.CommitCalled = true m.CommitMessage = message - return m.ReturnError + return git.CommitHash{}, m.ReturnError } func (m *MockGitClient) Push() error { @@ -138,7 +138,7 @@ func TestGitOperations(t *testing.T) { }) t.Run("operations on non-configured workspace", func(t *testing.T) { - err := s.StageCommitAndPush(1, 1, "test commit") + _, err := s.StageCommitAndPush(1, 1, "test commit") if err == nil { t.Error("expected error for non-configured workspace, got nil") } @@ -157,7 +157,7 @@ func TestGitOperations(t *testing.T) { s.GitRepos[1][1] = mockClient // Test commit and push - err := s.StageCommitAndPush(1, 1, "test commit") + _, err := s.StageCommitAndPush(1, 1, "test commit") if err != nil { t.Errorf("unexpected error: %v", err) } @@ -189,7 +189,7 @@ func TestGitOperations(t *testing.T) { s.GitRepos[1][1] = mockClient // Test commit error - err := s.StageCommitAndPush(1, 1, "test commit") + _, err := s.StageCommitAndPush(1, 1, "test commit") if err == nil { t.Error("expected error for commit, got nil") }