package alertopsapi import ( "context" "encoding/json" "errors" "io" "net/http" "strconv" "strings" "time" "github.com/itworx/pulse/internal/alert" "github.com/itworx/pulse/internal/audit" "github.com/itworx/pulse/internal/auth" "github.com/itworx/pulse/internal/correlation" "github.com/itworx/pulse/internal/problem" ) type Store interface { alert.AlertReader AcknowledgeRevision(context.Context, string, string, string, time.Time, int64) (alert.Instance, alert.Occurrence, bool, error) Unacknowledge(context.Context, string, string, string, time.Time, int64) (alert.Instance, alert.Occurrence, bool, error) } type Handler struct { Store Store Audit audit.Store } func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { principal, ok := auth.PrincipalFromContext(r.Context()) if !ok { fail(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required.") return } path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/v1/alerts"), "/") if path == "" && r.Method == http.MethodGet { h.list(w, r) return } parts := strings.Split(path, "/") if len(parts) == 1 && parts[0] != "" && r.Method == http.MethodGet { h.get(w, r, parts[0]) return } if len(parts) == 2 && (parts[1] == "acknowledge" || parts[1] == "unacknowledge") && r.Method == http.MethodPost { if !auth.Allows(principal.Role, auth.PermissionOperate) { fail(w, r, http.StatusForbidden, "FORBIDDEN", "Alert operations are not allowed for this role.") return } h.operate(w, r, parts[0], principal.Subject, parts[1] == "acknowledge") return } fail(w, r, http.StatusNotFound, "NOT_FOUND", "Alert route not found.") } func (h Handler) list(w http.ResponseWriter, r *http.Request) { limit := 100 if value := r.URL.Query().Get("limit"); value != "" { parsed, err := strconv.Atoi(value) if err != nil || parsed < 1 || parsed > 100 { fail(w, r, http.StatusBadRequest, "INVALID_LIMIT", "The alert limit must be between 1 and 100.") return } limit = parsed } items, err := h.Store.ListAlerts(r.Context(), limit, r.URL.Query().Get("state")) if err != nil { h.repositoryFailure(w, r, err) return } write(w, http.StatusOK, map[string]any{"items": items}) } func (h Handler) get(w http.ResponseWriter, r *http.Request, id string) { limit := 100 if value := r.URL.Query().Get("occurrenceLimit"); value != "" { parsed, err := strconv.Atoi(value) if err != nil || parsed < 1 || parsed > 500 { fail(w, r, http.StatusBadRequest, "INVALID_LIMIT", "The occurrence limit must be between 1 and 500.") return } limit = parsed } item, err := h.Store.GetAlert(r.Context(), id, limit) if err != nil { h.repositoryFailure(w, r, err) return } write(w, http.StatusOK, map[string]any{"alert": item}) } func (h Handler) operate(w http.ResponseWriter, r *http.Request, id, actor string, acknowledge bool) { expected, err := revision(r) if err != nil { fail(w, r, http.StatusBadRequest, "INVALID_REVISION", "A valid If-Match or revision value is required.") return } evaluationKey := strings.TrimSpace(r.Header.Get("Idempotency-Key")) if evaluationKey == "" { var request struct { EvaluationKey string `json:"evaluationKey"` } if err := decode(r, &request); err != nil { fail(w, r, http.StatusBadRequest, "INVALID_OPERATION", "The alert operation request is invalid.") return } evaluationKey = strings.TrimSpace(request.EvaluationKey) } if evaluationKey == "" || len(evaluationKey) > 160 { fail(w, r, http.StatusBadRequest, "INVALID_OPERATION", "A bounded evaluation key or Idempotency-Key is required.") return } var instance alert.Instance var occurrence alert.Occurrence var duplicate bool if acknowledge { instance, occurrence, duplicate, err = h.Store.AcknowledgeRevision(r.Context(), id, actor, evaluationKey, time.Now().UTC(), expected) } else { instance, occurrence, duplicate, err = h.Store.Unacknowledge(r.Context(), id, actor, evaluationKey, time.Now().UTC(), expected) } if err != nil { h.repositoryFailure(w, r, err) return } action := "alert.unacknowledge" if acknowledge { action = "alert.acknowledge" } result := "success" if duplicate { result = "idempotent" } if h.Audit != nil { if err := h.Audit.Append(r.Context(), audit.Event{Actor: actor, Action: action, ResourceType: "alert_instance", ResourceID: id, Result: result, CorrelationID: correlation.FromContext(r.Context()), After: map[string]any{"state": instance.State, "revision": instance.Revision, "duplicate": duplicate}}); err != nil { fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.") return } } write(w, http.StatusOK, map[string]any{"instance": instance, "occurrence": occurrence, "duplicate": duplicate}) } func (h Handler) repositoryFailure(w http.ResponseWriter, r *http.Request, err error) { switch { case errors.Is(err, alert.ErrInvalidObservation): fail(w, r, http.StatusBadRequest, "INVALID_ALERT_OPERATION", "The alert operation is invalid.") case errors.Is(err, alert.ErrRevisionConflict): fail(w, r, http.StatusConflict, "REVISION_CONFLICT", "The alert changed before this operation was applied.") case errors.Is(err, alert.ErrStateConflict): fail(w, r, http.StatusConflict, "STATE_CONFLICT", "The alert is not in a state that supports this operation.") case errors.Is(err, alert.ErrInstanceNotFound): fail(w, r, http.StatusNotFound, "NOT_FOUND", "The alert instance was not found.") case errors.Is(err, alert.ErrUnavailable): fail(w, r, http.StatusServiceUnavailable, "DATABASE_UNAVAILABLE", "Alerts are unavailable.") default: fail(w, r, http.StatusInternalServerError, "ALERT_REQUEST_FAILED", "The alert request failed.") } } func revision(r *http.Request) (int64, error) { value := r.Header.Get("If-Match") if value == "" { value = r.URL.Query().Get("revision") } value = strings.Trim(value, "\"") if value == "" { return 0, errors.New("revision required") } parsed, err := strconv.ParseInt(value, 10, 64) if err != nil || parsed < 1 { return 0, errors.New("invalid revision") } return parsed, nil } func decode(r *http.Request, target any) error { body, err := io.ReadAll(io.LimitReader(r.Body, 2<<20+1)) if err != nil { return err } defer r.Body.Close() if len(body) > 2<<20 { return errors.New("request too large") } decoder := json.NewDecoder(strings.NewReader(string(body))) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { return err } var extra any if err := decoder.Decode(&extra); err != io.EOF { return errors.New("multiple JSON values") } return nil } 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 write(w http.ResponseWriter, status int, value any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(value) }