feat: gamemaster
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user