package control import ( "encoding/json" "errors" "net/http" "sort" "github.com/tegwick/fluid-core/internal/contract" "github.com/tegwick/fluid-core/internal/evidence" "github.com/tegwick/fluid-core/internal/publish" ) // RevisionAPI implements ArchitectureBlueprint.md section 44.1: create, verify, // publish, set state, query lineage. // // Create and verify are one operation here rather than two. A revision that // exists but has not been verified has no use and no authority, and offering it // as a separate resource would invite callers to treat it as one. type RevisionAPI struct { store evidence.Store pipeline *publish.Pipeline } // NewRevisionAPI returns the revision API. func NewRevisionAPI(store evidence.Store, p *publish.Pipeline) *RevisionAPI { return &RevisionAPI{store: store, pipeline: p} } // CreateRevisionRequest submits a candidate for verification and publication. type CreateRevisionRequest struct { Descriptor contract.Revision `json:"descriptor"` Origin contract.Actor `json:"origin"` AdaptationClasses []contract.AdaptationClass `json:"adaptation_classes,omitempty"` ComplexityDelta float64 `json:"complexity_delta,omitempty"` RequestedTrafficShare float64 `json:"requested_traffic_share,omitempty"` Approved bool `json:"approved,omitempty"` ApprovedBy *contract.Actor `json:"approved_by,omitempty"` } // CreateRevisionResponse reports the outcome, verified or not. type CreateRevisionResponse struct { Revision contract.RevisionID `json:"revision"` State string `json:"state"` Report publish.Report `json:"report"` // Descriptor is returned only on success, with its signature attached. Descriptor *contract.Revision `json:"descriptor,omitempty"` } func (a *RevisionAPI) handleCollection(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: a.list(w, r) case http.MethodPost: a.create(w, r) default: writeError(w, http.StatusMethodNotAllowed, "method not allowed") } } func (a *RevisionAPI) handleItem(w http.ResponseWriter, r *http.Request) { id := pathTail(r.URL.Path, "/control/v1/revisions") if id == "" { writeError(w, http.StatusNotFound, "no revision named") return } switch r.Method { case http.MethodGet: a.get(w, r, id) default: writeError(w, http.StatusMethodNotAllowed, "method not allowed") } } // create runs a candidate through the pipeline and publishes it if it passes. func (a *RevisionAPI) create(w http.ResponseWriter, r *http.Request) { var req CreateRevisionRequest if err := decodeBody(r, &req); err != nil { writeError(w, http.StatusBadRequest, "could not decode request", err.Error()) return } if req.Descriptor.ID == "" { writeError(w, http.StatusBadRequest, "descriptor has no revision id") return } if err := contract.RequireKind(string(req.Descriptor.ID), contract.KindRevision); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if req.Origin.ID == "" { writeError(w, http.StatusBadRequest, "every candidate must name its origin") return } candidate := publish.NewCandidate(req.Descriptor, req.Origin) verified, report, err := a.pipeline.Run(r.Context(), candidate, publish.PromotionRequest{ AdaptationClasses: req.AdaptationClasses, ComplexityDelta: req.ComplexityDelta, RequestedTrafficShare: req.RequestedTrafficShare, Approved: req.Approved, ApprovedBy: req.ApprovedBy, }) if err != nil { var rejected *publish.ErrRejected if errors.As(err, &rejected) { // A rejected candidate is a normal outcome (invariant 14), so it is // reported as a result with its evidence rather than as a server // fault. 422 says the request was well formed and the answer is no. writeJSON(w, http.StatusUnprocessableEntity, CreateRevisionResponse{ Revision: candidate.ID(), State: "REJECTED", Report: report, }) return } writeError(w, http.StatusInternalServerError, "pipeline failed", err.Error()) return } if err := a.pipeline.Publish(r.Context(), verified); err != nil { writeError(w, http.StatusInternalServerError, "verified but not published", err.Error()) return } d := verified.Descriptor() writeJSON(w, http.StatusCreated, CreateRevisionResponse{ Revision: d.ID, State: string(d.State), Report: report, Descriptor: &d, }) } func (a *RevisionAPI) get(w http.ResponseWriter, r *http.Request, id string) { body, err := a.store.Record(r.Context(), contract.KindRevision, id) if err != nil { writeError(w, statusForStoreError(err), "revision not found") return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write(body) } // LineageEntry is one step in a revision's ancestry. type LineageEntry struct { Revision contract.RevisionID `json:"revision"` State string `json:"state"` Intent string `json:"intent"` } func (a *RevisionAPI) list(w http.ResponseWriter, r *http.Request) { records, err := a.store.Records(r.Context(), contract.KindRevision) if err != nil { writeError(w, http.StatusInternalServerError, "could not list revisions") return } out := make([]LineageEntry, 0, len(records)) for _, body := range records { var d contract.Revision if err := json.Unmarshal(body, &d); err != nil { continue } out = append(out, LineageEntry{ Revision: d.ID, State: string(d.State), Intent: d.Intent.Version, }) } sort.Slice(out, func(i, j int) bool { return out[i].Revision < out[j].Revision }) writeJSON(w, http.StatusOK, map[string]any{"revisions": out}) }