package backendclient import ( "errors" "fmt" "net/url" "strings" "time" ) // Config describes the backend endpoint and gateway client identity used // to construct a Client. All fields are required when the gateway is // expected to talk to a real backend; the empty value yields an // always-unavailable client. type Config struct { // HTTPBaseURL is the absolute base URL of the backend HTTP listener // (`/api/v1/{public,user,internal}/*`). Required. HTTPBaseURL string // GRPCPushURL is the dial target of the backend `Push.SubscribePush` // listener (`host:port`). Required. GRPCPushURL string // GatewayClientID is the durable identifier this gateway instance // presents to backend in `GatewaySubscribeRequest.gateway_client_id`. // Required. GatewayClientID string // HTTPTimeout bounds individual REST calls. Must be positive. HTTPTimeout time.Duration // PushReconnectBaseBackoff is the starting delay between reconnect // attempts of `Push.SubscribePush`. Must be positive. PushReconnectBaseBackoff time.Duration // PushReconnectMaxBackoff is the upper bound for exponential // reconnect delays. Must be greater than or equal to // PushReconnectBaseBackoff. PushReconnectMaxBackoff time.Duration } // Validate reports a formatted error when cfg is missing required // values. The empty value is invalid; callers that intentionally omit // the backend may bypass this check by skipping NewClient entirely. func (cfg Config) Validate() error { trimmed := strings.TrimSpace(cfg.HTTPBaseURL) if trimmed == "" { return errors.New("backendclient: HTTPBaseURL must not be empty") } parsed, err := url.Parse(strings.TrimRight(trimmed, "/")) if err != nil { return fmt.Errorf("backendclient: parse HTTPBaseURL: %w", err) } if parsed.Scheme == "" || parsed.Host == "" { return errors.New("backendclient: HTTPBaseURL must be absolute") } if strings.TrimSpace(cfg.GRPCPushURL) == "" { return errors.New("backendclient: GRPCPushURL must not be empty") } if strings.TrimSpace(cfg.GatewayClientID) == "" { return errors.New("backendclient: GatewayClientID must not be empty") } if cfg.HTTPTimeout <= 0 { return errors.New("backendclient: HTTPTimeout must be positive") } if cfg.PushReconnectBaseBackoff <= 0 { return errors.New("backendclient: PushReconnectBaseBackoff must be positive") } if cfg.PushReconnectMaxBackoff < cfg.PushReconnectBaseBackoff { return errors.New("backendclient: PushReconnectMaxBackoff must be >= PushReconnectBaseBackoff") } return nil } // Client aggregates the REST and gRPC adapters that talk to backend. // One value is shared across the gateway process; all methods are safe // for concurrent use. type Client struct { rest *RESTClient push *PushClient } // NewClient constructs a Client that targets the configured backend. // REST adapter is always built. The gRPC push adapter is built lazily // when StartPush is called so unit tests can construct a Client with a // stubbed push transport. func NewClient(cfg Config) (*Client, error) { if err := cfg.Validate(); err != nil { return nil, err } rest, err := NewRESTClient(cfg) if err != nil { return nil, err } push, err := NewPushClient(cfg) if err != nil { return nil, err } return &Client{rest: rest, push: push}, nil } // REST returns the REST adapter. The returned value is nil when the // Client was constructed without a backend; callers must guard. func (c *Client) REST() *RESTClient { if c == nil { return nil } return c.rest } // Push returns the gRPC push adapter. The returned value is nil when // the Client was constructed without a backend. func (c *Client) Push() *PushClient { if c == nil { return nil } return c.push } // Close releases idle HTTP connections and closes the gRPC push // connection. Safe to call multiple times. func (c *Client) Close() error { if c == nil { return nil } var firstErr error if c.rest != nil { if err := c.rest.Close(); err != nil { firstErr = err } } if c.push != nil { if err := c.push.Close(); err != nil && firstErr == nil { firstErr = err } } return firstErr }