From adbdb400669357f1d07fc603091629443ce34b75 Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Fri, 17 Apr 2026 15:43:53 +0300 Subject: [PATCH] feat: Add charging engine HTTP client with credit management and health monitoring - Add ChargingEngineClient with base URL and HTTP client with configurable timeout - Add CheckCredit to verify subscriber credit availability by IP with bytes requested - Add DeductCredit to deduct bytes from subscriber credit balance - Add AddCredit to add bytes to subscriber credit balance - Add GetBalance to retrieve current credit balance for subscriber by IP - Add HealthCheck to verify charging engine availability --- .../internal/services/charging_client.go | 187 ++++++++++++++++++ .../internal/services/charging_client_test.go | 154 +++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 apps/api-server/internal/services/charging_client.go create mode 100644 apps/api-server/internal/services/charging_client_test.go diff --git a/apps/api-server/internal/services/charging_client.go b/apps/api-server/internal/services/charging_client.go new file mode 100644 index 0000000..27e9dee --- /dev/null +++ b/apps/api-server/internal/services/charging_client.go @@ -0,0 +1,187 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/nutcas3/telecom-platform/apps/api-server/internal/config" +) + +// ChargingEngineClient is an HTTP client for the Rust charging engine +type ChargingEngineClient struct { + baseURL string + httpClient *http.Client +} + +// NewChargingEngineClient creates a new client for the Rust charging engine +func NewChargingEngineClient(cfg *config.ChargingEngineConfig) *ChargingEngineClient { + return &ChargingEngineClient{ + baseURL: cfg.BaseURL, + httpClient: &http.Client{ + Timeout: cfg.Timeout, + }, + } +} + +type CreditCheckRequest struct { + BytesRequested uint64 `json:"bytes_requested"` +} + +type CreditCheckResponse struct { + Allowed bool `json:"allowed"` + RemainingBytes int64 `json:"remaining_bytes"` +} + +type DeductRequest struct { + BytesUsed uint64 `json:"bytes_used"` +} + +type AddCreditRequest struct { + BytesToAdd uint64 `json:"bytes_to_add"` +} + +type BalanceResponse struct { + IP string `json:"ip"` + BalanceBytes int64 `json:"balance_bytes"` +} + +type EngineHealthResponse struct { + Status string `json:"status"` + Timestamp string `json:"timestamp"` +} + +// CheckCredit checks if a subscriber (by IP) has enough credit +func (c *ChargingEngineClient) CheckCredit(ctx context.Context, ip string, bytesRequested uint64) (*CreditCheckResponse, error) { + body, err := json.Marshal(CreditCheckRequest{BytesRequested: bytesRequested}) + if err != nil { + return nil, fmt.Errorf("marshal credit check request: %w", err) + } + + url := fmt.Sprintf("%s/v1/credit/%s/check", c.baseURL, ip) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("charging engine request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("charging engine returned status %d", resp.StatusCode) + } + + var result CreditCheckResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return &result, nil +} + +// DeductCredit deducts bytes from a subscriber's credit balance +func (c *ChargingEngineClient) DeductCredit(ctx context.Context, ip string, bytesUsed uint64) error { + body, err := json.Marshal(DeductRequest{BytesUsed: bytesUsed}) + if err != nil { + return fmt.Errorf("marshal deduct request: %w", err) + } + + url := fmt.Sprintf("%s/v1/credit/%s/deduct", c.baseURL, ip) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("charging engine request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("charging engine returned status %d", resp.StatusCode) + } + return nil +} + +// AddCredit adds bytes to a subscriber's credit balance +func (c *ChargingEngineClient) AddCredit(ctx context.Context, ip string, bytesToAdd uint64) error { + body, err := json.Marshal(AddCreditRequest{BytesToAdd: bytesToAdd}) + if err != nil { + return fmt.Errorf("marshal add credit request: %w", err) + } + + url := fmt.Sprintf("%s/v1/credit/%s/add", c.baseURL, ip) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("charging engine request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("charging engine returned status %d", resp.StatusCode) + } + return nil +} + +// GetBalance gets the current credit balance for a subscriber +func (c *ChargingEngineClient) GetBalance(ctx context.Context, ip string) (*BalanceResponse, error) { + url := fmt.Sprintf("%s/v1/credit/%s/balance", c.baseURL, ip) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("charging engine request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("charging engine returned status %d", resp.StatusCode) + } + + var result BalanceResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return &result, nil +} + +// HealthCheck checks if the Rust charging engine is healthy +func (c *ChargingEngineClient) HealthCheck(ctx context.Context) (*EngineHealthResponse, error) { + url := fmt.Sprintf("%s/health", c.baseURL) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("charging engine health check failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("charging engine returned status %d", resp.StatusCode) + } + + var result EngineHealthResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return &result, nil +} diff --git a/apps/api-server/internal/services/charging_client_test.go b/apps/api-server/internal/services/charging_client_test.go new file mode 100644 index 0000000..e26f0e1 --- /dev/null +++ b/apps/api-server/internal/services/charging_client_test.go @@ -0,0 +1,154 @@ +package services + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/nutcas3/telecom-platform/apps/api-server/internal/config" +) + +func newTestServer(handler http.HandlerFunc) (*httptest.Server, *ChargingEngineClient) { + srv := httptest.NewServer(handler) + client := NewChargingEngineClient(&config.ChargingEngineConfig{ + BaseURL: srv.URL, + Timeout: 5 * time.Second, + }) + return srv, client +} + +func TestCheckCredit_Allowed(t *testing.T) { + srv, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/v1/credit/10.0.0.1/check" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + var req CreditCheckRequest + json.NewDecoder(r.Body).Decode(&req) + if req.BytesRequested != 1000 { + t.Errorf("expected 1000 bytes, got %d", req.BytesRequested) + } + json.NewEncoder(w).Encode(CreditCheckResponse{Allowed: true, RemainingBytes: 5000}) + }) + defer srv.Close() + + resp, err := client.CheckCredit(context.Background(), "10.0.0.1", 1000) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !resp.Allowed { + t.Error("expected allowed=true") + } + if resp.RemainingBytes != 5000 { + t.Errorf("expected remaining=5000, got %d", resp.RemainingBytes) + } +} + +func TestCheckCredit_ServerError(t *testing.T) { + srv, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + defer srv.Close() + + _, err := client.CheckCredit(context.Background(), "10.0.0.1", 1000) + if err == nil { + t.Fatal("expected error for 500 response") + } +} + +func TestDeductCredit(t *testing.T) { + srv, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/credit/10.0.0.2/deduct" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + var req DeductRequest + json.NewDecoder(r.Body).Decode(&req) + if req.BytesUsed != 500 { + t.Errorf("expected 500, got %d", req.BytesUsed) + } + w.WriteHeader(http.StatusOK) + }) + defer srv.Close() + + err := client.DeductCredit(context.Background(), "10.0.0.2", 500) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestAddCredit(t *testing.T) { + srv, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/credit/10.0.0.3/add" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + var req AddCreditRequest + json.NewDecoder(r.Body).Decode(&req) + if req.BytesToAdd != 2000 { + t.Errorf("expected 2000, got %d", req.BytesToAdd) + } + w.WriteHeader(http.StatusOK) + }) + defer srv.Close() + + err := client.AddCredit(context.Background(), "10.0.0.3", 2000) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestGetBalance(t *testing.T) { + srv, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + json.NewEncoder(w).Encode(BalanceResponse{IP: "10.0.0.4", BalanceBytes: 9999}) + }) + defer srv.Close() + + resp, err := client.GetBalance(context.Background(), "10.0.0.4") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.BalanceBytes != 9999 { + t.Errorf("expected 9999, got %d", resp.BalanceBytes) + } +} + +func TestHealthCheck(t *testing.T) { + srv, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/health" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(EngineHealthResponse{Status: "ok", Timestamp: "2026-04-14T12:00:00Z"}) + }) + defer srv.Close() + + resp, err := client.HealthCheck(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Status != "ok" { + t.Errorf("expected status=ok, got %s", resp.Status) + } +} + +func TestContextCancellation(t *testing.T) { + srv, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(2 * time.Second) + w.WriteHeader(http.StatusOK) + }) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + _, err := client.GetBalance(ctx, "10.0.0.1") + if err == nil { + t.Fatal("expected timeout error") + } +}