From 1e3988161b728beb3d9e57e66209607d2d1d4331 Mon Sep 17 00:00:00 2001 From: mAi Date: Fri, 15 May 2026 16:45:29 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20http=20server=20=E2=80=94=20net/http=20?= =?UTF-8?q?(Go=201.22=20ServeMux),=20/api/healthz=20+=20projects=20+=20cab?= =?UTF-8?q?le-types,=20JSON=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/server/handlers.go | 236 ++++++++++++++++++++++++++++++++++++ internal/server/server.go | 40 ++++++ 2 files changed, 276 insertions(+) create mode 100644 internal/server/handlers.go create mode 100644 internal/server/server.go diff --git a/internal/server/handlers.go b/internal/server/handlers.go new file mode 100644 index 0000000..3d190ac --- /dev/null +++ b/internal/server/handlers.go @@ -0,0 +1,236 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "mgit.msbls.de/m/mcables/internal/db" +) + +type handlers struct { + store *db.Store +} + +// ---------------------------------------------------------------- utility + +// writeJSON encodes v as JSON at the given status. Errors during encoding +// are logged-silent (the response has already started) — this is the +// last-resort path; callers should validate inputs early. +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +type errorBody struct { + Error string `json:"error"` + Details any `json:"details,omitempty"` +} + +// writeError maps a Store sentinel to an HTTP status + JSON body. +func writeError(w http.ResponseWriter, err error, details any) { + switch { + case errors.Is(err, db.ErrNotFound): + writeJSON(w, http.StatusNotFound, errorBody{Error: err.Error(), Details: details}) + case errors.Is(err, db.ErrConflict): + writeJSON(w, http.StatusConflict, errorBody{Error: err.Error(), Details: details}) + case errors.Is(err, db.ErrInUse): + writeJSON(w, http.StatusConflict, errorBody{Error: err.Error(), Details: details}) + case errors.Is(err, db.ErrConfirmName): + writeJSON(w, http.StatusBadRequest, errorBody{Error: err.Error(), Details: details}) + case errors.Is(err, db.ErrInvalidInput): + writeJSON(w, http.StatusBadRequest, errorBody{Error: err.Error(), Details: details}) + default: + writeJSON(w, http.StatusInternalServerError, errorBody{Error: err.Error(), Details: details}) + } +} + +func parseInt64Path(r *http.Request, key string) (int64, bool) { + raw := r.PathValue(key) + v, err := strconv.ParseInt(raw, 10, 64) + if err != nil || v <= 0 { + return 0, false + } + return v, true +} + +// ---------------------------------------------------------------- health + +func (h *handlers) healthz(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// ---------------------------------------------------------------- projects + +type projectCreate struct { + Name string `json:"name"` + DrawingName string `json:"drawing_name"` + Description string `json:"description"` +} + +type projectPatch struct { + Name *string `json:"name,omitempty"` + DrawingName *string `json:"drawing_name,omitempty"` + Description *string `json:"description,omitempty"` +} + +func (h *handlers) listProjects(w http.ResponseWriter, _ *http.Request) { + ps, err := h.store.ListProjects() + if err != nil { + writeError(w, err, nil) + return + } + if ps == nil { + ps = []db.Project{} + } + writeJSON(w, http.StatusOK, ps) +} + +func (h *handlers) createProject(w http.ResponseWriter, r *http.Request) { + var body projectCreate + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, errors.Join(db.ErrInvalidInput, err), nil) + return + } + p, err := h.store.CreateProject(body.Name, body.DrawingName, body.Description) + if err != nil { + writeError(w, err, nil) + return + } + writeJSON(w, http.StatusCreated, p) +} + +func (h *handlers) getProject(w http.ResponseWriter, r *http.Request) { + id, ok := parseInt64Path(r, "pid") + if !ok { + writeError(w, db.ErrInvalidInput, "pid must be a positive integer") + return + } + snap, err := h.store.Snapshot(id) + if err != nil { + writeError(w, err, nil) + return + } + writeJSON(w, http.StatusOK, snap) +} + +func (h *handlers) patchProject(w http.ResponseWriter, r *http.Request) { + id, ok := parseInt64Path(r, "pid") + if !ok { + writeError(w, db.ErrInvalidInput, "pid must be a positive integer") + return + } + var body projectPatch + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, errors.Join(db.ErrInvalidInput, err), nil) + return + } + p, err := h.store.UpdateProject(id, db.ProjectUpdate{ + Name: body.Name, + DrawingName: body.DrawingName, + Description: body.Description, + }) + if err != nil { + writeError(w, err, nil) + return + } + writeJSON(w, http.StatusOK, p) +} + +func (h *handlers) deleteProject(w http.ResponseWriter, r *http.Request) { + id, ok := parseInt64Path(r, "pid") + if !ok { + writeError(w, db.ErrInvalidInput, "pid must be a positive integer") + return + } + confirm := r.URL.Query().Get("confirm") + if confirm == "" { + writeError(w, db.ErrConfirmName, + "DELETE requires ?confirm= matching the project's current name") + return + } + if err := h.store.DeleteProject(id, confirm); err != nil { + writeError(w, err, nil) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ---------------------------------------------------------------- cable_types + +type cableTypeCreate struct { + Name string `json:"name"` + Color string `json:"color"` +} + +type cableTypePatch struct { + Name *string `json:"name,omitempty"` + Color *string `json:"color,omitempty"` +} + +func (h *handlers) listCableTypes(w http.ResponseWriter, _ *http.Request) { + ts, err := h.store.ListCableTypes() + if err != nil { + writeError(w, err, nil) + return + } + writeJSON(w, http.StatusOK, ts) +} + +func (h *handlers) createCableType(w http.ResponseWriter, r *http.Request) { + var body cableTypeCreate + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, errors.Join(db.ErrInvalidInput, err), nil) + return + } + t, err := h.store.CreateCableType(body.Name, body.Color) + if err != nil { + writeError(w, err, nil) + return + } + writeJSON(w, http.StatusCreated, t) +} + +func (h *handlers) patchCableType(w http.ResponseWriter, r *http.Request) { + id, ok := parseInt64Path(r, "id") + if !ok { + writeError(w, db.ErrInvalidInput, "id must be a positive integer") + return + } + var body cableTypePatch + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, errors.Join(db.ErrInvalidInput, err), nil) + return + } + t, err := h.store.UpdateCableType(id, db.CableTypeUpdate{ + Name: body.Name, + Color: body.Color, + }) + if err != nil { + writeError(w, err, nil) + return + } + writeJSON(w, http.StatusOK, t) +} + +func (h *handlers) deleteCableType(w http.ResponseWriter, r *http.Request) { + id, ok := parseInt64Path(r, "id") + if !ok { + writeError(w, db.ErrInvalidInput, "id must be a positive integer") + return + } + if err := h.store.DeleteCableType(id); err != nil { + // On ErrInUse, count referencing cables so the client can show + // "blocked by N cables". + if errors.Is(err, db.ErrInUse) { + n, _ := h.store.CountCablesUsingType(id) + writeError(w, err, map[string]int{"in_use_by_cables": n}) + return + } + writeError(w, err, nil) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..e0cb104 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,40 @@ +// Package server wires the HTTP API + the embedded frontend onto a +// single net/http handler. Routes use Go 1.22 ServeMux pattern matching +// (no router framework). +package server + +import ( + "io/fs" + "net/http" + + "mgit.msbls.de/m/mcables/internal/db" +) + +// New returns an http.Handler serving the mCables API at /api/ and the +// embedded frontend at /. The frontend FS should be rooted such that +// "index.html" is at its root. +func New(store *db.Store, frontend fs.FS) http.Handler { + mux := http.NewServeMux() + h := &handlers{store: store} + + // Health + mux.HandleFunc("GET /api/healthz", h.healthz) + + // Projects + mux.HandleFunc("GET /api/projects", h.listProjects) + mux.HandleFunc("POST /api/projects", h.createProject) + mux.HandleFunc("GET /api/projects/{pid}", h.getProject) + mux.HandleFunc("PATCH /api/projects/{pid}", h.patchProject) + mux.HandleFunc("DELETE /api/projects/{pid}", h.deleteProject) + + // Cable types (global) + mux.HandleFunc("GET /api/cable-types", h.listCableTypes) + mux.HandleFunc("POST /api/cable-types", h.createCableType) + mux.HandleFunc("PATCH /api/cable-types/{id}", h.patchCableType) + mux.HandleFunc("DELETE /api/cable-types/{id}", h.deleteCableType) + + // Frontend (embedded). Serve "/" → index.html via http.FileServerFS. + mux.Handle("/", http.FileServerFS(frontend)) + + return mux +}