423 lines
16 KiB
Go
423 lines
16 KiB
Go
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)
|
|
}
|