Files
agent-evals/internal/api/handlers.go
Evan Reichard 702281c6cf feat: implement WYSIWYG markdown editor with Go backend and React frontend
- Backend: Go HTTP server with Cobra CLI (--data-dir, --port, --host flags)
- CRUD REST API for markdown files with JSON error responses
- File storage in flat directory structure (flat structure, .md files only)
- Comprehensive logrus logging for all operations
- Static file serving for frontend build (./frontend/dist)
- Frontend: React + TypeScript + Tailwind CSS
- Markdown editor with live GFM preview
- File management: list, create, open, save, delete
- Theme system (Dark, Light, System) with persistence
- Responsive design for desktop and mobile
- Backend tests (storage, API handlers) and frontend tests
2026-02-06 16:04:34 -05:00

253 lines
7.5 KiB
Go

package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"eval/internal/config"
"eval/internal/storage"
"github.com/sirupsen/logrus"
)
// Handlers holds the API handlers
type Handlers struct {
store *storage.Storage
config *config.Config
logger *logrus.Logger
}
// NewHandlers creates a new Handlers instance
func NewHandlers(store *storage.Storage, config *config.Config, logger *logrus.Logger) *Handlers {
return &Handlers{
store: store,
config: config,
logger: logger,
}
}
// ErrorResponse represents an error response
type ErrorResponse struct {
Error string `json:"error"`
}
// writeJSON writes a JSON response
func (h *Handlers) writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(data); err != nil {
h.logger.Errorf("failed to encode response: %v", err)
http.Error(w, `{"error": "internal server error"}`, http.StatusInternalServerError)
}
}
// writeError writes an error response
func (h *Handlers) writeError(w http.ResponseWriter, status int, message string) {
h.writeJSON(w, status, ErrorResponse{Error: message})
}
// listFilesHandler handles GET /api/files - list all markdown files
func (h *Handlers) listFilesHandler(w http.ResponseWriter, r *http.Request) {
h.logger.Info("listing files")
files, err := h.store.ListFiles()
if err != nil {
h.logger.Errorf("failed to list files: %v", err)
h.writeError(w, http.StatusInternalServerError, "failed to list files")
return
}
h.writeJSON(w, http.StatusOK, files)
}
// getFileHandler handles GET /api/files/{filename} - get a specific file
func (h *Handlers) getFileHandler(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/files/")
filename := path
if filename == "" {
h.writeError(w, http.StatusBadRequest, "filename is required")
return
}
h.logger.WithField("filename", filename).Info("getting file")
file, err := h.store.GetFile(filename)
if err != nil {
if err.Error() == "file not found" {
h.writeError(w, http.StatusNotFound, "file not found")
return
}
h.logger.Errorf("failed to get file: %v", err)
h.writeError(w, http.StatusInternalServerError, "failed to get file")
return
}
h.writeJSON(w, http.StatusOK, file)
}
// createFileHandler handles POST /api/files - create a new file
func (h *Handlers) createFileHandler(w http.ResponseWriter, r *http.Request) {
var file storage.FileContent
if err := json.NewDecoder(r.Body).Decode(&file); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if file.Filename == "" {
h.writeError(w, http.StatusBadRequest, "filename is required")
return
}
h.logger.WithField("filename", file.Filename).Info("creating file")
result, err := h.store.CreateFile(file.Filename, file.Content)
if err != nil {
if err.Error() == "file already exists" {
h.writeError(w, http.StatusConflict, "file already exists")
return
}
h.logger.Errorf("failed to create file: %v", err)
h.writeError(w, http.StatusInternalServerError, "failed to create file")
return
}
h.writeJSON(w, http.StatusCreated, result)
}
// updateFileHandler handles PUT /api/files/{filename} - update a file
func (h *Handlers) updateFileHandler(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/files/")
filename := path
if filename == "" {
h.writeError(w, http.StatusBadRequest, "filename is required")
return
}
var file storage.FileContent
if err := json.NewDecoder(r.Body).Decode(&file); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
h.logger.WithField("filename", filename).Info("updating file")
result, err := h.store.UpdateFile(filename, file.Content)
if err != nil {
if err.Error() == "file not found" {
h.writeError(w, http.StatusNotFound, "file not found")
return
}
h.logger.Errorf("failed to update file: %v", err)
h.writeError(w, http.StatusInternalServerError, "failed to update file")
return
}
h.writeJSON(w, http.StatusOK, result)
}
// deleteFileHandler handles DELETE /api/files/{filename} - delete a file
func (h *Handlers) deleteFileHandler(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/files/")
filename := path
if filename == "" {
h.writeError(w, http.StatusBadRequest, "filename is required")
return
}
h.logger.WithField("filename", filename).Info("deleting file")
if err := h.store.DeleteFile(filename); err != nil {
if err.Error() == "file not found" {
h.writeError(w, http.StatusNotFound, "file not found")
return
}
h.logger.Errorf("failed to delete file: %v", err)
h.writeError(w, http.StatusInternalServerError, "failed to delete file")
return
}
h.writeJSON(w, http.StatusOK, map[string]string{"message": "file deleted"})
}
// registerAPIRoutes registers all API routes
func (h *Handlers) registerAPIRoutes(router *http.ServeMux) {
router.HandleFunc("GET /api/files", h.listFilesHandler)
router.HandleFunc("GET /api/files/", h.getFileHandler)
router.HandleFunc("POST /api/files", h.createFileHandler)
router.HandleFunc("PUT /api/files/", h.updateFileHandler)
router.HandleFunc("DELETE /api/files/", h.deleteFileHandler)
}
// ServeStaticHandler serves static files
type ServeStaticHandler struct {
fs http.FileSystem
}
// NewServeStaticHandler creates a new static file handler
func NewServeStaticHandler(dir string) (*ServeStaticHandler, error) {
fs := http.Dir(dir)
_, err := fs.Open(".")
if err != nil {
return nil, fmt.Errorf("static directory not found: %w", err)
}
return &ServeStaticHandler{fs: fs}, nil
}
// ServeHTTP serves files from the static directory
func (h *ServeStaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
file, err := h.fs.Open(r.URL.Path)
if err != nil {
// If file not found, serve index.html for SPA routing
file, err = h.fs.Open("/index.html")
if err != nil {
http.NotFound(w, r)
return
}
}
defer file.Close()
// Get file info to determine content type
info, err := file.Stat()
if err != nil {
http.NotFound(w, r)
return
}
if info.IsDir() {
http.NotFound(w, r)
return
}
http.ServeContent(w, r, info.Name(), info.ModTime(), file)
}
// SetupStaticHandler sets up the static file handler
func (h *Handlers) SetupStaticHandler(buildDir string) (http.HandlerFunc, error) {
handler, err := NewServeStaticHandler(buildDir)
if err != nil {
return nil, err
}
return handler.ServeHTTP, nil
}
// SetupRoutes sets up all routes and returns the router
func (h *Handlers) SetupRoutes(buildDir string) (*http.ServeMux, error) {
router := http.NewServeMux()
h.registerAPIRoutes(router)
// Setup static handler
if _, err := os.Stat(buildDir); err == nil {
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
http.ServeFile(w, r, filepath.Join(buildDir, "index.html"))
return
}
// Try to serve the file, if not found, serve index.html
file, err := http.Dir(buildDir).Open(r.URL.Path)
if err != nil {
http.ServeFile(w, r, filepath.Join(buildDir, "index.html"))
return
}
file.Close()
http.ServeFile(w, r, filepath.Join(buildDir, r.URL.Path))
})
} else {
h.logger.Warnf("build directory not found: %s, static file serving disabled", buildDir)
}
return router, nil
}
// StartServer starts the HTTP server
func (h *Handlers) StartServer(router *http.ServeMux) error {
addr := fmt.Sprintf("%s:%d", h.config.Host, h.config.Port)
h.logger.Infof("starting server on %s", addr)
return http.ListenAndServe(addr, router)
}