feat: http handlers — frames + devices CRUD under /api/projects/:pid/

All 8 endpoints (list, create, patch, delete) for both resources. Path
params parsed via Go 1.22 ServeMux PathValue.

devicePatch uses json.RawMessage for frame_id so the wire format
distinguishes:
  - key absent       → leave as-is
  - "frame_id": null → clear (device leaves all frames)
  - "frame_id": 42   → move to that frame
parseFrameRef translates that into the store's db.FrameRef tri-state.

Sentinel-error mapping unchanged (writeError covers ErrInvalidInput,
ErrConflict, ErrNotFound, etc.). Cross-project frame_id refs surface as
400.
This commit is contained in:
mAi
2026-05-15 18:17:43 +02:00
parent cf1671e8c1
commit 21bf00566c
2 changed files with 246 additions and 0 deletions

View File

@@ -0,0 +1,234 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/mcables/internal/db"
)
// ---------------------------------------------------------------- frames
type frameCreate struct {
Name string `json:"name"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
}
type framePatch struct {
Name *string `json:"name,omitempty"`
X *float64 `json:"x,omitempty"`
Y *float64 `json:"y,omitempty"`
Width *float64 `json:"width,omitempty"`
Height *float64 `json:"height,omitempty"`
}
func (h *handlers) listFrames(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
fs, err := h.store.ListFrames(pid)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, fs)
}
func (h *handlers) createFrame(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
var body frameCreate
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
f, err := h.store.CreateFrame(pid, db.FrameCreate{
Name: body.Name, X: body.X, Y: body.Y, Width: body.Width, Height: body.Height,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusCreated, f)
}
func (h *handlers) patchFrame(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
var body framePatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
f, err := h.store.UpdateFrame(pid, id, db.FrameUpdate{
Name: body.Name, X: body.X, Y: body.Y, Width: body.Width, Height: body.Height,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, f)
}
func (h *handlers) deleteFrame(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
if err := h.store.DeleteFrame(pid, id); err != nil {
writeError(w, err, nil)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------- devices
type deviceCreate struct {
Name string `json:"name"`
FrameID *int64 `json:"frame_id,omitempty"`
Color string `json:"color,omitempty"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
}
// devicePatch uses a raw `json.RawMessage` for frame_id so we can tell
// "key absent" (leave alone) from "key present and null" (set to NULL)
// from "key present with an int" (move to that frame). Standard encoding
// of nullable fields in JSON PATCH.
type devicePatch struct {
Name *string `json:"name,omitempty"`
FrameID json.RawMessage `json:"frame_id,omitempty"`
Color *string `json:"color,omitempty"`
X *float64 `json:"x,omitempty"`
Y *float64 `json:"y,omitempty"`
Width *float64 `json:"width,omitempty"`
Height *float64 `json:"height,omitempty"`
}
// parseFrameRef decodes the raw frame_id field into a tri-state.
func parseFrameRef(raw json.RawMessage) (db.FrameRef, error) {
if len(raw) == 0 {
return db.FrameRef{Set: false}, nil
}
// "null" → clear; otherwise expect an integer.
if string(raw) == "null" {
return db.FrameRef{Set: true, ID: nil}, nil
}
var id int64
if err := json.Unmarshal(raw, &id); err != nil {
return db.FrameRef{}, err
}
return db.FrameRef{Set: true, ID: &id}, nil
}
func (h *handlers) listDevices(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
ds, err := h.store.ListDevices(pid, nil)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, ds)
}
func (h *handlers) createDevice(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
var body deviceCreate
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
d, err := h.store.CreateDevice(pid, db.DeviceCreate{
Name: body.Name, FrameID: body.FrameID, Color: body.Color,
X: body.X, Y: body.Y, Width: body.Width, Height: body.Height,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusCreated, d)
}
func (h *handlers) patchDevice(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
var body devicePatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
ref, err := parseFrameRef(body.FrameID)
if err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), "frame_id must be an integer or null")
return
}
d, err := h.store.UpdateDevice(pid, id, db.DeviceUpdate{
Name: body.Name, FrameID: ref, Color: body.Color,
X: body.X, Y: body.Y, Width: body.Width, Height: body.Height,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, d)
}
func (h *handlers) deleteDevice(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
if err := h.store.DeleteDevice(pid, id); err != nil {
writeError(w, err, nil)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -33,6 +33,18 @@ func New(store *db.Store, frontend fs.FS) http.Handler {
mux.HandleFunc("PATCH /api/cable-types/{id}", h.patchCableType)
mux.HandleFunc("DELETE /api/cable-types/{id}", h.deleteCableType)
// Frames (project-scoped)
mux.HandleFunc("GET /api/projects/{pid}/frames", h.listFrames)
mux.HandleFunc("POST /api/projects/{pid}/frames", h.createFrame)
mux.HandleFunc("PATCH /api/projects/{pid}/frames/{id}", h.patchFrame)
mux.HandleFunc("DELETE /api/projects/{pid}/frames/{id}", h.deleteFrame)
// Devices (project-scoped)
mux.HandleFunc("GET /api/projects/{pid}/devices", h.listDevices)
mux.HandleFunc("POST /api/projects/{pid}/devices", h.createDevice)
mux.HandleFunc("PATCH /api/projects/{pid}/devices/{id}", h.patchDevice)
mux.HandleFunc("DELETE /api/projects/{pid}/devices/{id}", h.deleteDevice)
// Frontend (embedded). Serve "/" → index.html via http.FileServerFS.
mux.Handle("/", http.FileServerFS(frontend))