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