- 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
96 lines
3.2 KiB
Go
96 lines
3.2 KiB
Go
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
|
|
}
|