// Package api exposes Havenllo's same-origin JSON API and embedded SPA handler. package api import ( "io/fs" "net/http" "path" "strings" "havenllo/internal/app" ) type Handler struct { service *app.Service static fs.FS } func NewHandler(service *app.Service, static fs.FS) http.Handler { h := &Handler{service: service, static: static} mux := http.NewServeMux() mux.HandleFunc("GET /api/health", h.health) mux.HandleFunc("GET /api/board", h.board) mux.HandleFunc("GET /api/lists", h.lists) mux.HandleFunc("POST /api/lists", h.createList) mux.HandleFunc("GET /api/lists/{id}", h.list) mux.HandleFunc("PATCH /api/lists/{id}", h.updateList) mux.HandleFunc("DELETE /api/lists/{id}", h.deleteList) mux.HandleFunc("GET /api/lists/{id}/cards", h.cards) mux.HandleFunc("POST /api/lists/{id}/cards", h.createCard) mux.HandleFunc("GET /api/cards/{id}", h.card) mux.HandleFunc("PATCH /api/cards/{id}", h.updateCard) mux.HandleFunc("DELETE /api/cards/{id}", h.deleteCard) mux.HandleFunc("/api/", h.apiNotFound) mux.HandleFunc("/", h.serveApp) return mux } func (h *Handler) health(w http.ResponseWriter, r *http.Request) { if err := h.service.Health(r.Context()); err != nil { writeError(w, http.StatusServiceUnavailable, "unavailable", "database is unavailable") return } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (h *Handler) board(w http.ResponseWriter, r *http.Request) { snapshot, err := h.service.Board(r.Context()) if err != nil { writeDomainError(w, err) return } writeJSON(w, http.StatusOK, snapshot) } func (h *Handler) lists(w http.ResponseWriter, r *http.Request) { lists, err := h.service.Lists(r.Context()) if err != nil { writeDomainError(w, err) return } writeJSON(w, http.StatusOK, listsResponse{Lists: listSummaries(lists)}) } func (h *Handler) createList(w http.ResponseWriter, r *http.Request) { if !requireJSON(w, r) { return } var request createListRequest if !decodeJSON(w, r, &request) { return } list, err := h.service.CreateList(r.Context(), request.Name, request.Position) if err != nil { writeDomainError(w, err) return } writeJSON(w, http.StatusCreated, listResponse{List: list}) } func (h *Handler) list(w http.ResponseWriter, r *http.Request) { id, ok := pathID(w, r, "id") if !ok { return } list, err := h.service.List(r.Context(), id) if err != nil { writeDomainError(w, err) return } cards, err := h.service.Cards(r.Context(), id) if err != nil { writeDomainError(w, err) return } writeJSON(w, http.StatusOK, listDetailsResponse{List: list, Cards: cards}) } func (h *Handler) updateList(w http.ResponseWriter, r *http.Request) { id, ok := pathID(w, r, "id") if !ok || !requireJSON(w, r) { return } var request updateListRequest if !decodeJSON(w, r, &request) { return } list, err := h.service.UpdateList(r.Context(), id, request.Name, request.Position) if err != nil { writeDomainError(w, err) return } writeJSON(w, http.StatusOK, listResponse{List: list}) } func (h *Handler) deleteList(w http.ResponseWriter, r *http.Request) { id, ok := pathID(w, r, "id") if !ok { return } if err := h.service.DeleteList(r.Context(), id); err != nil { writeDomainError(w, err) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) cards(w http.ResponseWriter, r *http.Request) { listID, ok := pathID(w, r, "id") if !ok { return } cards, err := h.service.Cards(r.Context(), listID) if err != nil { writeDomainError(w, err) return } writeJSON(w, http.StatusOK, cardsResponse{Cards: cards}) } func (h *Handler) createCard(w http.ResponseWriter, r *http.Request) { listID, ok := pathID(w, r, "id") if !ok || !requireJSON(w, r) { return } var request createCardRequest if !decodeJSON(w, r, &request) { return } card, err := h.service.CreateCard(r.Context(), listID, request.Title, request.Description, request.Position) if err != nil { writeDomainError(w, err) return } writeJSON(w, http.StatusCreated, cardResponse{Card: card}) } func (h *Handler) card(w http.ResponseWriter, r *http.Request) { id, ok := pathID(w, r, "id") if !ok { return } card, err := h.service.Card(r.Context(), id) if err != nil { writeDomainError(w, err) return } writeJSON(w, http.StatusOK, cardResponse{Card: card}) } func (h *Handler) updateCard(w http.ResponseWriter, r *http.Request) { id, ok := pathID(w, r, "id") if !ok || !requireJSON(w, r) { return } var request updateCardRequest if !decodeJSON(w, r, &request) { return } card, err := h.service.UpdateCard(r.Context(), id, app.CardPatch{ Title: request.Title, Description: request.Description, ListID: request.ListID, Position: request.Position, }) if err != nil { writeDomainError(w, err) return } writeJSON(w, http.StatusOK, cardResponse{Card: card}) } func (h *Handler) deleteCard(w http.ResponseWriter, r *http.Request) { id, ok := pathID(w, r, "id") if !ok { return } if err := h.service.DeleteCard(r.Context(), id); err != nil { writeDomainError(w, err) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) apiNotFound(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "not_found", "API endpoint not found") } func (h *Handler) serveApp(w http.ResponseWriter, r *http.Request) { if h.static == nil { writeError(w, http.StatusNotFound, "not_found", "application bundle not found") return } requested := strings.TrimPrefix(path.Clean(r.URL.Path), "/") if requested != "" { if _, err := fs.Stat(h.static, requested); err == nil { http.FileServer(http.FS(h.static)).ServeHTTP(w, r) return } } index, err := fs.ReadFile(h.static, "index.html") if err != nil { writeError(w, http.StatusNotFound, "not_found", "application bundle not found") return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write(index) }