feat: backend service
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
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")
|
||||
}
|
||||
|
||||
// GetReport calls `GET /api/v1/report?player=<raceName>&turn=<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)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package engineclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/model/rest"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func newTestClient(t *testing.T, srv *httptest.Server) *Client {
|
||||
t.Helper()
|
||||
cli, err := NewClientWithHTTP(Config{CallTimeout: 2 * time.Second, ProbeTimeout: 1 * time.Second}, srv.Client())
|
||||
if err != nil {
|
||||
t.Fatalf("NewClientWithHTTP: %v", err)
|
||||
}
|
||||
return cli
|
||||
}
|
||||
|
||||
func TestClientInitSuccess(t *testing.T) {
|
||||
wantID := uuid.New()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != pathAdminInit {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("unexpected method: %s", r.Method)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: wantID, Turn: 1, Players: []rest.PlayerState{{ID: uuid.New(), RaceName: "alpha"}}})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cli := newTestClient(t, srv)
|
||||
got, err := cli.Init(context.Background(), srv.URL, rest.InitRequest{Races: []rest.InitRace{{RaceName: "alpha"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("Init returned error: %v", err)
|
||||
}
|
||||
if got.ID != wantID {
|
||||
t.Fatalf("ID = %s, want %s", got.ID, wantID)
|
||||
}
|
||||
if got.Turn != 1 {
|
||||
t.Fatalf("Turn = %d, want 1", got.Turn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientInitValidationError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"reason":"races empty"}`, http.StatusBadRequest)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cli := newTestClient(t, srv)
|
||||
_, err := cli.Init(context.Background(), srv.URL, rest.InitRequest{Races: []rest.InitRace{{RaceName: "x"}}})
|
||||
if !errors.Is(err, ErrEngineValidation) {
|
||||
t.Fatalf("expected ErrEngineValidation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientInitUnreachableOn5xx(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cli := newTestClient(t, srv)
|
||||
_, err := cli.Init(context.Background(), srv.URL, rest.InitRequest{Races: []rest.InitRace{{RaceName: "x"}}})
|
||||
if !errors.Is(err, ErrEngineUnreachable) {
|
||||
t.Fatalf("expected ErrEngineUnreachable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientInitProtocolViolation(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte("not-json"))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cli := newTestClient(t, srv)
|
||||
_, err := cli.Init(context.Background(), srv.URL, rest.InitRequest{Races: []rest.InitRace{{RaceName: "x"}}})
|
||||
if !errors.Is(err, ErrEngineProtocolViolation) {
|
||||
t.Fatalf("expected ErrEngineProtocolViolation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientStatusOK(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != pathAdminStatus || r.Method != http.MethodGet {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(rest.StateResponse{Turn: 5})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cli := newTestClient(t, srv)
|
||||
got, err := cli.Status(context.Background(), srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Status: %v", err)
|
||||
}
|
||||
if got.Turn != 5 {
|
||||
t.Fatalf("Turn = %d, want 5", got.Turn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientTurnOK(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != pathAdminTurn || r.Method != http.MethodPut {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(rest.StateResponse{Turn: 6, Finished: true})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cli := newTestClient(t, srv)
|
||||
got, err := cli.Turn(context.Background(), srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Turn: %v", err)
|
||||
}
|
||||
if !got.Finished {
|
||||
t.Fatalf("expected finished=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientBanishRace(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != pathAdminRaceBanish || r.Method != http.MethodPost {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
var got rest.BanishRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&got)
|
||||
if got.RaceName != "loser" {
|
||||
t.Fatalf("got race name %q", got.RaceName)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cli := newTestClient(t, srv)
|
||||
if err := cli.BanishRace(context.Background(), srv.URL, "loser"); err != nil {
|
||||
t.Fatalf("BanishRace: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientCommandsForwardsBody(t *testing.T) {
|
||||
want := json.RawMessage(`{"actor":"alpha","cmd":[{"@type":"raceQuit"}]}`)
|
||||
gotResp := json.RawMessage(`{"applied":true}`)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != pathPlayerCommand || r.Method != http.MethodPut {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write(gotResp)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cli := newTestClient(t, srv)
|
||||
resp, err := cli.ExecuteCommands(context.Background(), srv.URL, want)
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteCommands: %v", err)
|
||||
}
|
||||
if string(resp) != string(gotResp) {
|
||||
t.Fatalf("response = %s, want %s", string(resp), string(gotResp))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientReportsForwardsQuery(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != pathPlayerReport {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if r.URL.Query().Get("player") != "alpha" {
|
||||
t.Fatalf("player = %q", r.URL.Query().Get("player"))
|
||||
}
|
||||
if r.URL.Query().Get("turn") != "3" {
|
||||
t.Fatalf("turn = %q", r.URL.Query().Get("turn"))
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"turn":3}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cli := newTestClient(t, srv)
|
||||
body, err := cli.GetReport(context.Background(), srv.URL, "alpha", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("GetReport: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(body), `"turn":3`) {
|
||||
t.Fatalf("body = %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientHealthzSuccess(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != pathHealthz {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cli := newTestClient(t, srv)
|
||||
if err := cli.Healthz(context.Background(), srv.URL); err != nil {
|
||||
t.Fatalf("Healthz: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientHealthzFailure(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "down", http.StatusServiceUnavailable)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cli := newTestClient(t, srv)
|
||||
if err := cli.Healthz(context.Background(), srv.URL); !errors.Is(err, ErrEngineUnreachable) {
|
||||
t.Fatalf("expected ErrEngineUnreachable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRejectsInvalidBaseURL(t *testing.T) {
|
||||
cli, err := NewClientWithHTTP(Config{CallTimeout: time.Second, ProbeTimeout: time.Second}, http.DefaultClient)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClientWithHTTP: %v", err)
|
||||
}
|
||||
if _, err := cli.Status(context.Background(), ""); err == nil {
|
||||
t.Fatalf("expected error on empty base URL")
|
||||
}
|
||||
if _, err := cli.Status(context.Background(), "ftp://example.test"); err == nil {
|
||||
t.Fatalf("expected error on non-http base URL")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Package engineclient is the trusted-internal HTTP client `internal/runtime`
|
||||
// uses to talk to a running `galaxy-game` engine container. The engine
|
||||
// contract is the OpenAPI document shipped with the engine module
|
||||
// (`galaxy/game/openapi.yaml`); this package reuses the existing typed
|
||||
// DTOs in `pkg/model/{rest,order,report}` rather than introducing its
|
||||
// own request/response types.
|
||||
//
|
||||
// The engine endpoint URL is per-call: the runtime stores it on
|
||||
// `runtime_records.engine_endpoint` (the value the dockerclient adapter
|
||||
// returns from Run). The client therefore does not bind a base URL at
|
||||
// construction time — only the per-call timeouts are wired through
|
||||
// `Config`.
|
||||
//
|
||||
// Error model:
|
||||
//
|
||||
// - ErrEngineUnreachable — network failure, 5xx, or timeout. The
|
||||
// caller transitions the runtime record to `engine_unreachable`
|
||||
// and re-tries on the next snapshot tick.
|
||||
// - ErrEngineValidation — engine rejected the request (HTTP 4xx).
|
||||
// The caller surfaces the engine's body verbatim through to the
|
||||
// user.
|
||||
// - ErrEngineProtocolViolation — engine returned an empty body or a
|
||||
// malformed JSON response on a path that requires one.
|
||||
package engineclient
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
// ErrEngineUnreachable means the engine call failed because of a
|
||||
// transport error (network, DNS, connect refused, timeout, 5xx).
|
||||
// The implementation callers map this to a runtime status of
|
||||
// `engine_unreachable` after a snapshot read.
|
||||
ErrEngineUnreachable = errors.New("engineclient: engine unreachable")
|
||||
|
||||
// ErrEngineValidation means the engine returned a 4xx response.
|
||||
// Callers forward the engine body so end users see the engine's
|
||||
// per-command error reason verbatim.
|
||||
ErrEngineValidation = errors.New("engineclient: engine validation failed")
|
||||
|
||||
// ErrEngineProtocolViolation means the engine returned an empty or
|
||||
// malformed body on a path that contractually requires one.
|
||||
ErrEngineProtocolViolation = errors.New("engineclient: engine protocol violation")
|
||||
)
|
||||
Reference in New Issue
Block a user