172 lines
5.2 KiB
Go
172 lines
5.2 KiB
Go
|
|
package control
|
||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"net/http"
|
||
|
|
|
||
|
|
"github.com/tegwick/fluid-core/internal/contract"
|
||
|
|
"github.com/tegwick/fluid-core/internal/observation"
|
||
|
|
)
|
||
|
|
|
||
|
|
// PressureAPI implements ArchitectureBlueprint.md section 44.2: record
|
||
|
|
// pressure, aggregate, link evidence, link hypothesis, close or dismiss.
|
||
|
|
type PressureAPI struct {
|
||
|
|
registry *observation.PressureRegistry
|
||
|
|
ingest *observation.Ingest
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewPressureAPI returns the pressure API.
|
||
|
|
func NewPressureAPI(r *observation.PressureRegistry, in *observation.Ingest) *PressureAPI {
|
||
|
|
return &PressureAPI{registry: r, ingest: in}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *PressureAPI) handleCollection(w http.ResponseWriter, r *http.Request) {
|
||
|
|
switch r.Method {
|
||
|
|
case http.MethodGet:
|
||
|
|
status := contract.FluidPressureStatus(r.URL.Query().Get("status"))
|
||
|
|
if status != "" && !status.Valid() {
|
||
|
|
writeError(w, http.StatusBadRequest, "unknown status filter")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
list, err := a.registry.List(r.Context(), status)
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, http.StatusInternalServerError, "could not list pressure")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, map[string]any{"pressures": list})
|
||
|
|
default:
|
||
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *PressureAPI) handleItem(w http.ResponseWriter, r *http.Request) {
|
||
|
|
id := pathTail(r.URL.Path, "/control/v1/pressure")
|
||
|
|
if id == "" {
|
||
|
|
writeError(w, http.StatusNotFound, "no pressure named")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
switch r.Method {
|
||
|
|
case http.MethodGet:
|
||
|
|
p, err := a.registry.Get(r.Context(), contract.PressureID(id))
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, statusForStoreError(err), "pressure not found")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, p)
|
||
|
|
|
||
|
|
case http.MethodPatch:
|
||
|
|
a.patch(w, r, contract.PressureID(id))
|
||
|
|
|
||
|
|
default:
|
||
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// PatchPressureRequest changes a pressure's disposition.
|
||
|
|
type PatchPressureRequest struct {
|
||
|
|
Status contract.FluidPressureStatus `json:"status,omitempty"`
|
||
|
|
Reason string `json:"reason,omitempty"`
|
||
|
|
Actor contract.Actor `json:"actor"`
|
||
|
|
Hypothesis contract.HypothesisID `json:"link_hypothesis,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *PressureAPI) patch(w http.ResponseWriter, r *http.Request, id contract.PressureID) {
|
||
|
|
var req PatchPressureRequest
|
||
|
|
if err := decodeBody(r, &req); err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, "could not decode request", err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if req.Actor.ID == "" {
|
||
|
|
// A disposition change with no actor cannot be audited, and dismissals
|
||
|
|
// are exactly the decisions worth attributing.
|
||
|
|
writeError(w, http.StatusBadRequest, "a disposition change must name its actor")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if req.Hypothesis != "" {
|
||
|
|
if err := a.registry.LinkHypothesis(r.Context(), id, req.Hypothesis); err != nil {
|
||
|
|
writeError(w, statusForStoreError(err), err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if req.Status != "" {
|
||
|
|
if err := a.registry.SetStatus(r.Context(), id, req.Status, req.Actor, req.Reason); err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
p, err := a.registry.Get(r.Context(), id)
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, statusForStoreError(err), "pressure not found")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, p)
|
||
|
|
}
|
||
|
|
|
||
|
|
// handleTelemetry accepts telemetry from out-of-process adapters and consumers.
|
||
|
|
func (a *PressureAPI) handleTelemetry(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if r.Method != http.MethodPost {
|
||
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
var batch struct {
|
||
|
|
Events []contract.FluidTelemetry `json:"events"`
|
||
|
|
}
|
||
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&batch); err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, "could not decode telemetry", err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
accepted, rejected := a.ingest.WriteBatch(r.Context(), batch.Events)
|
||
|
|
|
||
|
|
causes := make([]string, 0, len(rejected))
|
||
|
|
for _, err := range rejected {
|
||
|
|
causes = append(causes, err.Error())
|
||
|
|
}
|
||
|
|
// Partial acceptance is reported rather than failed: telemetry is
|
||
|
|
// best-effort evidence and the good events are worth keeping.
|
||
|
|
writeJSON(w, http.StatusAccepted, map[string]any{
|
||
|
|
"accepted": accepted,
|
||
|
|
"rejected": len(rejected),
|
||
|
|
"causes": causes,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// handleFeedback accepts explicit consumer feedback.
|
||
|
|
//
|
||
|
|
// This endpoint is reachable by consumers, unlike the rest of the control
|
||
|
|
// plane. What it accepts is evidence, never authority: recording feedback
|
||
|
|
// creates no pressure and no hypothesis on its own.
|
||
|
|
func (a *PressureAPI) handleFeedback(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if r.Method != http.MethodPost {
|
||
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
var f contract.FluidFeedback
|
||
|
|
if err := decodeBody(r, &f); err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, "could not decode feedback", err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
stored, err := a.ingest.RecordFeedback(r.Context(), f)
|
||
|
|
if err != nil {
|
||
|
|
if errors.Is(err, observation.ErrWrongInterface) {
|
||
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
||
|
|
"id": stored.ID,
|
||
|
|
"note": "recorded as evidence; feedback does not itself authorize an interface change",
|
||
|
|
})
|
||
|
|
}
|