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,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))
}
}