package inventoryapi import ( "context" "encoding/base64" "encoding/json" "errors" "net/http" "strconv" "strings" "github.com/itworx/pulse/internal/auth" "github.com/itworx/pulse/internal/inventory" "github.com/itworx/pulse/internal/problem" ) type Repository interface { SearchEntities(context.Context, inventory.EntityFilter) ([]inventory.EntitySummary, error) GetEntityDetail(context.Context, string) (inventory.EntityDetail, error) } type Handler struct{ Repository Repository } func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if h.Repository == nil { fail(w, r, http.StatusServiceUnavailable, "INVENTORY_UNAVAILABLE", "Inventory is not configured.") return } if _, ok := auth.PrincipalFromContext(r.Context()); !ok { fail(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication is required to read inventory.") return } if r.Method != http.MethodGet { fail(w, r, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "Only read-only inventory operations are supported.") return } path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/v1/entities"), "/") if path == "" { h.list(w, r) return } parts := strings.Split(path, "/") if len(parts) == 1 && parts[0] != "" { h.detail(w, r, parts[0], false) return } if len(parts) == 2 && parts[0] != "" && parts[1] == "relations" { h.detail(w, r, parts[0], true) return } fail(w, r, http.StatusNotFound, "NOT_FOUND", "Inventory route not found.") } func (h Handler) list(w http.ResponseWriter, r *http.Request) { limit := 50 if raw := r.URL.Query().Get("limit"); raw != "" { value, err := strconv.Atoi(raw) if err != nil || value < 1 || value > 100 { fail(w, r, http.StatusBadRequest, "INVALID_LIMIT", "The inventory limit must be between 1 and 100.") return } limit = value } afterName, afterID, err := decodeCursor(r.URL.Query().Get("after")) if err != nil { fail(w, r, http.StatusBadRequest, "INVALID_CURSOR", "The inventory cursor is invalid.") return } direction := r.URL.Query().Get("order") if direction == "" { direction = "asc" } if direction != "asc" && direction != "desc" { fail(w, r, http.StatusBadRequest, "INVALID_SORT", "Inventory order must be asc or desc.") return } filter := inventory.EntityFilter{Limit: limit, AfterName: afterName, AfterID: afterID, Search: strings.TrimSpace(r.URL.Query().Get("q")), EntityType: strings.TrimSpace(r.URL.Query().Get("type")), Status: strings.TrimSpace(r.URL.Query().Get("status")), Direction: direction} items, err := h.Repository.SearchEntities(r.Context(), filter) if err != nil { repositoryFailure(w, r, err) return } hasMore := len(items) > limit if hasMore { items = items[:limit] } next := "" if hasMore && len(items) > 0 { last := items[len(items)-1] next = encodeCursor(last.DisplayName, last.ID) } writeJSON(w, map[string]any{"items": items, "nextCursor": next, "hasMore": hasMore, "filters": map[string]string{"q": filter.Search, "type": filter.EntityType, "status": filter.Status, "order": filter.Direction}}) } func (h Handler) detail(w http.ResponseWriter, r *http.Request, id string, relationsOnly bool) { detail, err := h.Repository.GetEntityDetail(r.Context(), id) if err != nil { if errors.Is(err, inventory.ErrNotFound) { fail(w, r, http.StatusNotFound, "ENTITY_NOT_FOUND", "The inventory entity does not exist.") return } repositoryFailure(w, r, err) return } if relationsOnly { writeJSON(w, map[string]any{"items": detail.Relations}) return } writeJSON(w, detail) } func encodeCursor(name, id string) string { return base64.RawURLEncoding.EncodeToString([]byte(strings.ToLower(name) + "\x00" + id)) } func decodeCursor(value string) (string, string, error) { if value == "" { return "", "", nil } decoded, err := base64.RawURLEncoding.DecodeString(value) if err != nil { return "", "", err } parts := strings.Split(string(decoded), "\x00") if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return "", "", strconv.ErrSyntax } return parts[0], parts[1], nil } func repositoryFailure(w http.ResponseWriter, r *http.Request, err error) { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return } fail(w, r, http.StatusServiceUnavailable, "INVENTORY_UNAVAILABLE", "Inventory could not be read safely.") } func fail(w http.ResponseWriter, r *http.Request, status int, code, detail string) { problem.Write(w, r, status, code, http.StatusText(status), detail, nil) } func writeJSON(w http.ResponseWriter, value any) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "private, no-store") _ = json.NewEncoder(w).Encode(value) }