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)
|
||||
}
|
||||
Reference in New Issue
Block a user