package httpapi import ( "context" "encoding/json" "errors" "net/http" "strings" "time" "supply-intelligence/internal/admission" "supply-intelligence/internal/discovery" "supply-intelligence/internal/domain" "supply-intelligence/internal/gatewayconsumer" "supply-intelligence/internal/probe" "supply-intelligence/internal/publish" "supply-intelligence/internal/repository" ) type Server struct { repo *repository.MemoryRepository probeService *probe.Service publishService *publish.Service gatewayConsumerService *gatewayconsumer.Service discoveryService *discovery.Service admissionService *admission.Service } type packageChangesResponse struct { Items []domain.PackageChangeEvent `json:"items"` NextCursor string `json:"next_cursor"` } type discoveryCandidatesResponse struct { Items []domain.DiscoveryCandidate `json:"items"` } func NewServer(repo *repository.MemoryRepository, probeService *probe.Service, publishService *publish.Service, gatewayConsumerService *gatewayconsumer.Service, discoveryService *discovery.Service, admissionService *admission.Service) *Server { return &Server{repo: repo, probeService: probeService, publishService: publishService, gatewayConsumerService: gatewayConsumerService, discoveryService: discoveryService, admissionService: admissionService} } func (s *Server) Routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/healthz", s.handleHealth) mux.HandleFunc("/internal/supply-intelligence/accounts/", s.handleGetRoutingState) mux.HandleFunc("/internal/supply-intelligence/probe/evaluate", s.handleEvaluateProbe) mux.HandleFunc("/internal/supply-intelligence/publish/package-event", s.handlePublishPackageEvent) mux.HandleFunc("/internal/supply-intelligence/discovery/candidates", s.handleDiscoveryCandidates) mux.HandleFunc("/internal/supply-intelligence/gateway/package-changes", s.handleListPackageChanges) mux.HandleFunc("/internal/supply-intelligence/gateway/package-changes/", s.handleAckPackageChange) mux.HandleFunc("/internal/supply-intelligence/gateway/consume-once", s.handleConsumeOnce) mux.HandleFunc("/internal/supply-intelligence/admission/run", s.handleAdmissionRun) mux.HandleFunc("/internal/supply-intelligence/admission/candidates", s.handleAdmissionCandidates) return mux } func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *Server) handleGetRoutingState(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"}) return } prefix := "/internal/supply-intelligence/accounts/" path := strings.TrimPrefix(r.URL.Path, prefix) if !strings.HasSuffix(path, "/routing-state") { writeJSON(w, http.StatusNotFound, map[string]string{"error": "not_found"}) return } accountIDPart := strings.TrimSuffix(path, "/routing-state") var accountID int64 if _, err := parseInt64(accountIDPart, &accountID); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_account_id"}) return } state, ok := s.repo.GetRoutingState(accountID) if !ok { writeJSON(w, http.StatusNotFound, map[string]string{"error": "not_found"}) return } writeJSON(w, http.StatusOK, state) } func (s *Server) handleEvaluateProbe(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"}) return } if s.probeService == nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "probe_service_unavailable"}) return } var payload struct { AccountID int64 `json:"account_id"` Platform string `json:"platform"` CurrentStatus string `json:"current_status"` StatusCode int `json:"status_code"` TransportError string `json:"transport_error"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_json"}) return } if payload.AccountID <= 0 { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_account_id"}) return } if payload.Platform == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing_platform"}) return } if payload.CurrentStatus == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing_current_status"}) return } var transportErr error if payload.TransportError != "" { transportErr = errors.New(payload.TransportError) } result, err := s.probeService.EvaluateHTTPResult(context.Background(), probe.EvaluateInput{ AccountID: payload.AccountID, Platform: payload.Platform, CurrentStatus: domainAccountStatus(payload.CurrentStatus), StatusCode: payload.StatusCode, TransportError: transportErr, }) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, result) } func (s *Server) handlePublishPackageEvent(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"}) return } if s.publishService == nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "publish_service_unavailable"}) return } var payload struct { EventID string `json:"event_id"` PackageID int64 `json:"package_id"` Platform string `json:"platform"` Model string `json:"model"` Version int64 `json:"version"` OccurredAt string `json:"occurred_at"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_json"}) return } var occurredAt time.Time if payload.OccurredAt != "" { parsed, err := time.Parse(time.RFC3339, payload.OccurredAt) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_occurred_at"}) return } occurredAt = parsed } event, err := s.publishService.RecordPackagePublished(r.Context(), publish.RecordPackagePublishedInput{ EventID: payload.EventID, PackageID: payload.PackageID, Platform: payload.Platform, Model: payload.Model, Version: payload.Version, OccurredAt: occurredAt, }) if err != nil { if errors.Is(err, publish.ErrInvalidPublishInput) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_publish_input"}) return } writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal_error"}) return } writeJSON(w, http.StatusOK, event) } func (s *Server) handleDiscoveryCandidates(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: s.handleCreateDiscoveryCandidate(w, r) case http.MethodGet: s.handleListDiscoveryCandidates(w, r) default: writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"}) } } func (s *Server) handleCreateDiscoveryCandidate(w http.ResponseWriter, r *http.Request) { if s.discoveryService == nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "discovery_service_unavailable"}) return } var payload struct { CandidateID string `json:"candidate_id"` AccountID int64 `json:"account_id"` Platform string `json:"platform"` Model string `json:"model"` Source string `json:"source"` ReasonCode string `json:"reason_code"` DiscoveredAt string `json:"discovered_at"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_json"}) return } var discoveredAt time.Time if strings.TrimSpace(payload.DiscoveredAt) != "" { parsed, err := time.Parse(time.RFC3339, payload.DiscoveredAt) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_discovered_at"}) return } discoveredAt = parsed } out, err := s.discoveryService.RecordCandidate(r.Context(), discovery.RecordCandidateInput{ CandidateID: payload.CandidateID, AccountID: payload.AccountID, Platform: payload.Platform, Model: payload.Model, Source: payload.Source, ReasonCode: payload.ReasonCode, DiscoveredAt: discoveredAt, }) if err != nil { if errors.Is(err, discovery.ErrInvalidCandidateInput) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_candidate_input"}) return } writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal_error"}) return } writeJSON(w, http.StatusOK, out) } func (s *Server) handleListDiscoveryCandidates(w http.ResponseWriter, r *http.Request) { if s.discoveryService == nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "discovery_service_unavailable"}) return } status, ok := parseDiscoveryCandidateStatus(strings.TrimSpace(r.URL.Query().Get("status"))) if !ok { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_status"}) return } writeJSON(w, http.StatusOK, discoveryCandidatesResponse{Items: s.discoveryService.ListCandidates(r.Context(), status)}) } func parseDiscoveryCandidateStatus(raw string) (domain.DiscoveryCandidateStatus, bool) { if raw == "" { return "", true } status := domain.DiscoveryCandidateStatus(raw) switch status { case domain.DiscoveryCandidateStatusPendingAdmission, domain.DiscoveryCandidateStatusAdmitted, domain.DiscoveryCandidateStatusRejected: return status, true default: return "", false } } func (s *Server) handleListPackageChanges(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"}) return } items, nextCursor := s.repo.ListPackageEventsAfter(strings.TrimSpace(r.URL.Query().Get("cursor"))) writeJSON(w, http.StatusOK, packageChangesResponse{Items: items, NextCursor: nextCursor}) } func (s *Server) handleAckPackageChange(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"}) return } prefix := "/internal/supply-intelligence/gateway/package-changes/" path := strings.TrimPrefix(r.URL.Path, prefix) if !strings.HasSuffix(path, "/ack") { writeJSON(w, http.StatusNotFound, map[string]string{"error": "not_found"}) return } eventID := strings.TrimSuffix(path, "/ack") var payload struct { Consumer string `json:"consumer"` Result string `json:"result"` Detail string `json:"detail"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_json"}) return } ackResult := domain.GatewayAckResult(payload.Result) if !repository.IsGatewayAckResult(ackResult) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_result"}) return } consumer := strings.TrimSpace(payload.Consumer) if consumer == "" { consumer = "gateway" } _, err := s.repo.AckPackageEvent(eventID, consumer, ackResult, payload.Detail, time.Now().UTC()) if err != nil { if errors.Is(err, repository.ErrEventNotFound) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "not_found"}) return } writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal_error"}) return } w.WriteHeader(http.StatusNoContent) } func (s *Server) handleConsumeOnce(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"}) return } if s.gatewayConsumerService == nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "gateway_consumer_unavailable"}) return } var payload struct { Consumer string `json:"consumer"` Cursor string `json:"cursor"` } if r.Body != nil { if err := json.NewDecoder(r.Body).Decode(&payload); err != nil && err.Error() != "EOF" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_json"}) return } } out, err := s.gatewayConsumerService.ConsumeOnce(r.Context(), gatewayconsumer.ConsumeOnceInput{Consumer: payload.Consumer, Cursor: payload.Cursor}) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "consume_failed"}) return } writeJSON(w, http.StatusOK, out) } func writeJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(body) } // handleAdmissionRun runs admission test for a specific candidate func (s *Server) handleAdmissionRun(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"}) return } if s.admissionService == nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "admission_service_unavailable"}) return } var payload struct { CandidateID string `json:"candidate_id"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_json"}) return } if strings.TrimSpace(payload.CandidateID) == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing_candidate_id"}) return } result, err := s.admissionService.RunAdmission(r.Context(), payload.CandidateID) if err != nil { switch { case errors.Is(err, admission.ErrCandidateNotFound): writeJSON(w, http.StatusNotFound, map[string]string{"error": "candidate_not_found"}) case errors.Is(err, admission.ErrCandidateNotRunnable): writeJSON(w, http.StatusConflict, map[string]string{"error": "candidate_not_runnable"}) default: writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "admission_run_failed"}) } return } writeJSON(w, http.StatusOK, result) } // handleAdmissionCandidates lists candidates pending admission testing func (s *Server) handleAdmissionCandidates(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"}) return } if s.admissionService == nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "admission_service_unavailable"}) return } candidates := s.admissionService.GetRunnableCandidates(r.Context()) writeJSON(w, http.StatusOK, map[string]any{"items": candidates}) } func domainAccountStatus(raw string) domain.AccountStatus { return domain.AccountStatus(raw) }