feat: implement Havenllo single-board kanban (Go+SQLite backend, Preact/Vite frontend)
- Single fixed 'Haven' board: lists (To do/In progress/Done) + cards, no multi-board - Go 1.24 backend, CGO-free modernc.org/sqlite, embedded SPA, REST /api - Preact + Vite + TS frontend, native HTML5 drag-and-drop, optimistic UI, dark Haven theme - PVC (nfs-client) for SQLite, root container on NFS, simple nginx ingress havenllo.haven - Dockerfile multi-arch build, Gitea CI via pipeline-actions@main
This commit is contained in:
224
internal/api/handlers.go
Normal file
224
internal/api/handlers.go
Normal file
@@ -0,0 +1,224 @@
|
||||
// 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, Done: request.Done, 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)
|
||||
}
|
||||
86
internal/api/handlers_test.go
Normal file
86
internal/api/handlers_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"havenllo/internal/api"
|
||||
"havenllo/internal/app"
|
||||
"havenllo/internal/database"
|
||||
)
|
||||
|
||||
func newHandler(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
db, err := database.Open(context.Background(), filepath.Join(t.TempDir(), "havenllo.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return api.NewHandler(app.New(db), fstest.MapFS{"index.html": {Data: []byte("<html></html>")}})
|
||||
}
|
||||
|
||||
func request(t *testing.T, handler http.Handler, method, path, body string, jsonBody bool) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
if jsonBody {
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestBoardAndHealth(t *testing.T) {
|
||||
t.Parallel()
|
||||
handler := newHandler(t)
|
||||
for _, path := range []string{"/api/health", "/api/board"} {
|
||||
response := request(t, handler, http.MethodGet, path, "", false)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("%s status = %d, body %s", path, response.Code, response.Body)
|
||||
}
|
||||
}
|
||||
var snapshot struct {
|
||||
Lists []json.RawMessage `json:"lists"`
|
||||
}
|
||||
if err := json.Unmarshal(request(t, handler, http.MethodGet, "/api/board", "", false).Body.Bytes(), &snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(snapshot.Lists) != 3 {
|
||||
t.Fatalf("snapshot lists = %d, want 3", len(snapshot.Lists))
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONValidationAndLastListConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
handler := newHandler(t)
|
||||
if got := request(t, handler, http.MethodPost, "/api/lists", `{"name":"later","unknown":true}`, true); got.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unknown field status = %d", got.Code)
|
||||
}
|
||||
if got := request(t, handler, http.MethodPost, "/api/lists", `{"name":"later"}`, false); got.Code != http.StatusUnsupportedMediaType {
|
||||
t.Fatalf("content type status = %d", got.Code)
|
||||
}
|
||||
response := request(t, handler, http.MethodGet, "/api/lists", "", false)
|
||||
var lists struct {
|
||||
Lists []struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"lists"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &lists); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, list := range lists.Lists[:2] {
|
||||
if got := request(t, handler, http.MethodDelete, "/api/lists/"+strconv.FormatInt(list.ID, 10), "", false); got.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete list status = %d", got.Code)
|
||||
}
|
||||
}
|
||||
if got := request(t, handler, http.MethodDelete, "/api/lists/"+strconv.FormatInt(lists.Lists[2].ID, 10), "", false); got.Code != http.StatusConflict {
|
||||
t.Fatalf("last list status = %d", got.Code)
|
||||
}
|
||||
}
|
||||
95
internal/api/http.go
Normal file
95
internal/api/http.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"havenllo/internal/domain"
|
||||
)
|
||||
|
||||
const maxJSONBody = 1 << 20
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, code, message string) {
|
||||
writeJSON(w, status, errorResponse{Error: apiError{Code: code, Message: message}})
|
||||
}
|
||||
|
||||
func writeDomainError(w http.ResponseWriter, err error) {
|
||||
var validation domain.ValidationError
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrNotFound):
|
||||
writeError(w, http.StatusNotFound, "not_found", "resource not found")
|
||||
case errors.Is(err, domain.ErrLastList):
|
||||
writeError(w, http.StatusConflict, "last_list", err.Error())
|
||||
case errors.As(err, &validation):
|
||||
writeError(w, http.StatusUnprocessableEntity, "validation_error", validation.Message)
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "an unexpected error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
func requireJSON(w http.ResponseWriter, r *http.Request) bool {
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil || mediaType != "application/json" {
|
||||
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media_type", "Content-Type must be application/json")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return false
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
writeError(w, http.StatusBadRequest, "invalid_json", "request body must contain one JSON value")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeDecodeError(w http.ResponseWriter, err error) {
|
||||
var syntaxError *json.SyntaxError
|
||||
var typeError *json.UnmarshalTypeError
|
||||
switch {
|
||||
case errors.As(err, &syntaxError):
|
||||
writeError(w, http.StatusBadRequest, "invalid_json", "request body contains malformed JSON")
|
||||
case errors.Is(err, io.ErrUnexpectedEOF):
|
||||
writeError(w, http.StatusBadRequest, "invalid_json", "request body contains malformed JSON")
|
||||
case errors.As(err, &typeError):
|
||||
writeError(w, http.StatusBadRequest, "invalid_json", fmt.Sprintf("invalid value for %s", typeError.Field))
|
||||
case strings.HasPrefix(err.Error(), "json: unknown field "):
|
||||
writeError(w, http.StatusBadRequest, "invalid_json", "request body contains an unknown field")
|
||||
case errors.Is(err, io.EOF):
|
||||
writeError(w, http.StatusBadRequest, "invalid_json", "request body must not be empty")
|
||||
case strings.Contains(err.Error(), "http: request body too large"):
|
||||
writeError(w, http.StatusBadRequest, "invalid_json", "request body is too large")
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "invalid_json", "request body is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func pathID(w http.ResponseWriter, r *http.Request, key string) (int64, bool) {
|
||||
id, err := strconv.ParseInt(r.PathValue(key), 10, 64)
|
||||
if err != nil || id < 1 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_id", "resource ID must be a positive integer")
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
72
internal/api/types.go
Normal file
72
internal/api/types.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package api
|
||||
|
||||
import "havenllo/internal/domain"
|
||||
|
||||
type createListRequest struct {
|
||||
Name string `json:"name"`
|
||||
Position *float64 `json:"position,omitempty"`
|
||||
}
|
||||
|
||||
type updateListRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Position *float64 `json:"position,omitempty"`
|
||||
}
|
||||
|
||||
type createCardRequest struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Position *float64 `json:"position,omitempty"`
|
||||
}
|
||||
|
||||
type updateCardRequest struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Done *bool `json:"done,omitempty"`
|
||||
ListID *int64 `json:"list_id,omitempty"`
|
||||
Position *float64 `json:"position,omitempty"`
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error apiError `json:"error"`
|
||||
}
|
||||
|
||||
type apiError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type listResponse struct {
|
||||
List domain.List `json:"list"`
|
||||
}
|
||||
|
||||
type listDetailsResponse struct {
|
||||
List domain.List `json:"list"`
|
||||
Cards []domain.Card `json:"cards"`
|
||||
}
|
||||
|
||||
type listSummary struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Position float64 `json:"position"`
|
||||
CardCount int `json:"card_count"`
|
||||
}
|
||||
|
||||
type listsResponse struct {
|
||||
Lists []listSummary `json:"lists"`
|
||||
}
|
||||
type cardResponse struct {
|
||||
Card domain.Card `json:"card"`
|
||||
}
|
||||
type cardsResponse struct {
|
||||
Cards []domain.Card `json:"cards"`
|
||||
}
|
||||
|
||||
func listSummaries(lists []domain.List) []listSummary {
|
||||
summaries := make([]listSummary, 0, len(lists))
|
||||
for _, list := range lists {
|
||||
summaries = append(summaries, listSummary{
|
||||
ID: list.ID, Name: list.Name, Position: list.Position, CardCount: list.CardCount,
|
||||
})
|
||||
}
|
||||
return summaries
|
||||
}
|
||||
Reference in New Issue
Block a user