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.` 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) }