Files
supply-intelligence/internal/httpapi/server.go
2026-05-07 10:16:46 +08:00

416 lines
15 KiB
Go

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)
}