package alertapi import ( "encoding/json" "errors" "io" "net/http" "strconv" "strings" "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/metriccatalog" "github.com/itworx/pulse/internal/problem" ) type Handler struct { Repository alert.Store Registry metriccatalog.Registry 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.TrimPrefix(r.URL.Path, "/api/v1/alert-rules") if path == "" || path == "/" { switch r.Method { case http.MethodGet: h.list(w, r) case http.MethodPost: if !requireEdit(w, r, principal.Role) { return } h.create(w, r, principal.Subject) default: fail(w, r, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "This method is not supported.") } return } parts := strings.Split(strings.Trim(path, "/"), "/") if len(parts) < 1 || parts[0] == "" || len(parts) > 2 { fail(w, r, http.StatusNotFound, "NOT_FOUND", "Alert rule route not found.") return } id := parts[0] if len(parts) == 1 && r.Method == http.MethodGet { h.get(w, r, id) return } if len(parts) == 2 && parts[1] == "versions" && r.Method == http.MethodGet { h.versions(w, r, id) return } if len(parts) == 2 && parts[1] == "test" && r.Method == http.MethodPost { if !requireEdit(w, r, principal.Role) { return } h.test(w, r, id) return } if len(parts) == 2 && (parts[1] == "enable" || parts[1] == "disable") && r.Method == http.MethodPost { if !requireEdit(w, r, principal.Role) { return } h.setEnabled(w, r, id, principal.Subject, parts[1] == "enable") return } if len(parts) == 1 && r.Method == http.MethodPut { if !requireEdit(w, r, principal.Role) { return } h.update(w, r, id, principal.Subject) return } fail(w, r, http.StatusNotFound, "NOT_FOUND", "Alert rule 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-rule limit must be between 1 and 100.") return } limit = parsed } items, err := h.Repository.List(r.Context(), limit) if err != nil { h.repositoryFailure(w, r, err, "Alert rules are unavailable.") return } write(w, http.StatusOK, map[string]any{"items": items}) } func (h Handler) get(w http.ResponseWriter, r *http.Request, id string) { item, err := h.Repository.Get(r.Context(), id) if err != nil { h.repositoryFailure(w, r, err, "Alert rule not found.") return } write(w, http.StatusOK, map[string]any{"rule": item}) } func (h Handler) versions(w http.ResponseWriter, r *http.Request, id string) { 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 version limit must be between 1 and 100.") return } limit = parsed } items, err := h.Repository.Versions(r.Context(), id, limit) if err != nil { h.repositoryFailure(w, r, err, "Alert-rule versions are unavailable.") return } write(w, http.StatusOK, map[string]any{"items": items}) } func (h Handler) create(w http.ResponseWriter, r *http.Request, actor string) { var document alert.Document if err := decode(r, &document); err != nil { fail(w, r, http.StatusBadRequest, "INVALID_RULE", "The alert-rule document is invalid.") return } if document.ID == "" { document.ID = alert.NewID() } rule, version, err := h.Repository.Create(r.Context(), actor, document, "initial version") if err != nil { h.repositoryFailure(w, r, err, "Alert rule could not be created.") return } if err := h.record(r, actor, "alert_rule.create", rule.ID, nil, map[string]any{"revision": rule.Revision, "version": version.VersionNumber}); err != nil { fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.") return } write(w, http.StatusCreated, map[string]any{"rule": rule, "version": version}) } func (h Handler) update(w http.ResponseWriter, r *http.Request, id, actor string) { expected, err := revision(r) if err != nil { fail(w, r, http.StatusBadRequest, "INVALID_REVISION", "A valid If-Match or revision value is required.") return } var document alert.Document if err := decode(r, &document); err != nil { fail(w, r, http.StatusBadRequest, "INVALID_RULE", "The alert-rule document is invalid.") return } updated, err := h.Repository.Update(r.Context(), id, actor, expected, document, "rule update") if err != nil { h.repositoryFailure(w, r, err, "Alert rule update failed.") return } if err := h.record(r, actor, "alert_rule.update", id, map[string]any{"revision": expected}, map[string]any{"revision": updated.Revision, "version": updated.CurrentVersion}); err != nil { fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.") return } write(w, http.StatusOK, map[string]any{"rule": updated}) } func (h Handler) setEnabled(w http.ResponseWriter, r *http.Request, id, actor string, enabled 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 } updated, err := h.Repository.SetEnabled(r.Context(), id, expected, enabled) if err != nil { h.repositoryFailure(w, r, err, "Alert rule state update failed.") return } action := "alert_rule.disable" if enabled { action = "alert_rule.enable" } if err := h.record(r, actor, action, id, map[string]any{"enabled": !enabled, "revision": expected}, map[string]any{"enabled": enabled, "revision": updated.Revision}); err != nil { fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.") return } write(w, http.StatusOK, map[string]any{"rule": updated}) } func (h Handler) test(w http.ResponseWriter, r *http.Request, id string) { var request struct { Rule *alert.Document `json:"rule"` Value any `json:"value"` Unknown bool `json:"unknown"` } if err := decode(r, &request); err != nil { fail(w, r, http.StatusBadRequest, "INVALID_PREVIEW", "The alert-rule preview request is invalid.") return } document := request.Rule if document == nil { current, err := h.Repository.Get(r.Context(), id) if err != nil { h.repositoryFailure(w, r, err, "Alert rule preview is unavailable.") return } document = ¤t.Document } result, err := alert.Preview(*document, alert.PreviewRequest{Value: request.Value, Unknown: request.Unknown}, h.Registry) if err != nil { h.repositoryFailure(w, r, err, "The alert-rule preview is invalid.") return } write(w, http.StatusOK, map[string]any{"preview": result}) } func (h Handler) record(r *http.Request, actor, action, resourceID string, before, after map[string]any) error { if h.Audit == nil { return nil } return h.Audit.Append(r.Context(), audit.Event{Actor: actor, Action: action, ResourceType: "alert_rule", ResourceID: resourceID, Result: "success", CorrelationID: correlation.FromContext(r.Context()), Before: before, After: after}) } func (h Handler) repositoryFailure(w http.ResponseWriter, r *http.Request, err error, fallback string) { switch { case errors.Is(err, alert.ErrInvalidRule): fail(w, r, http.StatusBadRequest, "INVALID_RULE", fallback) case errors.Is(err, alert.ErrConflict): fail(w, r, http.StatusConflict, "REVISION_CONFLICT", "The alert rule was changed by another request.") case errors.Is(err, alert.ErrNotFound): fail(w, r, http.StatusNotFound, "NOT_FOUND", fallback) case errors.Is(err, alert.ErrUnavailable): fail(w, r, http.StatusServiceUnavailable, "DATABASE_UNAVAILABLE", fallback) default: fail(w, r, http.StatusInternalServerError, "ALERT_RULE_REQUEST_FAILED", fallback) } } func requireEdit(w http.ResponseWriter, r *http.Request, role auth.Role) bool { if auth.Allows(role, auth.PermissionEdit) { return true } fail(w, r, http.StatusForbidden, "FORBIDDEN", "Alert-rule editing is not allowed for this role.") return false } func decode(r *http.Request, target any) error { contentType := strings.ToLower(strings.TrimSpace(strings.Split(r.Header.Get("Content-Type"), ";")[0])) if contentType != "" && contentType != "application/json" { return errors.New("unsupported content type") } 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 revision(r *http.Request) (int64, error) { value := r.Header.Get("If-Match") if value == "" { value = r.URL.Query().Get("revision") } return strconv.ParseInt(strings.Trim(value, "\""), 10, 64) } 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) }