package api import ( "errors" "log/slog" "net/http" "strconv" "git.k6n.net/mats/fullstack-template/internal/items" ) // itemHandler adapts HTTP requests to the items service: parse input, call // the service, map errors to status codes. No business logic lives here. type itemHandler struct { svc *items.Service logger *slog.Logger } func (h *itemHandler) list(w http.ResponseWriter, r *http.Request) { f := items.Filter{ Status: items.Status(r.URL.Query().Get("status")), Limit: queryInt(r, "limit", items.DefaultLimit), Offset: queryInt(r, "offset", 0), } result, err := h.svc.List(r.Context(), f) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, result) } func (h *itemHandler) create(w http.ResponseWriter, r *http.Request) { var in items.CreateInput if !decodeJSON(w, r, &in) { return } item, err := h.svc.Create(r.Context(), in) if err != nil { h.fail(w, err) return } w.Header().Set("Location", "/api/v1/items/"+strconv.FormatInt(item.ID, 10)) writeJSON(w, http.StatusCreated, item) } func (h *itemHandler) get(w http.ResponseWriter, r *http.Request) { id, ok := pathID(w, r) if !ok { return } item, err := h.svc.Get(r.Context(), id) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, item) } func (h *itemHandler) update(w http.ResponseWriter, r *http.Request) { id, ok := pathID(w, r) if !ok { return } var in items.UpdateInput if !decodeJSON(w, r, &in) { return } item, err := h.svc.Update(r.Context(), id, in) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, item) } func (h *itemHandler) delete(w http.ResponseWriter, r *http.Request) { id, ok := pathID(w, r) if !ok { return } if err := h.svc.Delete(r.Context(), id); err != nil { h.fail(w, err) return } w.WriteHeader(http.StatusNoContent) } // pathID parses the {id} path segment; writes a 400 and returns false on // input that can't be an id. func pathID(w http.ResponseWriter, r *http.Request) (int64, bool) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || id <= 0 { writeError(w, http.StatusBadRequest, "invalid_id", "id must be a positive integer") return 0, false } return id, true } // queryInt reads an int query parameter, falling back when absent or junk. func queryInt(r *http.Request, key string, fallback int) int { v := r.URL.Query().Get(key) if v == "" { return fallback } n, err := strconv.Atoi(v) if err != nil || n < 0 { return fallback } return n } // fail is the single translation point from items errors to the wire: // ErrNotFound → 404, *ValidationError → 400, anything else → 500 (logged). func (h *itemHandler) fail(w http.ResponseWriter, err error) { var vErr *items.ValidationError switch { case errors.Is(err, items.ErrNotFound): writeError(w, http.StatusNotFound, "not_found", "item not found") case errors.As(err, &vErr): writeError(w, http.StatusBadRequest, "validation_failed", vErr.Error()) default: h.logger.Error("request failed", "err", err) writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred") } }