Public source validation / validate (push) Failing after 3m8s
143 lines
4.6 KiB
Go
143 lines
4.6 KiB
Go
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)
|
|
}
|