Files
galaxy-game/rtmanager/internal/api/internalhttp/handlers/common.go
T
2026-04-28 20:39:18 +02:00

239 lines
8.2 KiB
Go

package handlers
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"time"
"galaxy/rtmanager/internal/domain/operation"
"galaxy/rtmanager/internal/domain/runtime"
"galaxy/rtmanager/internal/service/startruntime"
)
// JSONContentType is the Content-Type used by every internal REST
// response. Exported so the listener-level tests can match it without
// re-declaring the constant.
const JSONContentType = "application/json; charset=utf-8"
// gameIDPathParam is the name of the {game_id} path variable shared by
// every per-game runtime endpoint.
const gameIDPathParam = "game_id"
// callerHeader is the HTTP header that distinguishes Game Master from
// Admin Service in the operation log. Documented in
// `rtmanager/api/internal-openapi.yaml` and
// `rtmanager/docs/services.md` §18.
const callerHeader = "X-Galaxy-Caller"
// errorCodeDockerUnavailable mirrors the OpenAPI error code value. The
// lifecycle services do not currently emit it (they use
// `service_unavailable` for Docker daemon failures); the handler layer
// maps it to 503 anyway so future producers do not require a handler
// change.
const errorCodeDockerUnavailable = "docker_unavailable"
// 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 fields use plain strings; nullable fields use pointers so an
// absent value encodes as the JSON literal `null` (matches the
// `nullable: true` declaration in the spec). Times are RFC3339 UTC.
type runtimeRecordResponse struct {
GameID string `json:"game_id"`
Status string `json:"status"`
CurrentContainerID *string `json:"current_container_id"`
CurrentImageRef *string `json:"current_image_ref"`
EngineEndpoint *string `json:"engine_endpoint"`
StatePath string `json:"state_path"`
DockerNetwork string `json:"docker_network"`
StartedAt *string `json:"started_at"`
StoppedAt *string `json:"stopped_at"`
RemovedAt *string `json:"removed_at"`
LastOpAt string `json:"last_op_at"`
CreatedAt string `json:"created_at"`
}
// runtimesListResponse mirrors the OpenAPI RuntimesList schema. Items
// is always non-nil so the JSON form carries `[]` rather than `null`
// for an empty result.
type runtimesListResponse struct {
Items []runtimeRecordResponse `json:"items"`
}
// encodeRuntimeRecord turns a domain RuntimeRecord into its wire shape.
func encodeRuntimeRecord(record runtime.RuntimeRecord) runtimeRecordResponse {
resp := runtimeRecordResponse{
GameID: record.GameID,
Status: string(record.Status),
StatePath: record.StatePath,
DockerNetwork: record.DockerNetwork,
LastOpAt: record.LastOpAt.UTC().Format(time.RFC3339Nano),
CreatedAt: record.CreatedAt.UTC().Format(time.RFC3339Nano),
}
if record.CurrentContainerID != "" {
v := record.CurrentContainerID
resp.CurrentContainerID = &v
}
if record.CurrentImageRef != "" {
v := record.CurrentImageRef
resp.CurrentImageRef = &v
}
if record.EngineEndpoint != "" {
v := record.EngineEndpoint
resp.EngineEndpoint = &v
}
if record.StartedAt != nil {
v := record.StartedAt.UTC().Format(time.RFC3339Nano)
resp.StartedAt = &v
}
if record.StoppedAt != nil {
v := record.StoppedAt.UTC().Format(time.RFC3339Nano)
resp.StoppedAt = &v
}
if record.RemovedAt != nil {
v := record.RemovedAt.UTC().Format(time.RFC3339Nano)
resp.RemovedAt = &v
}
return resp
}
// encodeRuntimesList builds the wire shape returned by the list handler.
// records may be nil (empty store); the result still carries an empty
// items slice so the JSON form is `{"items":[]}`.
func encodeRuntimesList(records []runtime.RuntimeRecord) runtimesListResponse {
resp := runtimesListResponse{
Items: make([]runtimeRecordResponse, 0, len(records)),
}
for _, record := range records {
resp.Items = append(resp.Items, encodeRuntimeRecord(record))
}
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)
}
// 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. Used by every lifecycle 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 `rtmanager/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 startruntime.ErrorCodeInvalidRequest,
startruntime.ErrorCodeStartConfigInvalid,
startruntime.ErrorCodeImageRefNotSemver:
return http.StatusBadRequest
case startruntime.ErrorCodeNotFound:
return http.StatusNotFound
case startruntime.ErrorCodeConflict,
startruntime.ErrorCodeSemverPatchOnly:
return http.StatusConflict
case startruntime.ErrorCodeServiceUnavailable,
errorCodeDockerUnavailable:
return http.StatusServiceUnavailable
case startruntime.ErrorCodeImagePullFailed,
startruntime.ErrorCodeContainerStartFailed,
startruntime.ErrorCodeInternal:
return http.StatusInternalServerError
default:
return http.StatusInternalServerError
}
}
// 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's internal HTTP layer.
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
}
// 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,
startruntime.ErrorCodeInvalidRequest,
"game id is required",
)
return "", false
}
return raw, true
}
// resolveOpSource maps the X-Galaxy-Caller header to an
// `operation.OpSource`. Missing or unknown values default to
// `OpSourceAdminRest`, matching the contract documented in
// `rtmanager/api/internal-openapi.yaml`.
func resolveOpSource(request *http.Request) operation.OpSource {
switch strings.ToLower(strings.TrimSpace(request.Header.Get(callerHeader))) {
case "gm":
return operation.OpSourceGMRest
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; the
// listener does not currently install a request-id middleware so the
// header path is the only source.
func requestSourceRef(request *http.Request) string {
if v := strings.TrimSpace(request.Header.Get("X-Request-ID")); v != "" {
return v
}
return ""
}
// loggerFor returns a logger annotated with the operation tag. Each
// handler scopes its logs by op so operators filtering on
// `op=internal_rest.start` 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)
}