package savedsearch import ( "context" "encoding/json" "log/slog" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "go.bigb.es/auxilia/culpa" "go.bigb.es/auxilia/scribe" "sourcecraft.dev/bigbes/lethe/internal/pkg/apierror" "sourcecraft.dev/bigbes/lethe/internal/pkg/httputil" "sourcecraft.dev/bigbes/lethe/internal/server/auth" ) // Handler is the steward-managed HTTP boundary for the saved-searches CRUD API. // Repo is the injected SQL steward; the Handler holds no other state. type Handler struct { Repo *Repository `inject:""` } // Init satisfies the steward Initer contract. The Handler is stateless beyond // its injected dependencies. func (h *Handler) Init(_ context.Context) error { return nil } // Mount registers the four CRUD routes under r. Server.Init mounts this inside // the /api/v1 group, so the effective paths are: // // GET /api/v1/saved-searches // POST /api/v1/saved-searches // PUT /api/v1/saved-searches/{name} // DELETE /api/v1/saved-searches/{name} func (h *Handler) Mount(r chi.Router) { r.Get("/saved-searches", h.List) r.Post("/saved-searches", h.Create) r.Put("/saved-searches/{name}", h.Update) r.Delete("/saved-searches/{name}", h.Delete) } // ownerOf derives the owner from the authenticated identity on the context. // The ?owner= query parameter is intentionally ignored (IV2). func (h *Handler) ownerOf(r *http.Request) string { return auth.MustIdentity(r.Context()).User } // validateName enforces the name constraints (IV7, UK1): // - non-empty // - at most 64 characters // - no "/" character func validateName(name string) error { if name == "" { return culpa.WithCode( culpa.WithPublic(culpa.New("name is empty"), "name must not be empty"), "VALIDATION", ) } if len(name) > 64 { return culpa.WithCode( culpa.WithPublic(culpa.New("name too long"), "name must be 64 characters or fewer"), "VALIDATION", ) } if strings.Contains(name, "/") { return culpa.WithCode( culpa.WithPublic(culpa.New("name contains slash"), "name must not contain '/'"), "VALIDATION", ) } return nil } // validateQuery enforces the query constraints: non-empty. func validateQuery(query string) error { if query == "" { return culpa.WithCode( culpa.WithPublic(culpa.New("query is empty"), "query must not be empty"), "VALIDATION", ) } return nil } // listResponse is the JSON body returned by List. type listResponse struct { SavedSearches []SavedSearch `json:"saved_searches"` } // List handles GET /saved-searches. The owner is derived from the auth context; // ?owner= is silently ignored (IV2). Writes { saved_searches: [...] }. func (h *Handler) List(w http.ResponseWriter, r *http.Request) { owner := h.ownerOf(r) rows, err := h.Repo.List(r.Context(), owner) if err != nil { apierror.Render(w, r, err) return } if writeErr := httputil.WriteJSON(w, http.StatusOK, listResponse{SavedSearches: rows}); writeErr != nil { slog.Default().ErrorContext(r.Context(), "write saved-searches list response", scribe.Err(writeErr)) } } // createRequest is the decoded JSON body for POST /saved-searches. type createRequest struct { Name string `json:"name"` Query string `json:"query"` } // Create handles POST /saved-searches. Decodes { name, query }, validates both // fields, inserts the row with now as both timestamps, and returns 201 Created // with the new row body. func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { // Defensive: reject any explicit ?owner= param to prevent confusion (IV2). if r.URL.Query().Get("owner") != "" { apierror.Render(w, r, culpa.WithCode( culpa.WithPublic(culpa.New("?owner= not accepted on write paths"), "?owner= is not accepted on saved-search write paths"), "INVALID", )) return } var req createRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { apierror.Render(w, r, culpa.WithCode( culpa.WithPublic(culpa.Wrap(err, "decode body"), "invalid request body"), "INVALID", )) return } if err := validateName(req.Name); err != nil { apierror.Render(w, r, err) return } if err := validateQuery(req.Query); err != nil { apierror.Render(w, r, err) return } now := time.Now().Unix() s := SavedSearch{ Owner: h.ownerOf(r), Name: req.Name, Query: req.Query, CreatedAt: now, UpdatedAt: now, } if err := h.Repo.Create(r.Context(), s); err != nil { apierror.Render(w, r, err) return } if writeErr := httputil.WriteJSON(w, http.StatusCreated, s); writeErr != nil { slog.Default().ErrorContext(r.Context(), "write saved-search create response", scribe.Err(writeErr)) } } // updateRequest is the decoded JSON body for PUT /saved-searches/{name}. // Both fields are optional; at least one must be non-nil. type updateRequest struct { Name *string `json:"name"` Query *string `json:"query"` } // Update handles PUT /saved-searches/{name}. Decodes { name?, query? }, requires // at least one field, validates non-nil fields, and returns 200 with the updated row. func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { // Defensive: reject any explicit ?owner= param (IV2). if r.URL.Query().Get("owner") != "" { apierror.Render(w, r, culpa.WithCode( culpa.WithPublic(culpa.New("?owner= not accepted on write paths"), "?owner= is not accepted on saved-search write paths"), "INVALID", )) return } urlName := chi.URLParam(r, "name") var req updateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { apierror.Render(w, r, culpa.WithCode( culpa.WithPublic(culpa.Wrap(err, "decode body"), "invalid request body"), "INVALID", )) return } if req.Name == nil && req.Query == nil { apierror.Render(w, r, culpa.WithCode( culpa.WithPublic(culpa.New("no fields to update"), "at least one of name or query must be provided"), "VALIDATION", )) return } if req.Name != nil { if err := validateName(*req.Name); err != nil { apierror.Render(w, r, err) return } } if req.Query != nil { if err := validateQuery(*req.Query); err != nil { apierror.Render(w, r, err) return } } owner := h.ownerOf(r) now := time.Now().Unix() updated, err := h.Repo.Update(r.Context(), owner, urlName, req.Name, req.Query, now) if err != nil { apierror.Render(w, r, err) return } if writeErr := httputil.WriteJSON(w, http.StatusOK, updated); writeErr != nil { slog.Default().ErrorContext(r.Context(), "write saved-search update response", scribe.Err(writeErr)) } } // Delete handles DELETE /saved-searches/{name}. Returns 204 No Content on // success, 404 when the row does not exist. func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { // Defensive: reject any explicit ?owner= param to prevent confusion (IV2). if r.URL.Query().Get("owner") != "" { apierror.Render(w, r, culpa.WithCode( culpa.WithPublic(culpa.New("?owner= not accepted on write paths"), "?owner= is not accepted on saved-search write paths"), "INVALID", )) return } owner := h.ownerOf(r) name := chi.URLParam(r, "name") if err := h.Repo.Delete(r.Context(), owner, name); err != nil { apierror.Render(w, r, err) return } w.WriteHeader(http.StatusNoContent) }