feat: complete cmpp gateway delivery recovery workflows
This commit is contained in:
@@ -9,6 +9,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmpp-platform/gateway/internal/inbound"
|
||||
"cmpp-platform/gateway/internal/queue"
|
||||
"cmpp-platform/gateway/internal/upstream"
|
||||
|
||||
cmpp "github.com/bigwhite/gocmpp"
|
||||
)
|
||||
|
||||
@@ -53,9 +57,17 @@ type ConnectionStateCallback struct {
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
APIBaseURL string
|
||||
HTTPClient *http.Client
|
||||
Dial DialFunc
|
||||
APIBaseURL string
|
||||
HTTPClient *http.Client
|
||||
Dial DialFunc
|
||||
Upstream *upstream.Manager
|
||||
RecoveryCandidates func(context.Context) ([]inbound.DownstreamPresence, error)
|
||||
RecoveryStatuses func(context.Context) ([]inbound.DownstreamRecoveryStatus, error)
|
||||
}
|
||||
|
||||
type DownstreamRecoveryOverview struct {
|
||||
Candidates []inbound.DownstreamPresence `json:"candidates"`
|
||||
Statuses []inbound.DownstreamRecoveryStatus `json:"statuses"`
|
||||
}
|
||||
|
||||
func Register(mux *http.ServeMux, server Server) {
|
||||
@@ -65,7 +77,16 @@ func Register(mux *http.ServeMux, server Server) {
|
||||
if server.Dial == nil {
|
||||
server.Dial = DialCMPP
|
||||
}
|
||||
if server.Upstream == nil {
|
||||
server.Upstream = &upstream.Manager{APIBaseURL: server.APIBaseURL, HTTPClient: server.HTTPClient}
|
||||
}
|
||||
mux.HandleFunc("/connections/connect", server.handleConnectChannel)
|
||||
mux.HandleFunc("/upstream/submit", server.handleUpstreamSubmit)
|
||||
mux.HandleFunc("/downstream/receipt", server.handleDownstreamReceipt)
|
||||
mux.HandleFunc("/downstream/uplink", server.handleDownstreamUplink)
|
||||
mux.HandleFunc("/downstream/recovery-candidates", server.handleDownstreamRecoveryCandidates)
|
||||
mux.HandleFunc("/downstream/recovery-statuses", server.handleDownstreamRecoveryStatuses)
|
||||
mux.HandleFunc("/downstream/recovery-overview", server.handleDownstreamRecoveryOverview)
|
||||
}
|
||||
|
||||
func (s Server) handleConnectChannel(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -111,6 +132,132 @@ func (s Server) handleConnectChannel(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(status)
|
||||
}
|
||||
|
||||
func (s Server) handleUpstreamSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var command queue.SubmitCommand
|
||||
if err := json.NewDecoder(r.Body).Decode(&command); err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid submit command: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err := s.Upstream.Submit(r.Context(), command)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
func (s Server) handleDownstreamReceipt(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var event inbound.DownstreamReceipt
|
||||
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid downstream receipt: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
delivered, err := inbound.PushReceipt(event)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"delivered": delivered})
|
||||
}
|
||||
|
||||
func (s Server) handleDownstreamUplink(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var event inbound.DownstreamUplink
|
||||
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid downstream uplink: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
delivered, err := inbound.PushUplink(event)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"delivered": delivered})
|
||||
}
|
||||
|
||||
func (s Server) handleDownstreamRecoveryCandidates(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if s.RecoveryCandidates == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode([]inbound.DownstreamPresence{})
|
||||
return
|
||||
}
|
||||
candidates, err := s.RecoveryCandidates(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to load recovery candidates: %v", err), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(candidates)
|
||||
}
|
||||
|
||||
func (s Server) handleDownstreamRecoveryStatuses(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if s.RecoveryStatuses == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode([]inbound.DownstreamRecoveryStatus{})
|
||||
return
|
||||
}
|
||||
statuses, err := s.RecoveryStatuses(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to load recovery statuses: %v", err), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(statuses)
|
||||
}
|
||||
|
||||
func (s Server) handleDownstreamRecoveryOverview(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
candidates := []inbound.DownstreamPresence{}
|
||||
if s.RecoveryCandidates != nil {
|
||||
result, err := s.RecoveryCandidates(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to load recovery candidates: %v", err), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
candidates = result
|
||||
}
|
||||
statuses := []inbound.DownstreamRecoveryStatus{}
|
||||
if s.RecoveryStatuses != nil {
|
||||
result, err := s.RecoveryStatuses(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to load recovery statuses: %v", err), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
statuses = result
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(DownstreamRecoveryOverview{
|
||||
Candidates: candidates,
|
||||
Statuses: statuses,
|
||||
})
|
||||
}
|
||||
|
||||
func DialCMPP(ctx context.Context, command ConnectChannelCommand) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, defaultConnectTimeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -7,6 +7,9 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmpp-platform/gateway/internal/inbound"
|
||||
)
|
||||
|
||||
func TestConnectChannelCallbacksConnectedState(t *testing.T) {
|
||||
@@ -84,6 +87,99 @@ func TestConnectChannelRejectsInvalidCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryCandidatesEndpointReturnsView(t *testing.T) {
|
||||
handler := handlerWithServer(Server{
|
||||
RecoveryCandidates: func(context.Context) ([]inbound.DownstreamPresence, error) {
|
||||
return []inbound.DownstreamPresence{{
|
||||
Account: "100001",
|
||||
GatewayInstanceID: "gateway-a",
|
||||
State: "connected",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}}, nil
|
||||
},
|
||||
})
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/downstream/recovery-candidates", nil)
|
||||
handler.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected response status: %d body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var payload []inbound.DownstreamPresence
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(payload) != 1 || payload[0].Account != "100001" {
|
||||
t.Fatalf("unexpected payload: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryStatusesEndpointReturnsView(t *testing.T) {
|
||||
handler := handlerWithServer(Server{
|
||||
RecoveryStatuses: func(context.Context) ([]inbound.DownstreamRecoveryStatus, error) {
|
||||
return []inbound.DownstreamRecoveryStatus{{
|
||||
Account: "100001",
|
||||
GatewayInstanceID: "gateway-a",
|
||||
State: "waiting_connection",
|
||||
AttemptCount: 2,
|
||||
}}, nil
|
||||
},
|
||||
})
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/downstream/recovery-statuses", nil)
|
||||
handler.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected response status: %d body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var payload []inbound.DownstreamRecoveryStatus
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(payload) != 1 || payload[0].State != "waiting_connection" || payload[0].AttemptCount != 2 {
|
||||
t.Fatalf("unexpected payload: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryOverviewEndpointReturnsCombinedView(t *testing.T) {
|
||||
handler := handlerWithServer(Server{
|
||||
RecoveryCandidates: func(context.Context) ([]inbound.DownstreamPresence, error) {
|
||||
return []inbound.DownstreamPresence{{
|
||||
Account: "100001",
|
||||
GatewayInstanceID: "gateway-a",
|
||||
State: "connected",
|
||||
}}, nil
|
||||
},
|
||||
RecoveryStatuses: func(context.Context) ([]inbound.DownstreamRecoveryStatus, error) {
|
||||
return []inbound.DownstreamRecoveryStatus{{
|
||||
Account: "100001",
|
||||
GatewayInstanceID: "gateway-a",
|
||||
State: "success",
|
||||
}}, nil
|
||||
},
|
||||
})
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/downstream/recovery-overview", nil)
|
||||
handler.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected response status: %d body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var payload DownstreamRecoveryOverview
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(payload.Candidates) != 1 || payload.Candidates[0].Account != "100001" {
|
||||
t.Fatalf("unexpected candidate payload: %+v", payload.Candidates)
|
||||
}
|
||||
if len(payload.Statuses) != 1 || payload.Statuses[0].State != "success" {
|
||||
t.Fatalf("unexpected status payload: %+v", payload.Statuses)
|
||||
}
|
||||
}
|
||||
|
||||
type testDialError struct{}
|
||||
|
||||
func (testDialError) Error() string {
|
||||
@@ -93,8 +189,12 @@ func (testDialError) Error() string {
|
||||
var errTestDial testDialError
|
||||
|
||||
func handlerWithDial(apiBaseURL string, dial DialFunc) http.Handler {
|
||||
return handlerWithServer(Server{APIBaseURL: apiBaseURL, Dial: dial})
|
||||
}
|
||||
|
||||
func handlerWithServer(server Server) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
Register(mux, Server{APIBaseURL: apiBaseURL, Dial: dial})
|
||||
Register(mux, server)
|
||||
return mux
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user