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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user