package engineclient import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "galaxy/model/rest" ) const ( pathAdminInit = "/api/v1/admin/init" pathAdminStatus = "/api/v1/admin/status" pathAdminTurn = "/api/v1/admin/turn" pathAdminRaceBanish = "/api/v1/admin/race/banish" pathPlayerCommand = "/api/v1/command" pathPlayerOrder = "/api/v1/order" pathPlayerReport = "/api/v1/report" pathHealthz = "/healthz" ) // Config configures one HTTP-backed engine client. type Config struct { // CallTimeout bounds turn-generation-class operations: init, turn, // banish, command, order. Mirrors `BACKEND_ENGINE_CALL_TIMEOUT`. CallTimeout time.Duration // ProbeTimeout bounds inspect-style reads: status, report, healthz. // Mirrors `BACKEND_ENGINE_PROBE_TIMEOUT`. ProbeTimeout time.Duration } // Client is the engine HTTP client. The zero value is not usable — use // NewClient. type Client struct { callTimeout time.Duration probeTimeout time.Duration httpClient *http.Client closeIdleConnections func() } // NewClient constructs a Client with an `otelhttp`-instrumented // transport cloned from `http.DefaultTransport`. Close releases idle // connections owned by the cloned transport. func NewClient(cfg Config) (*Client, error) { transport, ok := http.DefaultTransport.(*http.Transport) if !ok { return nil, errors.New("engineclient: default transport is not *http.Transport") } cloned := transport.Clone() return newClient(cfg, &http.Client{Transport: otelhttp.NewTransport(cloned)}, cloned.CloseIdleConnections) } // NewClientWithHTTP constructs a Client around a caller-supplied // `*http.Client`. Used in tests to inject `httptest`-backed transports. func NewClientWithHTTP(cfg Config, hc *http.Client) (*Client, error) { return newClient(cfg, hc, nil) } func newClient(cfg Config, hc *http.Client, closeIdle func()) (*Client, error) { switch { case cfg.CallTimeout <= 0: return nil, errors.New("engineclient: call timeout must be positive") case cfg.ProbeTimeout <= 0: return nil, errors.New("engineclient: probe timeout must be positive") case hc == nil: return nil, errors.New("engineclient: http client must not be nil") } return &Client{ callTimeout: cfg.CallTimeout, probeTimeout: cfg.ProbeTimeout, httpClient: hc, closeIdleConnections: closeIdle, }, nil } // Close releases idle HTTP connections owned by the underlying // transport. Safe to call multiple times. func (c *Client) Close() error { if c == nil || c.closeIdleConnections == nil { return nil } c.closeIdleConnections() return nil } // Init calls `POST /api/v1/admin/init`. func (c *Client) Init(ctx context.Context, baseURL string, request rest.InitRequest) (rest.StateResponse, error) { if err := validateBaseURL(baseURL); err != nil { return rest.StateResponse{}, err } body, err := json.Marshal(request) if err != nil { return rest.StateResponse{}, fmt.Errorf("engineclient init: encode request: %w", err) } payload, status, doErr := c.doRequest(ctx, http.MethodPost, baseURL+pathAdminInit, body, c.callTimeout) if doErr != nil { return rest.StateResponse{}, fmt.Errorf("%w: engine init: %w", ErrEngineUnreachable, doErr) } switch status { case http.StatusOK, http.StatusCreated: return decodeStateResponse(payload, "engine init") case http.StatusBadRequest: return rest.StateResponse{}, fmt.Errorf("%w: engine init: %s", ErrEngineValidation, summariseEngineError(payload, status)) default: return rest.StateResponse{}, fmt.Errorf("%w: engine init: %s", ErrEngineUnreachable, summariseEngineError(payload, status)) } } // Status calls `GET /api/v1/admin/status`. func (c *Client) Status(ctx context.Context, baseURL string) (rest.StateResponse, error) { if err := validateBaseURL(baseURL); err != nil { return rest.StateResponse{}, err } payload, status, doErr := c.doRequest(ctx, http.MethodGet, baseURL+pathAdminStatus, nil, c.probeTimeout) if doErr != nil { return rest.StateResponse{}, fmt.Errorf("%w: engine status: %w", ErrEngineUnreachable, doErr) } switch status { case http.StatusOK: return decodeStateResponse(payload, "engine status") case http.StatusBadRequest: return rest.StateResponse{}, fmt.Errorf("%w: engine status: %s", ErrEngineValidation, summariseEngineError(payload, status)) default: return rest.StateResponse{}, fmt.Errorf("%w: engine status: %s", ErrEngineUnreachable, summariseEngineError(payload, status)) } } // Turn calls `PUT /api/v1/admin/turn`. func (c *Client) Turn(ctx context.Context, baseURL string) (rest.StateResponse, error) { if err := validateBaseURL(baseURL); err != nil { return rest.StateResponse{}, err } payload, status, doErr := c.doRequest(ctx, http.MethodPut, baseURL+pathAdminTurn, nil, c.callTimeout) if doErr != nil { return rest.StateResponse{}, fmt.Errorf("%w: engine turn: %w", ErrEngineUnreachable, doErr) } switch status { case http.StatusOK: return decodeStateResponse(payload, "engine turn") case http.StatusBadRequest: return rest.StateResponse{}, fmt.Errorf("%w: engine turn: %s", ErrEngineValidation, summariseEngineError(payload, status)) default: return rest.StateResponse{}, fmt.Errorf("%w: engine turn: %s", ErrEngineUnreachable, summariseEngineError(payload, status)) } } // BanishRace calls `POST /api/v1/admin/race/banish` with body // `{race_name}`. Engine returns 204 on success. func (c *Client) BanishRace(ctx context.Context, baseURL, raceName string) error { if err := validateBaseURL(baseURL); err != nil { return err } if strings.TrimSpace(raceName) == "" { return errors.New("engineclient banish: race name must not be empty") } body, err := json.Marshal(rest.BanishRequest{RaceName: raceName}) if err != nil { return fmt.Errorf("engineclient banish: encode: %w", err) } payload, status, doErr := c.doRequest(ctx, http.MethodPost, baseURL+pathAdminRaceBanish, body, c.callTimeout) if doErr != nil { return fmt.Errorf("%w: engine banish: %w", ErrEngineUnreachable, doErr) } switch status { case http.StatusNoContent, http.StatusOK: return nil case http.StatusBadRequest: return fmt.Errorf("%w: engine banish: %s", ErrEngineValidation, summariseEngineError(payload, status)) default: return fmt.Errorf("%w: engine banish: %s", ErrEngineUnreachable, summariseEngineError(payload, status)) } } // ExecuteCommands calls `PUT /api/v1/command` with payload forwarded // verbatim. The engine response body is returned verbatim; on 4xx the // body is returned alongside ErrEngineValidation so callers can // forward the per-command error. func (c *Client) ExecuteCommands(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) { return c.forwardPlayerWrite(ctx, baseURL, pathPlayerCommand, payload, "engine command") } // PutOrders calls `PUT /api/v1/order` with the same forwarding // semantics as ExecuteCommands. func (c *Client) PutOrders(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) { return c.forwardPlayerWrite(ctx, baseURL, pathPlayerOrder, payload, "engine order") } // GetOrder calls `GET /api/v1/order?player=&turn=` and // returns the engine response body verbatim. A `204 No Content` body // is signalled by `(nil, http.StatusNoContent, nil)` so callers can // surface "no stored order" without parsing the empty payload. // Other non-`200` statuses come back wrapped in `ErrEngineValidation` // (4xx) or `ErrEngineUnreachable` (everything else), matching the // existing player-write conventions. func (c *Client) GetOrder(ctx context.Context, baseURL, raceName string, turn int) (json.RawMessage, int, error) { if err := validateBaseURL(baseURL); err != nil { return nil, 0, err } if strings.TrimSpace(raceName) == "" { return nil, 0, errors.New("engineclient order get: race name must not be empty") } if turn < 0 { return nil, 0, fmt.Errorf("engineclient order get: turn must not be negative, got %d", turn) } values := url.Values{} values.Set("player", raceName) values.Set("turn", strconv.Itoa(turn)) target := baseURL + pathPlayerOrder + "?" + values.Encode() body, status, doErr := c.doRequest(ctx, http.MethodGet, target, nil, c.probeTimeout) if doErr != nil { return nil, 0, fmt.Errorf("%w: engine order get: %w", ErrEngineUnreachable, doErr) } switch status { case http.StatusOK: if len(body) == 0 { return nil, status, fmt.Errorf("%w: engine order get: empty response body", ErrEngineProtocolViolation) } return json.RawMessage(body), status, nil case http.StatusNoContent: return nil, status, nil case http.StatusBadRequest, http.StatusConflict: return json.RawMessage(body), status, fmt.Errorf("%w: engine order get: %s", ErrEngineValidation, summariseEngineError(body, status)) default: return nil, status, fmt.Errorf("%w: engine order get: %s", ErrEngineUnreachable, summariseEngineError(body, status)) } } // GetReport calls `GET /api/v1/report?player=&turn=` // and returns the engine response body verbatim. func (c *Client) GetReport(ctx context.Context, baseURL, raceName string, turn int) (json.RawMessage, error) { if err := validateBaseURL(baseURL); err != nil { return nil, err } if strings.TrimSpace(raceName) == "" { return nil, errors.New("engineclient report: race name must not be empty") } if turn < 0 { return nil, fmt.Errorf("engineclient report: turn must not be negative, got %d", turn) } values := url.Values{} values.Set("player", raceName) values.Set("turn", strconv.Itoa(turn)) target := baseURL + pathPlayerReport + "?" + values.Encode() body, status, doErr := c.doRequest(ctx, http.MethodGet, target, nil, c.probeTimeout) if doErr != nil { return nil, fmt.Errorf("%w: engine report: %w", ErrEngineUnreachable, doErr) } switch status { case http.StatusOK: if len(body) == 0 { return nil, fmt.Errorf("%w: engine report: empty response body", ErrEngineProtocolViolation) } return json.RawMessage(body), nil case http.StatusBadRequest: return json.RawMessage(body), fmt.Errorf("%w: engine report: %s", ErrEngineValidation, summariseEngineError(body, status)) default: return nil, fmt.Errorf("%w: engine report: %s", ErrEngineUnreachable, summariseEngineError(body, status)) } } // Healthz calls `GET /healthz`. Returns nil on 2xx. func (c *Client) Healthz(ctx context.Context, baseURL string) error { if err := validateBaseURL(baseURL); err != nil { return err } body, status, doErr := c.doRequest(ctx, http.MethodGet, baseURL+pathHealthz, nil, c.probeTimeout) if doErr != nil { return fmt.Errorf("%w: engine healthz: %w", ErrEngineUnreachable, doErr) } if status/100 == 2 { return nil } return fmt.Errorf("%w: engine healthz: %s", ErrEngineUnreachable, summariseEngineError(body, status)) } func (c *Client) forwardPlayerWrite(ctx context.Context, baseURL, requestPath string, payload json.RawMessage, opLabel string) (json.RawMessage, error) { if err := validateBaseURL(baseURL); err != nil { return nil, err } if len(bytes.TrimSpace(payload)) == 0 { return nil, fmt.Errorf("%s: payload must not be empty", opLabel) } body, status, doErr := c.doRequest(ctx, http.MethodPut, baseURL+requestPath, []byte(payload), c.callTimeout) if doErr != nil { return nil, fmt.Errorf("%w: %s: %w", ErrEngineUnreachable, opLabel, doErr) } switch status { case http.StatusOK, http.StatusAccepted: return json.RawMessage(body), nil case http.StatusBadRequest, http.StatusConflict: return json.RawMessage(body), fmt.Errorf("%w: %s: %s", ErrEngineValidation, opLabel, summariseEngineError(body, status)) default: return nil, fmt.Errorf("%w: %s: %s", ErrEngineUnreachable, opLabel, summariseEngineError(body, status)) } } func (c *Client) doRequest(ctx context.Context, method, target string, body []byte, timeout time.Duration) ([]byte, int, error) { reqCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() var reader io.Reader if body != nil { reader = bytes.NewReader(body) } req, err := http.NewRequestWithContext(reqCtx, method, target, reader) if err != nil { return nil, 0, fmt.Errorf("build request: %w", err) } if body != nil { req.Header.Set("Content-Type", "application/json") } req.Header.Set("Accept", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return nil, 0, err } defer func() { _ = resp.Body.Close() }() payload, err := io.ReadAll(resp.Body) if err != nil { return nil, resp.StatusCode, fmt.Errorf("read body: %w", err) } return payload, resp.StatusCode, nil } func validateBaseURL(baseURL string) error { if strings.TrimSpace(baseURL) == "" { return errors.New("engineclient: baseURL must not be empty") } if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { return fmt.Errorf("engineclient: baseURL %q must start with http:// or https://", baseURL) } return nil } func decodeStateResponse(body []byte, op string) (rest.StateResponse, error) { if len(bytes.TrimSpace(body)) == 0 { return rest.StateResponse{}, fmt.Errorf("%w: %s: empty body", ErrEngineProtocolViolation, op) } var out rest.StateResponse if err := json.Unmarshal(body, &out); err != nil { return rest.StateResponse{}, fmt.Errorf("%w: %s: %v", ErrEngineProtocolViolation, op, err) } return out, nil } func summariseEngineError(body []byte, status int) string { if len(body) == 0 { return fmt.Sprintf("status=%d", status) } trimmed := strings.TrimSpace(string(body)) if len(trimmed) > 256 { trimmed = trimmed[:256] + "…" } return fmt.Sprintf("status=%d body=%s", status, trimmed) }