feat: gamemaster
This commit is contained in:
@@ -0,0 +1,611 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/gamemaster/internal/api/internalhttp/handlers"
|
||||
"galaxy/gamemaster/internal/domain/engineversion"
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
domainruntime "galaxy/gamemaster/internal/domain/runtime"
|
||||
"galaxy/gamemaster/internal/service/adminbanish"
|
||||
"galaxy/gamemaster/internal/service/adminforce"
|
||||
"galaxy/gamemaster/internal/service/adminpatch"
|
||||
"galaxy/gamemaster/internal/service/adminstop"
|
||||
"galaxy/gamemaster/internal/service/commandexecute"
|
||||
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
|
||||
"galaxy/gamemaster/internal/service/livenessreply"
|
||||
"galaxy/gamemaster/internal/service/orderput"
|
||||
"galaxy/gamemaster/internal/service/registerruntime"
|
||||
"galaxy/gamemaster/internal/service/reportget"
|
||||
"galaxy/gamemaster/internal/service/turngeneration"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/getkin/kin-openapi/openapi3filter"
|
||||
"github.com/getkin/kin-openapi/routers"
|
||||
"github.com/getkin/kin-openapi/routers/legacy"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestInternalRESTConformance loads the OpenAPI specification, drives
|
||||
// every internal REST operation against the live listener backed by
|
||||
// stub services, and validates each request and response body
|
||||
// against the spec via `openapi3filter.ValidateRequest` and
|
||||
// `openapi3filter.ValidateResponse`. Failure-path response shapes
|
||||
// are intentionally out of scope here; per-handler tests under
|
||||
// `handlers/<op>_test.go` cover the failure branches.
|
||||
func TestInternalRESTConformance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadConformanceSpec(t)
|
||||
|
||||
router, err := legacy.NewRouter(doc)
|
||||
require.NoError(t, err)
|
||||
|
||||
deps := newConformanceDeps()
|
||||
server, err := NewServer(newConformanceConfig(), Dependencies{
|
||||
Logger: nil,
|
||||
Telemetry: nil,
|
||||
Readiness: nil,
|
||||
RuntimeRecords: deps.runtimeRecords,
|
||||
RegisterRuntime: deps.registerRuntime,
|
||||
ForceNextTurn: deps.forceNextTurn,
|
||||
StopRuntime: deps.stopRuntime,
|
||||
PatchRuntime: deps.patchRuntime,
|
||||
BanishRace: deps.banishRace,
|
||||
InvalidateMemberships: deps.membership,
|
||||
GameLiveness: deps.liveness,
|
||||
EngineVersions: deps.engineVersions,
|
||||
CommandExecute: deps.commandExecute,
|
||||
PutOrders: deps.putOrders,
|
||||
GetReport: deps.getReport,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cases := []conformanceCase{
|
||||
{name: "internalHealthz", method: http.MethodGet, path: "/healthz"},
|
||||
{name: "internalReadyz", method: http.MethodGet, path: "/readyz"},
|
||||
{
|
||||
name: "internalRegisterRuntime",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/games/" + conformanceGameID + "/register-runtime",
|
||||
contentType: "application/json",
|
||||
body: `{
|
||||
"engine_endpoint": "http://galaxy-game-` + conformanceGameID + `:8080",
|
||||
"members": [{"user_id": "user-1", "race_name": "Aelinari"}],
|
||||
"target_engine_version": "1.2.3",
|
||||
"turn_schedule": "0 18 * * *"
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "internalBanishRace",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/games/" + conformanceGameID + "/race/Aelinari/banish",
|
||||
expectedStatus: http.StatusNoContent,
|
||||
},
|
||||
{
|
||||
name: "internalInvalidateMemberships",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/games/" + conformanceGameID + "/memberships/invalidate",
|
||||
expectedStatus: http.StatusNoContent,
|
||||
},
|
||||
{
|
||||
name: "internalGameLiveness",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/games/" + conformanceGameID + "/liveness",
|
||||
},
|
||||
{name: "internalListRuntimes", method: http.MethodGet, path: "/api/v1/internal/runtimes"},
|
||||
{
|
||||
name: "internalGetRuntime",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/runtimes/" + conformanceGameID,
|
||||
},
|
||||
{
|
||||
name: "internalForceNextTurn",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/force-next-turn",
|
||||
},
|
||||
{
|
||||
name: "internalStopRuntime",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/stop",
|
||||
contentType: "application/json",
|
||||
body: `{"reason":"admin_request"}`,
|
||||
},
|
||||
{
|
||||
name: "internalPatchRuntime",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/patch",
|
||||
contentType: "application/json",
|
||||
body: `{"version":"1.2.4"}`,
|
||||
},
|
||||
{name: "internalListEngineVersions", method: http.MethodGet, path: "/api/v1/internal/engine-versions"},
|
||||
{
|
||||
name: "internalCreateEngineVersion",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/engine-versions",
|
||||
contentType: "application/json",
|
||||
body: `{"version":"1.2.5","image_ref":"galaxy/game:1.2.5"}`,
|
||||
expectedStatus: http.StatusCreated,
|
||||
},
|
||||
{
|
||||
name: "internalGetEngineVersion",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/engine-versions/1.2.3",
|
||||
},
|
||||
{
|
||||
name: "internalUpdateEngineVersion",
|
||||
method: http.MethodPatch,
|
||||
path: "/api/v1/internal/engine-versions/1.2.3",
|
||||
contentType: "application/json",
|
||||
body: `{"image_ref":"galaxy/game:1.2.3-patch"}`,
|
||||
},
|
||||
{
|
||||
name: "internalDeprecateEngineVersion",
|
||||
method: http.MethodDelete,
|
||||
path: "/api/v1/internal/engine-versions/1.2.3",
|
||||
expectedStatus: http.StatusNoContent,
|
||||
},
|
||||
{
|
||||
name: "internalResolveEngineVersionImageRef",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/engine-versions/1.2.3/image-ref",
|
||||
},
|
||||
{
|
||||
name: "internalExecuteCommands",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/games/" + conformanceGameID + "/commands",
|
||||
contentType: "application/json",
|
||||
body: `{"commands":[{"name":"build","args":{}}]}`,
|
||||
extraHeaders: map[string]string{userIDHeader: conformanceUserID},
|
||||
},
|
||||
{
|
||||
name: "internalPutOrders",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/games/" + conformanceGameID + "/orders",
|
||||
contentType: "application/json",
|
||||
body: `{"commands":[{"name":"move","args":{}}]}`,
|
||||
extraHeaders: map[string]string{userIDHeader: conformanceUserID},
|
||||
},
|
||||
{
|
||||
name: "internalGetReport",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/games/" + conformanceGameID + "/reports/0",
|
||||
extraHeaders: map[string]string{userIDHeader: conformanceUserID},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
runConformanceCase(t, server.handler, router, tc)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
conformanceGameID = "game-conformance"
|
||||
conformanceUserID = "user-conformance"
|
||||
conformanceServerURL = "http://localhost:8097"
|
||||
userIDHeader = "X-User-ID"
|
||||
)
|
||||
|
||||
type conformanceCase struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
contentType string
|
||||
body string
|
||||
expectedStatus int
|
||||
extraHeaders map[string]string
|
||||
}
|
||||
|
||||
func runConformanceCase(t *testing.T, handler http.Handler, router routers.Router, tc conformanceCase) {
|
||||
t.Helper()
|
||||
|
||||
expectedStatus := tc.expectedStatus
|
||||
if expectedStatus == 0 {
|
||||
expectedStatus = http.StatusOK
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if tc.body != "" {
|
||||
bodyReader = strings.NewReader(tc.body)
|
||||
}
|
||||
request := httptest.NewRequest(tc.method, tc.path, bodyReader)
|
||||
if tc.contentType != "" {
|
||||
request.Header.Set("Content-Type", tc.contentType)
|
||||
}
|
||||
request.Header.Set("X-Galaxy-Caller", "admin")
|
||||
for key, value := range tc.extraHeaders {
|
||||
request.Header.Set(key, value)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
require.Equalf(t, expectedStatus, recorder.Code,
|
||||
"operation %s returned %d: %s", tc.name, recorder.Code, recorder.Body.String())
|
||||
|
||||
validationURL := conformanceServerURL + tc.path
|
||||
validationRequest := httptest.NewRequest(tc.method, validationURL, bodyReaderFor(tc.body))
|
||||
if tc.contentType != "" {
|
||||
validationRequest.Header.Set("Content-Type", tc.contentType)
|
||||
}
|
||||
validationRequest.Header.Set("X-Galaxy-Caller", "admin")
|
||||
for key, value := range tc.extraHeaders {
|
||||
validationRequest.Header.Set(key, value)
|
||||
}
|
||||
|
||||
route, pathParams, err := router.FindRoute(validationRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
requestInput := &openapi3filter.RequestValidationInput{
|
||||
Request: validationRequest,
|
||||
PathParams: pathParams,
|
||||
Route: route,
|
||||
Options: &openapi3filter.Options{
|
||||
IncludeResponseStatus: true,
|
||||
},
|
||||
}
|
||||
require.NoError(t, openapi3filter.ValidateRequest(context.Background(), requestInput))
|
||||
|
||||
responseInput := &openapi3filter.ResponseValidationInput{
|
||||
RequestValidationInput: requestInput,
|
||||
Status: recorder.Code,
|
||||
Header: recorder.Header(),
|
||||
Options: &openapi3filter.Options{
|
||||
IncludeResponseStatus: true,
|
||||
},
|
||||
}
|
||||
responseInput.SetBodyBytes(recorder.Body.Bytes())
|
||||
require.NoError(t, openapi3filter.ValidateResponse(context.Background(), responseInput))
|
||||
}
|
||||
|
||||
func loadConformanceSpec(t *testing.T) *openapi3.T {
|
||||
t.Helper()
|
||||
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
require.True(t, ok)
|
||||
|
||||
specPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "api", "internal-openapi.yaml")
|
||||
loader := openapi3.NewLoader()
|
||||
doc, err := loader.LoadFromFile(specPath)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, doc.Validate(context.Background()))
|
||||
return doc
|
||||
}
|
||||
|
||||
func bodyReaderFor(raw string) io.Reader {
|
||||
if raw == "" {
|
||||
return http.NoBody
|
||||
}
|
||||
return bytes.NewBufferString(raw)
|
||||
}
|
||||
|
||||
func newConformanceConfig() Config {
|
||||
return Config{
|
||||
Addr: ":0",
|
||||
ReadHeaderTimeout: time.Second,
|
||||
ReadTimeout: time.Second,
|
||||
WriteTimeout: time.Second,
|
||||
IdleTimeout: time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// conformanceDeps groups the stub collaborators handed to the listener.
|
||||
type conformanceDeps struct {
|
||||
runtimeRecords *conformanceRuntimeRecords
|
||||
registerRuntime *conformanceRegister
|
||||
forceNextTurn *conformanceForce
|
||||
stopRuntime *conformanceStop
|
||||
patchRuntime *conformancePatch
|
||||
banishRace *conformanceBanish
|
||||
membership *conformanceMembership
|
||||
liveness *conformanceLiveness
|
||||
engineVersions *conformanceEngineVersions
|
||||
commandExecute *conformanceCommands
|
||||
putOrders *conformanceOrders
|
||||
getReport *conformanceReport
|
||||
}
|
||||
|
||||
func newConformanceDeps() *conformanceDeps {
|
||||
return &conformanceDeps{
|
||||
runtimeRecords: newConformanceRuntimeRecords(),
|
||||
registerRuntime: &conformanceRegister{},
|
||||
forceNextTurn: &conformanceForce{},
|
||||
stopRuntime: &conformanceStop{},
|
||||
patchRuntime: &conformancePatch{},
|
||||
banishRace: &conformanceBanish{},
|
||||
membership: &conformanceMembership{},
|
||||
liveness: &conformanceLiveness{},
|
||||
engineVersions: newConformanceEngineVersions(),
|
||||
commandExecute: &conformanceCommands{},
|
||||
putOrders: &conformanceOrders{},
|
||||
getReport: &conformanceReport{},
|
||||
}
|
||||
}
|
||||
|
||||
// conformanceRecord builds a canonical running runtime record used
|
||||
// by every stub service.
|
||||
func conformanceRuntimeRecord() domainruntime.RuntimeRecord {
|
||||
moment := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC)
|
||||
next := moment.Add(time.Minute)
|
||||
started := moment
|
||||
return domainruntime.RuntimeRecord{
|
||||
GameID: conformanceGameID,
|
||||
Status: domainruntime.StatusRunning,
|
||||
EngineEndpoint: "http://galaxy-game-" + conformanceGameID + ":8080",
|
||||
CurrentImageRef: "galaxy/game:1.2.3",
|
||||
CurrentEngineVersion: "1.2.3",
|
||||
TurnSchedule: "0 18 * * *",
|
||||
CurrentTurn: 0,
|
||||
NextGenerationAt: &next,
|
||||
SkipNextTick: false,
|
||||
EngineHealth: "healthy",
|
||||
CreatedAt: moment,
|
||||
UpdatedAt: moment,
|
||||
StartedAt: &started,
|
||||
}
|
||||
}
|
||||
|
||||
func conformanceEngineVersionRecord(version string) engineversion.EngineVersion {
|
||||
moment := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC)
|
||||
return engineversion.EngineVersion{
|
||||
Version: version,
|
||||
ImageRef: "galaxy/game:" + version,
|
||||
Options: nil,
|
||||
Status: engineversion.StatusActive,
|
||||
CreatedAt: moment,
|
||||
UpdatedAt: moment,
|
||||
}
|
||||
}
|
||||
|
||||
// conformanceRuntimeRecords is an in-memory store seeded with the
|
||||
// canonical record so the get/list endpoints have something to return.
|
||||
type conformanceRuntimeRecords struct {
|
||||
mu sync.Mutex
|
||||
stored map[string]domainruntime.RuntimeRecord
|
||||
}
|
||||
|
||||
func newConformanceRuntimeRecords() *conformanceRuntimeRecords {
|
||||
return &conformanceRuntimeRecords{
|
||||
stored: map[string]domainruntime.RuntimeRecord{
|
||||
conformanceGameID: conformanceRuntimeRecord(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *conformanceRuntimeRecords) Get(_ context.Context, gameID string) (domainruntime.RuntimeRecord, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
record, ok := s.stored[gameID]
|
||||
if !ok {
|
||||
return domainruntime.RuntimeRecord{}, domainruntime.ErrNotFound
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *conformanceRuntimeRecords) List(_ context.Context) ([]domainruntime.RuntimeRecord, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]domainruntime.RuntimeRecord, 0, len(s.stored))
|
||||
for _, record := range s.stored {
|
||||
out = append(out, record)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *conformanceRuntimeRecords) ListByStatus(_ context.Context, status domainruntime.Status) ([]domainruntime.RuntimeRecord, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]domainruntime.RuntimeRecord, 0, len(s.stored))
|
||||
for _, record := range s.stored {
|
||||
if record.Status == status {
|
||||
out = append(out, record)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type conformanceRegister struct{}
|
||||
|
||||
func (s *conformanceRegister) Handle(_ context.Context, _ registerruntime.Input) (registerruntime.Result, error) {
|
||||
return registerruntime.Result{
|
||||
Record: conformanceRuntimeRecord(),
|
||||
Outcome: operation.OutcomeSuccess,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type conformanceForce struct{}
|
||||
|
||||
func (s *conformanceForce) Handle(_ context.Context, _ adminforce.Input) (adminforce.Result, error) {
|
||||
return adminforce.Result{
|
||||
TurnGeneration: turngeneration.Result{Record: conformanceRuntimeRecord()},
|
||||
SkipScheduled: true,
|
||||
Outcome: operation.OutcomeSuccess,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type conformanceStop struct{}
|
||||
|
||||
func (s *conformanceStop) Handle(_ context.Context, _ adminstop.Input) (adminstop.Result, error) {
|
||||
rec := conformanceRuntimeRecord()
|
||||
rec.Status = domainruntime.StatusStopped
|
||||
stopped := rec.UpdatedAt.Add(time.Second)
|
||||
rec.StoppedAt = &stopped
|
||||
rec.UpdatedAt = stopped
|
||||
return adminstop.Result{Record: rec, Outcome: operation.OutcomeSuccess}, nil
|
||||
}
|
||||
|
||||
type conformancePatch struct{}
|
||||
|
||||
func (s *conformancePatch) Handle(_ context.Context, in adminpatch.Input) (adminpatch.Result, error) {
|
||||
rec := conformanceRuntimeRecord()
|
||||
if in.Version != "" {
|
||||
rec.CurrentImageRef = "galaxy/game:" + in.Version
|
||||
rec.CurrentEngineVersion = in.Version
|
||||
}
|
||||
return adminpatch.Result{Record: rec, Outcome: operation.OutcomeSuccess}, nil
|
||||
}
|
||||
|
||||
type conformanceBanish struct{}
|
||||
|
||||
func (s *conformanceBanish) Handle(_ context.Context, _ adminbanish.Input) (adminbanish.Result, error) {
|
||||
return adminbanish.Result{Outcome: operation.OutcomeSuccess}, nil
|
||||
}
|
||||
|
||||
type conformanceMembership struct{}
|
||||
|
||||
func (m *conformanceMembership) Invalidate(string) {}
|
||||
|
||||
type conformanceLiveness struct{}
|
||||
|
||||
func (s *conformanceLiveness) Handle(_ context.Context, _ livenessreply.Input) (livenessreply.Result, error) {
|
||||
return livenessreply.Result{
|
||||
Ready: true,
|
||||
Status: domainruntime.StatusRunning,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type conformanceEngineVersions struct {
|
||||
mu sync.Mutex
|
||||
versions map[string]engineversion.EngineVersion
|
||||
}
|
||||
|
||||
func newConformanceEngineVersions() *conformanceEngineVersions {
|
||||
return &conformanceEngineVersions{
|
||||
versions: map[string]engineversion.EngineVersion{
|
||||
"1.2.3": conformanceEngineVersionRecord("1.2.3"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *conformanceEngineVersions) List(_ context.Context, _ *engineversion.Status) ([]engineversion.EngineVersion, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]engineversion.EngineVersion, 0, len(s.versions))
|
||||
for _, version := range s.versions {
|
||||
out = append(out, version)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *conformanceEngineVersions) Get(_ context.Context, version string) (engineversion.EngineVersion, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v, ok := s.versions[version]
|
||||
if !ok {
|
||||
return engineversion.EngineVersion{}, engineversionsvc.ErrNotFound
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (s *conformanceEngineVersions) ResolveImageRef(_ context.Context, version string) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v, ok := s.versions[version]
|
||||
if !ok {
|
||||
return "", engineversionsvc.ErrNotFound
|
||||
}
|
||||
return v.ImageRef, nil
|
||||
}
|
||||
|
||||
func (s *conformanceEngineVersions) Create(_ context.Context, in engineversionsvc.CreateInput) (engineversion.EngineVersion, error) {
|
||||
rec := engineversion.EngineVersion{
|
||||
Version: in.Version,
|
||||
ImageRef: in.ImageRef,
|
||||
Options: in.Options,
|
||||
Status: engineversion.StatusActive,
|
||||
CreatedAt: time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC),
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.versions[in.Version] = rec
|
||||
s.mu.Unlock()
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func (s *conformanceEngineVersions) Update(_ context.Context, in engineversionsvc.UpdateInput) (engineversion.EngineVersion, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
rec, ok := s.versions[in.Version]
|
||||
if !ok {
|
||||
return engineversion.EngineVersion{}, engineversionsvc.ErrNotFound
|
||||
}
|
||||
if in.ImageRef != nil {
|
||||
rec.ImageRef = *in.ImageRef
|
||||
}
|
||||
if in.Status != nil {
|
||||
rec.Status = *in.Status
|
||||
}
|
||||
rec.UpdatedAt = time.Date(2026, 4, 30, 13, 0, 0, 0, time.UTC)
|
||||
s.versions[in.Version] = rec
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func (s *conformanceEngineVersions) Deprecate(_ context.Context, in engineversionsvc.DeprecateInput) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
rec, ok := s.versions[in.Version]
|
||||
if !ok {
|
||||
return engineversionsvc.ErrNotFound
|
||||
}
|
||||
rec.Status = engineversion.StatusDeprecated
|
||||
rec.UpdatedAt = time.Date(2026, 4, 30, 14, 0, 0, 0, time.UTC)
|
||||
s.versions[in.Version] = rec
|
||||
return nil
|
||||
}
|
||||
|
||||
type conformanceCommands struct{}
|
||||
|
||||
func (s *conformanceCommands) Handle(_ context.Context, _ commandexecute.Input) (commandexecute.Result, error) {
|
||||
return commandexecute.Result{
|
||||
Outcome: operation.OutcomeSuccess,
|
||||
RawResponse: json.RawMessage(`{"results":[]}`),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type conformanceOrders struct{}
|
||||
|
||||
func (s *conformanceOrders) Handle(_ context.Context, _ orderput.Input) (orderput.Result, error) {
|
||||
return orderput.Result{
|
||||
Outcome: operation.OutcomeSuccess,
|
||||
RawResponse: json.RawMessage(`{"results":[]}`),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type conformanceReport struct{}
|
||||
|
||||
func (s *conformanceReport) Handle(_ context.Context, _ reportget.Input) (reportget.Result, error) {
|
||||
return reportget.Result{
|
||||
Outcome: operation.OutcomeSuccess,
|
||||
RawResponse: json.RawMessage(`{"player":"Aelinari","turn":0}`),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Compile-time guards that the stubs satisfy the handler-level
|
||||
// service interfaces accepted by the listener.
|
||||
var (
|
||||
_ handlers.RegisterRuntimeService = (*conformanceRegister)(nil)
|
||||
_ handlers.ForceNextTurnService = (*conformanceForce)(nil)
|
||||
_ handlers.StopRuntimeService = (*conformanceStop)(nil)
|
||||
_ handlers.PatchRuntimeService = (*conformancePatch)(nil)
|
||||
_ handlers.BanishRaceService = (*conformanceBanish)(nil)
|
||||
_ handlers.MembershipInvalidator = (*conformanceMembership)(nil)
|
||||
_ handlers.LivenessService = (*conformanceLiveness)(nil)
|
||||
_ handlers.EngineVersionService = (*conformanceEngineVersions)(nil)
|
||||
_ handlers.CommandExecuteService = (*conformanceCommands)(nil)
|
||||
_ handlers.OrderPutService = (*conformanceOrders)(nil)
|
||||
_ handlers.ReportGetService = (*conformanceReport)(nil)
|
||||
_ handlers.RuntimeRecordsReader = (*conformanceRuntimeRecords)(nil)
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
"galaxy/gamemaster/internal/service/adminbanish"
|
||||
)
|
||||
|
||||
// newBanishRaceHandler returns the handler for
|
||||
// `POST /api/v1/internal/games/{game_id}/race/{race_name}/banish`. The
|
||||
// request has no body; both identifiers come from the URL path.
|
||||
// Success returns `204 No Content`.
|
||||
func newBanishRaceHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.banish_race")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.BanishRace == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "banish race service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
raceName, ok := extractRaceName(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := deps.BanishRace.Handle(request.Context(), adminbanish.Input{
|
||||
GameID: gameID,
|
||||
RaceName: raceName,
|
||||
OpSource: resolveOpSource(request),
|
||||
SourceRef: requestSourceRef(request),
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "banish race service errored",
|
||||
"game_id", gameID,
|
||||
"race_name", raceName,
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "banish race service failed")
|
||||
return
|
||||
}
|
||||
|
||||
if result.Outcome == operation.OutcomeFailure {
|
||||
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
writeNoContent(writer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/engineversion"
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
"galaxy/gamemaster/internal/domain/runtime"
|
||||
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
|
||||
)
|
||||
|
||||
// jsonContentType is the Content-Type used by every internal REST
|
||||
// response body except the engine pass-through bodies which retain
|
||||
// the engine's chosen Content-Type.
|
||||
const jsonContentType = "application/json; charset=utf-8"
|
||||
|
||||
// callerHeader is the optional caller-classification header used to
|
||||
// attribute each request to a specific entry point. Documented in
|
||||
// `gamemaster/README.md` §«Internal REST API». Missing or unknown
|
||||
// values map to OpSourceAdminRest.
|
||||
const callerHeader = "X-Galaxy-Caller"
|
||||
|
||||
// userIDHeader carries the verified player identity propagated by
|
||||
// Edge Gateway on hot-path operations. Required for
|
||||
// `internalExecuteCommands`, `internalPutOrders`, and
|
||||
// `internalGetReport`.
|
||||
const userIDHeader = "X-User-ID"
|
||||
|
||||
// requestIDHeader is read into `operation_log.source_ref` when present
|
||||
// so REST callers can correlate audit rows with their requests.
|
||||
const requestIDHeader = "X-Request-ID"
|
||||
|
||||
// gameIDPathParam, raceNamePathParam, versionPathParam, turnPathParam
|
||||
// mirror the parameter names declared in
|
||||
// `gamemaster/api/internal-openapi.yaml`.
|
||||
const (
|
||||
gameIDPathParam = "game_id"
|
||||
raceNamePathParam = "race_name"
|
||||
versionPathParam = "version"
|
||||
turnPathParam = "turn"
|
||||
)
|
||||
|
||||
// Stable error codes used by the handler layer when no service result
|
||||
// is available (e.g., the service is not wired or the request shape
|
||||
// failed pre-decode validation). The values match the vocabulary
|
||||
// frozen by `gamemaster/README.md §Error Model` and
|
||||
// `gamemaster/api/internal-openapi.yaml`.
|
||||
const (
|
||||
errorCodeInvalidRequest = "invalid_request"
|
||||
errorCodeForbidden = "forbidden"
|
||||
errorCodeRuntimeNotFound = "runtime_not_found"
|
||||
errorCodeEngineVersionNotFound = "engine_version_not_found"
|
||||
errorCodeEngineVersionInUse = "engine_version_in_use"
|
||||
errorCodeConflict = "conflict"
|
||||
errorCodeRuntimeNotRunning = "runtime_not_running"
|
||||
errorCodeSemverPatchOnly = "semver_patch_only"
|
||||
errorCodeEngineUnreachable = "engine_unreachable"
|
||||
errorCodeEngineValidationError = "engine_validation_error"
|
||||
errorCodeEngineProtocolError = "engine_protocol_violation"
|
||||
errorCodeServiceUnavailable = "service_unavailable"
|
||||
errorCodeInternal = "internal_error"
|
||||
)
|
||||
|
||||
// errorBody mirrors the `error` element of the OpenAPI ErrorResponse
|
||||
// schema.
|
||||
type errorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// errorResponse mirrors the OpenAPI ErrorResponse envelope.
|
||||
type errorResponse struct {
|
||||
Error errorBody `json:"error"`
|
||||
}
|
||||
|
||||
// runtimeRecordResponse mirrors the OpenAPI RuntimeRecord schema.
|
||||
// Required timestamps are always present and encode as int64 UTC
|
||||
// milliseconds; optional ones use `*int64` so an absent value is
|
||||
// omitted from the JSON form (rather than encoded as `null`).
|
||||
type runtimeRecordResponse struct {
|
||||
GameID string `json:"game_id"`
|
||||
RuntimeStatus string `json:"runtime_status"`
|
||||
EngineEndpoint string `json:"engine_endpoint"`
|
||||
CurrentImageRef string `json:"current_image_ref"`
|
||||
CurrentEngineVersion string `json:"current_engine_version"`
|
||||
TurnSchedule string `json:"turn_schedule"`
|
||||
CurrentTurn int `json:"current_turn"`
|
||||
NextGenerationAt int64 `json:"next_generation_at"`
|
||||
SkipNextTick bool `json:"skip_next_tick"`
|
||||
EngineHealthSummary string `json:"engine_health_summary"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
StartedAt *int64 `json:"started_at,omitempty"`
|
||||
StoppedAt *int64 `json:"stopped_at,omitempty"`
|
||||
FinishedAt *int64 `json:"finished_at,omitempty"`
|
||||
}
|
||||
|
||||
// runtimeListResponse mirrors the OpenAPI RuntimeListResponse schema.
|
||||
// Runtimes is always non-nil so an empty result encodes as
|
||||
// `{"runtimes":[]}` rather than `{"runtimes":null}`.
|
||||
type runtimeListResponse struct {
|
||||
Runtimes []runtimeRecordResponse `json:"runtimes"`
|
||||
}
|
||||
|
||||
// engineVersionResponse mirrors the OpenAPI EngineVersion schema.
|
||||
// Options is a `json.RawMessage` so the engine-side document passes
|
||||
// through verbatim.
|
||||
type engineVersionResponse struct {
|
||||
Version string `json:"version"`
|
||||
ImageRef string `json:"image_ref"`
|
||||
Options json.RawMessage `json:"options"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// engineVersionListResponse mirrors the OpenAPI
|
||||
// EngineVersionListResponse schema.
|
||||
type engineVersionListResponse struct {
|
||||
Versions []engineVersionResponse `json:"versions"`
|
||||
}
|
||||
|
||||
// imageRefResponse mirrors the OpenAPI ImageRefResponse schema.
|
||||
type imageRefResponse struct {
|
||||
ImageRef string `json:"image_ref"`
|
||||
}
|
||||
|
||||
// livenessResponse mirrors the OpenAPI LivenessResponse schema.
|
||||
type livenessResponse struct {
|
||||
Ready bool `json:"ready"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// encodeRuntimeRecord turns a domain RuntimeRecord into its wire shape.
|
||||
// Required `next_generation_at` encodes as `0` when the record carries
|
||||
// no scheduled tick (e.g., status=starting before the first
|
||||
// scheduling write); optional lifecycle timestamps are omitted when
|
||||
// nil.
|
||||
func encodeRuntimeRecord(record runtime.RuntimeRecord) runtimeRecordResponse {
|
||||
resp := runtimeRecordResponse{
|
||||
GameID: record.GameID,
|
||||
RuntimeStatus: string(record.Status),
|
||||
EngineEndpoint: record.EngineEndpoint,
|
||||
CurrentImageRef: record.CurrentImageRef,
|
||||
CurrentEngineVersion: record.CurrentEngineVersion,
|
||||
TurnSchedule: record.TurnSchedule,
|
||||
CurrentTurn: record.CurrentTurn,
|
||||
SkipNextTick: record.SkipNextTick,
|
||||
EngineHealthSummary: record.EngineHealth,
|
||||
CreatedAt: record.CreatedAt.UTC().UnixMilli(),
|
||||
UpdatedAt: record.UpdatedAt.UTC().UnixMilli(),
|
||||
}
|
||||
if record.NextGenerationAt != nil {
|
||||
resp.NextGenerationAt = record.NextGenerationAt.UTC().UnixMilli()
|
||||
}
|
||||
if record.StartedAt != nil {
|
||||
v := record.StartedAt.UTC().UnixMilli()
|
||||
resp.StartedAt = &v
|
||||
}
|
||||
if record.StoppedAt != nil {
|
||||
v := record.StoppedAt.UTC().UnixMilli()
|
||||
resp.StoppedAt = &v
|
||||
}
|
||||
if record.FinishedAt != nil {
|
||||
v := record.FinishedAt.UTC().UnixMilli()
|
||||
resp.FinishedAt = &v
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// encodeRuntimeList turns a domain RuntimeRecord slice into a wire
|
||||
// list response. records may be nil (empty store); the result still
|
||||
// carries an empty Runtimes slice so the JSON form is `{"runtimes":[]}`.
|
||||
func encodeRuntimeList(records []runtime.RuntimeRecord) runtimeListResponse {
|
||||
resp := runtimeListResponse{
|
||||
Runtimes: make([]runtimeRecordResponse, 0, len(records)),
|
||||
}
|
||||
for _, record := range records {
|
||||
resp.Runtimes = append(resp.Runtimes, encodeRuntimeRecord(record))
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// encodeEngineVersion turns a domain EngineVersion into its wire shape.
|
||||
// Empty Options bytes encode as the JSON object literal `{}` to
|
||||
// satisfy the schema (`type: object`).
|
||||
func encodeEngineVersion(version engineversion.EngineVersion) engineVersionResponse {
|
||||
options := json.RawMessage(version.Options)
|
||||
if len(options) == 0 {
|
||||
options = json.RawMessage("{}")
|
||||
}
|
||||
return engineVersionResponse{
|
||||
Version: version.Version,
|
||||
ImageRef: version.ImageRef,
|
||||
Options: options,
|
||||
Status: string(version.Status),
|
||||
CreatedAt: version.CreatedAt.UTC().UnixMilli(),
|
||||
UpdatedAt: version.UpdatedAt.UTC().UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
// encodeEngineVersionList turns a slice of domain EngineVersions into
|
||||
// a wire list response. The Versions slice is always non-nil.
|
||||
func encodeEngineVersionList(versions []engineversion.EngineVersion) engineVersionListResponse {
|
||||
resp := engineVersionListResponse{
|
||||
Versions: make([]engineVersionResponse, 0, len(versions)),
|
||||
}
|
||||
for _, version := range versions {
|
||||
resp.Versions = append(resp.Versions, encodeEngineVersion(version))
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// writeJSON writes payload as a JSON response with the given status
|
||||
// code.
|
||||
func writeJSON(writer http.ResponseWriter, statusCode int, payload any) {
|
||||
writer.Header().Set("Content-Type", jsonContentType)
|
||||
writer.WriteHeader(statusCode)
|
||||
_ = json.NewEncoder(writer).Encode(payload)
|
||||
}
|
||||
|
||||
// writeNoContent writes `204 No Content` with no body. The
|
||||
// Content-Type header is intentionally omitted so kin-openapi's
|
||||
// response validator does not look for a body.
|
||||
func writeNoContent(writer http.ResponseWriter) {
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// writeRawJSON writes raw, already-encoded JSON bytes as the response
|
||||
// body with the given status code. Used by the hot-path handlers
|
||||
// where the engine's response body is forwarded verbatim.
|
||||
func writeRawJSON(writer http.ResponseWriter, statusCode int, body []byte) {
|
||||
writer.Header().Set("Content-Type", jsonContentType)
|
||||
writer.WriteHeader(statusCode)
|
||||
_, _ = writer.Write(body)
|
||||
}
|
||||
|
||||
// writeError writes the canonical error envelope at statusCode.
|
||||
func writeError(writer http.ResponseWriter, statusCode int, code, message string) {
|
||||
writeJSON(writer, statusCode, errorResponse{
|
||||
Error: errorBody{Code: code, Message: message},
|
||||
})
|
||||
}
|
||||
|
||||
// writeFailure writes the canonical error envelope using the HTTP
|
||||
// status mapped from code via mapErrorCodeToStatus. Used by every
|
||||
// service-backed handler when its service returns
|
||||
// `Outcome=failure`.
|
||||
func writeFailure(writer http.ResponseWriter, code, message string) {
|
||||
writeError(writer, mapErrorCodeToStatus(code), code, message)
|
||||
}
|
||||
|
||||
// mapErrorCodeToStatus maps a stable error code to the HTTP status
|
||||
// declared by `gamemaster/api/internal-openapi.yaml`. Unknown codes
|
||||
// degrade to 500 so a future error code that ships ahead of its
|
||||
// handler-layer mapping still produces a structurally valid response.
|
||||
func mapErrorCodeToStatus(code string) int {
|
||||
switch code {
|
||||
case errorCodeInvalidRequest:
|
||||
return http.StatusBadRequest
|
||||
case errorCodeForbidden:
|
||||
return http.StatusForbidden
|
||||
case errorCodeRuntimeNotFound, errorCodeEngineVersionNotFound:
|
||||
return http.StatusNotFound
|
||||
case errorCodeConflict,
|
||||
errorCodeRuntimeNotRunning,
|
||||
errorCodeSemverPatchOnly,
|
||||
errorCodeEngineVersionInUse:
|
||||
return http.StatusConflict
|
||||
case errorCodeEngineUnreachable,
|
||||
errorCodeEngineValidationError,
|
||||
errorCodeEngineProtocolError:
|
||||
return http.StatusBadGateway
|
||||
case errorCodeServiceUnavailable:
|
||||
return http.StatusServiceUnavailable
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
// mapServiceError translates one of the `engineversionsvc` sentinel
|
||||
// errors into the corresponding HTTP status, error code, and message.
|
||||
// Unknown errors degrade to `500 internal_error`.
|
||||
func mapServiceError(err error) (int, string, string) {
|
||||
switch {
|
||||
case errors.Is(err, engineversionsvc.ErrInvalidRequest):
|
||||
return http.StatusBadRequest, errorCodeInvalidRequest, err.Error()
|
||||
case errors.Is(err, engineversionsvc.ErrNotFound):
|
||||
return http.StatusNotFound, errorCodeEngineVersionNotFound, err.Error()
|
||||
case errors.Is(err, engineversionsvc.ErrConflict):
|
||||
return http.StatusConflict, errorCodeConflict, err.Error()
|
||||
case errors.Is(err, engineversionsvc.ErrInUse):
|
||||
return http.StatusConflict, errorCodeEngineVersionInUse, err.Error()
|
||||
case errors.Is(err, engineversionsvc.ErrServiceUnavailable):
|
||||
return http.StatusServiceUnavailable, errorCodeServiceUnavailable, err.Error()
|
||||
default:
|
||||
return http.StatusInternalServerError, errorCodeInternal, "internal server error"
|
||||
}
|
||||
}
|
||||
|
||||
// decodeStrictJSON decodes one request body into target with strict
|
||||
// JSON semantics: unknown fields are rejected and trailing content is
|
||||
// rejected. Mirrors the helper used by lobby and rtmanager.
|
||||
func decodeStrictJSON(body io.Reader, target any) error {
|
||||
decoder := json.NewDecoder(body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if decoder.More() {
|
||||
return errors.New("unexpected trailing content after JSON body")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readRawJSONBody returns the raw request body provided it parses as
|
||||
// a JSON value. The hot-path handlers use this helper because the
|
||||
// envelope is engine-owned (`additionalProperties: true` on
|
||||
// ExecuteCommandsRequest / PutOrdersRequest); strict decoding would
|
||||
// reject legitimate extra fields.
|
||||
func readRawJSONBody(reader io.Reader) ([]byte, error) {
|
||||
if reader == nil {
|
||||
return nil, errors.New("request body is required")
|
||||
}
|
||||
body, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return nil, errors.New("request body is required")
|
||||
}
|
||||
if !json.Valid(body) {
|
||||
return nil, errors.New("request body is not valid JSON")
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// extractGameID pulls the {game_id} path variable from request. An
|
||||
// empty or whitespace-only value writes a `400 invalid_request` and
|
||||
// returns ok=false so callers can short-circuit.
|
||||
func extractGameID(writer http.ResponseWriter, request *http.Request) (string, bool) {
|
||||
raw := request.PathValue(gameIDPathParam)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "game id is required")
|
||||
return "", false
|
||||
}
|
||||
return raw, true
|
||||
}
|
||||
|
||||
// extractRaceName pulls the {race_name} path variable.
|
||||
func extractRaceName(writer http.ResponseWriter, request *http.Request) (string, bool) {
|
||||
raw := request.PathValue(raceNamePathParam)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "race name is required")
|
||||
return "", false
|
||||
}
|
||||
return raw, true
|
||||
}
|
||||
|
||||
// extractVersion pulls the {version} path variable.
|
||||
func extractVersion(writer http.ResponseWriter, request *http.Request) (string, bool) {
|
||||
raw := request.PathValue(versionPathParam)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "version is required")
|
||||
return "", false
|
||||
}
|
||||
return raw, true
|
||||
}
|
||||
|
||||
// extractUserID pulls the verified player identity from the
|
||||
// X-User-ID header. The hot-path operations require this header per
|
||||
// the OpenAPI spec; absent or whitespace-only values short-circuit
|
||||
// with `400 invalid_request`.
|
||||
func extractUserID(writer http.ResponseWriter, request *http.Request) (string, bool) {
|
||||
raw := strings.TrimSpace(request.Header.Get(userIDHeader))
|
||||
if raw == "" {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "X-User-ID header is required")
|
||||
return "", false
|
||||
}
|
||||
return raw, true
|
||||
}
|
||||
|
||||
// resolveOpSource maps the X-Galaxy-Caller header value to an
|
||||
// `operation.OpSource`. Missing or unknown values default to
|
||||
// OpSourceAdminRest, matching the documented contract in
|
||||
// `gamemaster/README.md` §«Internal REST API».
|
||||
func resolveOpSource(request *http.Request) operation.OpSource {
|
||||
switch strings.ToLower(strings.TrimSpace(request.Header.Get(callerHeader))) {
|
||||
case "gateway":
|
||||
return operation.OpSourceGatewayPlayer
|
||||
case "lobby":
|
||||
return operation.OpSourceLobbyInternal
|
||||
case "admin":
|
||||
return operation.OpSourceAdminRest
|
||||
default:
|
||||
return operation.OpSourceAdminRest
|
||||
}
|
||||
}
|
||||
|
||||
// requestSourceRef returns an opaque per-request reference recorded
|
||||
// in `operation_log.source_ref`. v1 reads the X-Request-ID header
|
||||
// when present so callers may correlate REST requests with audit
|
||||
// rows.
|
||||
func requestSourceRef(request *http.Request) string {
|
||||
return strings.TrimSpace(request.Header.Get(requestIDHeader))
|
||||
}
|
||||
|
||||
// loggerFor returns a logger annotated with the operation tag. Each
|
||||
// handler scopes its logs by op so operators filtering on
|
||||
// `op=internal_rest.<operation>` see exactly the lifecycle they care
|
||||
// about.
|
||||
func loggerFor(parent *slog.Logger, op string) *slog.Logger {
|
||||
if parent == nil {
|
||||
parent = slog.Default()
|
||||
}
|
||||
return parent.With("component", "internal_http.handlers", "op", op)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/engineversion"
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
"galaxy/gamemaster/internal/domain/runtime"
|
||||
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMapErrorCodeToStatusCoversEveryDocumentedCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]int{
|
||||
errorCodeInvalidRequest: http.StatusBadRequest,
|
||||
errorCodeForbidden: http.StatusForbidden,
|
||||
errorCodeRuntimeNotFound: http.StatusNotFound,
|
||||
errorCodeEngineVersionNotFound: http.StatusNotFound,
|
||||
errorCodeConflict: http.StatusConflict,
|
||||
errorCodeRuntimeNotRunning: http.StatusConflict,
|
||||
errorCodeSemverPatchOnly: http.StatusConflict,
|
||||
errorCodeEngineVersionInUse: http.StatusConflict,
|
||||
errorCodeEngineUnreachable: http.StatusBadGateway,
|
||||
errorCodeEngineValidationError: http.StatusBadGateway,
|
||||
errorCodeEngineProtocolError: http.StatusBadGateway,
|
||||
errorCodeServiceUnavailable: http.StatusServiceUnavailable,
|
||||
errorCodeInternal: http.StatusInternalServerError,
|
||||
"unknown_code": http.StatusInternalServerError,
|
||||
}
|
||||
|
||||
for code, expected := range cases {
|
||||
assert.Equalf(t, expected, mapErrorCodeToStatus(code), "code %q", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapServiceErrorMapsEverySentinel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
err error
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{engineversionsvc.ErrInvalidRequest, http.StatusBadRequest, errorCodeInvalidRequest},
|
||||
{engineversionsvc.ErrNotFound, http.StatusNotFound, errorCodeEngineVersionNotFound},
|
||||
{engineversionsvc.ErrConflict, http.StatusConflict, errorCodeConflict},
|
||||
{engineversionsvc.ErrInUse, http.StatusConflict, errorCodeEngineVersionInUse},
|
||||
{engineversionsvc.ErrServiceUnavailable, http.StatusServiceUnavailable, errorCodeServiceUnavailable},
|
||||
{errors.New("plain go error"), http.StatusInternalServerError, errorCodeInternal},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
status, code, _ := mapServiceError(tc.err)
|
||||
assert.Equalf(t, tc.status, status, "status for %v", tc.err)
|
||||
assert.Equalf(t, tc.code, code, "code for %v", tc.err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpSourceMapsCallerHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]operation.OpSource{
|
||||
"": operation.OpSourceAdminRest,
|
||||
"unknown": operation.OpSourceAdminRest,
|
||||
"GATEWAY": operation.OpSourceGatewayPlayer,
|
||||
" lobby ": operation.OpSourceLobbyInternal,
|
||||
"admin": operation.OpSourceAdminRest,
|
||||
}
|
||||
|
||||
for value, expected := range cases {
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if value != "" {
|
||||
request.Header.Set(callerHeader, value)
|
||||
}
|
||||
assert.Equalf(t, expected, resolveOpSource(request), "header %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestSourceRefReadsXRequestID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
assert.Empty(t, requestSourceRef(request))
|
||||
|
||||
request.Header.Set(requestIDHeader, " trace-123 ")
|
||||
assert.Equal(t, "trace-123", requestSourceRef(request))
|
||||
}
|
||||
|
||||
func TestDecodeStrictJSONRejectsUnknownFieldsAndTrailingContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type input struct {
|
||||
Field string `json:"field"`
|
||||
}
|
||||
|
||||
var ok input
|
||||
require.NoError(t, decodeStrictJSON(strings.NewReader(`{"field":"value"}`), &ok))
|
||||
assert.Equal(t, "value", ok.Field)
|
||||
|
||||
var rejected input
|
||||
err := decodeStrictJSON(strings.NewReader(`{"field":"v","extra":1}`), &rejected)
|
||||
require.Error(t, err)
|
||||
|
||||
var trailing input
|
||||
err = decodeStrictJSON(strings.NewReader(`{"field":"v"}{"another":true}`), &trailing)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestReadRawJSONBodyValidatesPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body, err := readRawJSONBody(strings.NewReader(`{"commands":[]}`))
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{"commands":[]}`, string(body))
|
||||
|
||||
_, err = readRawJSONBody(strings.NewReader(""))
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = readRawJSONBody(strings.NewReader("not json"))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestEncodeRuntimeRecordIncludesEveryRequiredField(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
moment := time.Date(2026, 5, 1, 9, 30, 0, 0, time.UTC)
|
||||
next := moment.Add(time.Minute)
|
||||
record := runtime.RuntimeRecord{
|
||||
GameID: "game-1",
|
||||
Status: runtime.StatusRunning,
|
||||
EngineEndpoint: "http://example:8080",
|
||||
CurrentImageRef: "galaxy/game:1.2.3",
|
||||
CurrentEngineVersion: "1.2.3",
|
||||
TurnSchedule: "0 18 * * *",
|
||||
CurrentTurn: 7,
|
||||
NextGenerationAt: &next,
|
||||
SkipNextTick: true,
|
||||
EngineHealth: "healthy",
|
||||
CreatedAt: moment,
|
||||
UpdatedAt: moment,
|
||||
StartedAt: &moment,
|
||||
}
|
||||
|
||||
encoded := encodeRuntimeRecord(record)
|
||||
assert.Equal(t, "game-1", encoded.GameID)
|
||||
assert.Equal(t, "running", encoded.RuntimeStatus)
|
||||
assert.Equal(t, moment.UnixMilli(), encoded.CreatedAt)
|
||||
assert.Equal(t, next.UnixMilli(), encoded.NextGenerationAt)
|
||||
require.NotNil(t, encoded.StartedAt)
|
||||
assert.Equal(t, moment.UnixMilli(), *encoded.StartedAt)
|
||||
assert.Nil(t, encoded.StoppedAt)
|
||||
assert.Nil(t, encoded.FinishedAt)
|
||||
}
|
||||
|
||||
func TestEncodeRuntimeRecordZerosNextGenerationWhenNil(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
moment := time.Date(2026, 5, 1, 9, 30, 0, 0, time.UTC)
|
||||
record := runtime.RuntimeRecord{
|
||||
GameID: "game-1",
|
||||
Status: runtime.StatusStarting,
|
||||
EngineEndpoint: "http://example:8080",
|
||||
CurrentImageRef: "galaxy/game:1.2.3",
|
||||
CurrentEngineVersion: "1.2.3",
|
||||
TurnSchedule: "0 18 * * *",
|
||||
CreatedAt: moment,
|
||||
UpdatedAt: moment,
|
||||
}
|
||||
|
||||
encoded := encodeRuntimeRecord(record)
|
||||
assert.Equal(t, int64(0), encoded.NextGenerationAt)
|
||||
assert.Nil(t, encoded.StartedAt)
|
||||
}
|
||||
|
||||
func TestEncodeEngineVersionDefaultsEmptyOptionsToObject(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
moment := time.Date(2026, 5, 1, 9, 30, 0, 0, time.UTC)
|
||||
encoded := encodeEngineVersion(engineversion.EngineVersion{
|
||||
Version: "1.2.3",
|
||||
ImageRef: "galaxy/game:1.2.3",
|
||||
Status: engineversion.StatusActive,
|
||||
CreatedAt: moment,
|
||||
UpdatedAt: moment,
|
||||
})
|
||||
assert.Equal(t, "{}", string(encoded.Options))
|
||||
assert.Equal(t, "active", encoded.Status)
|
||||
}
|
||||
|
||||
func TestEncodeRuntimeListAlwaysReturnsNonNilSlice(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resp := encodeRuntimeList(nil)
|
||||
require.NotNil(t, resp.Runtimes)
|
||||
assert.Empty(t, resp.Runtimes)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
|
||||
)
|
||||
|
||||
// createEngineVersionRequestBody mirrors the OpenAPI
|
||||
// CreateEngineVersionRequest schema.
|
||||
type createEngineVersionRequestBody struct {
|
||||
Version string `json:"version"`
|
||||
ImageRef string `json:"image_ref"`
|
||||
Options json.RawMessage `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// newCreateEngineVersionHandler returns the handler for
|
||||
// `POST /api/v1/internal/engine-versions`.
|
||||
func newCreateEngineVersionHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.create_engine_version")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.EngineVersions == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "engine version service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
var body createEngineVersionRequestBody
|
||||
if err := decodeStrictJSON(request.Body, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
record, err := deps.EngineVersions.Create(request.Context(), engineversionsvc.CreateInput{
|
||||
Version: body.Version,
|
||||
ImageRef: body.ImageRef,
|
||||
Options: []byte(body.Options),
|
||||
OpSource: resolveOpSource(request),
|
||||
SourceRef: requestSourceRef(request),
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "create engine version failed", "err", err.Error())
|
||||
status, code, message := mapServiceError(err)
|
||||
writeError(writer, status, code, message)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusCreated, encodeEngineVersion(record))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
|
||||
)
|
||||
|
||||
// newDeprecateEngineVersionHandler returns the handler for
|
||||
// `DELETE /api/v1/internal/engine-versions/{version}`. The endpoint
|
||||
// flips the row's status to `deprecated` (decision D2 in
|
||||
// `gamemaster/docs/stage19-internal-rest-handlers.md`); hard removal
|
||||
// is reserved for future Admin Service operations and not exposed
|
||||
// here.
|
||||
func newDeprecateEngineVersionHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.deprecate_engine_version")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.EngineVersions == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "engine version service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
version, ok := extractVersion(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := deps.EngineVersions.Deprecate(request.Context(), engineversionsvc.DeprecateInput{
|
||||
Version: version,
|
||||
OpSource: resolveOpSource(request),
|
||||
SourceRef: requestSourceRef(request),
|
||||
}); err != nil {
|
||||
logger.ErrorContext(request.Context(), "deprecate engine version failed",
|
||||
"version", version,
|
||||
"err", err.Error(),
|
||||
)
|
||||
status, code, message := mapServiceError(err)
|
||||
writeError(writer, status, code, message)
|
||||
return
|
||||
}
|
||||
|
||||
writeNoContent(writer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
"galaxy/gamemaster/internal/service/commandexecute"
|
||||
)
|
||||
|
||||
// newExecuteCommandsHandler returns the handler for
|
||||
// `POST /api/v1/internal/games/{game_id}/commands`. The request body
|
||||
// is engine-owned (`additionalProperties: true`) and is forwarded to
|
||||
// the service as a `json.RawMessage`. The response on success is the
|
||||
// engine's payload byte-for-byte; failure outcomes use the canonical
|
||||
// error envelope per the OpenAPI contract.
|
||||
func newExecuteCommandsHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.execute_commands")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.CommandExecute == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "command execute service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, ok := extractUserID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := readRawJSONBody(request.Body)
|
||||
if err != nil {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := deps.CommandExecute.Handle(request.Context(), commandexecute.Input{
|
||||
GameID: gameID,
|
||||
UserID: userID,
|
||||
Payload: body,
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "command execute service errored",
|
||||
"game_id", gameID,
|
||||
"user_id", userID,
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "command execute service failed")
|
||||
return
|
||||
}
|
||||
|
||||
if result.Outcome == operation.OutcomeFailure {
|
||||
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
writeRawJSON(writer, http.StatusOK, []byte(result.RawResponse))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
"galaxy/gamemaster/internal/service/adminforce"
|
||||
)
|
||||
|
||||
// newForceNextTurnHandler returns the handler for
|
||||
// `POST /api/v1/internal/runtimes/{game_id}/force-next-turn`. The
|
||||
// request has no body; the handler delegates to
|
||||
// `adminforce.Service.Handle` and encodes the resulting runtime
|
||||
// record on success.
|
||||
func newForceNextTurnHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.force_next_turn")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.ForceNextTurn == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "force next turn service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := deps.ForceNextTurn.Handle(request.Context(), adminforce.Input{
|
||||
GameID: gameID,
|
||||
OpSource: resolveOpSource(request),
|
||||
SourceRef: requestSourceRef(request),
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "force next turn service errored",
|
||||
"game_id", gameID,
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "force next turn service failed")
|
||||
return
|
||||
}
|
||||
|
||||
if result.Outcome == operation.OutcomeFailure {
|
||||
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeRuntimeRecord(result.TurnGeneration.Record))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/gamemaster/internal/service/livenessreply"
|
||||
)
|
||||
|
||||
// newGameLivenessHandler returns the handler for
|
||||
// `GET /api/v1/internal/games/{game_id}/liveness`. The endpoint
|
||||
// always responds with 200 + LivenessResponse; Go-level errors
|
||||
// returned by the service map to 500 / 503 according to their
|
||||
// embedded error code prefix.
|
||||
func newGameLivenessHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.game_liveness")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.GameLiveness == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "game liveness service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := deps.GameLiveness.Handle(request.Context(), livenessreply.Input{GameID: gameID})
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "game liveness service errored",
|
||||
"game_id", gameID,
|
||||
"err", err.Error(),
|
||||
)
|
||||
switch {
|
||||
case strings.HasPrefix(err.Error(), livenessreply.ErrorCodeInvalidRequest+":"):
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
|
||||
case strings.HasPrefix(err.Error(), livenessreply.ErrorCodeServiceUnavailable+":"):
|
||||
writeError(writer, http.StatusServiceUnavailable, errorCodeServiceUnavailable, "service unavailable")
|
||||
default:
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "game liveness service failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, livenessResponse{
|
||||
Ready: result.Ready,
|
||||
Status: string(result.Status),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package handlers
|
||||
|
||||
import "net/http"
|
||||
|
||||
// newGetEngineVersionHandler returns the handler for
|
||||
// `GET /api/v1/internal/engine-versions/{version}`.
|
||||
func newGetEngineVersionHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.get_engine_version")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.EngineVersions == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "engine version service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
version, ok := extractVersion(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := deps.EngineVersions.Get(request.Context(), version)
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "get engine version failed",
|
||||
"version", version,
|
||||
"err", err.Error(),
|
||||
)
|
||||
status, code, message := mapServiceError(err)
|
||||
writeError(writer, status, code, message)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeEngineVersion(record))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
"galaxy/gamemaster/internal/service/reportget"
|
||||
)
|
||||
|
||||
// newGetReportHandler returns the handler for
|
||||
// `GET /api/v1/internal/games/{game_id}/reports/{turn}`. Path
|
||||
// validation rejects non-numeric or negative turn values with
|
||||
// `400 invalid_request` before the service is touched.
|
||||
func newGetReportHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.get_report")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.GetReport == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "get report service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, ok := extractUserID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
raw := strings.TrimSpace(request.PathValue(turnPathParam))
|
||||
if raw == "" {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "turn is required")
|
||||
return
|
||||
}
|
||||
turn, err := strconv.Atoi(raw)
|
||||
if err != nil || turn < 0 {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "turn must be a non-negative integer")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := deps.GetReport.Handle(request.Context(), reportget.Input{
|
||||
GameID: gameID,
|
||||
UserID: userID,
|
||||
Turn: turn,
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "get report service errored",
|
||||
"game_id", gameID,
|
||||
"user_id", userID,
|
||||
"turn", turn,
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "get report service failed")
|
||||
return
|
||||
}
|
||||
|
||||
if result.Outcome == operation.OutcomeFailure {
|
||||
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
writeRawJSON(writer, http.StatusOK, []byte(result.RawResponse))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/runtime"
|
||||
)
|
||||
|
||||
// newGetRuntimeHandler returns the handler for
|
||||
// `GET /api/v1/internal/runtimes/{game_id}`. Reads from
|
||||
// `RuntimeRecordsReader.Get` and translates `runtime.ErrNotFound` to
|
||||
// `404 runtime_not_found`.
|
||||
func newGetRuntimeHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.get_runtime")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.RuntimeRecords == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "runtime records store is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := deps.RuntimeRecords.Get(request.Context(), gameID)
|
||||
if err != nil {
|
||||
if errors.Is(err, runtime.ErrNotFound) {
|
||||
writeError(writer, http.StatusNotFound, errorCodeRuntimeNotFound, "runtime not found")
|
||||
return
|
||||
}
|
||||
logger.ErrorContext(request.Context(), "get runtime record failed",
|
||||
"game_id", gameID,
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "failed to read runtime record")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeRuntimeRecord(record))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Package handlers serves the trusted internal REST surface of Game
|
||||
// Master frozen by `gamemaster/api/internal-openapi.yaml`. The package
|
||||
// owns one HandlerFunc per OpenAPI operation; route registration goes
|
||||
// through Register so the listener (`internal/api/internalhttp`) keeps
|
||||
// its lifecycle code separate from the per-operation logic. Handlers
|
||||
// delegate every business decision to the `internal/service/*`
|
||||
// packages and never decode engine-owned hot-path payloads.
|
||||
//
|
||||
// The pattern mirrors `rtmanager/internal/api/internalhttp/handlers`
|
||||
// so a reader familiar with one service can find their way around the
|
||||
// other.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Route paths frozen by `gamemaster/api/internal-openapi.yaml`. The
|
||||
// values match the operation IDs asserted in
|
||||
// `gamemaster/contract_openapi_test.go`; renaming any of them is a
|
||||
// contract change.
|
||||
const (
|
||||
registerRuntimePath = "/api/v1/internal/games/{game_id}/register-runtime"
|
||||
banishRacePath = "/api/v1/internal/games/{game_id}/race/{race_name}/banish"
|
||||
invalidateMembershipsPath = "/api/v1/internal/games/{game_id}/memberships/invalidate"
|
||||
gameLivenessPath = "/api/v1/internal/games/{game_id}/liveness"
|
||||
listRuntimesPath = "/api/v1/internal/runtimes"
|
||||
getRuntimePath = "/api/v1/internal/runtimes/{game_id}"
|
||||
forceNextTurnPath = "/api/v1/internal/runtimes/{game_id}/force-next-turn"
|
||||
stopRuntimePath = "/api/v1/internal/runtimes/{game_id}/stop"
|
||||
patchRuntimePath = "/api/v1/internal/runtimes/{game_id}/patch"
|
||||
listEngineVersionsPath = "/api/v1/internal/engine-versions"
|
||||
createEngineVersionPath = "/api/v1/internal/engine-versions"
|
||||
engineVersionItemPath = "/api/v1/internal/engine-versions/{version}"
|
||||
resolveEngineVersionImageRefPath = "/api/v1/internal/engine-versions/{version}/image-ref"
|
||||
executeCommandsPath = "/api/v1/internal/games/{game_id}/commands"
|
||||
putOrdersPath = "/api/v1/internal/games/{game_id}/orders"
|
||||
getReportPath = "/api/v1/internal/games/{game_id}/reports/{turn}"
|
||||
)
|
||||
|
||||
// Dependencies bundles the collaborators required to serve the
|
||||
// gateway-, Lobby-, and Admin-facing internal REST surface. Any port
|
||||
// may be nil; in that case the routes that depend on it return
|
||||
// `500 internal_error` with the message «service is not wired». This
|
||||
// mirrors the rtmanager handlers' guard so partially-wired listener
|
||||
// tests do not crash on routes they do not exercise.
|
||||
type Dependencies struct {
|
||||
// Logger receives structured per-handler logs. nil falls back to
|
||||
// slog.Default.
|
||||
Logger *slog.Logger
|
||||
|
||||
// RuntimeRecords backs the read-only list/get runtime endpoints.
|
||||
// Reads do not produce operation_log rows, mirroring
|
||||
// `rtmanager/docs/services.md` §18.
|
||||
RuntimeRecords RuntimeRecordsReader
|
||||
|
||||
// RegisterRuntime is the orchestrator for the
|
||||
// `internalRegisterRuntime` operation.
|
||||
RegisterRuntime RegisterRuntimeService
|
||||
|
||||
// ForceNextTurn drives the synchronous force-next-turn flow.
|
||||
ForceNextTurn ForceNextTurnService
|
||||
|
||||
// StopRuntime drives the admin stop flow.
|
||||
StopRuntime StopRuntimeService
|
||||
|
||||
// PatchRuntime drives the admin patch flow.
|
||||
PatchRuntime PatchRuntimeService
|
||||
|
||||
// BanishRace drives the engine race-banish flow.
|
||||
BanishRace BanishRaceService
|
||||
|
||||
// InvalidateMemberships purges the in-process membership cache for a
|
||||
// game id; backed by `service/membership.Cache.Invalidate`.
|
||||
InvalidateMemberships MembershipInvalidator
|
||||
|
||||
// GameLiveness returns the current runtime status without
|
||||
// contacting the engine.
|
||||
GameLiveness LivenessService
|
||||
|
||||
// EngineVersions exposes the multi-method engine-version registry
|
||||
// service (List/Get/ResolveImageRef/Create/Update/Deprecate).
|
||||
EngineVersions EngineVersionService
|
||||
|
||||
// CommandExecute forwards a player command batch to the engine.
|
||||
CommandExecute CommandExecuteService
|
||||
|
||||
// PutOrders forwards a player order batch to the engine.
|
||||
PutOrders OrderPutService
|
||||
|
||||
// GetReport reads a per-player turn report from the engine.
|
||||
GetReport ReportGetService
|
||||
}
|
||||
|
||||
// Register attaches every internal REST route to mux. The function is
|
||||
// idempotent against the listener-level probes (`/healthz`,
|
||||
// `/readyz`); the probe routes are owned by the listener and remain
|
||||
// disjoint from the paths registered here.
|
||||
func Register(mux *http.ServeMux, deps Dependencies) {
|
||||
mux.HandleFunc(http.MethodPost+" "+registerRuntimePath, newRegisterRuntimeHandler(deps))
|
||||
mux.HandleFunc(http.MethodGet+" "+getRuntimePath, newGetRuntimeHandler(deps))
|
||||
mux.HandleFunc(http.MethodGet+" "+listRuntimesPath, newListRuntimesHandler(deps))
|
||||
mux.HandleFunc(http.MethodPost+" "+forceNextTurnPath, newForceNextTurnHandler(deps))
|
||||
mux.HandleFunc(http.MethodPost+" "+stopRuntimePath, newStopRuntimeHandler(deps))
|
||||
mux.HandleFunc(http.MethodPost+" "+patchRuntimePath, newPatchRuntimeHandler(deps))
|
||||
mux.HandleFunc(http.MethodPost+" "+banishRacePath, newBanishRaceHandler(deps))
|
||||
mux.HandleFunc(http.MethodPost+" "+invalidateMembershipsPath, newInvalidateMembershipsHandler(deps))
|
||||
mux.HandleFunc(http.MethodGet+" "+gameLivenessPath, newGameLivenessHandler(deps))
|
||||
mux.HandleFunc(http.MethodGet+" "+listEngineVersionsPath, newListEngineVersionsHandler(deps))
|
||||
mux.HandleFunc(http.MethodPost+" "+createEngineVersionPath, newCreateEngineVersionHandler(deps))
|
||||
mux.HandleFunc(http.MethodGet+" "+engineVersionItemPath, newGetEngineVersionHandler(deps))
|
||||
mux.HandleFunc(http.MethodPatch+" "+engineVersionItemPath, newUpdateEngineVersionHandler(deps))
|
||||
mux.HandleFunc(http.MethodDelete+" "+engineVersionItemPath, newDeprecateEngineVersionHandler(deps))
|
||||
mux.HandleFunc(http.MethodGet+" "+resolveEngineVersionImageRefPath, newResolveEngineVersionImageRefHandler(deps))
|
||||
mux.HandleFunc(http.MethodPost+" "+executeCommandsPath, newExecuteCommandsHandler(deps))
|
||||
mux.HandleFunc(http.MethodPost+" "+putOrdersPath, newPutOrdersHandler(deps))
|
||||
mux.HandleFunc(http.MethodGet+" "+getReportPath, newGetReportHandler(deps))
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/gamemaster/internal/api/internalhttp/handlers"
|
||||
"galaxy/gamemaster/internal/api/internalhttp/handlers/mocks"
|
||||
"galaxy/gamemaster/internal/domain/engineversion"
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
"galaxy/gamemaster/internal/domain/runtime"
|
||||
"galaxy/gamemaster/internal/service/adminstop"
|
||||
"galaxy/gamemaster/internal/service/commandexecute"
|
||||
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
|
||||
"galaxy/gamemaster/internal/service/livenessreply"
|
||||
"galaxy/gamemaster/internal/service/registerruntime"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// driveHandler builds a fresh ServeMux + handler set bound to deps,
|
||||
// fires one request, and returns the recorder.
|
||||
func driveHandler(t *testing.T, deps handlers.Dependencies, method, path string, body io.Reader, headers map[string]string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
handlers.Register(mux, deps)
|
||||
request := httptest.NewRequest(method, path, body)
|
||||
for key, value := range headers {
|
||||
request.Header.Set(key, value)
|
||||
}
|
||||
if body != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func decodeErrorBody(t *testing.T, recorder *httptest.ResponseRecorder) (string, string) {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
|
||||
return body.Error.Code, body.Error.Message
|
||||
}
|
||||
|
||||
func TestRegisterRuntimeHandlerHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
|
||||
moment := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
|
||||
record := runtime.RuntimeRecord{
|
||||
GameID: "game-1",
|
||||
Status: runtime.StatusRunning,
|
||||
EngineEndpoint: "http://engine:8080",
|
||||
CurrentImageRef: "galaxy/game:1.2.3",
|
||||
CurrentEngineVersion: "1.2.3",
|
||||
TurnSchedule: "0 18 * * *",
|
||||
CreatedAt: moment,
|
||||
UpdatedAt: moment,
|
||||
}
|
||||
|
||||
registerSvc := mocks.NewMockRegisterRuntimeService(ctrl)
|
||||
registerSvc.EXPECT().
|
||||
Handle(gomock.Any(), gomock.AssignableToTypeOf(registerruntime.Input{})).
|
||||
DoAndReturn(func(_ context.Context, in registerruntime.Input) (registerruntime.Result, error) {
|
||||
assert.Equal(t, "game-1", in.GameID)
|
||||
assert.Equal(t, "http://engine:8080", in.EngineEndpoint)
|
||||
assert.Equal(t, operation.OpSourceLobbyInternal, in.OpSource)
|
||||
require.Len(t, in.Members, 1)
|
||||
return registerruntime.Result{Record: record, Outcome: operation.OutcomeSuccess}, nil
|
||||
})
|
||||
|
||||
body := strings.NewReader(`{
|
||||
"engine_endpoint": "http://engine:8080",
|
||||
"members": [{"user_id":"u1","race_name":"Aelinari"}],
|
||||
"target_engine_version": "1.2.3",
|
||||
"turn_schedule": "0 18 * * *"
|
||||
}`)
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{RegisterRuntime: registerSvc},
|
||||
http.MethodPost,
|
||||
"/api/v1/internal/games/game-1/register-runtime",
|
||||
body,
|
||||
map[string]string{"X-Galaxy-Caller": "lobby"},
|
||||
)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
assert.Contains(t, recorder.Body.String(), `"game_id":"game-1"`)
|
||||
}
|
||||
|
||||
func TestRegisterRuntimeHandlerRejectsUnknownFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
registerSvc := mocks.NewMockRegisterRuntimeService(ctrl)
|
||||
// no expectations — handler must short-circuit before calling.
|
||||
|
||||
body := strings.NewReader(`{"engine_endpoint":"http://e","extra":1}`)
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{RegisterRuntime: registerSvc},
|
||||
http.MethodPost,
|
||||
"/api/v1/internal/games/game-1/register-runtime",
|
||||
body,
|
||||
nil,
|
||||
)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
code, _ := decodeErrorBody(t, recorder)
|
||||
assert.Equal(t, "invalid_request", code)
|
||||
}
|
||||
|
||||
func TestRegisterRuntimeHandlerWiresFailureCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
errCode string
|
||||
wantStatus int
|
||||
}{
|
||||
{"invalid_request", registerruntime.ErrorCodeInvalidRequest, http.StatusBadRequest},
|
||||
{"conflict", registerruntime.ErrorCodeConflict, http.StatusConflict},
|
||||
{"engine_version_not_found", registerruntime.ErrorCodeEngineVersionNotFound, http.StatusNotFound},
|
||||
{"engine_unreachable", registerruntime.ErrorCodeEngineUnreachable, http.StatusBadGateway},
|
||||
{"service_unavailable", registerruntime.ErrorCodeServiceUnavailable, http.StatusServiceUnavailable},
|
||||
{"internal_error", registerruntime.ErrorCodeInternal, http.StatusInternalServerError},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
svc := mocks.NewMockRegisterRuntimeService(ctrl)
|
||||
svc.EXPECT().
|
||||
Handle(gomock.Any(), gomock.Any()).
|
||||
Return(registerruntime.Result{
|
||||
Outcome: operation.OutcomeFailure,
|
||||
ErrorCode: tc.errCode,
|
||||
ErrorMessage: tc.errCode + " details",
|
||||
}, nil)
|
||||
|
||||
body := strings.NewReader(`{
|
||||
"engine_endpoint": "http://e",
|
||||
"members":[{"user_id":"u1","race_name":"r"}],
|
||||
"target_engine_version":"1.0.0",
|
||||
"turn_schedule":"* * * * *"
|
||||
}`)
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{RegisterRuntime: svc},
|
||||
http.MethodPost,
|
||||
"/api/v1/internal/games/game-1/register-runtime",
|
||||
body,
|
||||
nil,
|
||||
)
|
||||
|
||||
assert.Equal(t, tc.wantStatus, recorder.Code)
|
||||
code, _ := decodeErrorBody(t, recorder)
|
||||
assert.Equal(t, tc.errCode, code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRuntimeHandlerNilServiceReturns500(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := strings.NewReader(`{"engine_endpoint":"http://e"}`)
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{},
|
||||
http.MethodPost,
|
||||
"/api/v1/internal/games/game-1/register-runtime",
|
||||
body,
|
||||
nil,
|
||||
)
|
||||
require.Equal(t, http.StatusInternalServerError, recorder.Code)
|
||||
code, _ := decodeErrorBody(t, recorder)
|
||||
assert.Equal(t, "internal_error", code)
|
||||
}
|
||||
|
||||
func TestStopRuntimeHandlerForwardsReason(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
|
||||
moment := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
|
||||
record := runtime.RuntimeRecord{
|
||||
GameID: "game-1",
|
||||
Status: runtime.StatusStopped,
|
||||
EngineEndpoint: "http://engine:8080",
|
||||
CurrentImageRef: "galaxy/game:1.2.3",
|
||||
CurrentEngineVersion: "1.2.3",
|
||||
TurnSchedule: "0 18 * * *",
|
||||
CreatedAt: moment,
|
||||
UpdatedAt: moment,
|
||||
}
|
||||
|
||||
stopSvc := mocks.NewMockStopRuntimeService(ctrl)
|
||||
stopSvc.EXPECT().
|
||||
Handle(gomock.Any(), gomock.AssignableToTypeOf(adminstop.Input{})).
|
||||
DoAndReturn(func(_ context.Context, in adminstop.Input) (adminstop.Result, error) {
|
||||
assert.Equal(t, "admin_request", in.Reason)
|
||||
return adminstop.Result{Record: record, Outcome: operation.OutcomeSuccess}, nil
|
||||
})
|
||||
|
||||
body := strings.NewReader(`{"reason":"admin_request"}`)
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{StopRuntime: stopSvc},
|
||||
http.MethodPost,
|
||||
"/api/v1/internal/runtimes/game-1/stop",
|
||||
body,
|
||||
nil,
|
||||
)
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
func TestGetEngineVersionHandlerMapsNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
svc := mocks.NewMockEngineVersionService(ctrl)
|
||||
svc.EXPECT().
|
||||
Get(gomock.Any(), "9.9.9").
|
||||
Return(engineversion.EngineVersion{}, engineversionsvc.ErrNotFound)
|
||||
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{EngineVersions: svc},
|
||||
http.MethodGet,
|
||||
"/api/v1/internal/engine-versions/9.9.9",
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, recorder.Code)
|
||||
code, _ := decodeErrorBody(t, recorder)
|
||||
assert.Equal(t, "engine_version_not_found", code)
|
||||
}
|
||||
|
||||
func TestListEngineVersionsHandlerRejectsUnknownStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
svc := mocks.NewMockEngineVersionService(ctrl)
|
||||
// no expectations — short-circuits.
|
||||
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{EngineVersions: svc},
|
||||
http.MethodGet,
|
||||
"/api/v1/internal/engine-versions?status=mystery",
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
code, _ := decodeErrorBody(t, recorder)
|
||||
assert.Equal(t, "invalid_request", code)
|
||||
}
|
||||
|
||||
func TestDeprecateEngineVersionReturns204(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
svc := mocks.NewMockEngineVersionService(ctrl)
|
||||
svc.EXPECT().
|
||||
Deprecate(gomock.Any(), gomock.AssignableToTypeOf(engineversionsvc.DeprecateInput{})).
|
||||
Return(nil)
|
||||
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{EngineVersions: svc},
|
||||
http.MethodDelete,
|
||||
"/api/v1/internal/engine-versions/1.0.0",
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
assert.Equal(t, http.StatusNoContent, recorder.Code)
|
||||
assert.Empty(t, recorder.Body.String())
|
||||
}
|
||||
|
||||
func TestDeprecateEngineVersionDoesNotReportInUse(t *testing.T) {
|
||||
t.Parallel()
|
||||
// D2: the DELETE endpoint flips status; the handler does not call
|
||||
// Service.Delete and therefore can never produce
|
||||
// `engine_version_in_use`. Deprecate's own error vocabulary is
|
||||
// limited to invalid_request / not_found / service_unavailable.
|
||||
ctrl := gomock.NewController(t)
|
||||
svc := mocks.NewMockEngineVersionService(ctrl)
|
||||
svc.EXPECT().
|
||||
Deprecate(gomock.Any(), gomock.Any()).
|
||||
Return(engineversionsvc.ErrNotFound)
|
||||
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{EngineVersions: svc},
|
||||
http.MethodDelete,
|
||||
"/api/v1/internal/engine-versions/9.9.9",
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
assert.Equal(t, http.StatusNotFound, recorder.Code)
|
||||
}
|
||||
|
||||
func TestExecuteCommandsRequiresUserIDHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
svc := mocks.NewMockCommandExecuteService(ctrl)
|
||||
// short-circuit before service is touched.
|
||||
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{CommandExecute: svc},
|
||||
http.MethodPost,
|
||||
"/api/v1/internal/games/game-1/commands",
|
||||
strings.NewReader(`{"commands":[]}`),
|
||||
nil,
|
||||
)
|
||||
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
code, msg := decodeErrorBody(t, recorder)
|
||||
assert.Equal(t, "invalid_request", code)
|
||||
assert.Contains(t, msg, "X-User-ID")
|
||||
}
|
||||
|
||||
func TestExecuteCommandsRejectsInvalidJSONBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
svc := mocks.NewMockCommandExecuteService(ctrl)
|
||||
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{CommandExecute: svc},
|
||||
http.MethodPost,
|
||||
"/api/v1/internal/games/game-1/commands",
|
||||
strings.NewReader("not json"),
|
||||
map[string]string{"X-User-ID": "u1"},
|
||||
)
|
||||
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
code, _ := decodeErrorBody(t, recorder)
|
||||
assert.Equal(t, "invalid_request", code)
|
||||
}
|
||||
|
||||
func TestExecuteCommandsForwardsRawResponseOnSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
svc := mocks.NewMockCommandExecuteService(ctrl)
|
||||
svc.EXPECT().
|
||||
Handle(gomock.Any(), gomock.AssignableToTypeOf(commandexecute.Input{})).
|
||||
DoAndReturn(func(_ context.Context, in commandexecute.Input) (commandexecute.Result, error) {
|
||||
assert.Equal(t, "game-1", in.GameID)
|
||||
assert.Equal(t, "u1", in.UserID)
|
||||
assert.JSONEq(t, `{"commands":[{"name":"build"}]}`, string(in.Payload))
|
||||
return commandexecute.Result{
|
||||
Outcome: operation.OutcomeSuccess,
|
||||
RawResponse: []byte(`{"results":[{"ok":true}]}`),
|
||||
}, nil
|
||||
})
|
||||
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{CommandExecute: svc},
|
||||
http.MethodPost,
|
||||
"/api/v1/internal/games/game-1/commands",
|
||||
strings.NewReader(`{"commands":[{"name":"build"}]}`),
|
||||
map[string]string{"X-User-ID": "u1"},
|
||||
)
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
assert.JSONEq(t, `{"results":[{"ok":true}]}`, recorder.Body.String())
|
||||
}
|
||||
|
||||
func TestInvalidateMembershipsAlwaysReturns204(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
cache := mocks.NewMockMembershipInvalidator(ctrl)
|
||||
cache.EXPECT().Invalidate("game-7").Times(1)
|
||||
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{InvalidateMemberships: cache},
|
||||
http.MethodPost,
|
||||
"/api/v1/internal/games/game-7/memberships/invalidate",
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
assert.Equal(t, http.StatusNoContent, recorder.Code)
|
||||
}
|
||||
|
||||
func TestGameLivenessHandlerMapsServiceUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
svc := mocks.NewMockLivenessService(ctrl)
|
||||
svc.EXPECT().
|
||||
Handle(gomock.Any(), livenessreply.Input{GameID: "game-1"}).
|
||||
Return(livenessreply.Result{}, errors.New(livenessreply.ErrorCodeServiceUnavailable+": store ping"))
|
||||
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{GameLiveness: svc},
|
||||
http.MethodGet,
|
||||
"/api/v1/internal/games/game-1/liveness",
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, recorder.Code)
|
||||
code, _ := decodeErrorBody(t, recorder)
|
||||
assert.Equal(t, "service_unavailable", code)
|
||||
}
|
||||
|
||||
func TestGetReportRejectsNegativeTurn(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
svc := mocks.NewMockReportGetService(ctrl)
|
||||
// short-circuits.
|
||||
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{GetReport: svc},
|
||||
http.MethodGet,
|
||||
"/api/v1/internal/games/game-1/reports/-3",
|
||||
nil,
|
||||
map[string]string{"X-User-ID": "u1"},
|
||||
)
|
||||
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
code, _ := decodeErrorBody(t, recorder)
|
||||
assert.Equal(t, "invalid_request", code)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package handlers
|
||||
|
||||
import "net/http"
|
||||
|
||||
// newInvalidateMembershipsHandler returns the handler for
|
||||
// `POST /api/v1/internal/games/{game_id}/memberships/invalidate`. The
|
||||
// underlying cache invalidation is a fire-and-forget local operation,
|
||||
// so the handler always responds with `204 No Content` once the path
|
||||
// parameter validates.
|
||||
func newInvalidateMembershipsHandler(deps Dependencies) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.InvalidateMemberships == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "membership cache invalidator is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
deps.InvalidateMemberships.Invalidate(gameID)
|
||||
writeNoContent(writer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/engineversion"
|
||||
)
|
||||
|
||||
// newListEngineVersionsHandler returns the handler for
|
||||
// `GET /api/v1/internal/engine-versions`. The optional `status`
|
||||
// query parameter narrows the result.
|
||||
func newListEngineVersionsHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.list_engine_versions")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.EngineVersions == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "engine version service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
var statusFilter *engineversion.Status
|
||||
raw := strings.TrimSpace(request.URL.Query().Get("status"))
|
||||
if raw != "" {
|
||||
candidate := engineversion.Status(raw)
|
||||
if !candidate.IsKnown() {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "status query parameter is unsupported")
|
||||
return
|
||||
}
|
||||
statusFilter = &candidate
|
||||
}
|
||||
|
||||
versions, err := deps.EngineVersions.List(request.Context(), statusFilter)
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "list engine versions failed", "err", err.Error())
|
||||
status, code, message := mapServiceError(err)
|
||||
writeError(writer, status, code, message)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeEngineVersionList(versions))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/runtime"
|
||||
)
|
||||
|
||||
// newListRuntimesHandler returns the handler for
|
||||
// `GET /api/v1/internal/runtimes`. The optional `status` query
|
||||
// parameter narrows the result; an unknown value short-circuits with
|
||||
// `400 invalid_request`. Records are returned ordered by
|
||||
// `created_at DESC` (the underlying store guarantees the ordering).
|
||||
func newListRuntimesHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.list_runtimes")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.RuntimeRecords == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "runtime records store is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := request.Context()
|
||||
|
||||
raw := strings.TrimSpace(request.URL.Query().Get("status"))
|
||||
if raw == "" {
|
||||
records, err := deps.RuntimeRecords.List(ctx)
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "list runtime records failed", "err", err.Error())
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "failed to list runtime records")
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, encodeRuntimeList(records))
|
||||
return
|
||||
}
|
||||
|
||||
status := runtime.Status(raw)
|
||||
if !status.IsKnown() {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "status query parameter is unsupported")
|
||||
return
|
||||
}
|
||||
|
||||
records, err := deps.RuntimeRecords.ListByStatus(ctx, status)
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "list runtime records by status failed",
|
||||
"status", string(status),
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "failed to list runtime records")
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, encodeRuntimeList(records))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: galaxy/gamemaster/internal/api/internalhttp/handlers (interfaces: RegisterRuntimeService,ForceNextTurnService,StopRuntimeService,PatchRuntimeService,BanishRaceService,LivenessService,CommandExecuteService,OrderPutService,ReportGetService,MembershipInvalidator,EngineVersionService,RuntimeRecordsReader)
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -destination=./mocks/mock_services.go -package=mocks galaxy/gamemaster/internal/api/internalhttp/handlers RegisterRuntimeService,ForceNextTurnService,StopRuntimeService,PatchRuntimeService,BanishRaceService,LivenessService,CommandExecuteService,OrderPutService,ReportGetService,MembershipInvalidator,EngineVersionService,RuntimeRecordsReader
|
||||
//
|
||||
|
||||
// Package mocks is a generated GoMock package.
|
||||
package mocks
|
||||
|
||||
import (
|
||||
context "context"
|
||||
engineversion "galaxy/gamemaster/internal/domain/engineversion"
|
||||
runtime "galaxy/gamemaster/internal/domain/runtime"
|
||||
adminbanish "galaxy/gamemaster/internal/service/adminbanish"
|
||||
adminforce "galaxy/gamemaster/internal/service/adminforce"
|
||||
adminpatch "galaxy/gamemaster/internal/service/adminpatch"
|
||||
adminstop "galaxy/gamemaster/internal/service/adminstop"
|
||||
commandexecute "galaxy/gamemaster/internal/service/commandexecute"
|
||||
engineversion0 "galaxy/gamemaster/internal/service/engineversion"
|
||||
livenessreply "galaxy/gamemaster/internal/service/livenessreply"
|
||||
orderput "galaxy/gamemaster/internal/service/orderput"
|
||||
registerruntime "galaxy/gamemaster/internal/service/registerruntime"
|
||||
reportget "galaxy/gamemaster/internal/service/reportget"
|
||||
reflect "reflect"
|
||||
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockRegisterRuntimeService is a mock of RegisterRuntimeService interface.
|
||||
type MockRegisterRuntimeService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockRegisterRuntimeServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockRegisterRuntimeServiceMockRecorder is the mock recorder for MockRegisterRuntimeService.
|
||||
type MockRegisterRuntimeServiceMockRecorder struct {
|
||||
mock *MockRegisterRuntimeService
|
||||
}
|
||||
|
||||
// NewMockRegisterRuntimeService creates a new mock instance.
|
||||
func NewMockRegisterRuntimeService(ctrl *gomock.Controller) *MockRegisterRuntimeService {
|
||||
mock := &MockRegisterRuntimeService{ctrl: ctrl}
|
||||
mock.recorder = &MockRegisterRuntimeServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockRegisterRuntimeService) EXPECT() *MockRegisterRuntimeServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Handle mocks base method.
|
||||
func (m *MockRegisterRuntimeService) Handle(ctx context.Context, in registerruntime.Input) (registerruntime.Result, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Handle", ctx, in)
|
||||
ret0, _ := ret[0].(registerruntime.Result)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Handle indicates an expected call of Handle.
|
||||
func (mr *MockRegisterRuntimeServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockRegisterRuntimeService)(nil).Handle), ctx, in)
|
||||
}
|
||||
|
||||
// MockForceNextTurnService is a mock of ForceNextTurnService interface.
|
||||
type MockForceNextTurnService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockForceNextTurnServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockForceNextTurnServiceMockRecorder is the mock recorder for MockForceNextTurnService.
|
||||
type MockForceNextTurnServiceMockRecorder struct {
|
||||
mock *MockForceNextTurnService
|
||||
}
|
||||
|
||||
// NewMockForceNextTurnService creates a new mock instance.
|
||||
func NewMockForceNextTurnService(ctrl *gomock.Controller) *MockForceNextTurnService {
|
||||
mock := &MockForceNextTurnService{ctrl: ctrl}
|
||||
mock.recorder = &MockForceNextTurnServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockForceNextTurnService) EXPECT() *MockForceNextTurnServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Handle mocks base method.
|
||||
func (m *MockForceNextTurnService) Handle(ctx context.Context, in adminforce.Input) (adminforce.Result, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Handle", ctx, in)
|
||||
ret0, _ := ret[0].(adminforce.Result)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Handle indicates an expected call of Handle.
|
||||
func (mr *MockForceNextTurnServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockForceNextTurnService)(nil).Handle), ctx, in)
|
||||
}
|
||||
|
||||
// MockStopRuntimeService is a mock of StopRuntimeService interface.
|
||||
type MockStopRuntimeService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockStopRuntimeServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockStopRuntimeServiceMockRecorder is the mock recorder for MockStopRuntimeService.
|
||||
type MockStopRuntimeServiceMockRecorder struct {
|
||||
mock *MockStopRuntimeService
|
||||
}
|
||||
|
||||
// NewMockStopRuntimeService creates a new mock instance.
|
||||
func NewMockStopRuntimeService(ctrl *gomock.Controller) *MockStopRuntimeService {
|
||||
mock := &MockStopRuntimeService{ctrl: ctrl}
|
||||
mock.recorder = &MockStopRuntimeServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockStopRuntimeService) EXPECT() *MockStopRuntimeServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Handle mocks base method.
|
||||
func (m *MockStopRuntimeService) Handle(ctx context.Context, in adminstop.Input) (adminstop.Result, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Handle", ctx, in)
|
||||
ret0, _ := ret[0].(adminstop.Result)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Handle indicates an expected call of Handle.
|
||||
func (mr *MockStopRuntimeServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockStopRuntimeService)(nil).Handle), ctx, in)
|
||||
}
|
||||
|
||||
// MockPatchRuntimeService is a mock of PatchRuntimeService interface.
|
||||
type MockPatchRuntimeService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockPatchRuntimeServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockPatchRuntimeServiceMockRecorder is the mock recorder for MockPatchRuntimeService.
|
||||
type MockPatchRuntimeServiceMockRecorder struct {
|
||||
mock *MockPatchRuntimeService
|
||||
}
|
||||
|
||||
// NewMockPatchRuntimeService creates a new mock instance.
|
||||
func NewMockPatchRuntimeService(ctrl *gomock.Controller) *MockPatchRuntimeService {
|
||||
mock := &MockPatchRuntimeService{ctrl: ctrl}
|
||||
mock.recorder = &MockPatchRuntimeServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockPatchRuntimeService) EXPECT() *MockPatchRuntimeServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Handle mocks base method.
|
||||
func (m *MockPatchRuntimeService) Handle(ctx context.Context, in adminpatch.Input) (adminpatch.Result, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Handle", ctx, in)
|
||||
ret0, _ := ret[0].(adminpatch.Result)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Handle indicates an expected call of Handle.
|
||||
func (mr *MockPatchRuntimeServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockPatchRuntimeService)(nil).Handle), ctx, in)
|
||||
}
|
||||
|
||||
// MockBanishRaceService is a mock of BanishRaceService interface.
|
||||
type MockBanishRaceService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockBanishRaceServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockBanishRaceServiceMockRecorder is the mock recorder for MockBanishRaceService.
|
||||
type MockBanishRaceServiceMockRecorder struct {
|
||||
mock *MockBanishRaceService
|
||||
}
|
||||
|
||||
// NewMockBanishRaceService creates a new mock instance.
|
||||
func NewMockBanishRaceService(ctrl *gomock.Controller) *MockBanishRaceService {
|
||||
mock := &MockBanishRaceService{ctrl: ctrl}
|
||||
mock.recorder = &MockBanishRaceServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockBanishRaceService) EXPECT() *MockBanishRaceServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Handle mocks base method.
|
||||
func (m *MockBanishRaceService) Handle(ctx context.Context, in adminbanish.Input) (adminbanish.Result, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Handle", ctx, in)
|
||||
ret0, _ := ret[0].(adminbanish.Result)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Handle indicates an expected call of Handle.
|
||||
func (mr *MockBanishRaceServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockBanishRaceService)(nil).Handle), ctx, in)
|
||||
}
|
||||
|
||||
// MockLivenessService is a mock of LivenessService interface.
|
||||
type MockLivenessService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockLivenessServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockLivenessServiceMockRecorder is the mock recorder for MockLivenessService.
|
||||
type MockLivenessServiceMockRecorder struct {
|
||||
mock *MockLivenessService
|
||||
}
|
||||
|
||||
// NewMockLivenessService creates a new mock instance.
|
||||
func NewMockLivenessService(ctrl *gomock.Controller) *MockLivenessService {
|
||||
mock := &MockLivenessService{ctrl: ctrl}
|
||||
mock.recorder = &MockLivenessServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockLivenessService) EXPECT() *MockLivenessServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Handle mocks base method.
|
||||
func (m *MockLivenessService) Handle(ctx context.Context, in livenessreply.Input) (livenessreply.Result, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Handle", ctx, in)
|
||||
ret0, _ := ret[0].(livenessreply.Result)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Handle indicates an expected call of Handle.
|
||||
func (mr *MockLivenessServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockLivenessService)(nil).Handle), ctx, in)
|
||||
}
|
||||
|
||||
// MockCommandExecuteService is a mock of CommandExecuteService interface.
|
||||
type MockCommandExecuteService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockCommandExecuteServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockCommandExecuteServiceMockRecorder is the mock recorder for MockCommandExecuteService.
|
||||
type MockCommandExecuteServiceMockRecorder struct {
|
||||
mock *MockCommandExecuteService
|
||||
}
|
||||
|
||||
// NewMockCommandExecuteService creates a new mock instance.
|
||||
func NewMockCommandExecuteService(ctrl *gomock.Controller) *MockCommandExecuteService {
|
||||
mock := &MockCommandExecuteService{ctrl: ctrl}
|
||||
mock.recorder = &MockCommandExecuteServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockCommandExecuteService) EXPECT() *MockCommandExecuteServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Handle mocks base method.
|
||||
func (m *MockCommandExecuteService) Handle(ctx context.Context, in commandexecute.Input) (commandexecute.Result, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Handle", ctx, in)
|
||||
ret0, _ := ret[0].(commandexecute.Result)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Handle indicates an expected call of Handle.
|
||||
func (mr *MockCommandExecuteServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockCommandExecuteService)(nil).Handle), ctx, in)
|
||||
}
|
||||
|
||||
// MockOrderPutService is a mock of OrderPutService interface.
|
||||
type MockOrderPutService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockOrderPutServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockOrderPutServiceMockRecorder is the mock recorder for MockOrderPutService.
|
||||
type MockOrderPutServiceMockRecorder struct {
|
||||
mock *MockOrderPutService
|
||||
}
|
||||
|
||||
// NewMockOrderPutService creates a new mock instance.
|
||||
func NewMockOrderPutService(ctrl *gomock.Controller) *MockOrderPutService {
|
||||
mock := &MockOrderPutService{ctrl: ctrl}
|
||||
mock.recorder = &MockOrderPutServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockOrderPutService) EXPECT() *MockOrderPutServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Handle mocks base method.
|
||||
func (m *MockOrderPutService) Handle(ctx context.Context, in orderput.Input) (orderput.Result, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Handle", ctx, in)
|
||||
ret0, _ := ret[0].(orderput.Result)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Handle indicates an expected call of Handle.
|
||||
func (mr *MockOrderPutServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockOrderPutService)(nil).Handle), ctx, in)
|
||||
}
|
||||
|
||||
// MockReportGetService is a mock of ReportGetService interface.
|
||||
type MockReportGetService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockReportGetServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockReportGetServiceMockRecorder is the mock recorder for MockReportGetService.
|
||||
type MockReportGetServiceMockRecorder struct {
|
||||
mock *MockReportGetService
|
||||
}
|
||||
|
||||
// NewMockReportGetService creates a new mock instance.
|
||||
func NewMockReportGetService(ctrl *gomock.Controller) *MockReportGetService {
|
||||
mock := &MockReportGetService{ctrl: ctrl}
|
||||
mock.recorder = &MockReportGetServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockReportGetService) EXPECT() *MockReportGetServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Handle mocks base method.
|
||||
func (m *MockReportGetService) Handle(ctx context.Context, in reportget.Input) (reportget.Result, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Handle", ctx, in)
|
||||
ret0, _ := ret[0].(reportget.Result)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Handle indicates an expected call of Handle.
|
||||
func (mr *MockReportGetServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockReportGetService)(nil).Handle), ctx, in)
|
||||
}
|
||||
|
||||
// MockMembershipInvalidator is a mock of MembershipInvalidator interface.
|
||||
type MockMembershipInvalidator struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockMembershipInvalidatorMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockMembershipInvalidatorMockRecorder is the mock recorder for MockMembershipInvalidator.
|
||||
type MockMembershipInvalidatorMockRecorder struct {
|
||||
mock *MockMembershipInvalidator
|
||||
}
|
||||
|
||||
// NewMockMembershipInvalidator creates a new mock instance.
|
||||
func NewMockMembershipInvalidator(ctrl *gomock.Controller) *MockMembershipInvalidator {
|
||||
mock := &MockMembershipInvalidator{ctrl: ctrl}
|
||||
mock.recorder = &MockMembershipInvalidatorMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockMembershipInvalidator) EXPECT() *MockMembershipInvalidatorMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Invalidate mocks base method.
|
||||
func (m *MockMembershipInvalidator) Invalidate(gameID string) {
|
||||
m.ctrl.T.Helper()
|
||||
m.ctrl.Call(m, "Invalidate", gameID)
|
||||
}
|
||||
|
||||
// Invalidate indicates an expected call of Invalidate.
|
||||
func (mr *MockMembershipInvalidatorMockRecorder) Invalidate(gameID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Invalidate", reflect.TypeOf((*MockMembershipInvalidator)(nil).Invalidate), gameID)
|
||||
}
|
||||
|
||||
// MockEngineVersionService is a mock of EngineVersionService interface.
|
||||
type MockEngineVersionService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockEngineVersionServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockEngineVersionServiceMockRecorder is the mock recorder for MockEngineVersionService.
|
||||
type MockEngineVersionServiceMockRecorder struct {
|
||||
mock *MockEngineVersionService
|
||||
}
|
||||
|
||||
// NewMockEngineVersionService creates a new mock instance.
|
||||
func NewMockEngineVersionService(ctrl *gomock.Controller) *MockEngineVersionService {
|
||||
mock := &MockEngineVersionService{ctrl: ctrl}
|
||||
mock.recorder = &MockEngineVersionServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockEngineVersionService) EXPECT() *MockEngineVersionServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Create mocks base method.
|
||||
func (m *MockEngineVersionService) Create(ctx context.Context, in engineversion0.CreateInput) (engineversion.EngineVersion, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Create", ctx, in)
|
||||
ret0, _ := ret[0].(engineversion.EngineVersion)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Create indicates an expected call of Create.
|
||||
func (mr *MockEngineVersionServiceMockRecorder) Create(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockEngineVersionService)(nil).Create), ctx, in)
|
||||
}
|
||||
|
||||
// Deprecate mocks base method.
|
||||
func (m *MockEngineVersionService) Deprecate(ctx context.Context, in engineversion0.DeprecateInput) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Deprecate", ctx, in)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Deprecate indicates an expected call of Deprecate.
|
||||
func (mr *MockEngineVersionServiceMockRecorder) Deprecate(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Deprecate", reflect.TypeOf((*MockEngineVersionService)(nil).Deprecate), ctx, in)
|
||||
}
|
||||
|
||||
// Get mocks base method.
|
||||
func (m *MockEngineVersionService) Get(ctx context.Context, version string) (engineversion.EngineVersion, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Get", ctx, version)
|
||||
ret0, _ := ret[0].(engineversion.EngineVersion)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Get indicates an expected call of Get.
|
||||
func (mr *MockEngineVersionServiceMockRecorder) Get(ctx, version any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockEngineVersionService)(nil).Get), ctx, version)
|
||||
}
|
||||
|
||||
// List mocks base method.
|
||||
func (m *MockEngineVersionService) List(ctx context.Context, statusFilter *engineversion.Status) ([]engineversion.EngineVersion, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "List", ctx, statusFilter)
|
||||
ret0, _ := ret[0].([]engineversion.EngineVersion)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// List indicates an expected call of List.
|
||||
func (mr *MockEngineVersionServiceMockRecorder) List(ctx, statusFilter any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockEngineVersionService)(nil).List), ctx, statusFilter)
|
||||
}
|
||||
|
||||
// ResolveImageRef mocks base method.
|
||||
func (m *MockEngineVersionService) ResolveImageRef(ctx context.Context, version string) (string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ResolveImageRef", ctx, version)
|
||||
ret0, _ := ret[0].(string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ResolveImageRef indicates an expected call of ResolveImageRef.
|
||||
func (mr *MockEngineVersionServiceMockRecorder) ResolveImageRef(ctx, version any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveImageRef", reflect.TypeOf((*MockEngineVersionService)(nil).ResolveImageRef), ctx, version)
|
||||
}
|
||||
|
||||
// Update mocks base method.
|
||||
func (m *MockEngineVersionService) Update(ctx context.Context, in engineversion0.UpdateInput) (engineversion.EngineVersion, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Update", ctx, in)
|
||||
ret0, _ := ret[0].(engineversion.EngineVersion)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Update indicates an expected call of Update.
|
||||
func (mr *MockEngineVersionServiceMockRecorder) Update(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockEngineVersionService)(nil).Update), ctx, in)
|
||||
}
|
||||
|
||||
// MockRuntimeRecordsReader is a mock of RuntimeRecordsReader interface.
|
||||
type MockRuntimeRecordsReader struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockRuntimeRecordsReaderMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockRuntimeRecordsReaderMockRecorder is the mock recorder for MockRuntimeRecordsReader.
|
||||
type MockRuntimeRecordsReaderMockRecorder struct {
|
||||
mock *MockRuntimeRecordsReader
|
||||
}
|
||||
|
||||
// NewMockRuntimeRecordsReader creates a new mock instance.
|
||||
func NewMockRuntimeRecordsReader(ctrl *gomock.Controller) *MockRuntimeRecordsReader {
|
||||
mock := &MockRuntimeRecordsReader{ctrl: ctrl}
|
||||
mock.recorder = &MockRuntimeRecordsReaderMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockRuntimeRecordsReader) EXPECT() *MockRuntimeRecordsReaderMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Get mocks base method.
|
||||
func (m *MockRuntimeRecordsReader) Get(ctx context.Context, gameID string) (runtime.RuntimeRecord, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Get", ctx, gameID)
|
||||
ret0, _ := ret[0].(runtime.RuntimeRecord)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Get indicates an expected call of Get.
|
||||
func (mr *MockRuntimeRecordsReaderMockRecorder) Get(ctx, gameID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockRuntimeRecordsReader)(nil).Get), ctx, gameID)
|
||||
}
|
||||
|
||||
// List mocks base method.
|
||||
func (m *MockRuntimeRecordsReader) List(ctx context.Context) ([]runtime.RuntimeRecord, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "List", ctx)
|
||||
ret0, _ := ret[0].([]runtime.RuntimeRecord)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// List indicates an expected call of List.
|
||||
func (mr *MockRuntimeRecordsReaderMockRecorder) List(ctx any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockRuntimeRecordsReader)(nil).List), ctx)
|
||||
}
|
||||
|
||||
// ListByStatus mocks base method.
|
||||
func (m *MockRuntimeRecordsReader) ListByStatus(ctx context.Context, status runtime.Status) ([]runtime.RuntimeRecord, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ListByStatus", ctx, status)
|
||||
ret0, _ := ret[0].([]runtime.RuntimeRecord)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ListByStatus indicates an expected call of ListByStatus.
|
||||
func (mr *MockRuntimeRecordsReaderMockRecorder) ListByStatus(ctx, status any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByStatus", reflect.TypeOf((*MockRuntimeRecordsReader)(nil).ListByStatus), ctx, status)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
"galaxy/gamemaster/internal/service/adminpatch"
|
||||
)
|
||||
|
||||
// patchRuntimeRequestBody mirrors the OpenAPI PatchRuntimeRequest
|
||||
// schema.
|
||||
type patchRuntimeRequestBody struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// newPatchRuntimeHandler returns the handler for
|
||||
// `POST /api/v1/internal/runtimes/{game_id}/patch`.
|
||||
func newPatchRuntimeHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.patch_runtime")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.PatchRuntime == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "patch runtime service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body patchRuntimeRequestBody
|
||||
if err := decodeStrictJSON(request.Body, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := deps.PatchRuntime.Handle(request.Context(), adminpatch.Input{
|
||||
GameID: gameID,
|
||||
Version: body.Version,
|
||||
OpSource: resolveOpSource(request),
|
||||
SourceRef: requestSourceRef(request),
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "patch runtime service errored",
|
||||
"game_id", gameID,
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "patch runtime service failed")
|
||||
return
|
||||
}
|
||||
|
||||
if result.Outcome == operation.OutcomeFailure {
|
||||
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeRuntimeRecord(result.Record))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
"galaxy/gamemaster/internal/service/orderput"
|
||||
)
|
||||
|
||||
// newPutOrdersHandler returns the handler for
|
||||
// `POST /api/v1/internal/games/{game_id}/orders`. The shape and
|
||||
// semantics mirror executeCommands: engine-owned body, raw JSON
|
||||
// pass-through on success, error envelope on failure.
|
||||
func newPutOrdersHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.put_orders")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.PutOrders == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "put orders service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, ok := extractUserID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := readRawJSONBody(request.Body)
|
||||
if err != nil {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := deps.PutOrders.Handle(request.Context(), orderput.Input{
|
||||
GameID: gameID,
|
||||
UserID: userID,
|
||||
Payload: body,
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "put orders service errored",
|
||||
"game_id", gameID,
|
||||
"user_id", userID,
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "put orders service failed")
|
||||
return
|
||||
}
|
||||
|
||||
if result.Outcome == operation.OutcomeFailure {
|
||||
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
writeRawJSON(writer, http.StatusOK, []byte(result.RawResponse))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
"galaxy/gamemaster/internal/service/registerruntime"
|
||||
)
|
||||
|
||||
// registerRuntimeRequestBody mirrors the OpenAPI
|
||||
// RegisterRuntimeRequest schema. Strict decoding rejects unknown
|
||||
// fields.
|
||||
type registerRuntimeRequestBody struct {
|
||||
EngineEndpoint string `json:"engine_endpoint"`
|
||||
Members []registerRuntimeMemberBody `json:"members"`
|
||||
TargetEngineVersion string `json:"target_engine_version"`
|
||||
TurnSchedule string `json:"turn_schedule"`
|
||||
}
|
||||
|
||||
// registerRuntimeMemberBody mirrors the OpenAPI
|
||||
// RegisterRuntimeMember schema.
|
||||
type registerRuntimeMemberBody struct {
|
||||
UserID string `json:"user_id"`
|
||||
RaceName string `json:"race_name"`
|
||||
}
|
||||
|
||||
// newRegisterRuntimeHandler returns the handler for
|
||||
// `POST /api/v1/internal/games/{game_id}/register-runtime`.
|
||||
func newRegisterRuntimeHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.register_runtime")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.RegisterRuntime == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "register runtime service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body registerRuntimeRequestBody
|
||||
if err := decodeStrictJSON(request.Body, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
members := make([]registerruntime.Member, 0, len(body.Members))
|
||||
for _, member := range body.Members {
|
||||
members = append(members, registerruntime.Member{
|
||||
UserID: member.UserID,
|
||||
RaceName: member.RaceName,
|
||||
})
|
||||
}
|
||||
|
||||
result, err := deps.RegisterRuntime.Handle(request.Context(), registerruntime.Input{
|
||||
GameID: gameID,
|
||||
EngineEndpoint: body.EngineEndpoint,
|
||||
Members: members,
|
||||
TargetEngineVersion: body.TargetEngineVersion,
|
||||
TurnSchedule: body.TurnSchedule,
|
||||
OpSource: resolveOpSource(request),
|
||||
SourceRef: requestSourceRef(request),
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "register runtime service errored",
|
||||
"game_id", gameID,
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "register runtime service failed")
|
||||
return
|
||||
}
|
||||
|
||||
if result.Outcome == operation.OutcomeFailure {
|
||||
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeRuntimeRecord(result.Record))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package handlers
|
||||
|
||||
import "net/http"
|
||||
|
||||
// newResolveEngineVersionImageRefHandler returns the handler for
|
||||
// `GET /api/v1/internal/engine-versions/{version}/image-ref`. It is
|
||||
// the hot-path Lobby calls before publishing a `runtime:start_jobs`
|
||||
// envelope; the response carries only the image reference.
|
||||
func newResolveEngineVersionImageRefHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.resolve_image_ref")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.EngineVersions == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "engine version service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
version, ok := extractVersion(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
imageRef, err := deps.EngineVersions.ResolveImageRef(request.Context(), version)
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "resolve image ref failed",
|
||||
"version", version,
|
||||
"err", err.Error(),
|
||||
)
|
||||
status, code, message := mapServiceError(err)
|
||||
writeError(writer, status, code, message)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, imageRefResponse{ImageRef: imageRef})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/engineversion"
|
||||
"galaxy/gamemaster/internal/domain/runtime"
|
||||
"galaxy/gamemaster/internal/service/adminbanish"
|
||||
"galaxy/gamemaster/internal/service/adminforce"
|
||||
"galaxy/gamemaster/internal/service/adminpatch"
|
||||
"galaxy/gamemaster/internal/service/adminstop"
|
||||
"galaxy/gamemaster/internal/service/commandexecute"
|
||||
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
|
||||
"galaxy/gamemaster/internal/service/livenessreply"
|
||||
"galaxy/gamemaster/internal/service/orderput"
|
||||
"galaxy/gamemaster/internal/service/registerruntime"
|
||||
"galaxy/gamemaster/internal/service/reportget"
|
||||
)
|
||||
|
||||
//go:generate go run go.uber.org/mock/mockgen -destination=./mocks/mock_services.go -package=mocks galaxy/gamemaster/internal/api/internalhttp/handlers RegisterRuntimeService,ForceNextTurnService,StopRuntimeService,PatchRuntimeService,BanishRaceService,LivenessService,CommandExecuteService,OrderPutService,ReportGetService,MembershipInvalidator,EngineVersionService,RuntimeRecordsReader
|
||||
|
||||
// RegisterRuntimeService wires the `internalRegisterRuntime` handler
|
||||
// to the underlying register-runtime orchestrator.
|
||||
type RegisterRuntimeService interface {
|
||||
Handle(ctx context.Context, in registerruntime.Input) (registerruntime.Result, error)
|
||||
}
|
||||
|
||||
// ForceNextTurnService wires the `internalForceNextTurn` handler.
|
||||
type ForceNextTurnService interface {
|
||||
Handle(ctx context.Context, in adminforce.Input) (adminforce.Result, error)
|
||||
}
|
||||
|
||||
// StopRuntimeService wires the `internalStopRuntime` handler.
|
||||
type StopRuntimeService interface {
|
||||
Handle(ctx context.Context, in adminstop.Input) (adminstop.Result, error)
|
||||
}
|
||||
|
||||
// PatchRuntimeService wires the `internalPatchRuntime` handler.
|
||||
type PatchRuntimeService interface {
|
||||
Handle(ctx context.Context, in adminpatch.Input) (adminpatch.Result, error)
|
||||
}
|
||||
|
||||
// BanishRaceService wires the `internalBanishRace` handler.
|
||||
type BanishRaceService interface {
|
||||
Handle(ctx context.Context, in adminbanish.Input) (adminbanish.Result, error)
|
||||
}
|
||||
|
||||
// LivenessService wires the `internalGameLiveness` handler.
|
||||
type LivenessService interface {
|
||||
Handle(ctx context.Context, in livenessreply.Input) (livenessreply.Result, error)
|
||||
}
|
||||
|
||||
// CommandExecuteService wires the `internalExecuteCommands` handler.
|
||||
type CommandExecuteService interface {
|
||||
Handle(ctx context.Context, in commandexecute.Input) (commandexecute.Result, error)
|
||||
}
|
||||
|
||||
// OrderPutService wires the `internalPutOrders` handler.
|
||||
type OrderPutService interface {
|
||||
Handle(ctx context.Context, in orderput.Input) (orderput.Result, error)
|
||||
}
|
||||
|
||||
// ReportGetService wires the `internalGetReport` handler.
|
||||
type ReportGetService interface {
|
||||
Handle(ctx context.Context, in reportget.Input) (reportget.Result, error)
|
||||
}
|
||||
|
||||
// MembershipInvalidator wires the `internalInvalidateMemberships`
|
||||
// handler. Backed by `service/membership.Cache.Invalidate`.
|
||||
type MembershipInvalidator interface {
|
||||
// Invalidate purges the in-process membership cache entry for
|
||||
// gameID. The call is fire-and-forget and never returns an error;
|
||||
// missing entries are a no-op.
|
||||
Invalidate(gameID string)
|
||||
}
|
||||
|
||||
// EngineVersionService wires every engine-version registry handler. The
|
||||
// service exposes one Go-error-returning method per OpenAPI operation;
|
||||
// the handler layer translates the wrapped sentinel errors into
|
||||
// `engine_version_*` codes via `mapServiceError`.
|
||||
type EngineVersionService interface {
|
||||
List(ctx context.Context, statusFilter *engineversion.Status) ([]engineversion.EngineVersion, error)
|
||||
Get(ctx context.Context, version string) (engineversion.EngineVersion, error)
|
||||
ResolveImageRef(ctx context.Context, version string) (string, error)
|
||||
Create(ctx context.Context, in engineversionsvc.CreateInput) (engineversion.EngineVersion, error)
|
||||
Update(ctx context.Context, in engineversionsvc.UpdateInput) (engineversion.EngineVersion, error)
|
||||
Deprecate(ctx context.Context, in engineversionsvc.DeprecateInput) error
|
||||
}
|
||||
|
||||
// RuntimeRecordsReader exposes the read-only subset of
|
||||
// `ports.RuntimeRecordStore` required by the get/list runtime
|
||||
// handlers. The narrower surface keeps the handler layer from
|
||||
// inadvertently mutating runtime state.
|
||||
type RuntimeRecordsReader interface {
|
||||
Get(ctx context.Context, gameID string) (runtime.RuntimeRecord, error)
|
||||
List(ctx context.Context) ([]runtime.RuntimeRecord, error)
|
||||
ListByStatus(ctx context.Context, status runtime.Status) ([]runtime.RuntimeRecord, error)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
"galaxy/gamemaster/internal/service/adminstop"
|
||||
)
|
||||
|
||||
// stopRuntimeRequestBody mirrors the OpenAPI StopRuntimeRequest
|
||||
// schema.
|
||||
type stopRuntimeRequestBody struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// newStopRuntimeHandler returns the handler for
|
||||
// `POST /api/v1/internal/runtimes/{game_id}/stop`.
|
||||
func newStopRuntimeHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.stop_runtime")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.StopRuntime == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "stop runtime service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body stopRuntimeRequestBody
|
||||
if err := decodeStrictJSON(request.Body, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := deps.StopRuntime.Handle(request.Context(), adminstop.Input{
|
||||
GameID: gameID,
|
||||
Reason: body.Reason,
|
||||
OpSource: resolveOpSource(request),
|
||||
SourceRef: requestSourceRef(request),
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "stop runtime service errored",
|
||||
"game_id", gameID,
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "stop runtime service failed")
|
||||
return
|
||||
}
|
||||
|
||||
if result.Outcome == operation.OutcomeFailure {
|
||||
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeRuntimeRecord(result.Record))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/engineversion"
|
||||
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
|
||||
)
|
||||
|
||||
// updateEngineVersionRequestBody mirrors the OpenAPI
|
||||
// UpdateEngineVersionRequest schema. Every field is optional; the
|
||||
// service rejects calls with no fields set as `invalid_request`.
|
||||
type updateEngineVersionRequestBody struct {
|
||||
ImageRef *string `json:"image_ref,omitempty"`
|
||||
Options *json.RawMessage `json:"options,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// newUpdateEngineVersionHandler returns the handler for
|
||||
// `PATCH /api/v1/internal/engine-versions/{version}`.
|
||||
func newUpdateEngineVersionHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.update_engine_version")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.EngineVersions == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "engine version service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
version, ok := extractVersion(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body updateEngineVersionRequestBody
|
||||
if err := decodeStrictJSON(request.Body, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
input := engineversionsvc.UpdateInput{
|
||||
Version: version,
|
||||
ImageRef: body.ImageRef,
|
||||
OpSource: resolveOpSource(request),
|
||||
SourceRef: requestSourceRef(request),
|
||||
}
|
||||
if body.Options != nil {
|
||||
optionBytes := []byte(*body.Options)
|
||||
input.Options = &optionBytes
|
||||
}
|
||||
if body.Status != nil {
|
||||
candidate := engineversion.Status(*body.Status)
|
||||
input.Status = &candidate
|
||||
}
|
||||
|
||||
record, err := deps.EngineVersions.Update(request.Context(), input)
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "update engine version failed",
|
||||
"version", version,
|
||||
"err", err.Error(),
|
||||
)
|
||||
status, code, message := mapServiceError(err)
|
||||
writeError(writer, status, code, message)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeEngineVersion(record))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
// Package internalhttp provides the trusted internal HTTP listener
|
||||
// used by the runnable Game Master process. It exposes the `/healthz`
|
||||
// and `/readyz` probes plus every internal REST operation declared in
|
||||
// `gamemaster/api/internal-openapi.yaml`. Per-operation handlers live
|
||||
// in the nested `handlers` package; this file owns the listener
|
||||
// lifecycle and the probe routes only.
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"galaxy/gamemaster/internal/api/internalhttp/handlers"
|
||||
"galaxy/gamemaster/internal/telemetry"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
)
|
||||
|
||||
const jsonContentType = "application/json; charset=utf-8"
|
||||
|
||||
// errorCodeServiceUnavailable mirrors the stable error code declared in
|
||||
// `gamemaster/api/internal-openapi.yaml` §Error Model.
|
||||
const errorCodeServiceUnavailable = "service_unavailable"
|
||||
|
||||
// HealthzPath and ReadyzPath are the internal probe routes documented in
|
||||
// `gamemaster/api/internal-openapi.yaml`.
|
||||
const (
|
||||
HealthzPath = "/healthz"
|
||||
ReadyzPath = "/readyz"
|
||||
)
|
||||
|
||||
// ReadinessProbe reports whether the dependencies the listener guards
|
||||
// (PostgreSQL, Redis) are reachable. A non-nil error is reported to the
|
||||
// caller as `503 service_unavailable` with the wrapped message.
|
||||
type ReadinessProbe interface {
|
||||
Check(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Config describes the trusted internal HTTP listener owned by Game
|
||||
// Master.
|
||||
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
|
||||
|
||||
// WriteTimeout bounds how long the listener may spend writing one
|
||||
// response.
|
||||
WriteTimeout 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.WriteTimeout <= 0:
|
||||
return errors.New("internal HTTP write 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. The probe-only fields (Logger, Telemetry,
|
||||
// Readiness) drive `/healthz` and `/readyz`; the remaining fields
|
||||
// pass through to the per-operation handlers registered by
|
||||
// `handlers.Register`.
|
||||
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
|
||||
|
||||
// Readiness reports whether PG / Redis are reachable. A nil
|
||||
// readiness probe makes `/readyz` always answer `200`; the runtime
|
||||
// always supplies a real probe in production wiring.
|
||||
Readiness ReadinessProbe
|
||||
|
||||
// RuntimeRecords backs the read-only list/get runtime endpoints.
|
||||
RuntimeRecords handlers.RuntimeRecordsReader
|
||||
|
||||
// RegisterRuntime is the orchestrator for `internalRegisterRuntime`.
|
||||
RegisterRuntime handlers.RegisterRuntimeService
|
||||
|
||||
// ForceNextTurn drives the synchronous force-next-turn flow.
|
||||
ForceNextTurn handlers.ForceNextTurnService
|
||||
|
||||
// StopRuntime drives the admin stop flow.
|
||||
StopRuntime handlers.StopRuntimeService
|
||||
|
||||
// PatchRuntime drives the admin patch flow.
|
||||
PatchRuntime handlers.PatchRuntimeService
|
||||
|
||||
// BanishRace drives the engine race-banish flow.
|
||||
BanishRace handlers.BanishRaceService
|
||||
|
||||
// InvalidateMemberships purges the in-process membership cache.
|
||||
InvalidateMemberships handlers.MembershipInvalidator
|
||||
|
||||
// GameLiveness returns the current runtime status without
|
||||
// contacting the engine.
|
||||
GameLiveness handlers.LivenessService
|
||||
|
||||
// EngineVersions exposes the multi-method engine-version registry
|
||||
// service.
|
||||
EngineVersions handlers.EngineVersionService
|
||||
|
||||
// CommandExecute forwards a player command batch to the engine.
|
||||
CommandExecute handlers.CommandExecuteService
|
||||
|
||||
// PutOrders forwards a player order batch to the engine.
|
||||
PutOrders handlers.OrderPutService
|
||||
|
||||
// GetReport reads a per-player turn report from the engine.
|
||||
GetReport handlers.ReportGetService
|
||||
}
|
||||
|
||||
// Server owns the trusted internal HTTP listener exposed by Game Master.
|
||||
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,
|
||||
WriteTimeout: server.cfg.WriteTimeout,
|
||||
IdleTimeout: server.cfg.IdleTimeout,
|
||||
}
|
||||
|
||||
server.stateMu.Lock()
|
||||
server.server = httpServer
|
||||
server.listener = listener
|
||||
server.stateMu.Unlock()
|
||||
|
||||
server.logger.Info("gamemaster 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("gamemaster 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 {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET "+HealthzPath, handleHealthz)
|
||||
mux.HandleFunc("GET "+ReadyzPath, handleReadyz(deps.Readiness, logger))
|
||||
handlers.Register(mux, handlers.Dependencies{
|
||||
Logger: logger,
|
||||
RuntimeRecords: deps.RuntimeRecords,
|
||||
RegisterRuntime: deps.RegisterRuntime,
|
||||
ForceNextTurn: deps.ForceNextTurn,
|
||||
StopRuntime: deps.StopRuntime,
|
||||
PatchRuntime: deps.PatchRuntime,
|
||||
BanishRace: deps.BanishRace,
|
||||
InvalidateMemberships: deps.InvalidateMemberships,
|
||||
GameLiveness: deps.GameLiveness,
|
||||
EngineVersions: deps.EngineVersions,
|
||||
CommandExecute: deps.CommandExecute,
|
||||
PutOrders: deps.PutOrders,
|
||||
GetReport: deps.GetReport,
|
||||
})
|
||||
|
||||
metrics := deps.Telemetry
|
||||
options := []otelhttp.Option{}
|
||||
if metrics != nil {
|
||||
options = append(options,
|
||||
otelhttp.WithTracerProvider(metrics.TracerProvider()),
|
||||
otelhttp.WithMeterProvider(metrics.MeterProvider()),
|
||||
)
|
||||
}
|
||||
|
||||
return otelhttp.NewHandler(withObservability(mux, metrics), "gamemaster.internal_http", options...)
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
if metrics != nil {
|
||||
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(probe ReadinessProbe, logger *slog.Logger) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if probe == nil {
|
||||
writeStatusResponse(writer, http.StatusOK, "ready")
|
||||
return
|
||||
}
|
||||
|
||||
if err := probe.Check(request.Context()); err != nil {
|
||||
logger.WarnContext(request.Context(), "gamemaster readiness probe failed",
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeServiceUnavailable(writer, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
func writeServiceUnavailable(writer http.ResponseWriter, message string) {
|
||||
writer.Header().Set("Content-Type", jsonContentType)
|
||||
writer.WriteHeader(http.StatusServiceUnavailable)
|
||||
_ = json.NewEncoder(writer).Encode(errorResponse{
|
||||
Error: errorBody{
|
||||
Code: errorCodeServiceUnavailable,
|
||||
Message: message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type statusResponse struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type errorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error errorBody `json:"error"`
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (recorder *statusRecorder) WriteHeader(statusCode int) {
|
||||
recorder.statusCode = statusCode
|
||||
recorder.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestConfig() Config {
|
||||
return Config{
|
||||
Addr: ":0",
|
||||
ReadHeaderTimeout: time.Second,
|
||||
ReadTimeout: time.Second,
|
||||
WriteTimeout: time.Second,
|
||||
IdleTimeout: time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
type stubReadiness struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (probe stubReadiness) Check(_ context.Context) error {
|
||||
return probe.err
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T, deps Dependencies) http.Handler {
|
||||
t.Helper()
|
||||
server, err := NewServer(newTestConfig(), deps)
|
||||
require.NoError(t, err)
|
||||
return server.handler
|
||||
}
|
||||
|
||||
func TestHealthzReturnsOK(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestServer(t, Dependencies{})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, HealthzPath, nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, jsonContentType, rec.Header().Get("Content-Type"))
|
||||
|
||||
var body statusResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
||||
require.Equal(t, "ok", body.Status)
|
||||
}
|
||||
|
||||
func TestReadyzReturnsReadyWhenProbeIsNil(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestServer(t, Dependencies{})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, ReadyzPath, nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var body statusResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
||||
require.Equal(t, "ready", body.Status)
|
||||
}
|
||||
|
||||
func TestReadyzReturnsReadyWhenProbeSucceeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestServer(t, Dependencies{Readiness: stubReadiness{}})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, ReadyzPath, nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var body statusResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
||||
require.Equal(t, "ready", body.Status)
|
||||
}
|
||||
|
||||
func TestReadyzReturnsServiceUnavailableWhenProbeFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestServer(t, Dependencies{
|
||||
Readiness: stubReadiness{err: errors.New("postgres ping: connection refused")},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, ReadyzPath, nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, rec.Code)
|
||||
require.Equal(t, jsonContentType, rec.Header().Get("Content-Type"))
|
||||
|
||||
var body errorResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
||||
require.Equal(t, errorCodeServiceUnavailable, body.Error.Code)
|
||||
require.True(t, strings.Contains(body.Error.Message, "postgres"))
|
||||
}
|
||||
|
||||
func TestNewServerRejectsInvalidConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := NewServer(Config{}, Dependencies{})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRunBindsListenerAndShutsDown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server, err := NewServer(newTestConfig(), Dependencies{})
|
||||
require.NoError(t, err)
|
||||
|
||||
runErr := make(chan error, 1)
|
||||
go func() {
|
||||
runErr <- server.Run(t.Context())
|
||||
}()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return server.Addr() != ""
|
||||
}, time.Second, 10*time.Millisecond, "listener should bind quickly")
|
||||
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer shutdownCancel()
|
||||
require.NoError(t, server.Shutdown(shutdownCtx))
|
||||
|
||||
select {
|
||||
case err := <-runErr:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("server did not return after shutdown")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user