feat: game lobby service

This commit is contained in:
Ilia Denisov
2026-04-25 23:20:55 +02:00
committed by GitHub
parent 32dc29359a
commit 48b0056b49
336 changed files with 57074 additions and 1418 deletions
@@ -0,0 +1,164 @@
package internalhttp
import (
"log/slog"
"net/http"
"strings"
"galaxy/lobby/internal/domain/application"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/membership"
"galaxy/lobby/internal/service/approveapplication"
"galaxy/lobby/internal/service/rejectapplication"
"galaxy/lobby/internal/service/shared"
)
// Internal HTTP route patterns for the admin application
// surface. Submit is intentionally not exposed on the internal port —
// applicants are authenticated users, never Admin Service.
const (
approveApplicationPath = "/api/v1/lobby/games/{game_id}/applications/{application_id}/approve"
rejectApplicationPath = "/api/v1/lobby/games/{game_id}/applications/{application_id}/reject"
applicationIDPathParamValue = "application_id"
)
// applicationRecordResponse mirrors the OpenAPI ApplicationRecord schema
// on the internal port.
type applicationRecordResponse struct {
ApplicationID string `json:"application_id"`
GameID string `json:"game_id"`
ApplicantUserID string `json:"applicant_user_id"`
RaceName string `json:"race_name"`
Status string `json:"status"`
CreatedAt int64 `json:"created_at"`
DecidedAt *int64 `json:"decided_at,omitempty"`
}
func encodeApplicationRecord(record application.Application) applicationRecordResponse {
resp := applicationRecordResponse{
ApplicationID: record.ApplicationID.String(),
GameID: record.GameID.String(),
ApplicantUserID: record.ApplicantUserID,
RaceName: record.RaceName,
Status: string(record.Status),
CreatedAt: record.CreatedAt.UTC().UnixMilli(),
}
if record.DecidedAt != nil {
decided := record.DecidedAt.UTC().UnixMilli()
resp.DecidedAt = &decided
}
return resp
}
// membershipRecordResponse mirrors the OpenAPI MembershipRecord schema.
// canonical_key is intentionally omitted from the wire shape.
type membershipRecordResponse struct {
MembershipID string `json:"membership_id"`
GameID string `json:"game_id"`
UserID string `json:"user_id"`
RaceName string `json:"race_name"`
Status string `json:"status"`
JoinedAt int64 `json:"joined_at"`
RemovedAt *int64 `json:"removed_at,omitempty"`
}
func encodeMembershipRecord(record membership.Membership) membershipRecordResponse {
resp := membershipRecordResponse{
MembershipID: record.MembershipID.String(),
GameID: record.GameID.String(),
UserID: record.UserID,
RaceName: record.RaceName,
Status: string(record.Status),
JoinedAt: record.JoinedAt.UTC().UnixMilli(),
}
if record.RemovedAt != nil {
removed := record.RemovedAt.UTC().UnixMilli()
resp.RemovedAt = &removed
}
return resp
}
func registerApplicationRoutes(mux *http.ServeMux, deps Dependencies, logger *slog.Logger) {
h := &applicationHandlers{
deps: deps,
logger: logger.With("component", "internal_http.applications"),
}
mux.HandleFunc("POST "+approveApplicationPath, h.handleApprove)
mux.HandleFunc("POST "+rejectApplicationPath, h.handleReject)
}
type applicationHandlers struct {
deps Dependencies
logger *slog.Logger
}
func (h *applicationHandlers) extractGameID(writer http.ResponseWriter, request *http.Request) (common.GameID, bool) {
raw := request.PathValue(gameIDPathParamValue)
if strings.TrimSpace(raw) == "" {
writeError(writer, http.StatusBadRequest, "invalid_request", "game id is required")
return "", false
}
return common.GameID(raw), true
}
func (h *applicationHandlers) extractApplicationID(writer http.ResponseWriter, request *http.Request) (common.ApplicationID, bool) {
raw := request.PathValue(applicationIDPathParamValue)
if strings.TrimSpace(raw) == "" {
writeError(writer, http.StatusBadRequest, "invalid_request", "application id is required")
return "", false
}
return common.ApplicationID(raw), true
}
func (h *applicationHandlers) handleApprove(writer http.ResponseWriter, request *http.Request) {
if h.deps.ApproveApplication == nil {
writeError(writer, http.StatusInternalServerError, "internal_error", "approve application service is not wired")
return
}
gameID, ok := h.extractGameID(writer, request)
if !ok {
return
}
applicationID, ok := h.extractApplicationID(writer, request)
if !ok {
return
}
record, err := h.deps.ApproveApplication.Handle(request.Context(), approveapplication.Input{
Actor: shared.NewAdminActor(),
GameID: gameID,
ApplicationID: applicationID,
})
if err != nil {
writeErrorFromService(writer, h.logger, err)
return
}
writeJSON(writer, http.StatusOK, encodeMembershipRecord(record))
}
func (h *applicationHandlers) handleReject(writer http.ResponseWriter, request *http.Request) {
if h.deps.RejectApplication == nil {
writeError(writer, http.StatusInternalServerError, "internal_error", "reject application service is not wired")
return
}
gameID, ok := h.extractGameID(writer, request)
if !ok {
return
}
applicationID, ok := h.extractApplicationID(writer, request)
if !ok {
return
}
record, err := h.deps.RejectApplication.Handle(request.Context(), rejectapplication.Input{
Actor: shared.NewAdminActor(),
GameID: gameID,
ApplicationID: applicationID,
})
if err != nil {
writeErrorFromService(writer, h.logger, err)
return
}
writeJSON(writer, http.StatusOK, encodeApplicationRecord(record))
}
+453
View File
@@ -0,0 +1,453 @@
package internalhttp
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"time"
"galaxy/lobby/internal/domain/application"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/game"
"galaxy/lobby/internal/domain/membership"
"galaxy/lobby/internal/ports"
"galaxy/lobby/internal/service/cancelgame"
"galaxy/lobby/internal/service/creategame"
"galaxy/lobby/internal/service/getgame"
"galaxy/lobby/internal/service/listgames"
"galaxy/lobby/internal/service/openenrollment"
"galaxy/lobby/internal/service/shared"
"galaxy/lobby/internal/service/updategame"
)
// Internal HTTP route patterns registered by registerGameRoutes. The Admin
// Service path set mirrors the public-port paths (see §internal-openapi.yaml
// under the AdminGames tag).
const (
gamesCollectionPath = "/api/v1/lobby/games"
gameItemPath = "/api/v1/lobby/games/{game_id}"
openEnrollmentPath = "/api/v1/lobby/games/{game_id}/open-enrollment"
cancelGamePath = "/api/v1/lobby/games/{game_id}/cancel"
gameIDPathParamValue = "game_id"
internalGameItemPath = "/api/v1/internal/games/{game_id}"
internalGameMembershipPath = "/api/v1/internal/games/{game_id}/memberships"
)
// errorResponse mirrors the `{ "error": { ... } }` shape documented in the
// internal OpenAPI contract.
type errorResponse struct {
Error errorBody `json:"error"`
}
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
// createGameRequest is the JSON shape for POST /api/v1/lobby/games on the
// internal port.
type createGameRequest struct {
GameName string `json:"game_name"`
Description string `json:"description"`
GameType string `json:"game_type"`
MinPlayers int `json:"min_players"`
MaxPlayers int `json:"max_players"`
StartGapHours int `json:"start_gap_hours"`
StartGapPlayers int `json:"start_gap_players"`
EnrollmentEndsAt int64 `json:"enrollment_ends_at"`
TurnSchedule string `json:"turn_schedule"`
TargetEngineVersion string `json:"target_engine_version"`
}
// updateGameRequest is the JSON shape for PATCH /api/v1/lobby/games/{id} on
// the internal port. Fields match the AdminGames contract.
type updateGameRequest struct {
GameName *string `json:"game_name"`
Description *string `json:"description"`
MinPlayers *int `json:"min_players"`
MaxPlayers *int `json:"max_players"`
StartGapHours *int `json:"start_gap_hours"`
StartGapPlayers *int `json:"start_gap_players"`
EnrollmentEndsAt *int64 `json:"enrollment_ends_at"`
TurnSchedule *string `json:"turn_schedule"`
TargetEngineVersion *string `json:"target_engine_version"`
}
// gameRecordResponse mirrors the GameRecord schema in internal-openapi.yaml.
type gameRecordResponse struct {
GameID string `json:"game_id"`
GameName string `json:"game_name"`
Description string `json:"description,omitempty"`
GameType string `json:"game_type"`
OwnerUserID string `json:"owner_user_id"`
Status string `json:"status"`
MinPlayers int `json:"min_players"`
MaxPlayers int `json:"max_players"`
StartGapHours int `json:"start_gap_hours"`
StartGapPlayers int `json:"start_gap_players"`
EnrollmentEndsAt int64 `json:"enrollment_ends_at"`
TurnSchedule string `json:"turn_schedule"`
TargetEngineVersion string `json:"target_engine_version"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
StartedAt *int64 `json:"started_at,omitempty"`
FinishedAt *int64 `json:"finished_at,omitempty"`
CurrentTurn int `json:"current_turn"`
RuntimeStatus string `json:"runtime_status"`
EngineHealthSummary string `json:"engine_health_summary"`
RuntimeBinding *runtimeBindingResponse `json:"runtime_binding,omitempty"`
}
// runtimeBindingResponse mirrors the RuntimeBinding schema. It is set
// only after a successful container start.
type runtimeBindingResponse struct {
ContainerID string `json:"container_id"`
EngineEndpoint string `json:"engine_endpoint"`
RuntimeJobID string `json:"runtime_job_id"`
BoundAt int64 `json:"bound_at"`
}
// encodeGameRecord converts one domain Game into the wire GameRecord.
func encodeGameRecord(record game.Game) gameRecordResponse {
resp := gameRecordResponse{
GameID: record.GameID.String(),
GameName: record.GameName,
Description: record.Description,
GameType: string(record.GameType),
OwnerUserID: record.OwnerUserID,
Status: string(record.Status),
MinPlayers: record.MinPlayers,
MaxPlayers: record.MaxPlayers,
StartGapHours: record.StartGapHours,
StartGapPlayers: record.StartGapPlayers,
EnrollmentEndsAt: record.EnrollmentEndsAt.UTC().Unix(),
TurnSchedule: record.TurnSchedule,
TargetEngineVersion: record.TargetEngineVersion,
CreatedAt: record.CreatedAt.UTC().UnixMilli(),
UpdatedAt: record.UpdatedAt.UTC().UnixMilli(),
CurrentTurn: record.RuntimeSnapshot.CurrentTurn,
RuntimeStatus: record.RuntimeSnapshot.RuntimeStatus,
EngineHealthSummary: record.RuntimeSnapshot.EngineHealthSummary,
}
if record.StartedAt != nil {
started := record.StartedAt.UTC().UnixMilli()
resp.StartedAt = &started
}
if record.FinishedAt != nil {
finished := record.FinishedAt.UTC().UnixMilli()
resp.FinishedAt = &finished
}
if record.RuntimeBinding != nil {
resp.RuntimeBinding = &runtimeBindingResponse{
ContainerID: record.RuntimeBinding.ContainerID,
EngineEndpoint: record.RuntimeBinding.EngineEndpoint,
RuntimeJobID: record.RuntimeBinding.RuntimeJobID,
BoundAt: record.RuntimeBinding.BoundAt.UTC().UnixMilli(),
}
}
return resp
}
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
}
func writeJSON(writer http.ResponseWriter, statusCode int, payload any) {
writer.Header().Set("Content-Type", jsonContentType)
writer.WriteHeader(statusCode)
_ = json.NewEncoder(writer).Encode(payload)
}
func writeError(writer http.ResponseWriter, statusCode int, code, message string) {
writeJSON(writer, statusCode, errorResponse{Error: errorBody{Code: code, Message: message}})
}
func writeErrorFromService(writer http.ResponseWriter, logger *slog.Logger, err error) {
switch {
case errors.Is(err, shared.ErrForbidden):
writeError(writer, http.StatusForbidden, "forbidden", "access denied")
case errors.Is(err, game.ErrNotFound),
errors.Is(err, application.ErrNotFound),
errors.Is(err, membership.ErrNotFound):
writeError(writer, http.StatusNotFound, "subject_not_found", "resource not found")
case errors.Is(err, game.ErrConflict),
errors.Is(err, game.ErrInvalidTransition),
errors.Is(err, application.ErrConflict),
errors.Is(err, application.ErrInvalidTransition),
errors.Is(err, membership.ErrConflict),
errors.Is(err, membership.ErrInvalidTransition):
writeError(writer, http.StatusConflict, "conflict", "operation not allowed in current status")
case errors.Is(err, shared.ErrEligibilityDenied):
writeError(writer, http.StatusUnprocessableEntity, "eligibility_denied", "user is not eligible to join games")
case errors.Is(err, ports.ErrNameTaken):
writeError(writer, http.StatusUnprocessableEntity, "name_taken", "race name is already taken")
case errors.Is(err, shared.ErrServiceUnavailable),
errors.Is(err, ports.ErrUserServiceUnavailable):
writeError(writer, http.StatusServiceUnavailable, "service_unavailable", "service is unavailable")
case isValidationError(err):
writeError(writer, http.StatusBadRequest, "invalid_request", err.Error())
default:
if logger != nil {
logger.Error("unhandled service error", "err", err.Error())
}
writeError(writer, http.StatusInternalServerError, "internal_error", "internal server error")
}
}
// isValidationError reports whether err carries a domain-validation
// signature. The helper mirrors the one in publichttp and is duplicated
// intentionally to keep the two HTTP packages independent.
func isValidationError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
switch {
case strings.Contains(msg, "must "),
strings.Contains(msg, "must not"),
strings.Contains(msg, "is unsupported"),
strings.Contains(msg, "invalid"):
return true
}
return false
}
// registerGameRoutes binds the game-lifecycle and
// game-read routes on mux using the admin actor shape (trusted caller,
// no X-User-ID header).
func registerGameRoutes(mux *http.ServeMux, deps Dependencies, logger *slog.Logger) {
h := &gameHandlers{
deps: deps,
logger: logger.With("component", "internal_http.games"),
}
mux.HandleFunc("POST "+gamesCollectionPath, h.handleCreate)
mux.HandleFunc("GET "+gamesCollectionPath, h.handleList)
mux.HandleFunc("GET "+gameItemPath, h.handleGet)
mux.HandleFunc("PATCH "+gameItemPath, h.handleUpdate)
mux.HandleFunc("POST "+openEnrollmentPath, h.handleOpenEnrollment)
mux.HandleFunc("POST "+cancelGamePath, h.handleCancel)
mux.HandleFunc("GET "+internalGameItemPath, h.handleGet)
}
type gameHandlers struct {
deps Dependencies
logger *slog.Logger
}
func (h *gameHandlers) extractGameID(writer http.ResponseWriter, request *http.Request) (common.GameID, bool) {
raw := request.PathValue(gameIDPathParamValue)
if strings.TrimSpace(raw) == "" {
writeError(writer, http.StatusBadRequest, "invalid_request", "game id is required")
return "", false
}
return common.GameID(raw), true
}
func (h *gameHandlers) handleCreate(writer http.ResponseWriter, request *http.Request) {
if h.deps.CreateGame == nil {
writeError(writer, http.StatusInternalServerError, "internal_error", "create game service is not wired")
return
}
var body createGameRequest
if err := decodeStrictJSON(request.Body, &body); err != nil {
writeError(writer, http.StatusBadRequest, "invalid_request", err.Error())
return
}
record, err := h.deps.CreateGame.Handle(request.Context(), creategame.Input{
Actor: shared.NewAdminActor(),
GameName: body.GameName,
Description: body.Description,
GameType: game.GameType(body.GameType),
MinPlayers: body.MinPlayers,
MaxPlayers: body.MaxPlayers,
StartGapHours: body.StartGapHours,
StartGapPlayers: body.StartGapPlayers,
EnrollmentEndsAt: time.Unix(body.EnrollmentEndsAt, 0).UTC(),
TurnSchedule: body.TurnSchedule,
TargetEngineVersion: body.TargetEngineVersion,
})
if err != nil {
writeErrorFromService(writer, h.logger, err)
return
}
writeJSON(writer, http.StatusCreated, encodeGameRecord(record))
}
func (h *gameHandlers) handleUpdate(writer http.ResponseWriter, request *http.Request) {
if h.deps.UpdateGame == nil {
writeError(writer, http.StatusInternalServerError, "internal_error", "update game service is not wired")
return
}
gameID, ok := h.extractGameID(writer, request)
if !ok {
return
}
var body updateGameRequest
if err := decodeStrictJSON(request.Body, &body); err != nil {
writeError(writer, http.StatusBadRequest, "invalid_request", err.Error())
return
}
input := updategame.Input{
Actor: shared.NewAdminActor(),
GameID: gameID,
GameName: body.GameName,
Description: body.Description,
MinPlayers: body.MinPlayers,
MaxPlayers: body.MaxPlayers,
StartGapHours: body.StartGapHours,
StartGapPlayers: body.StartGapPlayers,
TurnSchedule: body.TurnSchedule,
TargetEngineVersion: body.TargetEngineVersion,
}
if body.EnrollmentEndsAt != nil {
t := time.Unix(*body.EnrollmentEndsAt, 0).UTC()
input.EnrollmentEndsAt = &t
}
record, err := h.deps.UpdateGame.Handle(request.Context(), input)
if err != nil {
writeErrorFromService(writer, h.logger, err)
return
}
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
}
func (h *gameHandlers) handleOpenEnrollment(writer http.ResponseWriter, request *http.Request) {
if h.deps.OpenEnrollment == nil {
writeError(writer, http.StatusInternalServerError, "internal_error", "open enrollment service is not wired")
return
}
gameID, ok := h.extractGameID(writer, request)
if !ok {
return
}
record, err := h.deps.OpenEnrollment.Handle(request.Context(), openenrollment.Input{
Actor: shared.NewAdminActor(),
GameID: gameID,
})
if err != nil {
writeErrorFromService(writer, h.logger, err)
return
}
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
}
func (h *gameHandlers) handleCancel(writer http.ResponseWriter, request *http.Request) {
if h.deps.CancelGame == nil {
writeError(writer, http.StatusInternalServerError, "internal_error", "cancel game service is not wired")
return
}
gameID, ok := h.extractGameID(writer, request)
if !ok {
return
}
record, err := h.deps.CancelGame.Handle(request.Context(), cancelgame.Input{
Actor: shared.NewAdminActor(),
GameID: gameID,
})
if err != nil {
writeErrorFromService(writer, h.logger, err)
return
}
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
}
// gameListResponse mirrors the OpenAPI GameListResponse schema. Items
// are always non-nil so the JSON form carries `[]` rather than `null`
// for empty pages.
type gameListResponse struct {
Items []gameRecordResponse `json:"items"`
NextPageToken string `json:"next_page_token,omitempty"`
}
func encodeGameList(items []game.Game, nextPageToken string) gameListResponse {
resp := gameListResponse{
Items: make([]gameRecordResponse, 0, len(items)),
NextPageToken: nextPageToken,
}
for _, item := range items {
resp.Items = append(resp.Items, encodeGameRecord(item))
}
return resp
}
// parsePage decodes the `page_size` and `page_token` query parameters
// into a shared.Page. On failure it writes the OpenAPI-shaped
// invalid_request envelope and returns ok=false so the caller can
// short-circuit.
func parsePage(writer http.ResponseWriter, request *http.Request) (shared.Page, bool) {
page, err := shared.ParsePage(
request.URL.Query().Get("page_size"),
request.URL.Query().Get("page_token"),
)
if err != nil {
writeError(writer, http.StatusBadRequest, "invalid_request", err.Error())
return shared.Page{}, false
}
return page, true
}
func (h *gameHandlers) handleGet(writer http.ResponseWriter, request *http.Request) {
if h.deps.GetGame == nil {
writeError(writer, http.StatusInternalServerError, "internal_error", "get game service is not wired")
return
}
gameID, ok := h.extractGameID(writer, request)
if !ok {
return
}
record, err := h.deps.GetGame.Handle(request.Context(), getgame.Input{
Actor: shared.NewAdminActor(),
GameID: gameID,
})
if err != nil {
writeErrorFromService(writer, h.logger, err)
return
}
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
}
func (h *gameHandlers) handleList(writer http.ResponseWriter, request *http.Request) {
if h.deps.ListGames == nil {
writeError(writer, http.StatusInternalServerError, "internal_error", "list games service is not wired")
return
}
page, ok := parsePage(writer, request)
if !ok {
return
}
out, err := h.deps.ListGames.Handle(request.Context(), listgames.Input{
Actor: shared.NewAdminActor(),
Page: page,
})
if err != nil {
writeErrorFromService(writer, h.logger, err)
return
}
writeJSON(writer, http.StatusOK, encodeGameList(out.Items, out.NextPageToken))
}
@@ -0,0 +1,317 @@
package internalhttp
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"time"
"galaxy/lobby/internal/adapters/gamestub"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/game"
"galaxy/lobby/internal/ports"
"galaxy/lobby/internal/service/cancelgame"
"galaxy/lobby/internal/service/creategame"
"galaxy/lobby/internal/service/openenrollment"
"galaxy/lobby/internal/service/updategame"
"github.com/stretchr/testify/require"
)
type stubIDGenerator struct {
next common.GameID
}
func (g *stubIDGenerator) NewGameID() (common.GameID, error) {
return g.next, nil
}
func (g *stubIDGenerator) NewApplicationID() (common.ApplicationID, error) {
return "application-stub", nil
}
func (g *stubIDGenerator) NewInviteID() (common.InviteID, error) {
return "invite-stub", nil
}
func (g *stubIDGenerator) NewMembershipID() (common.MembershipID, error) {
return "membership-stub", nil
}
func silentLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func fixedClock(at time.Time) func() time.Time {
return func() time.Time { return at }
}
func buildHandler(t *testing.T, store *gamestub.Store, ids ports.IDGenerator, clock func() time.Time) http.Handler {
t.Helper()
logger := silentLogger()
createSvc, err := creategame.NewService(creategame.Dependencies{
Games: store,
IDs: ids,
Clock: clock,
Logger: logger,
})
require.NoError(t, err)
updateSvc, err := updategame.NewService(updategame.Dependencies{
Games: store,
Clock: clock,
Logger: logger,
})
require.NoError(t, err)
openSvc, err := openenrollment.NewService(openenrollment.Dependencies{
Games: store,
Clock: clock,
Logger: logger,
})
require.NoError(t, err)
cancelSvc, err := cancelgame.NewService(cancelgame.Dependencies{
Games: store,
Clock: clock,
Logger: logger,
})
require.NoError(t, err)
return newHandler(Dependencies{
Logger: logger,
CreateGame: createSvc,
UpdateGame: updateSvc,
OpenEnrollment: openSvc,
CancelGame: cancelSvc,
}, logger)
}
func doRequest(t *testing.T, handler http.Handler, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
var reader io.Reader
if body != nil {
data, err := json.Marshal(body)
require.NoError(t, err)
reader = bytes.NewReader(data)
}
req := httptest.NewRequestWithContext(context.Background(), method, path, reader)
if reader != nil {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
func decodeGameRecord(t *testing.T, rec *httptest.ResponseRecorder) gameRecordResponse {
t.Helper()
var payload gameRecordResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
return payload
}
func decodeError(t *testing.T, rec *httptest.ResponseRecorder) errorResponse {
t.Helper()
var payload errorResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
return payload
}
func TestAdminCreatesPublicGame(t *testing.T) {
t.Parallel()
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
store := gamestub.NewStore()
handler := buildHandler(t, store, &stubIDGenerator{next: "game-public"}, fixedClock(now))
body := createGameRequest{
GameName: "Winter Open",
GameType: "public",
MinPlayers: 4,
MaxPlayers: 8,
StartGapHours: 6,
StartGapPlayers: 2,
EnrollmentEndsAt: now.Add(48 * time.Hour).Unix(),
TurnSchedule: "0 */4 * * *",
TargetEngineVersion: "2.0.0",
}
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games", body)
require.Equal(t, http.StatusCreated, rec.Code)
decoded := decodeGameRecord(t, rec)
require.Equal(t, "public", decoded.GameType)
require.Equal(t, "", decoded.OwnerUserID)
require.Equal(t, "draft", decoded.Status)
}
func TestAdminCannotCreatePrivateGame(t *testing.T) {
t.Parallel()
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "game-priv"}, fixedClock(now))
body := createGameRequest{
GameName: "Private Lobby",
GameType: "private",
MinPlayers: 2,
MaxPlayers: 4,
StartGapHours: 4,
StartGapPlayers: 1,
EnrollmentEndsAt: now.Add(time.Hour).Unix(),
TurnSchedule: "0 0 * * *",
TargetEngineVersion: "1.0.0",
}
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games", body)
require.Equal(t, http.StatusForbidden, rec.Code)
decoded := decodeError(t, rec)
require.Equal(t, "forbidden", decoded.Error.Code)
}
func TestAdminValidationError(t *testing.T) {
t.Parallel()
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "game-bad"}, fixedClock(now))
body := createGameRequest{
GameName: "",
GameType: "public",
MinPlayers: 2,
MaxPlayers: 4,
StartGapHours: 4,
StartGapPlayers: 1,
EnrollmentEndsAt: now.Add(time.Hour).Unix(),
TurnSchedule: "0 0 * * *",
TargetEngineVersion: "1.0.0",
}
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games", body)
require.Equal(t, http.StatusBadRequest, rec.Code)
decoded := decodeError(t, rec)
require.Equal(t, "invalid_request", decoded.Error.Code)
}
func TestAdminUpdateAllFieldsInDraft(t *testing.T) {
t.Parallel()
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
store := gamestub.NewStore()
seedDraftForTest(t, store, "game-u", game.GameTypePublic, "", now)
handler := buildHandler(t, store, &stubIDGenerator{next: "unused"}, fixedClock(now.Add(time.Hour)))
desc := "Updated by admin"
body := updateGameRequest{Description: &desc}
rec := doRequest(t, handler, http.MethodPatch, "/api/v1/lobby/games/game-u", body)
require.Equal(t, http.StatusOK, rec.Code)
decoded := decodeGameRecord(t, rec)
require.Equal(t, "Updated by admin", decoded.Description)
}
func TestAdminOpenEnrollment(t *testing.T) {
t.Parallel()
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
store := gamestub.NewStore()
seedDraftForTest(t, store, "game-oe", game.GameTypePublic, "", now)
handler := buildHandler(t, store, &stubIDGenerator{next: "unused"}, fixedClock(now.Add(time.Hour)))
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games/game-oe/open-enrollment", nil)
require.Equal(t, http.StatusOK, rec.Code)
decoded := decodeGameRecord(t, rec)
require.Equal(t, "enrollment_open", decoded.Status)
}
func TestAdminCancelFromRunning(t *testing.T) {
t.Parallel()
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
store := gamestub.NewStore()
record := seedDraftForTest(t, store, "game-run", game.GameTypePublic, "", now)
// Force status to running to exercise the 409 conflict path.
record.Status = game.StatusRunning
startedAt := now.Add(time.Minute)
record.StartedAt = &startedAt
record.UpdatedAt = startedAt
require.NoError(t, store.Save(context.Background(), record))
handler := buildHandler(t, store, &stubIDGenerator{next: "unused"}, fixedClock(now.Add(time.Hour)))
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games/game-run/cancel", nil)
require.Equal(t, http.StatusConflict, rec.Code)
decoded := decodeError(t, rec)
require.Equal(t, "conflict", decoded.Error.Code)
}
func TestAdminUpdateNotFound(t *testing.T) {
t.Parallel()
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "unused"}, fixedClock(now))
desc := "x"
body := updateGameRequest{Description: &desc}
rec := doRequest(t, handler, http.MethodPatch, "/api/v1/lobby/games/game-missing", body)
require.Equal(t, http.StatusNotFound, rec.Code)
}
func TestAdminCreateUnknownFieldRejected(t *testing.T) {
t.Parallel()
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
handler := buildHandler(t, gamestub.NewStore(), &stubIDGenerator{next: "unused"}, fixedClock(now))
reqBody := map[string]any{
"game_name": "x",
"game_type": "public",
"min_players": 2,
"max_players": 4,
"start_gap_hours": 4,
"start_gap_players": 1,
"enrollment_ends_at": now.Add(time.Hour).Unix(),
"turn_schedule": "0 0 * * *",
"target_engine_version": "1.0.0",
"unexpected": "nope",
}
rec := doRequest(t, handler, http.MethodPost, "/api/v1/lobby/games", reqBody)
require.Equal(t, http.StatusBadRequest, rec.Code)
}
func seedDraftForTest(
t *testing.T,
store *gamestub.Store,
id common.GameID,
gameType game.GameType,
ownerUserID string,
now time.Time,
) game.Game {
t.Helper()
record, err := game.New(game.NewGameInput{
GameID: id,
GameName: "Seed",
GameType: gameType,
OwnerUserID: ownerUserID,
MinPlayers: 2,
MaxPlayers: 4,
StartGapHours: 4,
StartGapPlayers: 1,
EnrollmentEndsAt: now.Add(24 * time.Hour),
TurnSchedule: "0 */6 * * *",
TargetEngineVersion: "1.0.0",
Now: now,
})
require.NoError(t, err)
require.NoError(t, store.Save(context.Background(), record))
return record
}
@@ -0,0 +1,157 @@
package internalhttp
import (
"log/slog"
"net/http"
"strings"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/membership"
"galaxy/lobby/internal/service/blockmember"
"galaxy/lobby/internal/service/listmemberships"
"galaxy/lobby/internal/service/removemember"
"galaxy/lobby/internal/service/shared"
)
// Internal HTTP route patterns for the membership
// operations.
const (
listMembershipsPath = "/api/v1/lobby/games/{game_id}/memberships"
removeMemberPath = "/api/v1/lobby/games/{game_id}/memberships/{membership_id}/remove"
blockMemberPath = "/api/v1/lobby/games/{game_id}/memberships/{membership_id}/block"
membershipIDPathParamValue = "membership_id"
)
// registerMembershipRoutes binds the membership
// routes on the internal port. The actor is always admin (Admin
// Service / Game Master are the trusted callers).
func registerMembershipRoutes(mux *http.ServeMux, deps Dependencies, logger *slog.Logger) {
h := &membershipHandlers{
deps: deps,
logger: logger.With("component", "internal_http.memberships"),
}
mux.HandleFunc("GET "+listMembershipsPath, h.handleList)
mux.HandleFunc("GET "+internalGameMembershipPath, h.handleList)
mux.HandleFunc("POST "+removeMemberPath, h.handleRemove)
mux.HandleFunc("POST "+blockMemberPath, h.handleBlock)
}
type membershipHandlers struct {
deps Dependencies
logger *slog.Logger
}
func (h *membershipHandlers) extractMembershipID(writer http.ResponseWriter, request *http.Request) (common.MembershipID, bool) {
raw := request.PathValue(membershipIDPathParamValue)
if strings.TrimSpace(raw) == "" {
writeError(writer, http.StatusBadRequest, "invalid_request", "membership id is required")
return "", false
}
return common.MembershipID(raw), true
}
func (h *membershipHandlers) handleRemove(writer http.ResponseWriter, request *http.Request) {
if h.deps.RemoveMember == nil {
writeError(writer, http.StatusInternalServerError, "internal_error", "remove member service is not wired")
return
}
games := &gameHandlers{deps: h.deps, logger: h.logger}
gameID, ok := games.extractGameID(writer, request)
if !ok {
return
}
membershipID, ok := h.extractMembershipID(writer, request)
if !ok {
return
}
record, err := h.deps.RemoveMember.Handle(request.Context(), removemember.Input{
Actor: shared.NewAdminActor(),
GameID: gameID,
MembershipID: membershipID,
})
if err != nil {
writeErrorFromService(writer, h.logger, err)
return
}
writeJSON(writer, http.StatusOK, encodeMembershipRecord(record))
}
// membershipListResponse mirrors the OpenAPI MembershipListResponse
// schema. Items are always non-nil so the JSON form carries `[]`
// rather than `null` for empty pages.
type membershipListResponse struct {
Items []membershipRecordResponse `json:"items"`
NextPageToken string `json:"next_page_token,omitempty"`
}
func encodeMembershipList(items []membership.Membership, nextPageToken string) membershipListResponse {
resp := membershipListResponse{
Items: make([]membershipRecordResponse, 0, len(items)),
NextPageToken: nextPageToken,
}
for _, item := range items {
resp.Items = append(resp.Items, encodeMembershipRecord(item))
}
return resp
}
func (h *membershipHandlers) handleList(writer http.ResponseWriter, request *http.Request) {
if h.deps.ListMemberships == nil {
writeError(writer, http.StatusInternalServerError, "internal_error",
"list memberships service is not wired")
return
}
games := &gameHandlers{deps: h.deps, logger: h.logger}
gameID, ok := games.extractGameID(writer, request)
if !ok {
return
}
page, ok := parsePage(writer, request)
if !ok {
return
}
out, err := h.deps.ListMemberships.Handle(request.Context(), listmemberships.Input{
Actor: shared.NewAdminActor(),
GameID: gameID,
Page: page,
})
if err != nil {
writeErrorFromService(writer, h.logger, err)
return
}
writeJSON(writer, http.StatusOK, encodeMembershipList(out.Items, out.NextPageToken))
}
func (h *membershipHandlers) handleBlock(writer http.ResponseWriter, request *http.Request) {
if h.deps.BlockMember == nil {
writeError(writer, http.StatusInternalServerError, "internal_error", "block member service is not wired")
return
}
games := &gameHandlers{deps: h.deps, logger: h.logger}
gameID, ok := games.extractGameID(writer, request)
if !ok {
return
}
membershipID, ok := h.extractMembershipID(writer, request)
if !ok {
return
}
record, err := h.deps.BlockMember.Handle(request.Context(), blockmember.Input{
Actor: shared.NewAdminActor(),
GameID: gameID,
MembershipID: membershipID,
})
if err != nil {
writeErrorFromService(writer, h.logger, err)
return
}
writeJSON(writer, http.StatusOK, encodeMembershipRecord(record))
}
@@ -0,0 +1,80 @@
package internalhttp
import (
"log/slog"
"net/http"
"galaxy/lobby/internal/service/pausegame"
"galaxy/lobby/internal/service/resumegame"
"galaxy/lobby/internal/service/shared"
)
const (
pauseGamePath = "/api/v1/lobby/games/{game_id}/pause"
resumeGamePath = "/api/v1/lobby/games/{game_id}/resume"
)
// registerPauseResumeRoutes binds the admin pause and resume
// routes on the internal port. The actor is always admin (Admin
// Service is the trusted caller).
func registerPauseResumeRoutes(mux *http.ServeMux, deps Dependencies, logger *slog.Logger) {
h := &pauseResumeHandlers{
deps: deps,
logger: logger.With("component", "internal_http.pauseresume"),
}
mux.HandleFunc("POST "+pauseGamePath, h.handlePause)
mux.HandleFunc("POST "+resumeGamePath, h.handleResume)
}
type pauseResumeHandlers struct {
deps Dependencies
logger *slog.Logger
}
func (h *pauseResumeHandlers) handlePause(writer http.ResponseWriter, request *http.Request) {
if h.deps.PauseGame == nil {
writeError(writer, http.StatusInternalServerError, "internal_error", "pause game service is not wired")
return
}
games := &gameHandlers{deps: h.deps, logger: h.logger}
gameID, ok := games.extractGameID(writer, request)
if !ok {
return
}
record, err := h.deps.PauseGame.Handle(request.Context(), pausegame.Input{
Actor: shared.NewAdminActor(),
GameID: gameID,
})
if err != nil {
writeErrorFromService(writer, h.logger, err)
return
}
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
}
func (h *pauseResumeHandlers) handleResume(writer http.ResponseWriter, request *http.Request) {
if h.deps.ResumeGame == nil {
writeError(writer, http.StatusInternalServerError, "internal_error", "resume game service is not wired")
return
}
games := &gameHandlers{deps: h.deps, logger: h.logger}
gameID, ok := games.extractGameID(writer, request)
if !ok {
return
}
record, err := h.deps.ResumeGame.Handle(request.Context(), resumegame.Input{
Actor: shared.NewAdminActor(),
GameID: gameID,
})
if err != nil {
writeErrorFromService(writer, h.logger, err)
return
}
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
}
@@ -0,0 +1,52 @@
package internalhttp
import (
"log/slog"
"net/http"
"galaxy/lobby/internal/service/manualreadytostart"
"galaxy/lobby/internal/service/shared"
)
const readyToStartPath = "/api/v1/lobby/games/{game_id}/ready-to-start"
// registerReadyToStartRoutes binds the admin manual ready-to-start
// route on the internal port. The actor is always admin (Admin Service is
// the trusted caller; the internal port is not reachable from the public
// internet).
func registerReadyToStartRoutes(mux *http.ServeMux, deps Dependencies, logger *slog.Logger) {
h := &readyToStartHandlers{
deps: deps,
logger: logger.With("component", "internal_http.ready_to_start"),
}
mux.HandleFunc("POST "+readyToStartPath, h.handle)
}
type readyToStartHandlers struct {
deps Dependencies
logger *slog.Logger
}
func (h *readyToStartHandlers) handle(writer http.ResponseWriter, request *http.Request) {
if h.deps.ManualReadyToStart == nil {
writeError(writer, http.StatusInternalServerError, "internal_error", "manual ready-to-start service is not wired")
return
}
games := &gameHandlers{deps: h.deps, logger: h.logger}
gameID, ok := games.extractGameID(writer, request)
if !ok {
return
}
record, err := h.deps.ManualReadyToStart.Handle(request.Context(), manualreadytostart.Input{
Actor: shared.NewAdminActor(),
GameID: gameID,
})
if err != nil {
writeErrorFromService(writer, h.logger, err)
return
}
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
}
+367
View File
@@ -0,0 +1,367 @@
// Package internalhttp provides the trusted internal HTTP listener used by
// the runnable Game Lobby Service process. In the runnable
// skeleton it exposes only the platform liveness and readiness probes;
// later stages add Game Master registration and admin routes.
package internalhttp
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"strconv"
"sync"
"time"
"galaxy/lobby/internal/api/httpcommon"
"galaxy/lobby/internal/service/approveapplication"
"galaxy/lobby/internal/service/blockmember"
"galaxy/lobby/internal/service/cancelgame"
"galaxy/lobby/internal/service/creategame"
"galaxy/lobby/internal/service/getgame"
"galaxy/lobby/internal/service/listgames"
"galaxy/lobby/internal/service/listmemberships"
"galaxy/lobby/internal/service/manualreadytostart"
"galaxy/lobby/internal/service/openenrollment"
"galaxy/lobby/internal/service/pausegame"
"galaxy/lobby/internal/service/rejectapplication"
"galaxy/lobby/internal/service/removemember"
"galaxy/lobby/internal/service/resumegame"
"galaxy/lobby/internal/service/retrystartgame"
"galaxy/lobby/internal/service/startgame"
"galaxy/lobby/internal/service/updategame"
"galaxy/lobby/internal/telemetry"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel/attribute"
)
const jsonContentType = "application/json; charset=utf-8"
const (
// HealthzPath is the internal liveness probe route.
HealthzPath = "/healthz"
// ReadyzPath is the internal readiness probe route.
ReadyzPath = "/readyz"
)
// Config describes the trusted internal HTTP listener owned by
// Game Lobby Service.
type Config struct {
// Addr is the TCP listen address used by the internal HTTP server.
Addr string
// ReadHeaderTimeout bounds how long the listener may spend reading request
// headers before the server rejects the connection.
ReadHeaderTimeout time.Duration
// ReadTimeout bounds how long the listener may spend reading one request.
ReadTimeout time.Duration
// IdleTimeout bounds how long the listener keeps an idle keep-alive
// connection open.
IdleTimeout time.Duration
}
// Validate reports whether cfg contains a usable internal HTTP listener
// configuration.
func (cfg Config) Validate() error {
switch {
case cfg.Addr == "":
return errors.New("internal HTTP addr must not be empty")
case cfg.ReadHeaderTimeout <= 0:
return errors.New("internal HTTP read header timeout must be positive")
case cfg.ReadTimeout <= 0:
return errors.New("internal HTTP read timeout must be positive")
case cfg.IdleTimeout <= 0:
return errors.New("internal HTTP idle timeout must be positive")
default:
return nil
}
}
// Dependencies describes the collaborators used by the internal HTTP
// transport layer.
type Dependencies struct {
// Logger writes structured listener lifecycle logs. When nil,
// slog.Default is used.
Logger *slog.Logger
// Telemetry records low-cardinality probe metrics and lifecycle events.
Telemetry *telemetry.Runtime
// CreateGame handles admin-initiated `lobby.game.create` calls routed
// through Admin Service. A nil value makes the corresponding route
// return `internal_error`; tests that do not exercise the route may
// leave it nil.
CreateGame *creategame.Service
// UpdateGame handles admin-initiated `lobby.game.update` calls.
UpdateGame *updategame.Service
// OpenEnrollment handles admin-initiated `lobby.game.open_enrollment`
// calls.
OpenEnrollment *openenrollment.Service
// CancelGame handles admin-initiated `lobby.game.cancel` calls.
CancelGame *cancelgame.Service
// ManualReadyToStart handles admin-initiated
// `lobby.game.ready_to_start` calls.
ManualReadyToStart *manualreadytostart.Service
// StartGame handles admin-initiated `lobby.game.start` calls
//.
StartGame *startgame.Service
// RetryStartGame handles admin-initiated `lobby.game.retry_start`
// calls.
RetryStartGame *retrystartgame.Service
// PauseGame handles admin-initiated `lobby.game.pause` calls
//.
PauseGame *pausegame.Service
// ResumeGame handles admin-initiated `lobby.game.resume` calls
//.
ResumeGame *resumegame.Service
// ApproveApplication handles admin-initiated
// `lobby.application.approve` calls. Wired on the internal port for
// Admin Service routing.
ApproveApplication *approveapplication.Service
// RejectApplication handles admin-initiated
// `lobby.application.reject` calls.
RejectApplication *rejectapplication.Service
// RemoveMember handles admin-initiated `lobby.membership.remove`
// calls.
RemoveMember *removemember.Service
// BlockMember handles admin-initiated `lobby.membership.block`
// calls.
BlockMember *blockmember.Service
// GetGame handles `internalGetGame` and `adminGetGame` reads
//. The handler always passes shared.NewAdminActor() so
// the response is unrestricted by visibility rules.
GetGame *getgame.Service
// ListGames handles `adminListGames`. The handler
// always passes shared.NewAdminActor() so every status is included.
ListGames *listgames.Service
// ListMemberships handles `internalListMemberships` and
// `adminListMemberships` reads. The handler always
// passes shared.NewAdminActor() so every membership is returned.
ListMemberships *listmemberships.Service
}
// Server owns the trusted internal HTTP listener exposed by
// Game Lobby Service.
type Server struct {
cfg Config
handler http.Handler
logger *slog.Logger
metrics *telemetry.Runtime
stateMu sync.RWMutex
server *http.Server
listener net.Listener
}
// NewServer constructs one trusted internal HTTP server for cfg and deps.
func NewServer(cfg Config, deps Dependencies) (*Server, error) {
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("new internal HTTP server: %w", err)
}
logger := deps.Logger
if logger == nil {
logger = slog.Default()
}
return &Server{
cfg: cfg,
handler: newHandler(deps, logger),
logger: logger.With("component", "internal_http"),
metrics: deps.Telemetry,
}, nil
}
// Addr returns the currently bound listener address after Run is called. It
// returns an empty string if the server has not yet bound a listener.
func (server *Server) Addr() string {
server.stateMu.RLock()
defer server.stateMu.RUnlock()
if server.listener == nil {
return ""
}
return server.listener.Addr().String()
}
// Run binds the configured listener and serves the internal HTTP surface
// until Shutdown closes the server.
func (server *Server) Run(ctx context.Context) error {
if ctx == nil {
return errors.New("run internal HTTP server: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
listener, err := net.Listen("tcp", server.cfg.Addr)
if err != nil {
return fmt.Errorf("run internal HTTP server: listen on %q: %w", server.cfg.Addr, err)
}
httpServer := &http.Server{
Handler: server.handler,
ReadHeaderTimeout: server.cfg.ReadHeaderTimeout,
ReadTimeout: server.cfg.ReadTimeout,
IdleTimeout: server.cfg.IdleTimeout,
}
server.stateMu.Lock()
server.server = httpServer
server.listener = listener
server.stateMu.Unlock()
server.logger.Info("internal HTTP server started", "addr", listener.Addr().String())
defer func() {
server.stateMu.Lock()
server.server = nil
server.listener = nil
server.stateMu.Unlock()
}()
err = httpServer.Serve(listener)
switch {
case err == nil:
return nil
case errors.Is(err, http.ErrServerClosed):
server.logger.Info("internal HTTP server stopped")
return nil
default:
return fmt.Errorf("run internal HTTP server: serve on %q: %w", server.cfg.Addr, err)
}
}
// Shutdown gracefully stops the internal HTTP server within ctx.
func (server *Server) Shutdown(ctx context.Context) error {
if ctx == nil {
return errors.New("shutdown internal HTTP server: nil context")
}
server.stateMu.RLock()
httpServer := server.server
server.stateMu.RUnlock()
if httpServer == nil {
return nil
}
if err := httpServer.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("shutdown internal HTTP server: %w", err)
}
return nil
}
func newHandler(deps Dependencies, logger *slog.Logger) http.Handler {
if logger == nil {
logger = slog.Default()
}
mux := http.NewServeMux()
mux.HandleFunc("GET "+HealthzPath, handleHealthz)
mux.HandleFunc("GET "+ReadyzPath, handleReadyz)
registerGameRoutes(mux, deps, logger)
registerApplicationRoutes(mux, deps, logger)
registerReadyToStartRoutes(mux, deps, logger)
registerStartRoutes(mux, deps, logger)
registerPauseResumeRoutes(mux, deps, logger)
registerMembershipRoutes(mux, deps, logger)
metrics := deps.Telemetry
options := []otelhttp.Option{}
if metrics != nil {
options = append(options,
otelhttp.WithTracerProvider(metrics.TracerProvider()),
otelhttp.WithMeterProvider(metrics.MeterProvider()),
)
}
observable := otelhttp.NewHandler(withObservability(mux, metrics), "lobby.internal_http", options...)
return httpcommon.RequestID(observable)
}
func withObservability(next http.Handler, metrics *telemetry.Runtime) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
startedAt := time.Now()
recorder := &statusRecorder{
ResponseWriter: writer,
statusCode: http.StatusOK,
}
next.ServeHTTP(recorder, request)
route := request.Pattern
switch recorder.statusCode {
case http.StatusMethodNotAllowed:
route = "method_not_allowed"
case http.StatusNotFound:
route = "not_found"
case 0:
route = "unmatched"
}
if route == "" {
route = "unmatched"
}
metrics.RecordInternalHTTPRequest(
request.Context(),
[]attribute.KeyValue{
attribute.String("route", route),
attribute.String("method", request.Method),
attribute.String("status_code", strconv.Itoa(recorder.statusCode)),
},
time.Since(startedAt),
)
})
}
func handleHealthz(writer http.ResponseWriter, _ *http.Request) {
writeStatusResponse(writer, http.StatusOK, "ok")
}
func handleReadyz(writer http.ResponseWriter, _ *http.Request) {
writeStatusResponse(writer, http.StatusOK, "ready")
}
func writeStatusResponse(writer http.ResponseWriter, statusCode int, status string) {
writer.Header().Set("Content-Type", jsonContentType)
writer.WriteHeader(statusCode)
_ = json.NewEncoder(writer).Encode(statusResponse{Status: status})
}
type statusResponse struct {
Status string `json:"status"`
}
type statusRecorder struct {
http.ResponseWriter
statusCode int
}
func (recorder *statusRecorder) WriteHeader(statusCode int) {
recorder.statusCode = statusCode
recorder.ResponseWriter.WriteHeader(statusCode)
}
@@ -0,0 +1,155 @@
package internalhttp
import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConfigValidate(t *testing.T) {
t.Parallel()
base := Config{
Addr: ":0",
ReadHeaderTimeout: time.Second,
ReadTimeout: time.Second,
IdleTimeout: time.Second,
}
require.NoError(t, base.Validate())
tests := []struct {
name string
mutate func(*Config)
wantErr string
}{
{name: "empty addr", mutate: func(cfg *Config) { cfg.Addr = "" }, wantErr: "addr must not be empty"},
{name: "zero header", mutate: func(cfg *Config) { cfg.ReadHeaderTimeout = 0 }, wantErr: "read header timeout"},
{name: "zero read", mutate: func(cfg *Config) { cfg.ReadTimeout = 0 }, wantErr: "read timeout"},
{name: "zero idle", mutate: func(cfg *Config) { cfg.IdleTimeout = 0 }, wantErr: "idle timeout"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := base
tt.mutate(&cfg)
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), tt.wantErr)
})
}
}
func TestHandlerRoutes(t *testing.T) {
t.Parallel()
handler := newHandler(Dependencies{}, nil)
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
tests := []struct {
name string
method string
path string
wantStatus int
wantStatusBody string
}{
{name: "healthz", method: http.MethodGet, path: HealthzPath, wantStatus: http.StatusOK, wantStatusBody: "ok"},
{name: "readyz", method: http.MethodGet, path: ReadyzPath, wantStatus: http.StatusOK, wantStatusBody: "ready"},
{name: "not found", method: http.MethodGet, path: "/nope", wantStatus: http.StatusNotFound},
{name: "method not allowed", method: http.MethodPost, path: HealthzPath, wantStatus: http.StatusMethodNotAllowed},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
req, err := http.NewRequest(tt.method, server.URL+tt.path, nil)
require.NoError(t, err)
resp, err := server.Client().Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, tt.wantStatus, resp.StatusCode)
if tt.wantStatusBody != "" {
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, "application/json; charset=utf-8", resp.Header.Get("Content-Type"))
var payload statusResponse
require.NoError(t, json.Unmarshal(body, &payload))
assert.Equal(t, tt.wantStatusBody, payload.Status)
}
})
}
}
func TestServerRunAndShutdown(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
addr := listener.Addr().String()
require.NoError(t, listener.Close())
server, err := NewServer(Config{
Addr: addr,
ReadHeaderTimeout: time.Second,
ReadTimeout: time.Second,
IdleTimeout: time.Second,
}, Dependencies{})
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
runErr := make(chan error, 1)
go func() {
runErr <- server.Run(ctx)
}()
require.Eventually(t, func() bool {
return server.Addr() != ""
}, 2*time.Second, 10*time.Millisecond)
resp, err := http.Get("http://" + server.Addr() + ReadyzPath)
require.NoError(t, err)
_ = resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second)
t.Cleanup(shutdownCancel)
require.NoError(t, server.Shutdown(shutdownCtx))
select {
case err := <-runErr:
require.NoError(t, err)
case <-time.After(2 * time.Second):
t.Fatal("server did not stop after shutdown")
}
}
func TestShutdownBeforeRunIsNoop(t *testing.T) {
t.Parallel()
server, err := NewServer(Config{
Addr: "127.0.0.1:0",
ReadHeaderTimeout: time.Second,
ReadTimeout: time.Second,
IdleTimeout: time.Second,
}, Dependencies{})
require.NoError(t, err)
require.NoError(t, server.Shutdown(context.Background()))
}
+80
View File
@@ -0,0 +1,80 @@
package internalhttp
import (
"log/slog"
"net/http"
"galaxy/lobby/internal/service/retrystartgame"
"galaxy/lobby/internal/service/shared"
"galaxy/lobby/internal/service/startgame"
)
const (
startGamePath = "/api/v1/lobby/games/{game_id}/start"
retryStartGamePath = "/api/v1/lobby/games/{game_id}/retry-start"
)
// registerStartRoutes binds the admin start and retry-start
// routes on the internal port. The actor is always admin (Admin Service
// is the trusted caller).
func registerStartRoutes(mux *http.ServeMux, deps Dependencies, logger *slog.Logger) {
h := &startHandlers{
deps: deps,
logger: logger.With("component", "internal_http.startgame"),
}
mux.HandleFunc("POST "+startGamePath, h.handleStart)
mux.HandleFunc("POST "+retryStartGamePath, h.handleRetryStart)
}
type startHandlers struct {
deps Dependencies
logger *slog.Logger
}
func (h *startHandlers) handleStart(writer http.ResponseWriter, request *http.Request) {
if h.deps.StartGame == nil {
writeError(writer, http.StatusInternalServerError, "internal_error", "start game service is not wired")
return
}
games := &gameHandlers{deps: h.deps, logger: h.logger}
gameID, ok := games.extractGameID(writer, request)
if !ok {
return
}
record, err := h.deps.StartGame.Handle(request.Context(), startgame.Input{
Actor: shared.NewAdminActor(),
GameID: gameID,
})
if err != nil {
writeErrorFromService(writer, h.logger, err)
return
}
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
}
func (h *startHandlers) handleRetryStart(writer http.ResponseWriter, request *http.Request) {
if h.deps.RetryStartGame == nil {
writeError(writer, http.StatusInternalServerError, "internal_error", "retry start game service is not wired")
return
}
games := &gameHandlers{deps: h.deps, logger: h.logger}
gameID, ok := games.extractGameID(writer, request)
if !ok {
return
}
record, err := h.deps.RetryStartGame.Handle(request.Context(), retrystartgame.Input{
Actor: shared.NewAdminActor(),
GameID: gameID,
})
if err != nil {
writeErrorFromService(writer, h.logger, err)
return
}
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
}