feat: gamemaster

This commit is contained in:
Ilia Denisov
2026-05-03 07:59:03 +02:00
committed by GitHub
parent a7cee15115
commit 3e2622757e
229 changed files with 41521 additions and 1098 deletions
@@ -0,0 +1,611 @@
package internalhttp
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"galaxy/gamemaster/internal/api/internalhttp/handlers"
"galaxy/gamemaster/internal/domain/engineversion"
"galaxy/gamemaster/internal/domain/operation"
domainruntime "galaxy/gamemaster/internal/domain/runtime"
"galaxy/gamemaster/internal/service/adminbanish"
"galaxy/gamemaster/internal/service/adminforce"
"galaxy/gamemaster/internal/service/adminpatch"
"galaxy/gamemaster/internal/service/adminstop"
"galaxy/gamemaster/internal/service/commandexecute"
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
"galaxy/gamemaster/internal/service/livenessreply"
"galaxy/gamemaster/internal/service/orderput"
"galaxy/gamemaster/internal/service/registerruntime"
"galaxy/gamemaster/internal/service/reportget"
"galaxy/gamemaster/internal/service/turngeneration"
"github.com/getkin/kin-openapi/openapi3"
"github.com/getkin/kin-openapi/openapi3filter"
"github.com/getkin/kin-openapi/routers"
"github.com/getkin/kin-openapi/routers/legacy"
"github.com/stretchr/testify/require"
)
// TestInternalRESTConformance loads the OpenAPI specification, drives
// every internal REST operation against the live listener backed by
// stub services, and validates each request and response body
// against the spec via `openapi3filter.ValidateRequest` and
// `openapi3filter.ValidateResponse`. Failure-path response shapes
// are intentionally out of scope here; per-handler tests under
// `handlers/<op>_test.go` cover the failure branches.
func TestInternalRESTConformance(t *testing.T) {
t.Parallel()
doc := loadConformanceSpec(t)
router, err := legacy.NewRouter(doc)
require.NoError(t, err)
deps := newConformanceDeps()
server, err := NewServer(newConformanceConfig(), Dependencies{
Logger: nil,
Telemetry: nil,
Readiness: nil,
RuntimeRecords: deps.runtimeRecords,
RegisterRuntime: deps.registerRuntime,
ForceNextTurn: deps.forceNextTurn,
StopRuntime: deps.stopRuntime,
PatchRuntime: deps.patchRuntime,
BanishRace: deps.banishRace,
InvalidateMemberships: deps.membership,
GameLiveness: deps.liveness,
EngineVersions: deps.engineVersions,
CommandExecute: deps.commandExecute,
PutOrders: deps.putOrders,
GetReport: deps.getReport,
})
require.NoError(t, err)
cases := []conformanceCase{
{name: "internalHealthz", method: http.MethodGet, path: "/healthz"},
{name: "internalReadyz", method: http.MethodGet, path: "/readyz"},
{
name: "internalRegisterRuntime",
method: http.MethodPost,
path: "/api/v1/internal/games/" + conformanceGameID + "/register-runtime",
contentType: "application/json",
body: `{
"engine_endpoint": "http://galaxy-game-` + conformanceGameID + `:8080",
"members": [{"user_id": "user-1", "race_name": "Aelinari"}],
"target_engine_version": "1.2.3",
"turn_schedule": "0 18 * * *"
}`,
},
{
name: "internalBanishRace",
method: http.MethodPost,
path: "/api/v1/internal/games/" + conformanceGameID + "/race/Aelinari/banish",
expectedStatus: http.StatusNoContent,
},
{
name: "internalInvalidateMemberships",
method: http.MethodPost,
path: "/api/v1/internal/games/" + conformanceGameID + "/memberships/invalidate",
expectedStatus: http.StatusNoContent,
},
{
name: "internalGameLiveness",
method: http.MethodGet,
path: "/api/v1/internal/games/" + conformanceGameID + "/liveness",
},
{name: "internalListRuntimes", method: http.MethodGet, path: "/api/v1/internal/runtimes"},
{
name: "internalGetRuntime",
method: http.MethodGet,
path: "/api/v1/internal/runtimes/" + conformanceGameID,
},
{
name: "internalForceNextTurn",
method: http.MethodPost,
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/force-next-turn",
},
{
name: "internalStopRuntime",
method: http.MethodPost,
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/stop",
contentType: "application/json",
body: `{"reason":"admin_request"}`,
},
{
name: "internalPatchRuntime",
method: http.MethodPost,
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/patch",
contentType: "application/json",
body: `{"version":"1.2.4"}`,
},
{name: "internalListEngineVersions", method: http.MethodGet, path: "/api/v1/internal/engine-versions"},
{
name: "internalCreateEngineVersion",
method: http.MethodPost,
path: "/api/v1/internal/engine-versions",
contentType: "application/json",
body: `{"version":"1.2.5","image_ref":"galaxy/game:1.2.5"}`,
expectedStatus: http.StatusCreated,
},
{
name: "internalGetEngineVersion",
method: http.MethodGet,
path: "/api/v1/internal/engine-versions/1.2.3",
},
{
name: "internalUpdateEngineVersion",
method: http.MethodPatch,
path: "/api/v1/internal/engine-versions/1.2.3",
contentType: "application/json",
body: `{"image_ref":"galaxy/game:1.2.3-patch"}`,
},
{
name: "internalDeprecateEngineVersion",
method: http.MethodDelete,
path: "/api/v1/internal/engine-versions/1.2.3",
expectedStatus: http.StatusNoContent,
},
{
name: "internalResolveEngineVersionImageRef",
method: http.MethodGet,
path: "/api/v1/internal/engine-versions/1.2.3/image-ref",
},
{
name: "internalExecuteCommands",
method: http.MethodPost,
path: "/api/v1/internal/games/" + conformanceGameID + "/commands",
contentType: "application/json",
body: `{"commands":[{"name":"build","args":{}}]}`,
extraHeaders: map[string]string{userIDHeader: conformanceUserID},
},
{
name: "internalPutOrders",
method: http.MethodPost,
path: "/api/v1/internal/games/" + conformanceGameID + "/orders",
contentType: "application/json",
body: `{"commands":[{"name":"move","args":{}}]}`,
extraHeaders: map[string]string{userIDHeader: conformanceUserID},
},
{
name: "internalGetReport",
method: http.MethodGet,
path: "/api/v1/internal/games/" + conformanceGameID + "/reports/0",
extraHeaders: map[string]string{userIDHeader: conformanceUserID},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
runConformanceCase(t, server.handler, router, tc)
})
}
}
const (
conformanceGameID = "game-conformance"
conformanceUserID = "user-conformance"
conformanceServerURL = "http://localhost:8097"
userIDHeader = "X-User-ID"
)
type conformanceCase struct {
name string
method string
path string
contentType string
body string
expectedStatus int
extraHeaders map[string]string
}
func runConformanceCase(t *testing.T, handler http.Handler, router routers.Router, tc conformanceCase) {
t.Helper()
expectedStatus := tc.expectedStatus
if expectedStatus == 0 {
expectedStatus = http.StatusOK
}
var bodyReader io.Reader
if tc.body != "" {
bodyReader = strings.NewReader(tc.body)
}
request := httptest.NewRequest(tc.method, tc.path, bodyReader)
if tc.contentType != "" {
request.Header.Set("Content-Type", tc.contentType)
}
request.Header.Set("X-Galaxy-Caller", "admin")
for key, value := range tc.extraHeaders {
request.Header.Set(key, value)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
require.Equalf(t, expectedStatus, recorder.Code,
"operation %s returned %d: %s", tc.name, recorder.Code, recorder.Body.String())
validationURL := conformanceServerURL + tc.path
validationRequest := httptest.NewRequest(tc.method, validationURL, bodyReaderFor(tc.body))
if tc.contentType != "" {
validationRequest.Header.Set("Content-Type", tc.contentType)
}
validationRequest.Header.Set("X-Galaxy-Caller", "admin")
for key, value := range tc.extraHeaders {
validationRequest.Header.Set(key, value)
}
route, pathParams, err := router.FindRoute(validationRequest)
require.NoError(t, err)
requestInput := &openapi3filter.RequestValidationInput{
Request: validationRequest,
PathParams: pathParams,
Route: route,
Options: &openapi3filter.Options{
IncludeResponseStatus: true,
},
}
require.NoError(t, openapi3filter.ValidateRequest(context.Background(), requestInput))
responseInput := &openapi3filter.ResponseValidationInput{
RequestValidationInput: requestInput,
Status: recorder.Code,
Header: recorder.Header(),
Options: &openapi3filter.Options{
IncludeResponseStatus: true,
},
}
responseInput.SetBodyBytes(recorder.Body.Bytes())
require.NoError(t, openapi3filter.ValidateResponse(context.Background(), responseInput))
}
func loadConformanceSpec(t *testing.T) *openapi3.T {
t.Helper()
_, thisFile, _, ok := runtime.Caller(0)
require.True(t, ok)
specPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "api", "internal-openapi.yaml")
loader := openapi3.NewLoader()
doc, err := loader.LoadFromFile(specPath)
require.NoError(t, err)
require.NoError(t, doc.Validate(context.Background()))
return doc
}
func bodyReaderFor(raw string) io.Reader {
if raw == "" {
return http.NoBody
}
return bytes.NewBufferString(raw)
}
func newConformanceConfig() Config {
return Config{
Addr: ":0",
ReadHeaderTimeout: time.Second,
ReadTimeout: time.Second,
WriteTimeout: time.Second,
IdleTimeout: time.Second,
}
}
// conformanceDeps groups the stub collaborators handed to the listener.
type conformanceDeps struct {
runtimeRecords *conformanceRuntimeRecords
registerRuntime *conformanceRegister
forceNextTurn *conformanceForce
stopRuntime *conformanceStop
patchRuntime *conformancePatch
banishRace *conformanceBanish
membership *conformanceMembership
liveness *conformanceLiveness
engineVersions *conformanceEngineVersions
commandExecute *conformanceCommands
putOrders *conformanceOrders
getReport *conformanceReport
}
func newConformanceDeps() *conformanceDeps {
return &conformanceDeps{
runtimeRecords: newConformanceRuntimeRecords(),
registerRuntime: &conformanceRegister{},
forceNextTurn: &conformanceForce{},
stopRuntime: &conformanceStop{},
patchRuntime: &conformancePatch{},
banishRace: &conformanceBanish{},
membership: &conformanceMembership{},
liveness: &conformanceLiveness{},
engineVersions: newConformanceEngineVersions(),
commandExecute: &conformanceCommands{},
putOrders: &conformanceOrders{},
getReport: &conformanceReport{},
}
}
// conformanceRecord builds a canonical running runtime record used
// by every stub service.
func conformanceRuntimeRecord() domainruntime.RuntimeRecord {
moment := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC)
next := moment.Add(time.Minute)
started := moment
return domainruntime.RuntimeRecord{
GameID: conformanceGameID,
Status: domainruntime.StatusRunning,
EngineEndpoint: "http://galaxy-game-" + conformanceGameID + ":8080",
CurrentImageRef: "galaxy/game:1.2.3",
CurrentEngineVersion: "1.2.3",
TurnSchedule: "0 18 * * *",
CurrentTurn: 0,
NextGenerationAt: &next,
SkipNextTick: false,
EngineHealth: "healthy",
CreatedAt: moment,
UpdatedAt: moment,
StartedAt: &started,
}
}
func conformanceEngineVersionRecord(version string) engineversion.EngineVersion {
moment := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC)
return engineversion.EngineVersion{
Version: version,
ImageRef: "galaxy/game:" + version,
Options: nil,
Status: engineversion.StatusActive,
CreatedAt: moment,
UpdatedAt: moment,
}
}
// conformanceRuntimeRecords is an in-memory store seeded with the
// canonical record so the get/list endpoints have something to return.
type conformanceRuntimeRecords struct {
mu sync.Mutex
stored map[string]domainruntime.RuntimeRecord
}
func newConformanceRuntimeRecords() *conformanceRuntimeRecords {
return &conformanceRuntimeRecords{
stored: map[string]domainruntime.RuntimeRecord{
conformanceGameID: conformanceRuntimeRecord(),
},
}
}
func (s *conformanceRuntimeRecords) Get(_ context.Context, gameID string) (domainruntime.RuntimeRecord, error) {
s.mu.Lock()
defer s.mu.Unlock()
record, ok := s.stored[gameID]
if !ok {
return domainruntime.RuntimeRecord{}, domainruntime.ErrNotFound
}
return record, nil
}
func (s *conformanceRuntimeRecords) List(_ context.Context) ([]domainruntime.RuntimeRecord, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domainruntime.RuntimeRecord, 0, len(s.stored))
for _, record := range s.stored {
out = append(out, record)
}
return out, nil
}
func (s *conformanceRuntimeRecords) ListByStatus(_ context.Context, status domainruntime.Status) ([]domainruntime.RuntimeRecord, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domainruntime.RuntimeRecord, 0, len(s.stored))
for _, record := range s.stored {
if record.Status == status {
out = append(out, record)
}
}
return out, nil
}
type conformanceRegister struct{}
func (s *conformanceRegister) Handle(_ context.Context, _ registerruntime.Input) (registerruntime.Result, error) {
return registerruntime.Result{
Record: conformanceRuntimeRecord(),
Outcome: operation.OutcomeSuccess,
}, nil
}
type conformanceForce struct{}
func (s *conformanceForce) Handle(_ context.Context, _ adminforce.Input) (adminforce.Result, error) {
return adminforce.Result{
TurnGeneration: turngeneration.Result{Record: conformanceRuntimeRecord()},
SkipScheduled: true,
Outcome: operation.OutcomeSuccess,
}, nil
}
type conformanceStop struct{}
func (s *conformanceStop) Handle(_ context.Context, _ adminstop.Input) (adminstop.Result, error) {
rec := conformanceRuntimeRecord()
rec.Status = domainruntime.StatusStopped
stopped := rec.UpdatedAt.Add(time.Second)
rec.StoppedAt = &stopped
rec.UpdatedAt = stopped
return adminstop.Result{Record: rec, Outcome: operation.OutcomeSuccess}, nil
}
type conformancePatch struct{}
func (s *conformancePatch) Handle(_ context.Context, in adminpatch.Input) (adminpatch.Result, error) {
rec := conformanceRuntimeRecord()
if in.Version != "" {
rec.CurrentImageRef = "galaxy/game:" + in.Version
rec.CurrentEngineVersion = in.Version
}
return adminpatch.Result{Record: rec, Outcome: operation.OutcomeSuccess}, nil
}
type conformanceBanish struct{}
func (s *conformanceBanish) Handle(_ context.Context, _ adminbanish.Input) (adminbanish.Result, error) {
return adminbanish.Result{Outcome: operation.OutcomeSuccess}, nil
}
type conformanceMembership struct{}
func (m *conformanceMembership) Invalidate(string) {}
type conformanceLiveness struct{}
func (s *conformanceLiveness) Handle(_ context.Context, _ livenessreply.Input) (livenessreply.Result, error) {
return livenessreply.Result{
Ready: true,
Status: domainruntime.StatusRunning,
}, nil
}
type conformanceEngineVersions struct {
mu sync.Mutex
versions map[string]engineversion.EngineVersion
}
func newConformanceEngineVersions() *conformanceEngineVersions {
return &conformanceEngineVersions{
versions: map[string]engineversion.EngineVersion{
"1.2.3": conformanceEngineVersionRecord("1.2.3"),
},
}
}
func (s *conformanceEngineVersions) List(_ context.Context, _ *engineversion.Status) ([]engineversion.EngineVersion, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]engineversion.EngineVersion, 0, len(s.versions))
for _, version := range s.versions {
out = append(out, version)
}
return out, nil
}
func (s *conformanceEngineVersions) Get(_ context.Context, version string) (engineversion.EngineVersion, error) {
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.versions[version]
if !ok {
return engineversion.EngineVersion{}, engineversionsvc.ErrNotFound
}
return v, nil
}
func (s *conformanceEngineVersions) ResolveImageRef(_ context.Context, version string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.versions[version]
if !ok {
return "", engineversionsvc.ErrNotFound
}
return v.ImageRef, nil
}
func (s *conformanceEngineVersions) Create(_ context.Context, in engineversionsvc.CreateInput) (engineversion.EngineVersion, error) {
rec := engineversion.EngineVersion{
Version: in.Version,
ImageRef: in.ImageRef,
Options: in.Options,
Status: engineversion.StatusActive,
CreatedAt: time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC),
}
s.mu.Lock()
s.versions[in.Version] = rec
s.mu.Unlock()
return rec, nil
}
func (s *conformanceEngineVersions) Update(_ context.Context, in engineversionsvc.UpdateInput) (engineversion.EngineVersion, error) {
s.mu.Lock()
defer s.mu.Unlock()
rec, ok := s.versions[in.Version]
if !ok {
return engineversion.EngineVersion{}, engineversionsvc.ErrNotFound
}
if in.ImageRef != nil {
rec.ImageRef = *in.ImageRef
}
if in.Status != nil {
rec.Status = *in.Status
}
rec.UpdatedAt = time.Date(2026, 4, 30, 13, 0, 0, 0, time.UTC)
s.versions[in.Version] = rec
return rec, nil
}
func (s *conformanceEngineVersions) Deprecate(_ context.Context, in engineversionsvc.DeprecateInput) error {
s.mu.Lock()
defer s.mu.Unlock()
rec, ok := s.versions[in.Version]
if !ok {
return engineversionsvc.ErrNotFound
}
rec.Status = engineversion.StatusDeprecated
rec.UpdatedAt = time.Date(2026, 4, 30, 14, 0, 0, 0, time.UTC)
s.versions[in.Version] = rec
return nil
}
type conformanceCommands struct{}
func (s *conformanceCommands) Handle(_ context.Context, _ commandexecute.Input) (commandexecute.Result, error) {
return commandexecute.Result{
Outcome: operation.OutcomeSuccess,
RawResponse: json.RawMessage(`{"results":[]}`),
}, nil
}
type conformanceOrders struct{}
func (s *conformanceOrders) Handle(_ context.Context, _ orderput.Input) (orderput.Result, error) {
return orderput.Result{
Outcome: operation.OutcomeSuccess,
RawResponse: json.RawMessage(`{"results":[]}`),
}, nil
}
type conformanceReport struct{}
func (s *conformanceReport) Handle(_ context.Context, _ reportget.Input) (reportget.Result, error) {
return reportget.Result{
Outcome: operation.OutcomeSuccess,
RawResponse: json.RawMessage(`{"player":"Aelinari","turn":0}`),
}, nil
}
// Compile-time guards that the stubs satisfy the handler-level
// service interfaces accepted by the listener.
var (
_ handlers.RegisterRuntimeService = (*conformanceRegister)(nil)
_ handlers.ForceNextTurnService = (*conformanceForce)(nil)
_ handlers.StopRuntimeService = (*conformanceStop)(nil)
_ handlers.PatchRuntimeService = (*conformancePatch)(nil)
_ handlers.BanishRaceService = (*conformanceBanish)(nil)
_ handlers.MembershipInvalidator = (*conformanceMembership)(nil)
_ handlers.LivenessService = (*conformanceLiveness)(nil)
_ handlers.EngineVersionService = (*conformanceEngineVersions)(nil)
_ handlers.CommandExecuteService = (*conformanceCommands)(nil)
_ handlers.OrderPutService = (*conformanceOrders)(nil)
_ handlers.ReportGetService = (*conformanceReport)(nil)
_ handlers.RuntimeRecordsReader = (*conformanceRuntimeRecords)(nil)
)
@@ -0,0 +1,54 @@
package handlers
import (
"net/http"
"galaxy/gamemaster/internal/domain/operation"
"galaxy/gamemaster/internal/service/adminbanish"
)
// newBanishRaceHandler returns the handler for
// `POST /api/v1/internal/games/{game_id}/race/{race_name}/banish`. The
// request has no body; both identifiers come from the URL path.
// Success returns `204 No Content`.
func newBanishRaceHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.banish_race")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.BanishRace == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "banish race service is not wired")
return
}
gameID, ok := extractGameID(writer, request)
if !ok {
return
}
raceName, ok := extractRaceName(writer, request)
if !ok {
return
}
result, err := deps.BanishRace.Handle(request.Context(), adminbanish.Input{
GameID: gameID,
RaceName: raceName,
OpSource: resolveOpSource(request),
SourceRef: requestSourceRef(request),
})
if err != nil {
logger.ErrorContext(request.Context(), "banish race service errored",
"game_id", gameID,
"race_name", raceName,
"err", err.Error(),
)
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "banish race service failed")
return
}
if result.Outcome == operation.OutcomeFailure {
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
return
}
writeNoContent(writer)
}
}
@@ -0,0 +1,422 @@
package handlers
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"galaxy/gamemaster/internal/domain/engineversion"
"galaxy/gamemaster/internal/domain/operation"
"galaxy/gamemaster/internal/domain/runtime"
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
)
// jsonContentType is the Content-Type used by every internal REST
// response body except the engine pass-through bodies which retain
// the engine's chosen Content-Type.
const jsonContentType = "application/json; charset=utf-8"
// callerHeader is the optional caller-classification header used to
// attribute each request to a specific entry point. Documented in
// `gamemaster/README.md` §«Internal REST API». Missing or unknown
// values map to OpSourceAdminRest.
const callerHeader = "X-Galaxy-Caller"
// userIDHeader carries the verified player identity propagated by
// Edge Gateway on hot-path operations. Required for
// `internalExecuteCommands`, `internalPutOrders`, and
// `internalGetReport`.
const userIDHeader = "X-User-ID"
// requestIDHeader is read into `operation_log.source_ref` when present
// so REST callers can correlate audit rows with their requests.
const requestIDHeader = "X-Request-ID"
// gameIDPathParam, raceNamePathParam, versionPathParam, turnPathParam
// mirror the parameter names declared in
// `gamemaster/api/internal-openapi.yaml`.
const (
gameIDPathParam = "game_id"
raceNamePathParam = "race_name"
versionPathParam = "version"
turnPathParam = "turn"
)
// Stable error codes used by the handler layer when no service result
// is available (e.g., the service is not wired or the request shape
// failed pre-decode validation). The values match the vocabulary
// frozen by `gamemaster/README.md §Error Model` and
// `gamemaster/api/internal-openapi.yaml`.
const (
errorCodeInvalidRequest = "invalid_request"
errorCodeForbidden = "forbidden"
errorCodeRuntimeNotFound = "runtime_not_found"
errorCodeEngineVersionNotFound = "engine_version_not_found"
errorCodeEngineVersionInUse = "engine_version_in_use"
errorCodeConflict = "conflict"
errorCodeRuntimeNotRunning = "runtime_not_running"
errorCodeSemverPatchOnly = "semver_patch_only"
errorCodeEngineUnreachable = "engine_unreachable"
errorCodeEngineValidationError = "engine_validation_error"
errorCodeEngineProtocolError = "engine_protocol_violation"
errorCodeServiceUnavailable = "service_unavailable"
errorCodeInternal = "internal_error"
)
// errorBody mirrors the `error` element of the OpenAPI ErrorResponse
// schema.
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
// errorResponse mirrors the OpenAPI ErrorResponse envelope.
type errorResponse struct {
Error errorBody `json:"error"`
}
// runtimeRecordResponse mirrors the OpenAPI RuntimeRecord schema.
// Required timestamps are always present and encode as int64 UTC
// milliseconds; optional ones use `*int64` so an absent value is
// omitted from the JSON form (rather than encoded as `null`).
type runtimeRecordResponse struct {
GameID string `json:"game_id"`
RuntimeStatus string `json:"runtime_status"`
EngineEndpoint string `json:"engine_endpoint"`
CurrentImageRef string `json:"current_image_ref"`
CurrentEngineVersion string `json:"current_engine_version"`
TurnSchedule string `json:"turn_schedule"`
CurrentTurn int `json:"current_turn"`
NextGenerationAt int64 `json:"next_generation_at"`
SkipNextTick bool `json:"skip_next_tick"`
EngineHealthSummary string `json:"engine_health_summary"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
StartedAt *int64 `json:"started_at,omitempty"`
StoppedAt *int64 `json:"stopped_at,omitempty"`
FinishedAt *int64 `json:"finished_at,omitempty"`
}
// runtimeListResponse mirrors the OpenAPI RuntimeListResponse schema.
// Runtimes is always non-nil so an empty result encodes as
// `{"runtimes":[]}` rather than `{"runtimes":null}`.
type runtimeListResponse struct {
Runtimes []runtimeRecordResponse `json:"runtimes"`
}
// engineVersionResponse mirrors the OpenAPI EngineVersion schema.
// Options is a `json.RawMessage` so the engine-side document passes
// through verbatim.
type engineVersionResponse struct {
Version string `json:"version"`
ImageRef string `json:"image_ref"`
Options json.RawMessage `json:"options"`
Status string `json:"status"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// engineVersionListResponse mirrors the OpenAPI
// EngineVersionListResponse schema.
type engineVersionListResponse struct {
Versions []engineVersionResponse `json:"versions"`
}
// imageRefResponse mirrors the OpenAPI ImageRefResponse schema.
type imageRefResponse struct {
ImageRef string `json:"image_ref"`
}
// livenessResponse mirrors the OpenAPI LivenessResponse schema.
type livenessResponse struct {
Ready bool `json:"ready"`
Status string `json:"status"`
}
// encodeRuntimeRecord turns a domain RuntimeRecord into its wire shape.
// Required `next_generation_at` encodes as `0` when the record carries
// no scheduled tick (e.g., status=starting before the first
// scheduling write); optional lifecycle timestamps are omitted when
// nil.
func encodeRuntimeRecord(record runtime.RuntimeRecord) runtimeRecordResponse {
resp := runtimeRecordResponse{
GameID: record.GameID,
RuntimeStatus: string(record.Status),
EngineEndpoint: record.EngineEndpoint,
CurrentImageRef: record.CurrentImageRef,
CurrentEngineVersion: record.CurrentEngineVersion,
TurnSchedule: record.TurnSchedule,
CurrentTurn: record.CurrentTurn,
SkipNextTick: record.SkipNextTick,
EngineHealthSummary: record.EngineHealth,
CreatedAt: record.CreatedAt.UTC().UnixMilli(),
UpdatedAt: record.UpdatedAt.UTC().UnixMilli(),
}
if record.NextGenerationAt != nil {
resp.NextGenerationAt = record.NextGenerationAt.UTC().UnixMilli()
}
if record.StartedAt != nil {
v := record.StartedAt.UTC().UnixMilli()
resp.StartedAt = &v
}
if record.StoppedAt != nil {
v := record.StoppedAt.UTC().UnixMilli()
resp.StoppedAt = &v
}
if record.FinishedAt != nil {
v := record.FinishedAt.UTC().UnixMilli()
resp.FinishedAt = &v
}
return resp
}
// encodeRuntimeList turns a domain RuntimeRecord slice into a wire
// list response. records may be nil (empty store); the result still
// carries an empty Runtimes slice so the JSON form is `{"runtimes":[]}`.
func encodeRuntimeList(records []runtime.RuntimeRecord) runtimeListResponse {
resp := runtimeListResponse{
Runtimes: make([]runtimeRecordResponse, 0, len(records)),
}
for _, record := range records {
resp.Runtimes = append(resp.Runtimes, encodeRuntimeRecord(record))
}
return resp
}
// encodeEngineVersion turns a domain EngineVersion into its wire shape.
// Empty Options bytes encode as the JSON object literal `{}` to
// satisfy the schema (`type: object`).
func encodeEngineVersion(version engineversion.EngineVersion) engineVersionResponse {
options := json.RawMessage(version.Options)
if len(options) == 0 {
options = json.RawMessage("{}")
}
return engineVersionResponse{
Version: version.Version,
ImageRef: version.ImageRef,
Options: options,
Status: string(version.Status),
CreatedAt: version.CreatedAt.UTC().UnixMilli(),
UpdatedAt: version.UpdatedAt.UTC().UnixMilli(),
}
}
// encodeEngineVersionList turns a slice of domain EngineVersions into
// a wire list response. The Versions slice is always non-nil.
func encodeEngineVersionList(versions []engineversion.EngineVersion) engineVersionListResponse {
resp := engineVersionListResponse{
Versions: make([]engineVersionResponse, 0, len(versions)),
}
for _, version := range versions {
resp.Versions = append(resp.Versions, encodeEngineVersion(version))
}
return resp
}
// writeJSON writes payload as a JSON response with the given status
// code.
func writeJSON(writer http.ResponseWriter, statusCode int, payload any) {
writer.Header().Set("Content-Type", jsonContentType)
writer.WriteHeader(statusCode)
_ = json.NewEncoder(writer).Encode(payload)
}
// writeNoContent writes `204 No Content` with no body. The
// Content-Type header is intentionally omitted so kin-openapi's
// response validator does not look for a body.
func writeNoContent(writer http.ResponseWriter) {
writer.WriteHeader(http.StatusNoContent)
}
// writeRawJSON writes raw, already-encoded JSON bytes as the response
// body with the given status code. Used by the hot-path handlers
// where the engine's response body is forwarded verbatim.
func writeRawJSON(writer http.ResponseWriter, statusCode int, body []byte) {
writer.Header().Set("Content-Type", jsonContentType)
writer.WriteHeader(statusCode)
_, _ = writer.Write(body)
}
// writeError writes the canonical error envelope at statusCode.
func writeError(writer http.ResponseWriter, statusCode int, code, message string) {
writeJSON(writer, statusCode, errorResponse{
Error: errorBody{Code: code, Message: message},
})
}
// writeFailure writes the canonical error envelope using the HTTP
// status mapped from code via mapErrorCodeToStatus. Used by every
// service-backed handler when its service returns
// `Outcome=failure`.
func writeFailure(writer http.ResponseWriter, code, message string) {
writeError(writer, mapErrorCodeToStatus(code), code, message)
}
// mapErrorCodeToStatus maps a stable error code to the HTTP status
// declared by `gamemaster/api/internal-openapi.yaml`. Unknown codes
// degrade to 500 so a future error code that ships ahead of its
// handler-layer mapping still produces a structurally valid response.
func mapErrorCodeToStatus(code string) int {
switch code {
case errorCodeInvalidRequest:
return http.StatusBadRequest
case errorCodeForbidden:
return http.StatusForbidden
case errorCodeRuntimeNotFound, errorCodeEngineVersionNotFound:
return http.StatusNotFound
case errorCodeConflict,
errorCodeRuntimeNotRunning,
errorCodeSemverPatchOnly,
errorCodeEngineVersionInUse:
return http.StatusConflict
case errorCodeEngineUnreachable,
errorCodeEngineValidationError,
errorCodeEngineProtocolError:
return http.StatusBadGateway
case errorCodeServiceUnavailable:
return http.StatusServiceUnavailable
default:
return http.StatusInternalServerError
}
}
// mapServiceError translates one of the `engineversionsvc` sentinel
// errors into the corresponding HTTP status, error code, and message.
// Unknown errors degrade to `500 internal_error`.
func mapServiceError(err error) (int, string, string) {
switch {
case errors.Is(err, engineversionsvc.ErrInvalidRequest):
return http.StatusBadRequest, errorCodeInvalidRequest, err.Error()
case errors.Is(err, engineversionsvc.ErrNotFound):
return http.StatusNotFound, errorCodeEngineVersionNotFound, err.Error()
case errors.Is(err, engineversionsvc.ErrConflict):
return http.StatusConflict, errorCodeConflict, err.Error()
case errors.Is(err, engineversionsvc.ErrInUse):
return http.StatusConflict, errorCodeEngineVersionInUse, err.Error()
case errors.Is(err, engineversionsvc.ErrServiceUnavailable):
return http.StatusServiceUnavailable, errorCodeServiceUnavailable, err.Error()
default:
return http.StatusInternalServerError, errorCodeInternal, "internal server error"
}
}
// decodeStrictJSON decodes one request body into target with strict
// JSON semantics: unknown fields are rejected and trailing content is
// rejected. Mirrors the helper used by lobby and rtmanager.
func decodeStrictJSON(body io.Reader, target any) error {
decoder := json.NewDecoder(body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if decoder.More() {
return errors.New("unexpected trailing content after JSON body")
}
return nil
}
// readRawJSONBody returns the raw request body provided it parses as
// a JSON value. The hot-path handlers use this helper because the
// envelope is engine-owned (`additionalProperties: true` on
// ExecuteCommandsRequest / PutOrdersRequest); strict decoding would
// reject legitimate extra fields.
func readRawJSONBody(reader io.Reader) ([]byte, error) {
if reader == nil {
return nil, errors.New("request body is required")
}
body, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
if len(body) == 0 {
return nil, errors.New("request body is required")
}
if !json.Valid(body) {
return nil, errors.New("request body is not valid JSON")
}
return body, nil
}
// extractGameID pulls the {game_id} path variable from request. An
// empty or whitespace-only value writes a `400 invalid_request` and
// returns ok=false so callers can short-circuit.
func extractGameID(writer http.ResponseWriter, request *http.Request) (string, bool) {
raw := request.PathValue(gameIDPathParam)
if strings.TrimSpace(raw) == "" {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "game id is required")
return "", false
}
return raw, true
}
// extractRaceName pulls the {race_name} path variable.
func extractRaceName(writer http.ResponseWriter, request *http.Request) (string, bool) {
raw := request.PathValue(raceNamePathParam)
if strings.TrimSpace(raw) == "" {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "race name is required")
return "", false
}
return raw, true
}
// extractVersion pulls the {version} path variable.
func extractVersion(writer http.ResponseWriter, request *http.Request) (string, bool) {
raw := request.PathValue(versionPathParam)
if strings.TrimSpace(raw) == "" {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "version is required")
return "", false
}
return raw, true
}
// extractUserID pulls the verified player identity from the
// X-User-ID header. The hot-path operations require this header per
// the OpenAPI spec; absent or whitespace-only values short-circuit
// with `400 invalid_request`.
func extractUserID(writer http.ResponseWriter, request *http.Request) (string, bool) {
raw := strings.TrimSpace(request.Header.Get(userIDHeader))
if raw == "" {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "X-User-ID header is required")
return "", false
}
return raw, true
}
// resolveOpSource maps the X-Galaxy-Caller header value to an
// `operation.OpSource`. Missing or unknown values default to
// OpSourceAdminRest, matching the documented contract in
// `gamemaster/README.md` §«Internal REST API».
func resolveOpSource(request *http.Request) operation.OpSource {
switch strings.ToLower(strings.TrimSpace(request.Header.Get(callerHeader))) {
case "gateway":
return operation.OpSourceGatewayPlayer
case "lobby":
return operation.OpSourceLobbyInternal
case "admin":
return operation.OpSourceAdminRest
default:
return operation.OpSourceAdminRest
}
}
// requestSourceRef returns an opaque per-request reference recorded
// in `operation_log.source_ref`. v1 reads the X-Request-ID header
// when present so callers may correlate REST requests with audit
// rows.
func requestSourceRef(request *http.Request) string {
return strings.TrimSpace(request.Header.Get(requestIDHeader))
}
// loggerFor returns a logger annotated with the operation tag. Each
// handler scopes its logs by op so operators filtering on
// `op=internal_rest.<operation>` see exactly the lifecycle they care
// about.
func loggerFor(parent *slog.Logger, op string) *slog.Logger {
if parent == nil {
parent = slog.Default()
}
return parent.With("component", "internal_http.handlers", "op", op)
}
@@ -0,0 +1,205 @@
package handlers
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"galaxy/gamemaster/internal/domain/engineversion"
"galaxy/gamemaster/internal/domain/operation"
"galaxy/gamemaster/internal/domain/runtime"
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMapErrorCodeToStatusCoversEveryDocumentedCode(t *testing.T) {
t.Parallel()
cases := map[string]int{
errorCodeInvalidRequest: http.StatusBadRequest,
errorCodeForbidden: http.StatusForbidden,
errorCodeRuntimeNotFound: http.StatusNotFound,
errorCodeEngineVersionNotFound: http.StatusNotFound,
errorCodeConflict: http.StatusConflict,
errorCodeRuntimeNotRunning: http.StatusConflict,
errorCodeSemverPatchOnly: http.StatusConflict,
errorCodeEngineVersionInUse: http.StatusConflict,
errorCodeEngineUnreachable: http.StatusBadGateway,
errorCodeEngineValidationError: http.StatusBadGateway,
errorCodeEngineProtocolError: http.StatusBadGateway,
errorCodeServiceUnavailable: http.StatusServiceUnavailable,
errorCodeInternal: http.StatusInternalServerError,
"unknown_code": http.StatusInternalServerError,
}
for code, expected := range cases {
assert.Equalf(t, expected, mapErrorCodeToStatus(code), "code %q", code)
}
}
func TestMapServiceErrorMapsEverySentinel(t *testing.T) {
t.Parallel()
cases := []struct {
err error
status int
code string
}{
{engineversionsvc.ErrInvalidRequest, http.StatusBadRequest, errorCodeInvalidRequest},
{engineversionsvc.ErrNotFound, http.StatusNotFound, errorCodeEngineVersionNotFound},
{engineversionsvc.ErrConflict, http.StatusConflict, errorCodeConflict},
{engineversionsvc.ErrInUse, http.StatusConflict, errorCodeEngineVersionInUse},
{engineversionsvc.ErrServiceUnavailable, http.StatusServiceUnavailable, errorCodeServiceUnavailable},
{errors.New("plain go error"), http.StatusInternalServerError, errorCodeInternal},
}
for _, tc := range cases {
status, code, _ := mapServiceError(tc.err)
assert.Equalf(t, tc.status, status, "status for %v", tc.err)
assert.Equalf(t, tc.code, code, "code for %v", tc.err)
}
}
func TestResolveOpSourceMapsCallerHeader(t *testing.T) {
t.Parallel()
cases := map[string]operation.OpSource{
"": operation.OpSourceAdminRest,
"unknown": operation.OpSourceAdminRest,
"GATEWAY": operation.OpSourceGatewayPlayer,
" lobby ": operation.OpSourceLobbyInternal,
"admin": operation.OpSourceAdminRest,
}
for value, expected := range cases {
request := httptest.NewRequest(http.MethodGet, "/", nil)
if value != "" {
request.Header.Set(callerHeader, value)
}
assert.Equalf(t, expected, resolveOpSource(request), "header %q", value)
}
}
func TestRequestSourceRefReadsXRequestID(t *testing.T) {
t.Parallel()
request := httptest.NewRequest(http.MethodGet, "/", nil)
assert.Empty(t, requestSourceRef(request))
request.Header.Set(requestIDHeader, " trace-123 ")
assert.Equal(t, "trace-123", requestSourceRef(request))
}
func TestDecodeStrictJSONRejectsUnknownFieldsAndTrailingContent(t *testing.T) {
t.Parallel()
type input struct {
Field string `json:"field"`
}
var ok input
require.NoError(t, decodeStrictJSON(strings.NewReader(`{"field":"value"}`), &ok))
assert.Equal(t, "value", ok.Field)
var rejected input
err := decodeStrictJSON(strings.NewReader(`{"field":"v","extra":1}`), &rejected)
require.Error(t, err)
var trailing input
err = decodeStrictJSON(strings.NewReader(`{"field":"v"}{"another":true}`), &trailing)
require.Error(t, err)
}
func TestReadRawJSONBodyValidatesPayload(t *testing.T) {
t.Parallel()
body, err := readRawJSONBody(strings.NewReader(`{"commands":[]}`))
require.NoError(t, err)
assert.JSONEq(t, `{"commands":[]}`, string(body))
_, err = readRawJSONBody(strings.NewReader(""))
require.Error(t, err)
_, err = readRawJSONBody(strings.NewReader("not json"))
require.Error(t, err)
}
func TestEncodeRuntimeRecordIncludesEveryRequiredField(t *testing.T) {
t.Parallel()
moment := time.Date(2026, 5, 1, 9, 30, 0, 0, time.UTC)
next := moment.Add(time.Minute)
record := runtime.RuntimeRecord{
GameID: "game-1",
Status: runtime.StatusRunning,
EngineEndpoint: "http://example:8080",
CurrentImageRef: "galaxy/game:1.2.3",
CurrentEngineVersion: "1.2.3",
TurnSchedule: "0 18 * * *",
CurrentTurn: 7,
NextGenerationAt: &next,
SkipNextTick: true,
EngineHealth: "healthy",
CreatedAt: moment,
UpdatedAt: moment,
StartedAt: &moment,
}
encoded := encodeRuntimeRecord(record)
assert.Equal(t, "game-1", encoded.GameID)
assert.Equal(t, "running", encoded.RuntimeStatus)
assert.Equal(t, moment.UnixMilli(), encoded.CreatedAt)
assert.Equal(t, next.UnixMilli(), encoded.NextGenerationAt)
require.NotNil(t, encoded.StartedAt)
assert.Equal(t, moment.UnixMilli(), *encoded.StartedAt)
assert.Nil(t, encoded.StoppedAt)
assert.Nil(t, encoded.FinishedAt)
}
func TestEncodeRuntimeRecordZerosNextGenerationWhenNil(t *testing.T) {
t.Parallel()
moment := time.Date(2026, 5, 1, 9, 30, 0, 0, time.UTC)
record := runtime.RuntimeRecord{
GameID: "game-1",
Status: runtime.StatusStarting,
EngineEndpoint: "http://example:8080",
CurrentImageRef: "galaxy/game:1.2.3",
CurrentEngineVersion: "1.2.3",
TurnSchedule: "0 18 * * *",
CreatedAt: moment,
UpdatedAt: moment,
}
encoded := encodeRuntimeRecord(record)
assert.Equal(t, int64(0), encoded.NextGenerationAt)
assert.Nil(t, encoded.StartedAt)
}
func TestEncodeEngineVersionDefaultsEmptyOptionsToObject(t *testing.T) {
t.Parallel()
moment := time.Date(2026, 5, 1, 9, 30, 0, 0, time.UTC)
encoded := encodeEngineVersion(engineversion.EngineVersion{
Version: "1.2.3",
ImageRef: "galaxy/game:1.2.3",
Status: engineversion.StatusActive,
CreatedAt: moment,
UpdatedAt: moment,
})
assert.Equal(t, "{}", string(encoded.Options))
assert.Equal(t, "active", encoded.Status)
}
func TestEncodeRuntimeListAlwaysReturnsNonNilSlice(t *testing.T) {
t.Parallel()
resp := encodeRuntimeList(nil)
require.NotNil(t, resp.Runtimes)
assert.Empty(t, resp.Runtimes)
}
@@ -0,0 +1,50 @@
package handlers
import (
"encoding/json"
"net/http"
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
)
// createEngineVersionRequestBody mirrors the OpenAPI
// CreateEngineVersionRequest schema.
type createEngineVersionRequestBody struct {
Version string `json:"version"`
ImageRef string `json:"image_ref"`
Options json.RawMessage `json:"options,omitempty"`
}
// newCreateEngineVersionHandler returns the handler for
// `POST /api/v1/internal/engine-versions`.
func newCreateEngineVersionHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.create_engine_version")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.EngineVersions == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "engine version service is not wired")
return
}
var body createEngineVersionRequestBody
if err := decodeStrictJSON(request.Body, &body); err != nil {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
return
}
record, err := deps.EngineVersions.Create(request.Context(), engineversionsvc.CreateInput{
Version: body.Version,
ImageRef: body.ImageRef,
Options: []byte(body.Options),
OpSource: resolveOpSource(request),
SourceRef: requestSourceRef(request),
})
if err != nil {
logger.ErrorContext(request.Context(), "create engine version failed", "err", err.Error())
status, code, message := mapServiceError(err)
writeError(writer, status, code, message)
return
}
writeJSON(writer, http.StatusCreated, encodeEngineVersion(record))
}
}
@@ -0,0 +1,44 @@
package handlers
import (
"net/http"
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
)
// newDeprecateEngineVersionHandler returns the handler for
// `DELETE /api/v1/internal/engine-versions/{version}`. The endpoint
// flips the row's status to `deprecated` (decision D2 in
// `gamemaster/docs/stage19-internal-rest-handlers.md`); hard removal
// is reserved for future Admin Service operations and not exposed
// here.
func newDeprecateEngineVersionHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.deprecate_engine_version")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.EngineVersions == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "engine version service is not wired")
return
}
version, ok := extractVersion(writer, request)
if !ok {
return
}
if err := deps.EngineVersions.Deprecate(request.Context(), engineversionsvc.DeprecateInput{
Version: version,
OpSource: resolveOpSource(request),
SourceRef: requestSourceRef(request),
}); err != nil {
logger.ErrorContext(request.Context(), "deprecate engine version failed",
"version", version,
"err", err.Error(),
)
status, code, message := mapServiceError(err)
writeError(writer, status, code, message)
return
}
writeNoContent(writer)
}
}
@@ -0,0 +1,60 @@
package handlers
import (
"net/http"
"galaxy/gamemaster/internal/domain/operation"
"galaxy/gamemaster/internal/service/commandexecute"
)
// newExecuteCommandsHandler returns the handler for
// `POST /api/v1/internal/games/{game_id}/commands`. The request body
// is engine-owned (`additionalProperties: true`) and is forwarded to
// the service as a `json.RawMessage`. The response on success is the
// engine's payload byte-for-byte; failure outcomes use the canonical
// error envelope per the OpenAPI contract.
func newExecuteCommandsHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.execute_commands")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.CommandExecute == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "command execute service is not wired")
return
}
gameID, ok := extractGameID(writer, request)
if !ok {
return
}
userID, ok := extractUserID(writer, request)
if !ok {
return
}
body, err := readRawJSONBody(request.Body)
if err != nil {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
return
}
result, err := deps.CommandExecute.Handle(request.Context(), commandexecute.Input{
GameID: gameID,
UserID: userID,
Payload: body,
})
if err != nil {
logger.ErrorContext(request.Context(), "command execute service errored",
"game_id", gameID,
"user_id", userID,
"err", err.Error(),
)
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "command execute service failed")
return
}
if result.Outcome == operation.OutcomeFailure {
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
return
}
writeRawJSON(writer, http.StatusOK, []byte(result.RawResponse))
}
}
@@ -0,0 +1,49 @@
package handlers
import (
"net/http"
"galaxy/gamemaster/internal/domain/operation"
"galaxy/gamemaster/internal/service/adminforce"
)
// newForceNextTurnHandler returns the handler for
// `POST /api/v1/internal/runtimes/{game_id}/force-next-turn`. The
// request has no body; the handler delegates to
// `adminforce.Service.Handle` and encodes the resulting runtime
// record on success.
func newForceNextTurnHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.force_next_turn")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.ForceNextTurn == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "force next turn service is not wired")
return
}
gameID, ok := extractGameID(writer, request)
if !ok {
return
}
result, err := deps.ForceNextTurn.Handle(request.Context(), adminforce.Input{
GameID: gameID,
OpSource: resolveOpSource(request),
SourceRef: requestSourceRef(request),
})
if err != nil {
logger.ErrorContext(request.Context(), "force next turn service errored",
"game_id", gameID,
"err", err.Error(),
)
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "force next turn service failed")
return
}
if result.Outcome == operation.OutcomeFailure {
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
return
}
writeJSON(writer, http.StatusOK, encodeRuntimeRecord(result.TurnGeneration.Record))
}
}
@@ -0,0 +1,50 @@
package handlers
import (
"net/http"
"strings"
"galaxy/gamemaster/internal/service/livenessreply"
)
// newGameLivenessHandler returns the handler for
// `GET /api/v1/internal/games/{game_id}/liveness`. The endpoint
// always responds with 200 + LivenessResponse; Go-level errors
// returned by the service map to 500 / 503 according to their
// embedded error code prefix.
func newGameLivenessHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.game_liveness")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.GameLiveness == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "game liveness service is not wired")
return
}
gameID, ok := extractGameID(writer, request)
if !ok {
return
}
result, err := deps.GameLiveness.Handle(request.Context(), livenessreply.Input{GameID: gameID})
if err != nil {
logger.ErrorContext(request.Context(), "game liveness service errored",
"game_id", gameID,
"err", err.Error(),
)
switch {
case strings.HasPrefix(err.Error(), livenessreply.ErrorCodeInvalidRequest+":"):
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
case strings.HasPrefix(err.Error(), livenessreply.ErrorCodeServiceUnavailable+":"):
writeError(writer, http.StatusServiceUnavailable, errorCodeServiceUnavailable, "service unavailable")
default:
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "game liveness service failed")
}
return
}
writeJSON(writer, http.StatusOK, livenessResponse{
Ready: result.Ready,
Status: string(result.Status),
})
}
}
@@ -0,0 +1,33 @@
package handlers
import "net/http"
// newGetEngineVersionHandler returns the handler for
// `GET /api/v1/internal/engine-versions/{version}`.
func newGetEngineVersionHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.get_engine_version")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.EngineVersions == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "engine version service is not wired")
return
}
version, ok := extractVersion(writer, request)
if !ok {
return
}
record, err := deps.EngineVersions.Get(request.Context(), version)
if err != nil {
logger.ErrorContext(request.Context(), "get engine version failed",
"version", version,
"err", err.Error(),
)
status, code, message := mapServiceError(err)
writeError(writer, status, code, message)
return
}
writeJSON(writer, http.StatusOK, encodeEngineVersion(record))
}
}
@@ -0,0 +1,67 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"galaxy/gamemaster/internal/domain/operation"
"galaxy/gamemaster/internal/service/reportget"
)
// newGetReportHandler returns the handler for
// `GET /api/v1/internal/games/{game_id}/reports/{turn}`. Path
// validation rejects non-numeric or negative turn values with
// `400 invalid_request` before the service is touched.
func newGetReportHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.get_report")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.GetReport == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "get report service is not wired")
return
}
gameID, ok := extractGameID(writer, request)
if !ok {
return
}
userID, ok := extractUserID(writer, request)
if !ok {
return
}
raw := strings.TrimSpace(request.PathValue(turnPathParam))
if raw == "" {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "turn is required")
return
}
turn, err := strconv.Atoi(raw)
if err != nil || turn < 0 {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "turn must be a non-negative integer")
return
}
result, err := deps.GetReport.Handle(request.Context(), reportget.Input{
GameID: gameID,
UserID: userID,
Turn: turn,
})
if err != nil {
logger.ErrorContext(request.Context(), "get report service errored",
"game_id", gameID,
"user_id", userID,
"turn", turn,
"err", err.Error(),
)
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "get report service failed")
return
}
if result.Outcome == operation.OutcomeFailure {
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
return
}
writeRawJSON(writer, http.StatusOK, []byte(result.RawResponse))
}
}
@@ -0,0 +1,43 @@
package handlers
import (
"errors"
"net/http"
"galaxy/gamemaster/internal/domain/runtime"
)
// newGetRuntimeHandler returns the handler for
// `GET /api/v1/internal/runtimes/{game_id}`. Reads from
// `RuntimeRecordsReader.Get` and translates `runtime.ErrNotFound` to
// `404 runtime_not_found`.
func newGetRuntimeHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.get_runtime")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.RuntimeRecords == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "runtime records store is not wired")
return
}
gameID, ok := extractGameID(writer, request)
if !ok {
return
}
record, err := deps.RuntimeRecords.Get(request.Context(), gameID)
if err != nil {
if errors.Is(err, runtime.ErrNotFound) {
writeError(writer, http.StatusNotFound, errorCodeRuntimeNotFound, "runtime not found")
return
}
logger.ErrorContext(request.Context(), "get runtime record failed",
"game_id", gameID,
"err", err.Error(),
)
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "failed to read runtime record")
return
}
writeJSON(writer, http.StatusOK, encodeRuntimeRecord(record))
}
}
@@ -0,0 +1,119 @@
// Package handlers serves the trusted internal REST surface of Game
// Master frozen by `gamemaster/api/internal-openapi.yaml`. The package
// owns one HandlerFunc per OpenAPI operation; route registration goes
// through Register so the listener (`internal/api/internalhttp`) keeps
// its lifecycle code separate from the per-operation logic. Handlers
// delegate every business decision to the `internal/service/*`
// packages and never decode engine-owned hot-path payloads.
//
// The pattern mirrors `rtmanager/internal/api/internalhttp/handlers`
// so a reader familiar with one service can find their way around the
// other.
package handlers
import (
"log/slog"
"net/http"
)
// Route paths frozen by `gamemaster/api/internal-openapi.yaml`. The
// values match the operation IDs asserted in
// `gamemaster/contract_openapi_test.go`; renaming any of them is a
// contract change.
const (
registerRuntimePath = "/api/v1/internal/games/{game_id}/register-runtime"
banishRacePath = "/api/v1/internal/games/{game_id}/race/{race_name}/banish"
invalidateMembershipsPath = "/api/v1/internal/games/{game_id}/memberships/invalidate"
gameLivenessPath = "/api/v1/internal/games/{game_id}/liveness"
listRuntimesPath = "/api/v1/internal/runtimes"
getRuntimePath = "/api/v1/internal/runtimes/{game_id}"
forceNextTurnPath = "/api/v1/internal/runtimes/{game_id}/force-next-turn"
stopRuntimePath = "/api/v1/internal/runtimes/{game_id}/stop"
patchRuntimePath = "/api/v1/internal/runtimes/{game_id}/patch"
listEngineVersionsPath = "/api/v1/internal/engine-versions"
createEngineVersionPath = "/api/v1/internal/engine-versions"
engineVersionItemPath = "/api/v1/internal/engine-versions/{version}"
resolveEngineVersionImageRefPath = "/api/v1/internal/engine-versions/{version}/image-ref"
executeCommandsPath = "/api/v1/internal/games/{game_id}/commands"
putOrdersPath = "/api/v1/internal/games/{game_id}/orders"
getReportPath = "/api/v1/internal/games/{game_id}/reports/{turn}"
)
// Dependencies bundles the collaborators required to serve the
// gateway-, Lobby-, and Admin-facing internal REST surface. Any port
// may be nil; in that case the routes that depend on it return
// `500 internal_error` with the message «service is not wired». This
// mirrors the rtmanager handlers' guard so partially-wired listener
// tests do not crash on routes they do not exercise.
type Dependencies struct {
// Logger receives structured per-handler logs. nil falls back to
// slog.Default.
Logger *slog.Logger
// RuntimeRecords backs the read-only list/get runtime endpoints.
// Reads do not produce operation_log rows, mirroring
// `rtmanager/docs/services.md` §18.
RuntimeRecords RuntimeRecordsReader
// RegisterRuntime is the orchestrator for the
// `internalRegisterRuntime` operation.
RegisterRuntime RegisterRuntimeService
// ForceNextTurn drives the synchronous force-next-turn flow.
ForceNextTurn ForceNextTurnService
// StopRuntime drives the admin stop flow.
StopRuntime StopRuntimeService
// PatchRuntime drives the admin patch flow.
PatchRuntime PatchRuntimeService
// BanishRace drives the engine race-banish flow.
BanishRace BanishRaceService
// InvalidateMemberships purges the in-process membership cache for a
// game id; backed by `service/membership.Cache.Invalidate`.
InvalidateMemberships MembershipInvalidator
// GameLiveness returns the current runtime status without
// contacting the engine.
GameLiveness LivenessService
// EngineVersions exposes the multi-method engine-version registry
// service (List/Get/ResolveImageRef/Create/Update/Deprecate).
EngineVersions EngineVersionService
// CommandExecute forwards a player command batch to the engine.
CommandExecute CommandExecuteService
// PutOrders forwards a player order batch to the engine.
PutOrders OrderPutService
// GetReport reads a per-player turn report from the engine.
GetReport ReportGetService
}
// Register attaches every internal REST route to mux. The function is
// idempotent against the listener-level probes (`/healthz`,
// `/readyz`); the probe routes are owned by the listener and remain
// disjoint from the paths registered here.
func Register(mux *http.ServeMux, deps Dependencies) {
mux.HandleFunc(http.MethodPost+" "+registerRuntimePath, newRegisterRuntimeHandler(deps))
mux.HandleFunc(http.MethodGet+" "+getRuntimePath, newGetRuntimeHandler(deps))
mux.HandleFunc(http.MethodGet+" "+listRuntimesPath, newListRuntimesHandler(deps))
mux.HandleFunc(http.MethodPost+" "+forceNextTurnPath, newForceNextTurnHandler(deps))
mux.HandleFunc(http.MethodPost+" "+stopRuntimePath, newStopRuntimeHandler(deps))
mux.HandleFunc(http.MethodPost+" "+patchRuntimePath, newPatchRuntimeHandler(deps))
mux.HandleFunc(http.MethodPost+" "+banishRacePath, newBanishRaceHandler(deps))
mux.HandleFunc(http.MethodPost+" "+invalidateMembershipsPath, newInvalidateMembershipsHandler(deps))
mux.HandleFunc(http.MethodGet+" "+gameLivenessPath, newGameLivenessHandler(deps))
mux.HandleFunc(http.MethodGet+" "+listEngineVersionsPath, newListEngineVersionsHandler(deps))
mux.HandleFunc(http.MethodPost+" "+createEngineVersionPath, newCreateEngineVersionHandler(deps))
mux.HandleFunc(http.MethodGet+" "+engineVersionItemPath, newGetEngineVersionHandler(deps))
mux.HandleFunc(http.MethodPatch+" "+engineVersionItemPath, newUpdateEngineVersionHandler(deps))
mux.HandleFunc(http.MethodDelete+" "+engineVersionItemPath, newDeprecateEngineVersionHandler(deps))
mux.HandleFunc(http.MethodGet+" "+resolveEngineVersionImageRefPath, newResolveEngineVersionImageRefHandler(deps))
mux.HandleFunc(http.MethodPost+" "+executeCommandsPath, newExecuteCommandsHandler(deps))
mux.HandleFunc(http.MethodPost+" "+putOrdersPath, newPutOrdersHandler(deps))
mux.HandleFunc(http.MethodGet+" "+getReportPath, newGetReportHandler(deps))
}
@@ -0,0 +1,422 @@
package handlers_test
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"galaxy/gamemaster/internal/api/internalhttp/handlers"
"galaxy/gamemaster/internal/api/internalhttp/handlers/mocks"
"galaxy/gamemaster/internal/domain/engineversion"
"galaxy/gamemaster/internal/domain/operation"
"galaxy/gamemaster/internal/domain/runtime"
"galaxy/gamemaster/internal/service/adminstop"
"galaxy/gamemaster/internal/service/commandexecute"
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
"galaxy/gamemaster/internal/service/livenessreply"
"galaxy/gamemaster/internal/service/registerruntime"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)
// driveHandler builds a fresh ServeMux + handler set bound to deps,
// fires one request, and returns the recorder.
func driveHandler(t *testing.T, deps handlers.Dependencies, method, path string, body io.Reader, headers map[string]string) *httptest.ResponseRecorder {
t.Helper()
mux := http.NewServeMux()
handlers.Register(mux, deps)
request := httptest.NewRequest(method, path, body)
for key, value := range headers {
request.Header.Set(key, value)
}
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, request)
return recorder
}
func decodeErrorBody(t *testing.T, recorder *httptest.ResponseRecorder) (string, string) {
t.Helper()
var body struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
return body.Error.Code, body.Error.Message
}
func TestRegisterRuntimeHandlerHappyPath(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
moment := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
record := runtime.RuntimeRecord{
GameID: "game-1",
Status: runtime.StatusRunning,
EngineEndpoint: "http://engine:8080",
CurrentImageRef: "galaxy/game:1.2.3",
CurrentEngineVersion: "1.2.3",
TurnSchedule: "0 18 * * *",
CreatedAt: moment,
UpdatedAt: moment,
}
registerSvc := mocks.NewMockRegisterRuntimeService(ctrl)
registerSvc.EXPECT().
Handle(gomock.Any(), gomock.AssignableToTypeOf(registerruntime.Input{})).
DoAndReturn(func(_ context.Context, in registerruntime.Input) (registerruntime.Result, error) {
assert.Equal(t, "game-1", in.GameID)
assert.Equal(t, "http://engine:8080", in.EngineEndpoint)
assert.Equal(t, operation.OpSourceLobbyInternal, in.OpSource)
require.Len(t, in.Members, 1)
return registerruntime.Result{Record: record, Outcome: operation.OutcomeSuccess}, nil
})
body := strings.NewReader(`{
"engine_endpoint": "http://engine:8080",
"members": [{"user_id":"u1","race_name":"Aelinari"}],
"target_engine_version": "1.2.3",
"turn_schedule": "0 18 * * *"
}`)
recorder := driveHandler(t,
handlers.Dependencies{RegisterRuntime: registerSvc},
http.MethodPost,
"/api/v1/internal/games/game-1/register-runtime",
body,
map[string]string{"X-Galaxy-Caller": "lobby"},
)
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
assert.Contains(t, recorder.Body.String(), `"game_id":"game-1"`)
}
func TestRegisterRuntimeHandlerRejectsUnknownFields(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
registerSvc := mocks.NewMockRegisterRuntimeService(ctrl)
// no expectations — handler must short-circuit before calling.
body := strings.NewReader(`{"engine_endpoint":"http://e","extra":1}`)
recorder := driveHandler(t,
handlers.Dependencies{RegisterRuntime: registerSvc},
http.MethodPost,
"/api/v1/internal/games/game-1/register-runtime",
body,
nil,
)
require.Equal(t, http.StatusBadRequest, recorder.Code)
code, _ := decodeErrorBody(t, recorder)
assert.Equal(t, "invalid_request", code)
}
func TestRegisterRuntimeHandlerWiresFailureCodes(t *testing.T) {
t.Parallel()
cases := []struct {
name string
errCode string
wantStatus int
}{
{"invalid_request", registerruntime.ErrorCodeInvalidRequest, http.StatusBadRequest},
{"conflict", registerruntime.ErrorCodeConflict, http.StatusConflict},
{"engine_version_not_found", registerruntime.ErrorCodeEngineVersionNotFound, http.StatusNotFound},
{"engine_unreachable", registerruntime.ErrorCodeEngineUnreachable, http.StatusBadGateway},
{"service_unavailable", registerruntime.ErrorCodeServiceUnavailable, http.StatusServiceUnavailable},
{"internal_error", registerruntime.ErrorCodeInternal, http.StatusInternalServerError},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
svc := mocks.NewMockRegisterRuntimeService(ctrl)
svc.EXPECT().
Handle(gomock.Any(), gomock.Any()).
Return(registerruntime.Result{
Outcome: operation.OutcomeFailure,
ErrorCode: tc.errCode,
ErrorMessage: tc.errCode + " details",
}, nil)
body := strings.NewReader(`{
"engine_endpoint": "http://e",
"members":[{"user_id":"u1","race_name":"r"}],
"target_engine_version":"1.0.0",
"turn_schedule":"* * * * *"
}`)
recorder := driveHandler(t,
handlers.Dependencies{RegisterRuntime: svc},
http.MethodPost,
"/api/v1/internal/games/game-1/register-runtime",
body,
nil,
)
assert.Equal(t, tc.wantStatus, recorder.Code)
code, _ := decodeErrorBody(t, recorder)
assert.Equal(t, tc.errCode, code)
})
}
}
func TestRegisterRuntimeHandlerNilServiceReturns500(t *testing.T) {
t.Parallel()
body := strings.NewReader(`{"engine_endpoint":"http://e"}`)
recorder := driveHandler(t,
handlers.Dependencies{},
http.MethodPost,
"/api/v1/internal/games/game-1/register-runtime",
body,
nil,
)
require.Equal(t, http.StatusInternalServerError, recorder.Code)
code, _ := decodeErrorBody(t, recorder)
assert.Equal(t, "internal_error", code)
}
func TestStopRuntimeHandlerForwardsReason(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
moment := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
record := runtime.RuntimeRecord{
GameID: "game-1",
Status: runtime.StatusStopped,
EngineEndpoint: "http://engine:8080",
CurrentImageRef: "galaxy/game:1.2.3",
CurrentEngineVersion: "1.2.3",
TurnSchedule: "0 18 * * *",
CreatedAt: moment,
UpdatedAt: moment,
}
stopSvc := mocks.NewMockStopRuntimeService(ctrl)
stopSvc.EXPECT().
Handle(gomock.Any(), gomock.AssignableToTypeOf(adminstop.Input{})).
DoAndReturn(func(_ context.Context, in adminstop.Input) (adminstop.Result, error) {
assert.Equal(t, "admin_request", in.Reason)
return adminstop.Result{Record: record, Outcome: operation.OutcomeSuccess}, nil
})
body := strings.NewReader(`{"reason":"admin_request"}`)
recorder := driveHandler(t,
handlers.Dependencies{StopRuntime: stopSvc},
http.MethodPost,
"/api/v1/internal/runtimes/game-1/stop",
body,
nil,
)
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
}
func TestGetEngineVersionHandlerMapsNotFound(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
svc := mocks.NewMockEngineVersionService(ctrl)
svc.EXPECT().
Get(gomock.Any(), "9.9.9").
Return(engineversion.EngineVersion{}, engineversionsvc.ErrNotFound)
recorder := driveHandler(t,
handlers.Dependencies{EngineVersions: svc},
http.MethodGet,
"/api/v1/internal/engine-versions/9.9.9",
nil,
nil,
)
assert.Equal(t, http.StatusNotFound, recorder.Code)
code, _ := decodeErrorBody(t, recorder)
assert.Equal(t, "engine_version_not_found", code)
}
func TestListEngineVersionsHandlerRejectsUnknownStatus(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
svc := mocks.NewMockEngineVersionService(ctrl)
// no expectations — short-circuits.
recorder := driveHandler(t,
handlers.Dependencies{EngineVersions: svc},
http.MethodGet,
"/api/v1/internal/engine-versions?status=mystery",
nil,
nil,
)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
code, _ := decodeErrorBody(t, recorder)
assert.Equal(t, "invalid_request", code)
}
func TestDeprecateEngineVersionReturns204(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
svc := mocks.NewMockEngineVersionService(ctrl)
svc.EXPECT().
Deprecate(gomock.Any(), gomock.AssignableToTypeOf(engineversionsvc.DeprecateInput{})).
Return(nil)
recorder := driveHandler(t,
handlers.Dependencies{EngineVersions: svc},
http.MethodDelete,
"/api/v1/internal/engine-versions/1.0.0",
nil,
nil,
)
assert.Equal(t, http.StatusNoContent, recorder.Code)
assert.Empty(t, recorder.Body.String())
}
func TestDeprecateEngineVersionDoesNotReportInUse(t *testing.T) {
t.Parallel()
// D2: the DELETE endpoint flips status; the handler does not call
// Service.Delete and therefore can never produce
// `engine_version_in_use`. Deprecate's own error vocabulary is
// limited to invalid_request / not_found / service_unavailable.
ctrl := gomock.NewController(t)
svc := mocks.NewMockEngineVersionService(ctrl)
svc.EXPECT().
Deprecate(gomock.Any(), gomock.Any()).
Return(engineversionsvc.ErrNotFound)
recorder := driveHandler(t,
handlers.Dependencies{EngineVersions: svc},
http.MethodDelete,
"/api/v1/internal/engine-versions/9.9.9",
nil,
nil,
)
assert.Equal(t, http.StatusNotFound, recorder.Code)
}
func TestExecuteCommandsRequiresUserIDHeader(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
svc := mocks.NewMockCommandExecuteService(ctrl)
// short-circuit before service is touched.
recorder := driveHandler(t,
handlers.Dependencies{CommandExecute: svc},
http.MethodPost,
"/api/v1/internal/games/game-1/commands",
strings.NewReader(`{"commands":[]}`),
nil,
)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
code, msg := decodeErrorBody(t, recorder)
assert.Equal(t, "invalid_request", code)
assert.Contains(t, msg, "X-User-ID")
}
func TestExecuteCommandsRejectsInvalidJSONBody(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
svc := mocks.NewMockCommandExecuteService(ctrl)
recorder := driveHandler(t,
handlers.Dependencies{CommandExecute: svc},
http.MethodPost,
"/api/v1/internal/games/game-1/commands",
strings.NewReader("not json"),
map[string]string{"X-User-ID": "u1"},
)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
code, _ := decodeErrorBody(t, recorder)
assert.Equal(t, "invalid_request", code)
}
func TestExecuteCommandsForwardsRawResponseOnSuccess(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
svc := mocks.NewMockCommandExecuteService(ctrl)
svc.EXPECT().
Handle(gomock.Any(), gomock.AssignableToTypeOf(commandexecute.Input{})).
DoAndReturn(func(_ context.Context, in commandexecute.Input) (commandexecute.Result, error) {
assert.Equal(t, "game-1", in.GameID)
assert.Equal(t, "u1", in.UserID)
assert.JSONEq(t, `{"commands":[{"name":"build"}]}`, string(in.Payload))
return commandexecute.Result{
Outcome: operation.OutcomeSuccess,
RawResponse: []byte(`{"results":[{"ok":true}]}`),
}, nil
})
recorder := driveHandler(t,
handlers.Dependencies{CommandExecute: svc},
http.MethodPost,
"/api/v1/internal/games/game-1/commands",
strings.NewReader(`{"commands":[{"name":"build"}]}`),
map[string]string{"X-User-ID": "u1"},
)
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
assert.JSONEq(t, `{"results":[{"ok":true}]}`, recorder.Body.String())
}
func TestInvalidateMembershipsAlwaysReturns204(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
cache := mocks.NewMockMembershipInvalidator(ctrl)
cache.EXPECT().Invalidate("game-7").Times(1)
recorder := driveHandler(t,
handlers.Dependencies{InvalidateMemberships: cache},
http.MethodPost,
"/api/v1/internal/games/game-7/memberships/invalidate",
nil,
nil,
)
assert.Equal(t, http.StatusNoContent, recorder.Code)
}
func TestGameLivenessHandlerMapsServiceUnavailable(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
svc := mocks.NewMockLivenessService(ctrl)
svc.EXPECT().
Handle(gomock.Any(), livenessreply.Input{GameID: "game-1"}).
Return(livenessreply.Result{}, errors.New(livenessreply.ErrorCodeServiceUnavailable+": store ping"))
recorder := driveHandler(t,
handlers.Dependencies{GameLiveness: svc},
http.MethodGet,
"/api/v1/internal/games/game-1/liveness",
nil,
nil,
)
assert.Equal(t, http.StatusServiceUnavailable, recorder.Code)
code, _ := decodeErrorBody(t, recorder)
assert.Equal(t, "service_unavailable", code)
}
func TestGetReportRejectsNegativeTurn(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
svc := mocks.NewMockReportGetService(ctrl)
// short-circuits.
recorder := driveHandler(t,
handlers.Dependencies{GetReport: svc},
http.MethodGet,
"/api/v1/internal/games/game-1/reports/-3",
nil,
map[string]string{"X-User-ID": "u1"},
)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
code, _ := decodeErrorBody(t, recorder)
assert.Equal(t, "invalid_request", code)
}
@@ -0,0 +1,25 @@
package handlers
import "net/http"
// newInvalidateMembershipsHandler returns the handler for
// `POST /api/v1/internal/games/{game_id}/memberships/invalidate`. The
// underlying cache invalidation is a fire-and-forget local operation,
// so the handler always responds with `204 No Content` once the path
// parameter validates.
func newInvalidateMembershipsHandler(deps Dependencies) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if deps.InvalidateMemberships == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "membership cache invalidator is not wired")
return
}
gameID, ok := extractGameID(writer, request)
if !ok {
return
}
deps.InvalidateMemberships.Invalidate(gameID)
writeNoContent(writer)
}
}
@@ -0,0 +1,42 @@
package handlers
import (
"net/http"
"strings"
"galaxy/gamemaster/internal/domain/engineversion"
)
// newListEngineVersionsHandler returns the handler for
// `GET /api/v1/internal/engine-versions`. The optional `status`
// query parameter narrows the result.
func newListEngineVersionsHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.list_engine_versions")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.EngineVersions == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "engine version service is not wired")
return
}
var statusFilter *engineversion.Status
raw := strings.TrimSpace(request.URL.Query().Get("status"))
if raw != "" {
candidate := engineversion.Status(raw)
if !candidate.IsKnown() {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "status query parameter is unsupported")
return
}
statusFilter = &candidate
}
versions, err := deps.EngineVersions.List(request.Context(), statusFilter)
if err != nil {
logger.ErrorContext(request.Context(), "list engine versions failed", "err", err.Error())
status, code, message := mapServiceError(err)
writeError(writer, status, code, message)
return
}
writeJSON(writer, http.StatusOK, encodeEngineVersionList(versions))
}
}
@@ -0,0 +1,54 @@
package handlers
import (
"net/http"
"strings"
"galaxy/gamemaster/internal/domain/runtime"
)
// newListRuntimesHandler returns the handler for
// `GET /api/v1/internal/runtimes`. The optional `status` query
// parameter narrows the result; an unknown value short-circuits with
// `400 invalid_request`. Records are returned ordered by
// `created_at DESC` (the underlying store guarantees the ordering).
func newListRuntimesHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.list_runtimes")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.RuntimeRecords == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "runtime records store is not wired")
return
}
ctx := request.Context()
raw := strings.TrimSpace(request.URL.Query().Get("status"))
if raw == "" {
records, err := deps.RuntimeRecords.List(ctx)
if err != nil {
logger.ErrorContext(ctx, "list runtime records failed", "err", err.Error())
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "failed to list runtime records")
return
}
writeJSON(writer, http.StatusOK, encodeRuntimeList(records))
return
}
status := runtime.Status(raw)
if !status.IsKnown() {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "status query parameter is unsupported")
return
}
records, err := deps.RuntimeRecords.ListByStatus(ctx, status)
if err != nil {
logger.ErrorContext(ctx, "list runtime records by status failed",
"status", string(status),
"err", err.Error(),
)
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "failed to list runtime records")
return
}
writeJSON(writer, http.StatusOK, encodeRuntimeList(records))
}
}
@@ -0,0 +1,598 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: galaxy/gamemaster/internal/api/internalhttp/handlers (interfaces: RegisterRuntimeService,ForceNextTurnService,StopRuntimeService,PatchRuntimeService,BanishRaceService,LivenessService,CommandExecuteService,OrderPutService,ReportGetService,MembershipInvalidator,EngineVersionService,RuntimeRecordsReader)
//
// Generated by this command:
//
// mockgen -destination=./mocks/mock_services.go -package=mocks galaxy/gamemaster/internal/api/internalhttp/handlers RegisterRuntimeService,ForceNextTurnService,StopRuntimeService,PatchRuntimeService,BanishRaceService,LivenessService,CommandExecuteService,OrderPutService,ReportGetService,MembershipInvalidator,EngineVersionService,RuntimeRecordsReader
//
// Package mocks is a generated GoMock package.
package mocks
import (
context "context"
engineversion "galaxy/gamemaster/internal/domain/engineversion"
runtime "galaxy/gamemaster/internal/domain/runtime"
adminbanish "galaxy/gamemaster/internal/service/adminbanish"
adminforce "galaxy/gamemaster/internal/service/adminforce"
adminpatch "galaxy/gamemaster/internal/service/adminpatch"
adminstop "galaxy/gamemaster/internal/service/adminstop"
commandexecute "galaxy/gamemaster/internal/service/commandexecute"
engineversion0 "galaxy/gamemaster/internal/service/engineversion"
livenessreply "galaxy/gamemaster/internal/service/livenessreply"
orderput "galaxy/gamemaster/internal/service/orderput"
registerruntime "galaxy/gamemaster/internal/service/registerruntime"
reportget "galaxy/gamemaster/internal/service/reportget"
reflect "reflect"
gomock "go.uber.org/mock/gomock"
)
// MockRegisterRuntimeService is a mock of RegisterRuntimeService interface.
type MockRegisterRuntimeService struct {
ctrl *gomock.Controller
recorder *MockRegisterRuntimeServiceMockRecorder
isgomock struct{}
}
// MockRegisterRuntimeServiceMockRecorder is the mock recorder for MockRegisterRuntimeService.
type MockRegisterRuntimeServiceMockRecorder struct {
mock *MockRegisterRuntimeService
}
// NewMockRegisterRuntimeService creates a new mock instance.
func NewMockRegisterRuntimeService(ctrl *gomock.Controller) *MockRegisterRuntimeService {
mock := &MockRegisterRuntimeService{ctrl: ctrl}
mock.recorder = &MockRegisterRuntimeServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockRegisterRuntimeService) EXPECT() *MockRegisterRuntimeServiceMockRecorder {
return m.recorder
}
// Handle mocks base method.
func (m *MockRegisterRuntimeService) Handle(ctx context.Context, in registerruntime.Input) (registerruntime.Result, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Handle", ctx, in)
ret0, _ := ret[0].(registerruntime.Result)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Handle indicates an expected call of Handle.
func (mr *MockRegisterRuntimeServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockRegisterRuntimeService)(nil).Handle), ctx, in)
}
// MockForceNextTurnService is a mock of ForceNextTurnService interface.
type MockForceNextTurnService struct {
ctrl *gomock.Controller
recorder *MockForceNextTurnServiceMockRecorder
isgomock struct{}
}
// MockForceNextTurnServiceMockRecorder is the mock recorder for MockForceNextTurnService.
type MockForceNextTurnServiceMockRecorder struct {
mock *MockForceNextTurnService
}
// NewMockForceNextTurnService creates a new mock instance.
func NewMockForceNextTurnService(ctrl *gomock.Controller) *MockForceNextTurnService {
mock := &MockForceNextTurnService{ctrl: ctrl}
mock.recorder = &MockForceNextTurnServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockForceNextTurnService) EXPECT() *MockForceNextTurnServiceMockRecorder {
return m.recorder
}
// Handle mocks base method.
func (m *MockForceNextTurnService) Handle(ctx context.Context, in adminforce.Input) (adminforce.Result, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Handle", ctx, in)
ret0, _ := ret[0].(adminforce.Result)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Handle indicates an expected call of Handle.
func (mr *MockForceNextTurnServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockForceNextTurnService)(nil).Handle), ctx, in)
}
// MockStopRuntimeService is a mock of StopRuntimeService interface.
type MockStopRuntimeService struct {
ctrl *gomock.Controller
recorder *MockStopRuntimeServiceMockRecorder
isgomock struct{}
}
// MockStopRuntimeServiceMockRecorder is the mock recorder for MockStopRuntimeService.
type MockStopRuntimeServiceMockRecorder struct {
mock *MockStopRuntimeService
}
// NewMockStopRuntimeService creates a new mock instance.
func NewMockStopRuntimeService(ctrl *gomock.Controller) *MockStopRuntimeService {
mock := &MockStopRuntimeService{ctrl: ctrl}
mock.recorder = &MockStopRuntimeServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockStopRuntimeService) EXPECT() *MockStopRuntimeServiceMockRecorder {
return m.recorder
}
// Handle mocks base method.
func (m *MockStopRuntimeService) Handle(ctx context.Context, in adminstop.Input) (adminstop.Result, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Handle", ctx, in)
ret0, _ := ret[0].(adminstop.Result)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Handle indicates an expected call of Handle.
func (mr *MockStopRuntimeServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockStopRuntimeService)(nil).Handle), ctx, in)
}
// MockPatchRuntimeService is a mock of PatchRuntimeService interface.
type MockPatchRuntimeService struct {
ctrl *gomock.Controller
recorder *MockPatchRuntimeServiceMockRecorder
isgomock struct{}
}
// MockPatchRuntimeServiceMockRecorder is the mock recorder for MockPatchRuntimeService.
type MockPatchRuntimeServiceMockRecorder struct {
mock *MockPatchRuntimeService
}
// NewMockPatchRuntimeService creates a new mock instance.
func NewMockPatchRuntimeService(ctrl *gomock.Controller) *MockPatchRuntimeService {
mock := &MockPatchRuntimeService{ctrl: ctrl}
mock.recorder = &MockPatchRuntimeServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockPatchRuntimeService) EXPECT() *MockPatchRuntimeServiceMockRecorder {
return m.recorder
}
// Handle mocks base method.
func (m *MockPatchRuntimeService) Handle(ctx context.Context, in adminpatch.Input) (adminpatch.Result, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Handle", ctx, in)
ret0, _ := ret[0].(adminpatch.Result)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Handle indicates an expected call of Handle.
func (mr *MockPatchRuntimeServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockPatchRuntimeService)(nil).Handle), ctx, in)
}
// MockBanishRaceService is a mock of BanishRaceService interface.
type MockBanishRaceService struct {
ctrl *gomock.Controller
recorder *MockBanishRaceServiceMockRecorder
isgomock struct{}
}
// MockBanishRaceServiceMockRecorder is the mock recorder for MockBanishRaceService.
type MockBanishRaceServiceMockRecorder struct {
mock *MockBanishRaceService
}
// NewMockBanishRaceService creates a new mock instance.
func NewMockBanishRaceService(ctrl *gomock.Controller) *MockBanishRaceService {
mock := &MockBanishRaceService{ctrl: ctrl}
mock.recorder = &MockBanishRaceServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockBanishRaceService) EXPECT() *MockBanishRaceServiceMockRecorder {
return m.recorder
}
// Handle mocks base method.
func (m *MockBanishRaceService) Handle(ctx context.Context, in adminbanish.Input) (adminbanish.Result, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Handle", ctx, in)
ret0, _ := ret[0].(adminbanish.Result)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Handle indicates an expected call of Handle.
func (mr *MockBanishRaceServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockBanishRaceService)(nil).Handle), ctx, in)
}
// MockLivenessService is a mock of LivenessService interface.
type MockLivenessService struct {
ctrl *gomock.Controller
recorder *MockLivenessServiceMockRecorder
isgomock struct{}
}
// MockLivenessServiceMockRecorder is the mock recorder for MockLivenessService.
type MockLivenessServiceMockRecorder struct {
mock *MockLivenessService
}
// NewMockLivenessService creates a new mock instance.
func NewMockLivenessService(ctrl *gomock.Controller) *MockLivenessService {
mock := &MockLivenessService{ctrl: ctrl}
mock.recorder = &MockLivenessServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockLivenessService) EXPECT() *MockLivenessServiceMockRecorder {
return m.recorder
}
// Handle mocks base method.
func (m *MockLivenessService) Handle(ctx context.Context, in livenessreply.Input) (livenessreply.Result, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Handle", ctx, in)
ret0, _ := ret[0].(livenessreply.Result)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Handle indicates an expected call of Handle.
func (mr *MockLivenessServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockLivenessService)(nil).Handle), ctx, in)
}
// MockCommandExecuteService is a mock of CommandExecuteService interface.
type MockCommandExecuteService struct {
ctrl *gomock.Controller
recorder *MockCommandExecuteServiceMockRecorder
isgomock struct{}
}
// MockCommandExecuteServiceMockRecorder is the mock recorder for MockCommandExecuteService.
type MockCommandExecuteServiceMockRecorder struct {
mock *MockCommandExecuteService
}
// NewMockCommandExecuteService creates a new mock instance.
func NewMockCommandExecuteService(ctrl *gomock.Controller) *MockCommandExecuteService {
mock := &MockCommandExecuteService{ctrl: ctrl}
mock.recorder = &MockCommandExecuteServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockCommandExecuteService) EXPECT() *MockCommandExecuteServiceMockRecorder {
return m.recorder
}
// Handle mocks base method.
func (m *MockCommandExecuteService) Handle(ctx context.Context, in commandexecute.Input) (commandexecute.Result, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Handle", ctx, in)
ret0, _ := ret[0].(commandexecute.Result)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Handle indicates an expected call of Handle.
func (mr *MockCommandExecuteServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockCommandExecuteService)(nil).Handle), ctx, in)
}
// MockOrderPutService is a mock of OrderPutService interface.
type MockOrderPutService struct {
ctrl *gomock.Controller
recorder *MockOrderPutServiceMockRecorder
isgomock struct{}
}
// MockOrderPutServiceMockRecorder is the mock recorder for MockOrderPutService.
type MockOrderPutServiceMockRecorder struct {
mock *MockOrderPutService
}
// NewMockOrderPutService creates a new mock instance.
func NewMockOrderPutService(ctrl *gomock.Controller) *MockOrderPutService {
mock := &MockOrderPutService{ctrl: ctrl}
mock.recorder = &MockOrderPutServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockOrderPutService) EXPECT() *MockOrderPutServiceMockRecorder {
return m.recorder
}
// Handle mocks base method.
func (m *MockOrderPutService) Handle(ctx context.Context, in orderput.Input) (orderput.Result, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Handle", ctx, in)
ret0, _ := ret[0].(orderput.Result)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Handle indicates an expected call of Handle.
func (mr *MockOrderPutServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockOrderPutService)(nil).Handle), ctx, in)
}
// MockReportGetService is a mock of ReportGetService interface.
type MockReportGetService struct {
ctrl *gomock.Controller
recorder *MockReportGetServiceMockRecorder
isgomock struct{}
}
// MockReportGetServiceMockRecorder is the mock recorder for MockReportGetService.
type MockReportGetServiceMockRecorder struct {
mock *MockReportGetService
}
// NewMockReportGetService creates a new mock instance.
func NewMockReportGetService(ctrl *gomock.Controller) *MockReportGetService {
mock := &MockReportGetService{ctrl: ctrl}
mock.recorder = &MockReportGetServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockReportGetService) EXPECT() *MockReportGetServiceMockRecorder {
return m.recorder
}
// Handle mocks base method.
func (m *MockReportGetService) Handle(ctx context.Context, in reportget.Input) (reportget.Result, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Handle", ctx, in)
ret0, _ := ret[0].(reportget.Result)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Handle indicates an expected call of Handle.
func (mr *MockReportGetServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockReportGetService)(nil).Handle), ctx, in)
}
// MockMembershipInvalidator is a mock of MembershipInvalidator interface.
type MockMembershipInvalidator struct {
ctrl *gomock.Controller
recorder *MockMembershipInvalidatorMockRecorder
isgomock struct{}
}
// MockMembershipInvalidatorMockRecorder is the mock recorder for MockMembershipInvalidator.
type MockMembershipInvalidatorMockRecorder struct {
mock *MockMembershipInvalidator
}
// NewMockMembershipInvalidator creates a new mock instance.
func NewMockMembershipInvalidator(ctrl *gomock.Controller) *MockMembershipInvalidator {
mock := &MockMembershipInvalidator{ctrl: ctrl}
mock.recorder = &MockMembershipInvalidatorMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockMembershipInvalidator) EXPECT() *MockMembershipInvalidatorMockRecorder {
return m.recorder
}
// Invalidate mocks base method.
func (m *MockMembershipInvalidator) Invalidate(gameID string) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "Invalidate", gameID)
}
// Invalidate indicates an expected call of Invalidate.
func (mr *MockMembershipInvalidatorMockRecorder) Invalidate(gameID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Invalidate", reflect.TypeOf((*MockMembershipInvalidator)(nil).Invalidate), gameID)
}
// MockEngineVersionService is a mock of EngineVersionService interface.
type MockEngineVersionService struct {
ctrl *gomock.Controller
recorder *MockEngineVersionServiceMockRecorder
isgomock struct{}
}
// MockEngineVersionServiceMockRecorder is the mock recorder for MockEngineVersionService.
type MockEngineVersionServiceMockRecorder struct {
mock *MockEngineVersionService
}
// NewMockEngineVersionService creates a new mock instance.
func NewMockEngineVersionService(ctrl *gomock.Controller) *MockEngineVersionService {
mock := &MockEngineVersionService{ctrl: ctrl}
mock.recorder = &MockEngineVersionServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockEngineVersionService) EXPECT() *MockEngineVersionServiceMockRecorder {
return m.recorder
}
// Create mocks base method.
func (m *MockEngineVersionService) Create(ctx context.Context, in engineversion0.CreateInput) (engineversion.EngineVersion, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Create", ctx, in)
ret0, _ := ret[0].(engineversion.EngineVersion)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Create indicates an expected call of Create.
func (mr *MockEngineVersionServiceMockRecorder) Create(ctx, in any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockEngineVersionService)(nil).Create), ctx, in)
}
// Deprecate mocks base method.
func (m *MockEngineVersionService) Deprecate(ctx context.Context, in engineversion0.DeprecateInput) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Deprecate", ctx, in)
ret0, _ := ret[0].(error)
return ret0
}
// Deprecate indicates an expected call of Deprecate.
func (mr *MockEngineVersionServiceMockRecorder) Deprecate(ctx, in any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Deprecate", reflect.TypeOf((*MockEngineVersionService)(nil).Deprecate), ctx, in)
}
// Get mocks base method.
func (m *MockEngineVersionService) Get(ctx context.Context, version string) (engineversion.EngineVersion, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Get", ctx, version)
ret0, _ := ret[0].(engineversion.EngineVersion)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Get indicates an expected call of Get.
func (mr *MockEngineVersionServiceMockRecorder) Get(ctx, version any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockEngineVersionService)(nil).Get), ctx, version)
}
// List mocks base method.
func (m *MockEngineVersionService) List(ctx context.Context, statusFilter *engineversion.Status) ([]engineversion.EngineVersion, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "List", ctx, statusFilter)
ret0, _ := ret[0].([]engineversion.EngineVersion)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// List indicates an expected call of List.
func (mr *MockEngineVersionServiceMockRecorder) List(ctx, statusFilter any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockEngineVersionService)(nil).List), ctx, statusFilter)
}
// ResolveImageRef mocks base method.
func (m *MockEngineVersionService) ResolveImageRef(ctx context.Context, version string) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ResolveImageRef", ctx, version)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ResolveImageRef indicates an expected call of ResolveImageRef.
func (mr *MockEngineVersionServiceMockRecorder) ResolveImageRef(ctx, version any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveImageRef", reflect.TypeOf((*MockEngineVersionService)(nil).ResolveImageRef), ctx, version)
}
// Update mocks base method.
func (m *MockEngineVersionService) Update(ctx context.Context, in engineversion0.UpdateInput) (engineversion.EngineVersion, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Update", ctx, in)
ret0, _ := ret[0].(engineversion.EngineVersion)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Update indicates an expected call of Update.
func (mr *MockEngineVersionServiceMockRecorder) Update(ctx, in any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockEngineVersionService)(nil).Update), ctx, in)
}
// MockRuntimeRecordsReader is a mock of RuntimeRecordsReader interface.
type MockRuntimeRecordsReader struct {
ctrl *gomock.Controller
recorder *MockRuntimeRecordsReaderMockRecorder
isgomock struct{}
}
// MockRuntimeRecordsReaderMockRecorder is the mock recorder for MockRuntimeRecordsReader.
type MockRuntimeRecordsReaderMockRecorder struct {
mock *MockRuntimeRecordsReader
}
// NewMockRuntimeRecordsReader creates a new mock instance.
func NewMockRuntimeRecordsReader(ctrl *gomock.Controller) *MockRuntimeRecordsReader {
mock := &MockRuntimeRecordsReader{ctrl: ctrl}
mock.recorder = &MockRuntimeRecordsReaderMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockRuntimeRecordsReader) EXPECT() *MockRuntimeRecordsReaderMockRecorder {
return m.recorder
}
// Get mocks base method.
func (m *MockRuntimeRecordsReader) Get(ctx context.Context, gameID string) (runtime.RuntimeRecord, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Get", ctx, gameID)
ret0, _ := ret[0].(runtime.RuntimeRecord)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Get indicates an expected call of Get.
func (mr *MockRuntimeRecordsReaderMockRecorder) Get(ctx, gameID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockRuntimeRecordsReader)(nil).Get), ctx, gameID)
}
// List mocks base method.
func (m *MockRuntimeRecordsReader) List(ctx context.Context) ([]runtime.RuntimeRecord, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "List", ctx)
ret0, _ := ret[0].([]runtime.RuntimeRecord)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// List indicates an expected call of List.
func (mr *MockRuntimeRecordsReaderMockRecorder) List(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockRuntimeRecordsReader)(nil).List), ctx)
}
// ListByStatus mocks base method.
func (m *MockRuntimeRecordsReader) ListByStatus(ctx context.Context, status runtime.Status) ([]runtime.RuntimeRecord, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListByStatus", ctx, status)
ret0, _ := ret[0].([]runtime.RuntimeRecord)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListByStatus indicates an expected call of ListByStatus.
func (mr *MockRuntimeRecordsReaderMockRecorder) ListByStatus(ctx, status any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByStatus", reflect.TypeOf((*MockRuntimeRecordsReader)(nil).ListByStatus), ctx, status)
}
@@ -0,0 +1,59 @@
package handlers
import (
"net/http"
"galaxy/gamemaster/internal/domain/operation"
"galaxy/gamemaster/internal/service/adminpatch"
)
// patchRuntimeRequestBody mirrors the OpenAPI PatchRuntimeRequest
// schema.
type patchRuntimeRequestBody struct {
Version string `json:"version"`
}
// newPatchRuntimeHandler returns the handler for
// `POST /api/v1/internal/runtimes/{game_id}/patch`.
func newPatchRuntimeHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.patch_runtime")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.PatchRuntime == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "patch runtime service is not wired")
return
}
gameID, ok := extractGameID(writer, request)
if !ok {
return
}
var body patchRuntimeRequestBody
if err := decodeStrictJSON(request.Body, &body); err != nil {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
return
}
result, err := deps.PatchRuntime.Handle(request.Context(), adminpatch.Input{
GameID: gameID,
Version: body.Version,
OpSource: resolveOpSource(request),
SourceRef: requestSourceRef(request),
})
if err != nil {
logger.ErrorContext(request.Context(), "patch runtime service errored",
"game_id", gameID,
"err", err.Error(),
)
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "patch runtime service failed")
return
}
if result.Outcome == operation.OutcomeFailure {
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
return
}
writeJSON(writer, http.StatusOK, encodeRuntimeRecord(result.Record))
}
}
@@ -0,0 +1,58 @@
package handlers
import (
"net/http"
"galaxy/gamemaster/internal/domain/operation"
"galaxy/gamemaster/internal/service/orderput"
)
// newPutOrdersHandler returns the handler for
// `POST /api/v1/internal/games/{game_id}/orders`. The shape and
// semantics mirror executeCommands: engine-owned body, raw JSON
// pass-through on success, error envelope on failure.
func newPutOrdersHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.put_orders")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.PutOrders == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "put orders service is not wired")
return
}
gameID, ok := extractGameID(writer, request)
if !ok {
return
}
userID, ok := extractUserID(writer, request)
if !ok {
return
}
body, err := readRawJSONBody(request.Body)
if err != nil {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
return
}
result, err := deps.PutOrders.Handle(request.Context(), orderput.Input{
GameID: gameID,
UserID: userID,
Payload: body,
})
if err != nil {
logger.ErrorContext(request.Context(), "put orders service errored",
"game_id", gameID,
"user_id", userID,
"err", err.Error(),
)
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "put orders service failed")
return
}
if result.Outcome == operation.OutcomeFailure {
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
return
}
writeRawJSON(writer, http.StatusOK, []byte(result.RawResponse))
}
}
@@ -0,0 +1,81 @@
package handlers
import (
"net/http"
"galaxy/gamemaster/internal/domain/operation"
"galaxy/gamemaster/internal/service/registerruntime"
)
// registerRuntimeRequestBody mirrors the OpenAPI
// RegisterRuntimeRequest schema. Strict decoding rejects unknown
// fields.
type registerRuntimeRequestBody struct {
EngineEndpoint string `json:"engine_endpoint"`
Members []registerRuntimeMemberBody `json:"members"`
TargetEngineVersion string `json:"target_engine_version"`
TurnSchedule string `json:"turn_schedule"`
}
// registerRuntimeMemberBody mirrors the OpenAPI
// RegisterRuntimeMember schema.
type registerRuntimeMemberBody struct {
UserID string `json:"user_id"`
RaceName string `json:"race_name"`
}
// newRegisterRuntimeHandler returns the handler for
// `POST /api/v1/internal/games/{game_id}/register-runtime`.
func newRegisterRuntimeHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.register_runtime")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.RegisterRuntime == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "register runtime service is not wired")
return
}
gameID, ok := extractGameID(writer, request)
if !ok {
return
}
var body registerRuntimeRequestBody
if err := decodeStrictJSON(request.Body, &body); err != nil {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
return
}
members := make([]registerruntime.Member, 0, len(body.Members))
for _, member := range body.Members {
members = append(members, registerruntime.Member{
UserID: member.UserID,
RaceName: member.RaceName,
})
}
result, err := deps.RegisterRuntime.Handle(request.Context(), registerruntime.Input{
GameID: gameID,
EngineEndpoint: body.EngineEndpoint,
Members: members,
TargetEngineVersion: body.TargetEngineVersion,
TurnSchedule: body.TurnSchedule,
OpSource: resolveOpSource(request),
SourceRef: requestSourceRef(request),
})
if err != nil {
logger.ErrorContext(request.Context(), "register runtime service errored",
"game_id", gameID,
"err", err.Error(),
)
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "register runtime service failed")
return
}
if result.Outcome == operation.OutcomeFailure {
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
return
}
writeJSON(writer, http.StatusOK, encodeRuntimeRecord(result.Record))
}
}
@@ -0,0 +1,35 @@
package handlers
import "net/http"
// newResolveEngineVersionImageRefHandler returns the handler for
// `GET /api/v1/internal/engine-versions/{version}/image-ref`. It is
// the hot-path Lobby calls before publishing a `runtime:start_jobs`
// envelope; the response carries only the image reference.
func newResolveEngineVersionImageRefHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.resolve_image_ref")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.EngineVersions == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "engine version service is not wired")
return
}
version, ok := extractVersion(writer, request)
if !ok {
return
}
imageRef, err := deps.EngineVersions.ResolveImageRef(request.Context(), version)
if err != nil {
logger.ErrorContext(request.Context(), "resolve image ref failed",
"version", version,
"err", err.Error(),
)
status, code, message := mapServiceError(err)
writeError(writer, status, code, message)
return
}
writeJSON(writer, http.StatusOK, imageRefResponse{ImageRef: imageRef})
}
}
@@ -0,0 +1,98 @@
package handlers
import (
"context"
"galaxy/gamemaster/internal/domain/engineversion"
"galaxy/gamemaster/internal/domain/runtime"
"galaxy/gamemaster/internal/service/adminbanish"
"galaxy/gamemaster/internal/service/adminforce"
"galaxy/gamemaster/internal/service/adminpatch"
"galaxy/gamemaster/internal/service/adminstop"
"galaxy/gamemaster/internal/service/commandexecute"
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
"galaxy/gamemaster/internal/service/livenessreply"
"galaxy/gamemaster/internal/service/orderput"
"galaxy/gamemaster/internal/service/registerruntime"
"galaxy/gamemaster/internal/service/reportget"
)
//go:generate go run go.uber.org/mock/mockgen -destination=./mocks/mock_services.go -package=mocks galaxy/gamemaster/internal/api/internalhttp/handlers RegisterRuntimeService,ForceNextTurnService,StopRuntimeService,PatchRuntimeService,BanishRaceService,LivenessService,CommandExecuteService,OrderPutService,ReportGetService,MembershipInvalidator,EngineVersionService,RuntimeRecordsReader
// RegisterRuntimeService wires the `internalRegisterRuntime` handler
// to the underlying register-runtime orchestrator.
type RegisterRuntimeService interface {
Handle(ctx context.Context, in registerruntime.Input) (registerruntime.Result, error)
}
// ForceNextTurnService wires the `internalForceNextTurn` handler.
type ForceNextTurnService interface {
Handle(ctx context.Context, in adminforce.Input) (adminforce.Result, error)
}
// StopRuntimeService wires the `internalStopRuntime` handler.
type StopRuntimeService interface {
Handle(ctx context.Context, in adminstop.Input) (adminstop.Result, error)
}
// PatchRuntimeService wires the `internalPatchRuntime` handler.
type PatchRuntimeService interface {
Handle(ctx context.Context, in adminpatch.Input) (adminpatch.Result, error)
}
// BanishRaceService wires the `internalBanishRace` handler.
type BanishRaceService interface {
Handle(ctx context.Context, in adminbanish.Input) (adminbanish.Result, error)
}
// LivenessService wires the `internalGameLiveness` handler.
type LivenessService interface {
Handle(ctx context.Context, in livenessreply.Input) (livenessreply.Result, error)
}
// CommandExecuteService wires the `internalExecuteCommands` handler.
type CommandExecuteService interface {
Handle(ctx context.Context, in commandexecute.Input) (commandexecute.Result, error)
}
// OrderPutService wires the `internalPutOrders` handler.
type OrderPutService interface {
Handle(ctx context.Context, in orderput.Input) (orderput.Result, error)
}
// ReportGetService wires the `internalGetReport` handler.
type ReportGetService interface {
Handle(ctx context.Context, in reportget.Input) (reportget.Result, error)
}
// MembershipInvalidator wires the `internalInvalidateMemberships`
// handler. Backed by `service/membership.Cache.Invalidate`.
type MembershipInvalidator interface {
// Invalidate purges the in-process membership cache entry for
// gameID. The call is fire-and-forget and never returns an error;
// missing entries are a no-op.
Invalidate(gameID string)
}
// EngineVersionService wires every engine-version registry handler. The
// service exposes one Go-error-returning method per OpenAPI operation;
// the handler layer translates the wrapped sentinel errors into
// `engine_version_*` codes via `mapServiceError`.
type EngineVersionService interface {
List(ctx context.Context, statusFilter *engineversion.Status) ([]engineversion.EngineVersion, error)
Get(ctx context.Context, version string) (engineversion.EngineVersion, error)
ResolveImageRef(ctx context.Context, version string) (string, error)
Create(ctx context.Context, in engineversionsvc.CreateInput) (engineversion.EngineVersion, error)
Update(ctx context.Context, in engineversionsvc.UpdateInput) (engineversion.EngineVersion, error)
Deprecate(ctx context.Context, in engineversionsvc.DeprecateInput) error
}
// RuntimeRecordsReader exposes the read-only subset of
// `ports.RuntimeRecordStore` required by the get/list runtime
// handlers. The narrower surface keeps the handler layer from
// inadvertently mutating runtime state.
type RuntimeRecordsReader interface {
Get(ctx context.Context, gameID string) (runtime.RuntimeRecord, error)
List(ctx context.Context) ([]runtime.RuntimeRecord, error)
ListByStatus(ctx context.Context, status runtime.Status) ([]runtime.RuntimeRecord, error)
}
@@ -0,0 +1,59 @@
package handlers
import (
"net/http"
"galaxy/gamemaster/internal/domain/operation"
"galaxy/gamemaster/internal/service/adminstop"
)
// stopRuntimeRequestBody mirrors the OpenAPI StopRuntimeRequest
// schema.
type stopRuntimeRequestBody struct {
Reason string `json:"reason"`
}
// newStopRuntimeHandler returns the handler for
// `POST /api/v1/internal/runtimes/{game_id}/stop`.
func newStopRuntimeHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.stop_runtime")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.StopRuntime == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "stop runtime service is not wired")
return
}
gameID, ok := extractGameID(writer, request)
if !ok {
return
}
var body stopRuntimeRequestBody
if err := decodeStrictJSON(request.Body, &body); err != nil {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
return
}
result, err := deps.StopRuntime.Handle(request.Context(), adminstop.Input{
GameID: gameID,
Reason: body.Reason,
OpSource: resolveOpSource(request),
SourceRef: requestSourceRef(request),
})
if err != nil {
logger.ErrorContext(request.Context(), "stop runtime service errored",
"game_id", gameID,
"err", err.Error(),
)
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "stop runtime service failed")
return
}
if result.Outcome == operation.OutcomeFailure {
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
return
}
writeJSON(writer, http.StatusOK, encodeRuntimeRecord(result.Record))
}
}
@@ -0,0 +1,69 @@
package handlers
import (
"encoding/json"
"net/http"
"galaxy/gamemaster/internal/domain/engineversion"
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
)
// updateEngineVersionRequestBody mirrors the OpenAPI
// UpdateEngineVersionRequest schema. Every field is optional; the
// service rejects calls with no fields set as `invalid_request`.
type updateEngineVersionRequestBody struct {
ImageRef *string `json:"image_ref,omitempty"`
Options *json.RawMessage `json:"options,omitempty"`
Status *string `json:"status,omitempty"`
}
// newUpdateEngineVersionHandler returns the handler for
// `PATCH /api/v1/internal/engine-versions/{version}`.
func newUpdateEngineVersionHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.update_engine_version")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.EngineVersions == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "engine version service is not wired")
return
}
version, ok := extractVersion(writer, request)
if !ok {
return
}
var body updateEngineVersionRequestBody
if err := decodeStrictJSON(request.Body, &body); err != nil {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
return
}
input := engineversionsvc.UpdateInput{
Version: version,
ImageRef: body.ImageRef,
OpSource: resolveOpSource(request),
SourceRef: requestSourceRef(request),
}
if body.Options != nil {
optionBytes := []byte(*body.Options)
input.Options = &optionBytes
}
if body.Status != nil {
candidate := engineversion.Status(*body.Status)
input.Status = &candidate
}
record, err := deps.EngineVersions.Update(request.Context(), input)
if err != nil {
logger.ErrorContext(request.Context(), "update engine version failed",
"version", version,
"err", err.Error(),
)
status, code, message := mapServiceError(err)
writeError(writer, status, code, message)
return
}
writeJSON(writer, http.StatusOK, encodeEngineVersion(record))
}
}
@@ -0,0 +1,392 @@
// Package internalhttp provides the trusted internal HTTP listener
// used by the runnable Game Master process. It exposes the `/healthz`
// and `/readyz` probes plus every internal REST operation declared in
// `gamemaster/api/internal-openapi.yaml`. Per-operation handlers live
// in the nested `handlers` package; this file owns the listener
// lifecycle and the probe routes only.
package internalhttp
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"strconv"
"sync"
"time"
"galaxy/gamemaster/internal/api/internalhttp/handlers"
"galaxy/gamemaster/internal/telemetry"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel/attribute"
)
const jsonContentType = "application/json; charset=utf-8"
// errorCodeServiceUnavailable mirrors the stable error code declared in
// `gamemaster/api/internal-openapi.yaml` §Error Model.
const errorCodeServiceUnavailable = "service_unavailable"
// HealthzPath and ReadyzPath are the internal probe routes documented in
// `gamemaster/api/internal-openapi.yaml`.
const (
HealthzPath = "/healthz"
ReadyzPath = "/readyz"
)
// ReadinessProbe reports whether the dependencies the listener guards
// (PostgreSQL, Redis) are reachable. A non-nil error is reported to the
// caller as `503 service_unavailable` with the wrapped message.
type ReadinessProbe interface {
Check(ctx context.Context) error
}
// Config describes the trusted internal HTTP listener owned by Game
// Master.
type Config struct {
// Addr is the TCP listen address used by the internal HTTP server.
Addr string
// ReadHeaderTimeout bounds how long the listener may spend reading
// request headers before the server rejects the connection.
ReadHeaderTimeout time.Duration
// ReadTimeout bounds how long the listener may spend reading one
// request.
ReadTimeout time.Duration
// WriteTimeout bounds how long the listener may spend writing one
// response.
WriteTimeout time.Duration
// IdleTimeout bounds how long the listener keeps an idle keep-alive
// connection open.
IdleTimeout time.Duration
}
// Validate reports whether cfg contains a usable internal HTTP listener
// configuration.
func (cfg Config) Validate() error {
switch {
case cfg.Addr == "":
return errors.New("internal HTTP addr must not be empty")
case cfg.ReadHeaderTimeout <= 0:
return errors.New("internal HTTP read header timeout must be positive")
case cfg.ReadTimeout <= 0:
return errors.New("internal HTTP read timeout must be positive")
case cfg.WriteTimeout <= 0:
return errors.New("internal HTTP write timeout must be positive")
case cfg.IdleTimeout <= 0:
return errors.New("internal HTTP idle timeout must be positive")
default:
return nil
}
}
// Dependencies describes the collaborators used by the internal HTTP
// transport layer. The probe-only fields (Logger, Telemetry,
// Readiness) drive `/healthz` and `/readyz`; the remaining fields
// pass through to the per-operation handlers registered by
// `handlers.Register`.
type Dependencies struct {
// Logger writes structured listener lifecycle logs. When nil,
// slog.Default is used.
Logger *slog.Logger
// Telemetry records low-cardinality probe metrics and lifecycle
// events.
Telemetry *telemetry.Runtime
// Readiness reports whether PG / Redis are reachable. A nil
// readiness probe makes `/readyz` always answer `200`; the runtime
// always supplies a real probe in production wiring.
Readiness ReadinessProbe
// RuntimeRecords backs the read-only list/get runtime endpoints.
RuntimeRecords handlers.RuntimeRecordsReader
// RegisterRuntime is the orchestrator for `internalRegisterRuntime`.
RegisterRuntime handlers.RegisterRuntimeService
// ForceNextTurn drives the synchronous force-next-turn flow.
ForceNextTurn handlers.ForceNextTurnService
// StopRuntime drives the admin stop flow.
StopRuntime handlers.StopRuntimeService
// PatchRuntime drives the admin patch flow.
PatchRuntime handlers.PatchRuntimeService
// BanishRace drives the engine race-banish flow.
BanishRace handlers.BanishRaceService
// InvalidateMemberships purges the in-process membership cache.
InvalidateMemberships handlers.MembershipInvalidator
// GameLiveness returns the current runtime status without
// contacting the engine.
GameLiveness handlers.LivenessService
// EngineVersions exposes the multi-method engine-version registry
// service.
EngineVersions handlers.EngineVersionService
// CommandExecute forwards a player command batch to the engine.
CommandExecute handlers.CommandExecuteService
// PutOrders forwards a player order batch to the engine.
PutOrders handlers.OrderPutService
// GetReport reads a per-player turn report from the engine.
GetReport handlers.ReportGetService
}
// Server owns the trusted internal HTTP listener exposed by Game Master.
type Server struct {
cfg Config
handler http.Handler
logger *slog.Logger
metrics *telemetry.Runtime
stateMu sync.RWMutex
server *http.Server
listener net.Listener
}
// NewServer constructs one trusted internal HTTP server for cfg and deps.
func NewServer(cfg Config, deps Dependencies) (*Server, error) {
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("new internal HTTP server: %w", err)
}
logger := deps.Logger
if logger == nil {
logger = slog.Default()
}
return &Server{
cfg: cfg,
handler: newHandler(deps, logger),
logger: logger.With("component", "internal_http"),
metrics: deps.Telemetry,
}, nil
}
// Addr returns the currently bound listener address after Run is called.
// It returns an empty string if the server has not yet bound a listener.
func (server *Server) Addr() string {
server.stateMu.RLock()
defer server.stateMu.RUnlock()
if server.listener == nil {
return ""
}
return server.listener.Addr().String()
}
// Run binds the configured listener and serves the internal HTTP surface
// until Shutdown closes the server.
func (server *Server) Run(ctx context.Context) error {
if ctx == nil {
return errors.New("run internal HTTP server: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
listener, err := net.Listen("tcp", server.cfg.Addr)
if err != nil {
return fmt.Errorf("run internal HTTP server: listen on %q: %w", server.cfg.Addr, err)
}
httpServer := &http.Server{
Handler: server.handler,
ReadHeaderTimeout: server.cfg.ReadHeaderTimeout,
ReadTimeout: server.cfg.ReadTimeout,
WriteTimeout: server.cfg.WriteTimeout,
IdleTimeout: server.cfg.IdleTimeout,
}
server.stateMu.Lock()
server.server = httpServer
server.listener = listener
server.stateMu.Unlock()
server.logger.Info("gamemaster internal HTTP server started", "addr", listener.Addr().String())
defer func() {
server.stateMu.Lock()
server.server = nil
server.listener = nil
server.stateMu.Unlock()
}()
err = httpServer.Serve(listener)
switch {
case err == nil:
return nil
case errors.Is(err, http.ErrServerClosed):
server.logger.Info("gamemaster internal HTTP server stopped")
return nil
default:
return fmt.Errorf("run internal HTTP server: serve on %q: %w", server.cfg.Addr, err)
}
}
// Shutdown gracefully stops the internal HTTP server within ctx.
func (server *Server) Shutdown(ctx context.Context) error {
if ctx == nil {
return errors.New("shutdown internal HTTP server: nil context")
}
server.stateMu.RLock()
httpServer := server.server
server.stateMu.RUnlock()
if httpServer == nil {
return nil
}
if err := httpServer.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("shutdown internal HTTP server: %w", err)
}
return nil
}
func newHandler(deps Dependencies, logger *slog.Logger) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET "+HealthzPath, handleHealthz)
mux.HandleFunc("GET "+ReadyzPath, handleReadyz(deps.Readiness, logger))
handlers.Register(mux, handlers.Dependencies{
Logger: logger,
RuntimeRecords: deps.RuntimeRecords,
RegisterRuntime: deps.RegisterRuntime,
ForceNextTurn: deps.ForceNextTurn,
StopRuntime: deps.StopRuntime,
PatchRuntime: deps.PatchRuntime,
BanishRace: deps.BanishRace,
InvalidateMemberships: deps.InvalidateMemberships,
GameLiveness: deps.GameLiveness,
EngineVersions: deps.EngineVersions,
CommandExecute: deps.CommandExecute,
PutOrders: deps.PutOrders,
GetReport: deps.GetReport,
})
metrics := deps.Telemetry
options := []otelhttp.Option{}
if metrics != nil {
options = append(options,
otelhttp.WithTracerProvider(metrics.TracerProvider()),
otelhttp.WithMeterProvider(metrics.MeterProvider()),
)
}
return otelhttp.NewHandler(withObservability(mux, metrics), "gamemaster.internal_http", options...)
}
func withObservability(next http.Handler, metrics *telemetry.Runtime) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
startedAt := time.Now()
recorder := &statusRecorder{
ResponseWriter: writer,
statusCode: http.StatusOK,
}
next.ServeHTTP(recorder, request)
route := request.Pattern
switch recorder.statusCode {
case http.StatusMethodNotAllowed:
route = "method_not_allowed"
case http.StatusNotFound:
route = "not_found"
case 0:
route = "unmatched"
}
if route == "" {
route = "unmatched"
}
if metrics != nil {
metrics.RecordInternalHTTPRequest(
request.Context(),
[]attribute.KeyValue{
attribute.String("route", route),
attribute.String("method", request.Method),
attribute.String("status_code", strconv.Itoa(recorder.statusCode)),
},
time.Since(startedAt),
)
}
})
}
func handleHealthz(writer http.ResponseWriter, _ *http.Request) {
writeStatusResponse(writer, http.StatusOK, "ok")
}
func handleReadyz(probe ReadinessProbe, logger *slog.Logger) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if probe == nil {
writeStatusResponse(writer, http.StatusOK, "ready")
return
}
if err := probe.Check(request.Context()); err != nil {
logger.WarnContext(request.Context(), "gamemaster readiness probe failed",
"err", err.Error(),
)
writeServiceUnavailable(writer, err.Error())
return
}
writeStatusResponse(writer, http.StatusOK, "ready")
}
}
func writeStatusResponse(writer http.ResponseWriter, statusCode int, status string) {
writer.Header().Set("Content-Type", jsonContentType)
writer.WriteHeader(statusCode)
_ = json.NewEncoder(writer).Encode(statusResponse{Status: status})
}
func writeServiceUnavailable(writer http.ResponseWriter, message string) {
writer.Header().Set("Content-Type", jsonContentType)
writer.WriteHeader(http.StatusServiceUnavailable)
_ = json.NewEncoder(writer).Encode(errorResponse{
Error: errorBody{
Code: errorCodeServiceUnavailable,
Message: message,
},
})
}
type statusResponse struct {
Status string `json:"status"`
}
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
type errorResponse struct {
Error errorBody `json:"error"`
}
type statusRecorder struct {
http.ResponseWriter
statusCode int
}
func (recorder *statusRecorder) WriteHeader(statusCode int) {
recorder.statusCode = statusCode
recorder.ResponseWriter.WriteHeader(statusCode)
}
@@ -0,0 +1,142 @@
package internalhttp
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func newTestConfig() Config {
return Config{
Addr: ":0",
ReadHeaderTimeout: time.Second,
ReadTimeout: time.Second,
WriteTimeout: time.Second,
IdleTimeout: time.Second,
}
}
type stubReadiness struct {
err error
}
func (probe stubReadiness) Check(_ context.Context) error {
return probe.err
}
func newTestServer(t *testing.T, deps Dependencies) http.Handler {
t.Helper()
server, err := NewServer(newTestConfig(), deps)
require.NoError(t, err)
return server.handler
}
func TestHealthzReturnsOK(t *testing.T) {
t.Parallel()
handler := newTestServer(t, Dependencies{})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, HealthzPath, nil)
handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, jsonContentType, rec.Header().Get("Content-Type"))
var body statusResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
require.Equal(t, "ok", body.Status)
}
func TestReadyzReturnsReadyWhenProbeIsNil(t *testing.T) {
t.Parallel()
handler := newTestServer(t, Dependencies{})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, ReadyzPath, nil)
handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var body statusResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
require.Equal(t, "ready", body.Status)
}
func TestReadyzReturnsReadyWhenProbeSucceeds(t *testing.T) {
t.Parallel()
handler := newTestServer(t, Dependencies{Readiness: stubReadiness{}})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, ReadyzPath, nil)
handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var body statusResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
require.Equal(t, "ready", body.Status)
}
func TestReadyzReturnsServiceUnavailableWhenProbeFails(t *testing.T) {
t.Parallel()
handler := newTestServer(t, Dependencies{
Readiness: stubReadiness{err: errors.New("postgres ping: connection refused")},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, ReadyzPath, nil)
handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusServiceUnavailable, rec.Code)
require.Equal(t, jsonContentType, rec.Header().Get("Content-Type"))
var body errorResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
require.Equal(t, errorCodeServiceUnavailable, body.Error.Code)
require.True(t, strings.Contains(body.Error.Message, "postgres"))
}
func TestNewServerRejectsInvalidConfig(t *testing.T) {
t.Parallel()
_, err := NewServer(Config{}, Dependencies{})
require.Error(t, err)
}
func TestRunBindsListenerAndShutsDown(t *testing.T) {
t.Parallel()
server, err := NewServer(newTestConfig(), Dependencies{})
require.NoError(t, err)
runErr := make(chan error, 1)
go func() {
runErr <- server.Run(t.Context())
}()
require.Eventually(t, func() bool {
return server.Addr() != ""
}, time.Second, 10*time.Millisecond, "listener should bind quickly")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), time.Second)
defer shutdownCancel()
require.NoError(t, server.Shutdown(shutdownCtx))
select {
case err := <-runErr:
require.NoError(t, err)
case <-time.After(time.Second):
t.Fatal("server did not return after shutdown")
}
}