feat: backend service
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
// Package clientip exposes the helper that resolves the originating client
|
||||
// IP for an inbound HTTP request. Backend trusts the value because the
|
||||
// network segment between gateway and backend is the trust boundary
|
||||
// (`ARCHITECTURE.md` §15-16): gateway is responsible for sanitising and
|
||||
// populating `X-Forwarded-For` before the request reaches backend.
|
||||
//
|
||||
// Both the public-auth handler chain (handlers_auth_helpers.go) and the
|
||||
// user-surface geo-counter middleware reuse the same extraction so the two
|
||||
// surfaces never disagree about the IP they record.
|
||||
package clientip
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ExtractSourceIP returns the originating client IP for the request behind
|
||||
// c. The leftmost entry of `X-Forwarded-For` is preferred; when the header
|
||||
// is absent or empty, the connection RemoteAddr is used (with the port
|
||||
// stripped). The empty string is returned when neither source yields a
|
||||
// usable value, which lets callers treat the result as "no IP available"
|
||||
// and skip dependent work.
|
||||
func ExtractSourceIP(c *gin.Context) string {
|
||||
if c == nil || c.Request == nil {
|
||||
return ""
|
||||
}
|
||||
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
||||
first := xff
|
||||
if idx := strings.IndexByte(first, ','); idx >= 0 {
|
||||
first = first[:idx]
|
||||
}
|
||||
return strings.TrimSpace(first)
|
||||
}
|
||||
addr := c.Request.RemoteAddr
|
||||
if host, _, err := net.SplitHostPort(addr); err == nil {
|
||||
return host
|
||||
}
|
||||
return addr
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package clientip
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestExtractSourceIP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
header string
|
||||
remoteAddr string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "single XFF entry trimmed",
|
||||
header: " 198.51.100.7 ",
|
||||
remoteAddr: "10.0.0.1:5000",
|
||||
want: "198.51.100.7",
|
||||
},
|
||||
{
|
||||
name: "first XFF entry wins",
|
||||
header: "198.51.100.7, 10.0.0.1, 192.168.1.1",
|
||||
remoteAddr: "10.0.0.1:5000",
|
||||
want: "198.51.100.7",
|
||||
},
|
||||
{
|
||||
name: "fallback to RemoteAddr without port",
|
||||
header: "",
|
||||
remoteAddr: "203.0.113.42:65000",
|
||||
want: "203.0.113.42",
|
||||
},
|
||||
{
|
||||
name: "RemoteAddr without port preserved",
|
||||
header: "",
|
||||
remoteAddr: "203.0.113.42",
|
||||
want: "203.0.113.42",
|
||||
},
|
||||
{
|
||||
name: "no header and no RemoteAddr returns empty",
|
||||
header: "",
|
||||
remoteAddr: "",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = tc.remoteAddr
|
||||
if tc.header != "" {
|
||||
req.Header.Set("X-Forwarded-For", tc.header)
|
||||
}
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = req
|
||||
|
||||
got := ExtractSourceIP(c)
|
||||
if got != tc.want {
|
||||
t.Fatalf("ExtractSourceIP() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSourceIPNilSafety(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := ExtractSourceIP(nil); got != "" {
|
||||
t.Fatalf("nil context: want empty, got %q", got)
|
||||
}
|
||||
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
if got := ExtractSourceIP(c); got != "" {
|
||||
t.Fatalf("context with nil Request: want empty, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"galaxy/backend/internal/server/middleware/basicauth"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/getkin/kin-openapi/openapi3filter"
|
||||
"github.com/getkin/kin-openapi/routers"
|
||||
"github.com/getkin/kin-openapi/routers/gorillamux"
|
||||
)
|
||||
|
||||
// contractStubAdminPassword is the password the contract test injects
|
||||
// into the stub Basic Auth verifier wired by NewRouter. The value
|
||||
// never leaves the test binary; production wires the verifier from
|
||||
// the Postgres-backed admin.Service.
|
||||
const contractStubAdminPassword = "contract-test-secret"
|
||||
|
||||
// stubUserID is the deterministic UUID injected into `X-User-ID` for the
|
||||
// authenticated user surface.
|
||||
const stubUserID = "00000000-0000-0000-0000-0000000000a1"
|
||||
|
||||
// pathParamStubs lists the deterministic substitutions used to fill the path
|
||||
// templates declared in `openapi.yaml`. Every parameter that appears in a path
|
||||
// must have an entry here; the test fails loudly if a new parameter is added
|
||||
// to the spec without updating this map.
|
||||
var pathParamStubs = map[string]string{
|
||||
"game_id": "00000000-0000-0000-0000-000000000001",
|
||||
"application_id": "00000000-0000-0000-0000-000000000002",
|
||||
"invite_id": "00000000-0000-0000-0000-000000000003",
|
||||
"membership_id": "00000000-0000-0000-0000-000000000004",
|
||||
"notification_id": "00000000-0000-0000-0000-000000000005",
|
||||
"delivery_id": "00000000-0000-0000-0000-000000000006",
|
||||
"user_id": "00000000-0000-0000-0000-000000000007",
|
||||
"device_session_id": "00000000-0000-0000-0000-000000000008",
|
||||
"id": "1.2.3",
|
||||
"username": "alice",
|
||||
"turn": "42",
|
||||
}
|
||||
|
||||
// requestBodyStubs lists the JSON request bodies the contract test sends for
|
||||
// each operationId. Operations missing from the map default to an empty
|
||||
// object `{}`, which is a valid placeholder thanks to `additionalProperties:
|
||||
// true` in the matching schemas. Operations that require specific fields
|
||||
// listed in `openapi.yaml` get a hand-crafted body here.
|
||||
var requestBodyStubs = map[string]map[string]any{
|
||||
"publicAuthSendEmailCode": {
|
||||
"email": "pilot@example.test",
|
||||
},
|
||||
"publicAuthConfirmEmailCode": {
|
||||
"challenge_id": "challenge-123",
|
||||
"code": "654321",
|
||||
"client_public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
"time_zone": "Europe/Kaliningrad",
|
||||
},
|
||||
"adminAdminAccountsCreate": {
|
||||
"username": "carol",
|
||||
"password": "carol-secret",
|
||||
},
|
||||
"adminAdminAccountsResetPassword": {
|
||||
"password": "carol-new-secret",
|
||||
},
|
||||
"adminEngineVersionsCreate": {
|
||||
"version": "1.2.3",
|
||||
"image_ref": "registry.test/galaxy/engine:1.2.3",
|
||||
},
|
||||
"adminRuntimesPatch": {
|
||||
"target_version": "1.2.4",
|
||||
},
|
||||
"userLobbyRaceNamesRegister": {
|
||||
"name": "AndromedaConfederacy",
|
||||
},
|
||||
"adminUsersAddSanction": {
|
||||
"sanction_code": "permanent_block",
|
||||
"scope": "platform",
|
||||
"reason_code": "tos_violation",
|
||||
"actor": map[string]any{
|
||||
"type": "admin",
|
||||
"id": "operator",
|
||||
},
|
||||
},
|
||||
"adminUsersAddLimit": {
|
||||
"limit_code": "max_active_games",
|
||||
"value": 3,
|
||||
"reason_code": "manual_review",
|
||||
"actor": map[string]any{
|
||||
"type": "admin",
|
||||
"id": "operator",
|
||||
},
|
||||
},
|
||||
"adminUsersAddEntitlement": {
|
||||
"tier": "monthly",
|
||||
"source": "admin",
|
||||
"actor": map[string]any{
|
||||
"type": "admin",
|
||||
"id": "operator",
|
||||
},
|
||||
},
|
||||
"userLobbyGamesCreate": {
|
||||
"game_name": "Contract Test Game",
|
||||
"visibility": "private",
|
||||
"min_players": 2,
|
||||
"max_players": 8,
|
||||
"start_gap_hours": 24,
|
||||
"start_gap_players": 2,
|
||||
"enrollment_ends_at": "2099-01-02T03:04:05Z",
|
||||
"turn_schedule": "0 0 * * *",
|
||||
"target_engine_version": "1.0.0",
|
||||
},
|
||||
"adminGamesCreate": {
|
||||
"game_name": "Contract Test Public Game",
|
||||
"min_players": 4,
|
||||
"max_players": 12,
|
||||
"start_gap_hours": 12,
|
||||
"start_gap_players": 4,
|
||||
"enrollment_ends_at": "2099-01-02T03:04:05Z",
|
||||
"turn_schedule": "0 6 * * *",
|
||||
"target_engine_version": "1.0.0",
|
||||
},
|
||||
"userLobbyApplicationsSubmit": {
|
||||
"race_name": "ContractTestRace",
|
||||
},
|
||||
"userLobbyInvitesIssue": {
|
||||
"invited_user_id": pathParamStubs["user_id"],
|
||||
"race_name": "ContractTestRace",
|
||||
},
|
||||
"adminGamesBanMember": {
|
||||
"user_id": pathParamStubs["user_id"],
|
||||
"reason": "ToS violation",
|
||||
},
|
||||
}
|
||||
|
||||
// TestOpenAPIContract is the top-level OpenAPI contract test. It
|
||||
// validates that:
|
||||
//
|
||||
// 1. backend/openapi.yaml is well-formed;
|
||||
// 2. every documented operation maps to a registered gin handler;
|
||||
// 3. each handler returns a representable HTTP response that satisfies
|
||||
// the declared response schema (any defensive `501 not_implemented`
|
||||
// placeholder also conforms).
|
||||
func TestOpenAPIContract(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc, specPath := loadOpenAPISpec(t)
|
||||
spec := mustValidateSpec(t, doc, specPath)
|
||||
|
||||
router := mustNewGorillamuxRouter(t, doc)
|
||||
engine := mustBuildEngine(t)
|
||||
|
||||
expectedOps := collectOperations(t, doc)
|
||||
if len(expectedOps) == 0 {
|
||||
t.Fatalf("openapi.yaml declares no operations")
|
||||
}
|
||||
|
||||
_ = spec
|
||||
processed := 0
|
||||
for _, op := range expectedOps {
|
||||
t.Run(op.method+" "+op.path, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
runContractCase(t, router, engine, op)
|
||||
})
|
||||
processed++
|
||||
}
|
||||
|
||||
t.Logf("contract test exercised %d operations from %s", processed, specPath)
|
||||
}
|
||||
|
||||
type contractOperation struct {
|
||||
method string
|
||||
path string
|
||||
operationID string
|
||||
op *openapi3.Operation
|
||||
}
|
||||
|
||||
func collectOperations(t *testing.T, doc *openapi3.T) []contractOperation {
|
||||
t.Helper()
|
||||
|
||||
var ops []contractOperation
|
||||
for path, item := range doc.Paths.Map() {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
for method, op := range item.Operations() {
|
||||
if op == nil {
|
||||
continue
|
||||
}
|
||||
ops = append(ops, contractOperation{
|
||||
method: method,
|
||||
path: path,
|
||||
operationID: op.OperationID,
|
||||
op: op,
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.Slice(ops, func(i, j int) bool {
|
||||
if ops[i].path == ops[j].path {
|
||||
return ops[i].method < ops[j].method
|
||||
}
|
||||
return ops[i].path < ops[j].path
|
||||
})
|
||||
return ops
|
||||
}
|
||||
|
||||
func loadOpenAPISpec(t *testing.T) (*openapi3.T, string) {
|
||||
t.Helper()
|
||||
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatalf("runtime.Caller(0) failed")
|
||||
}
|
||||
specPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "openapi.yaml")
|
||||
|
||||
loader := openapi3.NewLoader()
|
||||
loader.IsExternalRefsAllowed = false
|
||||
doc, err := loader.LoadFromFile(specPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load openapi spec %s: %v", specPath, err)
|
||||
}
|
||||
return doc, specPath
|
||||
}
|
||||
|
||||
func mustValidateSpec(t *testing.T, doc *openapi3.T, specPath string) *openapi3.T {
|
||||
t.Helper()
|
||||
if err := doc.Validate(context.Background()); err != nil {
|
||||
t.Fatalf("openapi spec %s did not validate: %v", specPath, err)
|
||||
}
|
||||
if doc.Info == nil || doc.Info.Version != "v1" {
|
||||
t.Fatalf("openapi spec must declare info.version v1, got %+v", doc.Info)
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
func mustNewGorillamuxRouter(t *testing.T, doc *openapi3.T) routers.Router {
|
||||
t.Helper()
|
||||
router, err := gorillamux.NewRouter(doc)
|
||||
if err != nil {
|
||||
t.Fatalf("build gorillamux router: %v", err)
|
||||
}
|
||||
return router
|
||||
}
|
||||
|
||||
func mustBuildEngine(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
verifier := basicauth.NewStaticVerifier(contractStubAdminPassword)
|
||||
handler, err := NewRouter(RouterDependencies{
|
||||
AdminVerifier: verifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build router: %v", err)
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
func runContractCase(t *testing.T, router routers.Router, engine http.Handler, c contractOperation) {
|
||||
t.Helper()
|
||||
if c.operationID == "" {
|
||||
t.Fatalf("operation %s %s has no operationId", c.method, c.path)
|
||||
}
|
||||
|
||||
req := buildRequest(t, c)
|
||||
|
||||
route, pathParams, err := router.FindRoute(req)
|
||||
if err != nil {
|
||||
t.Fatalf("find route for %s %s: %v", c.method, req.URL.Path, err)
|
||||
}
|
||||
|
||||
requestInput := &openapi3filter.RequestValidationInput{
|
||||
Request: req,
|
||||
PathParams: pathParams,
|
||||
Route: route,
|
||||
Options: &openapi3filter.Options{
|
||||
AuthenticationFunc: openapi3filter.NoopAuthenticationFunc,
|
||||
},
|
||||
}
|
||||
if err := openapi3filter.ValidateRequest(req.Context(), requestInput); err != nil {
|
||||
t.Fatalf("ValidateRequest %s %s (%s): %v", c.method, c.path, c.operationID, err)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
|
||||
expectPlaceholder := groupForPath(c.path) != "probe"
|
||||
if expectPlaceholder {
|
||||
if got, want := recorder.Code, http.StatusNotImplemented; got != want {
|
||||
t.Fatalf("operation %s %s (%s) returned status %d, want %d (body: %s)",
|
||||
c.method, c.path, c.operationID, got, want, recorder.Body.String())
|
||||
}
|
||||
} else if recorder.Code/100 != 2 {
|
||||
t.Fatalf("probe operation %s %s (%s) returned non-2xx status %d (body: %s)",
|
||||
c.method, c.path, c.operationID, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if ct := recorder.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||
t.Fatalf("operation %s %s (%s) returned Content-Type %q, want application/json", c.method, c.path, c.operationID, ct)
|
||||
}
|
||||
|
||||
responseInput := &openapi3filter.ResponseValidationInput{
|
||||
RequestValidationInput: requestInput,
|
||||
Status: recorder.Code,
|
||||
Header: recorder.Header(),
|
||||
Options: &openapi3filter.Options{
|
||||
IncludeResponseStatus: true,
|
||||
},
|
||||
}
|
||||
responseInput.SetBodyBytes(recorder.Body.Bytes())
|
||||
if err := openapi3filter.ValidateResponse(req.Context(), responseInput); err != nil {
|
||||
t.Fatalf("ValidateResponse %s %s (%s): %v", c.method, c.path, c.operationID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func buildRequest(t *testing.T, c contractOperation) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
target := substitutePathParams(t, c.path)
|
||||
url := "http://backend.internal" + target
|
||||
|
||||
body := bodyFor(t, c)
|
||||
|
||||
req, err := http.NewRequest(c.method, url, body.reader)
|
||||
if err != nil {
|
||||
t.Fatalf("construct request %s %s: %v", c.method, url, err)
|
||||
}
|
||||
if body.contentType != "" {
|
||||
req.Header.Set("Content-Type", body.contentType)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
switch groupForPath(c.path) {
|
||||
case "user":
|
||||
req.Header.Set("X-User-ID", stubUserID)
|
||||
case "admin":
|
||||
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("operator:"+contractStubAdminPassword)))
|
||||
}
|
||||
req = req.WithContext(context.Background())
|
||||
return req
|
||||
}
|
||||
|
||||
type requestBody struct {
|
||||
reader io.Reader
|
||||
contentType string
|
||||
}
|
||||
|
||||
func bodyFor(t *testing.T, c contractOperation) requestBody {
|
||||
t.Helper()
|
||||
|
||||
if c.op.RequestBody == nil || c.op.RequestBody.Value == nil {
|
||||
return requestBody{}
|
||||
}
|
||||
media := c.op.RequestBody.Value.Content.Get("application/json")
|
||||
if media == nil {
|
||||
return requestBody{}
|
||||
}
|
||||
|
||||
stub, ok := requestBodyStubs[c.operationID]
|
||||
if !ok {
|
||||
stub = map[string]any{}
|
||||
}
|
||||
encoded, err := json.Marshal(stub)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request body for %s: %v", c.operationID, err)
|
||||
}
|
||||
return requestBody{
|
||||
reader: bytes.NewReader(encoded),
|
||||
contentType: "application/json",
|
||||
}
|
||||
}
|
||||
|
||||
func substitutePathParams(t *testing.T, templated string) string {
|
||||
t.Helper()
|
||||
|
||||
result := templated
|
||||
for {
|
||||
open := strings.Index(result, "{")
|
||||
if open < 0 {
|
||||
break
|
||||
}
|
||||
close := strings.Index(result[open:], "}")
|
||||
if close < 0 {
|
||||
t.Fatalf("malformed path template %q", templated)
|
||||
}
|
||||
name := result[open+1 : open+close]
|
||||
value, ok := pathParamStubs[name]
|
||||
if !ok {
|
||||
t.Fatalf("path template %q references unknown parameter %q", templated, name)
|
||||
}
|
||||
result = result[:open] + value + result[open+close+1:]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// groupForPath returns the route family that contractOperation.path belongs
|
||||
// to. The classification drives test-side header injection (X-User-ID,
|
||||
// Authorization).
|
||||
func groupForPath(path string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/api/v1/public"):
|
||||
return "public"
|
||||
case strings.HasPrefix(path, "/api/v1/user"):
|
||||
return "user"
|
||||
case strings.HasPrefix(path, "/api/v1/admin"):
|
||||
return "admin"
|
||||
case strings.HasPrefix(path, "/api/v1/internal"):
|
||||
return "internal"
|
||||
default:
|
||||
return "probe"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Package handlers exposes shared helpers used by the per-domain HTTP
|
||||
// handlers under `internal/server/handlers_*.go`. The only helper is
|
||||
// NotImplemented, which serves the standard `501 not_implemented`
|
||||
// envelope when a route is registered but the handler body is not
|
||||
// wired (a defensive fallback for new endpoints in flight).
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// NotImplemented returns a gin handler that emits the standard
|
||||
// `501 not_implemented` envelope. operationID names the OpenAPI operation the
|
||||
// handler will eventually implement; it is interpolated into the human-readable
|
||||
// message so that contract tests and operators can identify the endpoint.
|
||||
func NotImplemented(operationID string) gin.HandlerFunc {
|
||||
message := "endpoint is not implemented yet"
|
||||
if operationID != "" {
|
||||
message = "endpoint " + operationID + " is not implemented yet"
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
httperr.Abort(c, http.StatusNotImplemented, httperr.CodeNotImplemented, message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"galaxy/backend/internal/admin"
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/telemetry"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AdminAdminAccountsHandlers groups the admin-account CRUD handlers
|
||||
// under `/api/v1/admin/admin-accounts/*`. The current implementation ships real
|
||||
// implementations backed by `*admin.Service`; tests that supply a nil
|
||||
// service fall back to the Stage-3 placeholder body so the contract
|
||||
// test continues to validate the OpenAPI envelope without booting a
|
||||
// database.
|
||||
type AdminAdminAccountsHandlers struct {
|
||||
svc *admin.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewAdminAdminAccountsHandlers constructs the handler set. svc may be
|
||||
// nil — in that case every handler returns 501 not_implemented,
|
||||
// matching the pre-Stage-5.3 placeholder. logger may also be nil;
|
||||
// zap.NewNop is used in that case.
|
||||
func NewAdminAdminAccountsHandlers(svc *admin.Service, logger *zap.Logger) *AdminAdminAccountsHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &AdminAdminAccountsHandlers{svc: svc, logger: logger.Named("http.admin.admin-accounts")}
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/admin/admin-accounts.
|
||||
func (h *AdminAdminAccountsHandlers) List() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminAdminAccountsList")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
admins, err := h.svc.List(ctx)
|
||||
if err != nil {
|
||||
respondAdminAccountError(c, h.logger, "admin admin-accounts list", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, adminAccountListToWire(admins))
|
||||
}
|
||||
}
|
||||
|
||||
// Create handles POST /api/v1/admin/admin-accounts.
|
||||
func (h *AdminAdminAccountsHandlers) Create() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminAdminAccountsCreate")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
var req adminAccountCreateRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
created, err := h.svc.Create(ctx, admin.CreateInput{
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
})
|
||||
if err != nil {
|
||||
respondAdminAccountError(c, h.logger, "admin admin-accounts create", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, adminAccountToWire(created))
|
||||
}
|
||||
}
|
||||
|
||||
// Get handles GET /api/v1/admin/admin-accounts/{username}.
|
||||
func (h *AdminAdminAccountsHandlers) Get() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminAdminAccountsGet")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
got, err := h.svc.Get(ctx, c.Param("username"))
|
||||
if err != nil {
|
||||
respondAdminAccountError(c, h.logger, "admin admin-accounts get", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, adminAccountToWire(got))
|
||||
}
|
||||
}
|
||||
|
||||
// Disable handles POST /api/v1/admin/admin-accounts/{username}/disable.
|
||||
func (h *AdminAdminAccountsHandlers) Disable() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminAdminAccountsDisable")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
updated, err := h.svc.Disable(ctx, c.Param("username"))
|
||||
if err != nil {
|
||||
respondAdminAccountError(c, h.logger, "admin admin-accounts disable", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, adminAccountToWire(updated))
|
||||
}
|
||||
}
|
||||
|
||||
// Enable handles POST /api/v1/admin/admin-accounts/{username}/enable.
|
||||
func (h *AdminAdminAccountsHandlers) Enable() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminAdminAccountsEnable")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
updated, err := h.svc.Enable(ctx, c.Param("username"))
|
||||
if err != nil {
|
||||
respondAdminAccountError(c, h.logger, "admin admin-accounts enable", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, adminAccountToWire(updated))
|
||||
}
|
||||
}
|
||||
|
||||
// ResetPassword handles POST /api/v1/admin/admin-accounts/{username}/reset-password.
|
||||
func (h *AdminAdminAccountsHandlers) ResetPassword() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminAdminAccountsResetPassword")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
var req adminAccountResetPasswordRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
updated, err := h.svc.ResetPassword(ctx, c.Param("username"), req.Password)
|
||||
if err != nil {
|
||||
respondAdminAccountError(c, h.logger, "admin admin-accounts reset-password", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, adminAccountToWire(updated))
|
||||
}
|
||||
}
|
||||
|
||||
// respondAdminAccountError maps admin-package sentinels to the standard
|
||||
// JSON envelope. Unknown errors fall through to 500 with a structured
|
||||
// log so operators can correlate.
|
||||
func respondAdminAccountError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, admin.ErrNotFound):
|
||||
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "admin account not found")
|
||||
case errors.Is(err, admin.ErrUsernameTaken):
|
||||
httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, "username already in use")
|
||||
case errors.Is(err, admin.ErrInvalidInput):
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error())
|
||||
default:
|
||||
logger.Error(op+" failed",
|
||||
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
|
||||
)
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "internal error")
|
||||
}
|
||||
}
|
||||
|
||||
// adminAccountToWire renders an admin.Admin into the OpenAPI
|
||||
// `AdminAccount` schema declared at openapi.yaml:2596.
|
||||
func adminAccountToWire(a admin.Admin) adminAccountWire {
|
||||
out := adminAccountWire{
|
||||
Username: a.Username,
|
||||
CreatedAt: a.CreatedAt.UTC().Format(timestampLayout),
|
||||
}
|
||||
if a.LastUsedAt != nil {
|
||||
t := a.LastUsedAt.UTC().Format(timestampLayout)
|
||||
out.LastUsedAt = &t
|
||||
}
|
||||
if a.DisabledAt != nil {
|
||||
t := a.DisabledAt.UTC().Format(timestampLayout)
|
||||
out.DisabledAt = &t
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// adminAccountListToWire renders the admin slice into the OpenAPI
|
||||
// `AdminAccountList` schema.
|
||||
func adminAccountListToWire(admins []admin.Admin) adminAccountListWire {
|
||||
out := adminAccountListWire{
|
||||
Items: make([]adminAccountWire, 0, len(admins)),
|
||||
}
|
||||
for _, a := range admins {
|
||||
out.Items = append(out.Items, adminAccountToWire(a))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// adminAccountWire mirrors `AdminAccount`.
|
||||
type adminAccountWire struct {
|
||||
Username string `json:"username"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastUsedAt *string `json:"last_used_at,omitempty"`
|
||||
DisabledAt *string `json:"disabled_at,omitempty"`
|
||||
}
|
||||
|
||||
// adminAccountListWire mirrors `AdminAccountList`.
|
||||
type adminAccountListWire struct {
|
||||
Items []adminAccountWire `json:"items"`
|
||||
}
|
||||
|
||||
// adminAccountCreateRequestWire mirrors `AdminAccountCreateRequest`.
|
||||
type adminAccountCreateRequestWire struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// adminAccountResetPasswordRequestWire mirrors
|
||||
// `AdminAccountResetPasswordRequest`.
|
||||
type adminAccountResetPasswordRequestWire struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"galaxy/backend/internal/runtime"
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/telemetry"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AdminEngineVersionsHandlers groups the engine-version-registry
|
||||
// handlers under `/api/v1/admin/engine-versions/*`. The implementation swaps
|
||||
// the placeholder bodies for real `*runtime.EngineVersionService`
|
||||
// calls; tests that omit the service fall back to the Stage-3 501
|
||||
// envelope.
|
||||
type AdminEngineVersionsHandlers struct {
|
||||
svc *runtime.EngineVersionService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewAdminEngineVersionsHandlers constructs the handler set. svc may
|
||||
// be nil — in that case every handler returns 501.
|
||||
func NewAdminEngineVersionsHandlers(svc *runtime.EngineVersionService, logger *zap.Logger) *AdminEngineVersionsHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &AdminEngineVersionsHandlers{svc: svc, logger: logger.Named("http.admin.engine-versions")}
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/admin/engine-versions.
|
||||
func (h *AdminEngineVersionsHandlers) List() gin.HandlerFunc {
|
||||
if h == nil || h.svc == nil {
|
||||
return handlers.NotImplemented("adminEngineVersionsList")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
items, err := h.svc.List(ctx)
|
||||
if err != nil {
|
||||
respondEngineVersionError(c, h.logger, "admin engine-versions list", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, engineVersionListToWire(items))
|
||||
}
|
||||
}
|
||||
|
||||
// Create handles POST /api/v1/admin/engine-versions.
|
||||
func (h *AdminEngineVersionsHandlers) Create() gin.HandlerFunc {
|
||||
if h == nil || h.svc == nil {
|
||||
return handlers.NotImplemented("adminEngineVersionsCreate")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
var req engineVersionCreateRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
input := runtime.RegisterInput{Version: req.Version, ImageRef: req.ImageRef}
|
||||
if req.Enabled != nil {
|
||||
input.Enabled = req.Enabled
|
||||
}
|
||||
v, err := h.svc.Register(ctx, input)
|
||||
if err != nil {
|
||||
respondEngineVersionError(c, h.logger, "admin engine-versions create", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, engineVersionToWire(v))
|
||||
}
|
||||
}
|
||||
|
||||
// Update handles PATCH /api/v1/admin/engine-versions/{id}.
|
||||
func (h *AdminEngineVersionsHandlers) Update() gin.HandlerFunc {
|
||||
if h == nil || h.svc == nil {
|
||||
return handlers.NotImplemented("adminEngineVersionsUpdate")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
var req engineVersionUpdateRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
updated, err := h.svc.Update(ctx, c.Param("id"), runtime.UpdateInput{
|
||||
ImageRef: req.ImageRef,
|
||||
Enabled: req.Enabled,
|
||||
})
|
||||
if err != nil {
|
||||
respondEngineVersionError(c, h.logger, "admin engine-versions update", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, engineVersionToWire(updated))
|
||||
}
|
||||
}
|
||||
|
||||
// Disable handles POST /api/v1/admin/engine-versions/{id}/disable.
|
||||
func (h *AdminEngineVersionsHandlers) Disable() gin.HandlerFunc {
|
||||
if h == nil || h.svc == nil {
|
||||
return handlers.NotImplemented("adminEngineVersionsDisable")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
updated, err := h.svc.Disable(ctx, c.Param("id"))
|
||||
if err != nil {
|
||||
respondEngineVersionError(c, h.logger, "admin engine-versions disable", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, engineVersionToWire(updated))
|
||||
}
|
||||
}
|
||||
|
||||
func respondEngineVersionError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, runtime.ErrNotFound):
|
||||
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "engine version not found")
|
||||
case errors.Is(err, runtime.ErrEngineVersionTaken):
|
||||
httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, "engine version already registered")
|
||||
case errors.Is(err, runtime.ErrInvalidInput):
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error())
|
||||
default:
|
||||
logger.Error(op+" failed",
|
||||
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
|
||||
)
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "internal error")
|
||||
}
|
||||
}
|
||||
|
||||
func engineVersionToWire(v runtime.EngineVersion) engineVersionWire {
|
||||
return engineVersionWire{
|
||||
Version: v.Version,
|
||||
ImageRef: v.ImageRef,
|
||||
Enabled: v.Enabled,
|
||||
CreatedAt: v.CreatedAt.UTC().Format(timestampLayout),
|
||||
}
|
||||
}
|
||||
|
||||
func engineVersionListToWire(items []runtime.EngineVersion) engineVersionListWire {
|
||||
out := engineVersionListWire{Items: make([]engineVersionWire, 0, len(items))}
|
||||
for _, v := range items {
|
||||
out.Items = append(out.Items, engineVersionToWire(v))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// engineVersionWire mirrors `EngineVersion` from openapi.yaml.
|
||||
type engineVersionWire struct {
|
||||
Version string `json:"version"`
|
||||
ImageRef string `json:"image_ref"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// engineVersionListWire mirrors `EngineVersionList`.
|
||||
type engineVersionListWire struct {
|
||||
Items []engineVersionWire `json:"items"`
|
||||
}
|
||||
|
||||
// engineVersionCreateRequestWire mirrors `EngineVersionCreateRequest`.
|
||||
type engineVersionCreateRequestWire struct {
|
||||
Version string `json:"version"`
|
||||
ImageRef string `json:"image_ref"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// engineVersionUpdateRequestWire mirrors `EngineVersionUpdateRequest`.
|
||||
type engineVersionUpdateRequestWire struct {
|
||||
ImageRef *string `json:"image_ref,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/lobby"
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AdminGamesHandlers groups the admin-side game-management handlers
|
||||
// under `/api/v1/admin/games/*`. The current implementation ships real implementations
|
||||
// backed by `*lobby.Service` and adds the `Create` handler used by the
|
||||
// new POST /api/v1/admin/games endpoint for public-game creation.
|
||||
type AdminGamesHandlers struct {
|
||||
svc *lobby.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewAdminGamesHandlers constructs the handler set. svc may be nil —
|
||||
// in that case every handler returns 501 not_implemented.
|
||||
func NewAdminGamesHandlers(svc *lobby.Service, logger *zap.Logger) *AdminGamesHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &AdminGamesHandlers{svc: svc, logger: logger.Named("http.admin.games")}
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/admin/games.
|
||||
func (h *AdminGamesHandlers) List() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminGamesList")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
page := parsePositiveQueryInt(c.Query("page"), 1)
|
||||
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
||||
ctx := c.Request.Context()
|
||||
result, err := h.svc.ListAdminGames(ctx, page, pageSize)
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "admin games list", ctx, err)
|
||||
return
|
||||
}
|
||||
out := adminGameListWire{
|
||||
Items: make([]lobbyGameDetailWire, 0, len(result.Items)),
|
||||
Page: result.Page,
|
||||
PageSize: result.PageSize,
|
||||
Total: result.Total,
|
||||
}
|
||||
for _, g := range result.Items {
|
||||
out.Items = append(out.Items, lobbyGameDetailToWire(g))
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// Get handles GET /api/v1/admin/games/{game_id}.
|
||||
func (h *AdminGamesHandlers) Get() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminGamesGet")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
game, err := h.svc.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "admin games get", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, lobbyGameDetailToWire(game))
|
||||
}
|
||||
}
|
||||
|
||||
// Create handles POST /api/v1/admin/games — admin-only public-game
|
||||
// creation. The body intentionally omits `visibility`; the handler
|
||||
// hard-codes `visibility=public` and `owner_user_id=NULL`.
|
||||
func (h *AdminGamesHandlers) Create() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminGamesCreate")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
var req adminGameCreateRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
enrollmentEndsAt, err := time.Parse(time.RFC3339Nano, req.EnrollmentEndsAt)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "enrollment_ends_at must be RFC 3339")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
game, err := h.svc.CreateGame(ctx, lobby.CreateGameInput{
|
||||
OwnerUserID: nil,
|
||||
Visibility: lobby.VisibilityPublic,
|
||||
GameName: req.GameName,
|
||||
Description: req.Description,
|
||||
MinPlayers: req.MinPlayers,
|
||||
MaxPlayers: req.MaxPlayers,
|
||||
StartGapHours: req.StartGapHours,
|
||||
StartGapPlayers: req.StartGapPlayers,
|
||||
EnrollmentEndsAt: enrollmentEndsAt,
|
||||
TurnSchedule: req.TurnSchedule,
|
||||
TargetEngineVersion: req.TargetEngineVersion,
|
||||
})
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "admin games create", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, lobbyGameDetailToWire(game))
|
||||
}
|
||||
}
|
||||
|
||||
// ForceStart handles POST /api/v1/admin/games/{game_id}/force-start.
|
||||
func (h *AdminGamesHandlers) ForceStart() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminGamesForceStart")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
updated, err := h.svc.AdminForceStart(ctx, gameID)
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "admin games force-start", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, lobbyGameStateChangeToWire(updated))
|
||||
}
|
||||
}
|
||||
|
||||
// ForceStop handles POST /api/v1/admin/games/{game_id}/force-stop.
|
||||
func (h *AdminGamesHandlers) ForceStop() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminGamesForceStop")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
updated, err := h.svc.AdminForceStop(ctx, gameID)
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "admin games force-stop", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, lobbyGameStateChangeToWire(updated))
|
||||
}
|
||||
}
|
||||
|
||||
// BanMember handles POST /api/v1/admin/games/{game_id}/ban-member.
|
||||
func (h *AdminGamesHandlers) BanMember() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminGamesBanMember")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req adminGameBanMemberRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
userID, err := uuid.Parse(req.UserID)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user_id must be a valid UUID")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
updated, err := h.svc.AdminBanMember(ctx, gameID, userID, req.Reason)
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "admin games ban-member", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, lobbyMembershipDetailToWire(updated))
|
||||
}
|
||||
}
|
||||
|
||||
// adminGameListWire mirrors `AdminGameList`.
|
||||
type adminGameListWire struct {
|
||||
Items []lobbyGameDetailWire `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// adminGameCreateRequestWire mirrors `AdminGameCreateRequest`.
|
||||
type adminGameCreateRequestWire struct {
|
||||
GameName string `json:"game_name"`
|
||||
Description string `json:"description"`
|
||||
MinPlayers int32 `json:"min_players"`
|
||||
MaxPlayers int32 `json:"max_players"`
|
||||
StartGapHours int32 `json:"start_gap_hours"`
|
||||
StartGapPlayers int32 `json:"start_gap_players"`
|
||||
EnrollmentEndsAt string `json:"enrollment_ends_at"`
|
||||
TurnSchedule string `json:"turn_schedule"`
|
||||
TargetEngineVersion string `json:"target_engine_version"`
|
||||
}
|
||||
|
||||
// adminGameBanMemberRequestWire mirrors `AdminGameBanMemberRequest`.
|
||||
type adminGameBanMemberRequestWire struct {
|
||||
UserID string `json:"user_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"galaxy/backend/internal/geo"
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/telemetry"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AdminGeoLister is the narrow contract the admin geo handler needs
|
||||
// from the geo domain. `*geo.Service` satisfies it directly; tests
|
||||
// pass a recording fake.
|
||||
type AdminGeoLister interface {
|
||||
ListUserCounters(ctx context.Context, userID uuid.UUID) ([]geo.CountryCounter, error)
|
||||
}
|
||||
|
||||
// AdminGeoHandlers groups the admin-side geo-counter handlers under
|
||||
// `/api/v1/admin/geo/*`.
|
||||
type AdminGeoHandlers struct {
|
||||
svc AdminGeoLister
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewAdminGeoHandlers constructs the handler set. svc may be nil — in
|
||||
// that case every handler returns 501 not_implemented, matching the
|
||||
// pre-Stage-5.8 placeholder behaviour. logger may also be nil; zap.NewNop
|
||||
// is used in that case.
|
||||
func NewAdminGeoHandlers(svc AdminGeoLister, logger *zap.Logger) *AdminGeoHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &AdminGeoHandlers{svc: svc, logger: logger.Named("http.admin.geo")}
|
||||
}
|
||||
|
||||
// adminGeoCountryWire mirrors `GeoCountryCounter` from `openapi.yaml`.
|
||||
type adminGeoCountryWire struct {
|
||||
Country string `json:"country"`
|
||||
Count int64 `json:"count"`
|
||||
LastSeenAt *string `json:"last_seen_at,omitempty"`
|
||||
}
|
||||
|
||||
// adminGeoListWire mirrors `GeoCountryCounterList` from `openapi.yaml`.
|
||||
type adminGeoListWire struct {
|
||||
UserID string `json:"user_id"`
|
||||
Items []adminGeoCountryWire `json:"items"`
|
||||
}
|
||||
|
||||
// ListUserCountries handles GET /api/v1/admin/geo/users/{user_id}/countries.
|
||||
func (h *AdminGeoHandlers) ListUserCountries() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminGeoListUserCountries")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := parseUserIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
entries, err := h.svc.ListUserCounters(ctx, userID)
|
||||
if err != nil {
|
||||
h.logger.Error("admin geo list user countries failed",
|
||||
append(telemetry.TraceFieldsFromContext(ctx),
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.Error(err),
|
||||
)...,
|
||||
)
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, geoCountersToWire(userID, entries))
|
||||
}
|
||||
}
|
||||
|
||||
func geoCountersToWire(userID uuid.UUID, entries []geo.CountryCounter) adminGeoListWire {
|
||||
out := adminGeoListWire{
|
||||
UserID: userID.String(),
|
||||
Items: make([]adminGeoCountryWire, 0, len(entries)),
|
||||
}
|
||||
for _, e := range entries {
|
||||
item := adminGeoCountryWire{
|
||||
Country: e.Country,
|
||||
Count: e.Count,
|
||||
}
|
||||
if e.LastSeenAt != nil {
|
||||
formatted := e.LastSeenAt.UTC().Format("2006-01-02T15:04:05.000Z07:00")
|
||||
item.LastSeenAt = &formatted
|
||||
}
|
||||
out.Items = append(out.Items, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/geo"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap/zaptest"
|
||||
)
|
||||
|
||||
type fakeAdminGeoLister struct {
|
||||
entries []geo.CountryCounter
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeAdminGeoLister) ListUserCounters(_ context.Context, _ uuid.UUID) ([]geo.CountryCounter, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.entries, nil
|
||||
}
|
||||
|
||||
func newAdminGeoEngine(t *testing.T, fake AdminGeoLister) *gin.Engine {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
h := NewAdminGeoHandlers(fake, zaptest.NewLogger(t))
|
||||
r.GET("/api/v1/admin/geo/users/:user_id/countries", h.ListUserCountries())
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAdminGeoListUserCountriesSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
|
||||
fake := &fakeAdminGeoLister{entries: []geo.CountryCounter{
|
||||
{Country: "AU", Count: 3, LastSeenAt: &now},
|
||||
{Country: "DE", Count: 1, LastSeenAt: nil},
|
||||
}}
|
||||
r := newAdminGeoEngine(t, fake)
|
||||
|
||||
userID := uuid.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/geo/users/"+userID.String()+"/countries", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: want 200, got %d (%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body adminGeoListWire
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if body.UserID != userID.String() {
|
||||
t.Errorf("user_id: want %s, got %s", userID, body.UserID)
|
||||
}
|
||||
if len(body.Items) != 2 {
|
||||
t.Fatalf("items: want 2, got %d (%+v)", len(body.Items), body.Items)
|
||||
}
|
||||
if body.Items[0].Country != "AU" || body.Items[0].Count != 3 || body.Items[0].LastSeenAt == nil {
|
||||
t.Errorf("items[0] mismatch: %+v", body.Items[0])
|
||||
}
|
||||
if body.Items[1].Country != "DE" || body.Items[1].Count != 1 || body.Items[1].LastSeenAt != nil {
|
||||
t.Errorf("items[1] mismatch: %+v", body.Items[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGeoListUserCountriesEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newAdminGeoEngine(t, &fakeAdminGeoLister{})
|
||||
|
||||
userID := uuid.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/geo/users/"+userID.String()+"/countries", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: want 200, got %d", rec.Code)
|
||||
}
|
||||
var body adminGeoListWire
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if body.Items == nil {
|
||||
t.Fatal("items: want non-nil empty slice, got nil")
|
||||
}
|
||||
if len(body.Items) != 0 {
|
||||
t.Fatalf("items: want empty, got %+v", body.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGeoListUserCountriesInvalidUserID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newAdminGeoEngine(t, &fakeAdminGeoLister{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/geo/users/not-a-uuid/countries", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status: want 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGeoListUserCountriesStoreError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newAdminGeoEngine(t, &fakeAdminGeoLister{err: errors.New("boom")})
|
||||
|
||||
userID := uuid.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/geo/users/"+userID.String()+"/countries", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status: want 500, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGeoListUserCountriesNilServiceReturns501(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
h := NewAdminGeoHandlers(nil, zaptest.NewLogger(t))
|
||||
r.GET("/api/v1/admin/geo/users/:user_id/countries", h.ListUserCountries())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/geo/users/"+uuid.New().String()+"/countries", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotImplemented {
|
||||
t.Fatalf("status: want 501, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/mail"
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AdminMailHandlers groups the admin-side mail-outbox handlers under
|
||||
// `/api/v1/admin/mail/*`. The wiring connects real bodies backed by
|
||||
// `*mail.Service`; tests that supply a nil service fall back to the
|
||||
// Stage-3 placeholder body so the contract test continues to validate
|
||||
// the OpenAPI envelope without booting Postgres.
|
||||
type AdminMailHandlers struct {
|
||||
svc *mail.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewAdminMailHandlers constructs the handler set. svc may be nil — in
|
||||
// that case every handler returns 501 not_implemented, matching the
|
||||
// pre-Stage-5.6 placeholder. logger may also be nil; zap.NewNop is
|
||||
// used in that case.
|
||||
func NewAdminMailHandlers(svc *mail.Service, logger *zap.Logger) *AdminMailHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &AdminMailHandlers{svc: svc, logger: logger.Named("http.admin.mail")}
|
||||
}
|
||||
|
||||
// ListDeliveries handles GET /api/v1/admin/mail/deliveries.
|
||||
func (h *AdminMailHandlers) ListDeliveries() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminMailListDeliveries")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
page := parsePositiveQueryInt(c.Query("page"), 1)
|
||||
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
||||
ctx := c.Request.Context()
|
||||
out, err := h.svc.AdminListDeliveries(ctx, page, pageSize)
|
||||
if err != nil {
|
||||
respondMailError(c, h.logger, "admin mail list deliveries", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, mailDeliveryListToWire(out))
|
||||
}
|
||||
}
|
||||
|
||||
// GetDelivery handles GET /api/v1/admin/mail/deliveries/{delivery_id}.
|
||||
func (h *AdminMailHandlers) GetDelivery() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminMailGetDelivery")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
deliveryID, ok := parseDeliveryIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
d, err := h.svc.AdminGetDelivery(ctx, deliveryID)
|
||||
if err != nil {
|
||||
respondMailError(c, h.logger, "admin mail get delivery", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, mailDeliveryToWire(d))
|
||||
}
|
||||
}
|
||||
|
||||
// ListDeliveryAttempts handles GET /api/v1/admin/mail/deliveries/{delivery_id}/attempts.
|
||||
func (h *AdminMailHandlers) ListDeliveryAttempts() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminMailListDeliveryAttempts")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
deliveryID, ok := parseDeliveryIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
attempts, err := h.svc.AdminListAttempts(ctx, deliveryID)
|
||||
if err != nil {
|
||||
respondMailError(c, h.logger, "admin mail list attempts", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, mailAttemptListToWire(attempts))
|
||||
}
|
||||
}
|
||||
|
||||
// ResendDelivery handles POST /api/v1/admin/mail/deliveries/{delivery_id}/resend.
|
||||
func (h *AdminMailHandlers) ResendDelivery() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminMailResendDelivery")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
deliveryID, ok := parseDeliveryIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
d, err := h.svc.AdminResendDelivery(ctx, deliveryID)
|
||||
if err != nil {
|
||||
respondMailError(c, h.logger, "admin mail resend delivery", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, mailDeliveryToWire(d))
|
||||
}
|
||||
}
|
||||
|
||||
// ListDeadLetters handles GET /api/v1/admin/mail/dead-letters.
|
||||
func (h *AdminMailHandlers) ListDeadLetters() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminMailListDeadLetters")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
page := parsePositiveQueryInt(c.Query("page"), 1)
|
||||
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
||||
ctx := c.Request.Context()
|
||||
out, err := h.svc.AdminListDeadLetters(ctx, page, pageSize)
|
||||
if err != nil {
|
||||
respondMailError(c, h.logger, "admin mail list dead-letters", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, mailDeadLetterListToWire(out))
|
||||
}
|
||||
}
|
||||
|
||||
// parseDeliveryIDParam reads `delivery_id` from the path. On invalid
|
||||
// input it writes the standard 400 envelope and returns
|
||||
// (uuid.Nil, false).
|
||||
func parseDeliveryIDParam(c *gin.Context) (uuid.UUID, bool) {
|
||||
parsed, err := uuid.Parse(c.Param("delivery_id"))
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "delivery_id must be a valid UUID")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
// respondMailError translates the mail-domain sentinels to HTTP. Any
|
||||
// other error is logged and surfaced as 500 internal_error so the
|
||||
// handler always emits the documented envelope.
|
||||
func respondMailError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, mail.ErrDeliveryNotFound):
|
||||
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "mail delivery not found")
|
||||
case errors.Is(err, mail.ErrResendOnSent):
|
||||
httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, "delivery already sent")
|
||||
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
|
||||
httperr.Abort(c, http.StatusServiceUnavailable, httperr.CodeServiceUnavailable, "request cancelled")
|
||||
default:
|
||||
logger.Error(op+" failed", zap.Error(err))
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "internal server error")
|
||||
}
|
||||
_ = ctx
|
||||
}
|
||||
|
||||
// Wire DTOs mirror the schemas in `backend/openapi.yaml`.
|
||||
|
||||
type mailDeliveryWire struct {
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
TemplateID string `json:"template_id"`
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Attempts int32 `json:"attempts"`
|
||||
NextAttemptAt *string `json:"next_attempt_at,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type mailDeliveryListWire struct {
|
||||
Items []mailDeliveryWire `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type mailAttemptWire struct {
|
||||
AttemptID string `json:"attempt_id"`
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
AttemptNo int32 `json:"attempt_no"`
|
||||
StartedAt string `json:"started_at"`
|
||||
FinishedAt *string `json:"finished_at,omitempty"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type mailAttemptListWire struct {
|
||||
Items []mailAttemptWire `json:"items"`
|
||||
}
|
||||
|
||||
type mailDeadLetterWire struct {
|
||||
DeadLetterID string `json:"dead_letter_id"`
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
ArchivedAt string `json:"archived_at"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type mailDeadLetterListWire struct {
|
||||
Items []mailDeadLetterWire `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
func mailDeliveryToWire(d mail.Delivery) mailDeliveryWire {
|
||||
out := mailDeliveryWire{
|
||||
DeliveryID: d.DeliveryID.String(),
|
||||
TemplateID: d.TemplateID,
|
||||
IdempotencyKey: d.IdempotencyKey,
|
||||
Status: d.Status,
|
||||
Attempts: d.Attempts,
|
||||
CreatedAt: d.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if d.NextAttemptAt != nil {
|
||||
s := d.NextAttemptAt.UTC().Format(time.RFC3339Nano)
|
||||
out.NextAttemptAt = &s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mailDeliveryListToWire(p mail.AdminListDeliveriesPage) mailDeliveryListWire {
|
||||
items := make([]mailDeliveryWire, 0, len(p.Items))
|
||||
for _, d := range p.Items {
|
||||
items = append(items, mailDeliveryToWire(d))
|
||||
}
|
||||
return mailDeliveryListWire{
|
||||
Items: items,
|
||||
Page: p.Page,
|
||||
PageSize: p.PageSize,
|
||||
Total: p.Total,
|
||||
}
|
||||
}
|
||||
|
||||
func mailAttemptToWire(a mail.Attempt) mailAttemptWire {
|
||||
out := mailAttemptWire{
|
||||
AttemptID: a.AttemptID.String(),
|
||||
DeliveryID: a.DeliveryID.String(),
|
||||
AttemptNo: a.AttemptNo,
|
||||
StartedAt: a.StartedAt.UTC().Format(time.RFC3339Nano),
|
||||
Outcome: a.Outcome,
|
||||
Error: a.Error,
|
||||
}
|
||||
if a.FinishedAt != nil {
|
||||
s := a.FinishedAt.UTC().Format(time.RFC3339Nano)
|
||||
out.FinishedAt = &s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mailAttemptListToWire(items []mail.Attempt) mailAttemptListWire {
|
||||
out := mailAttemptListWire{Items: make([]mailAttemptWire, 0, len(items))}
|
||||
for _, a := range items {
|
||||
out.Items = append(out.Items, mailAttemptToWire(a))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mailDeadLetterToWire(dl mail.DeadLetter) mailDeadLetterWire {
|
||||
return mailDeadLetterWire{
|
||||
DeadLetterID: dl.DeadLetterID.String(),
|
||||
DeliveryID: dl.DeliveryID.String(),
|
||||
ArchivedAt: dl.ArchivedAt.UTC().Format(time.RFC3339Nano),
|
||||
Reason: dl.Reason,
|
||||
}
|
||||
}
|
||||
|
||||
func mailDeadLetterListToWire(p mail.AdminListDeadLettersPage) mailDeadLetterListWire {
|
||||
items := make([]mailDeadLetterWire, 0, len(p.Items))
|
||||
for _, dl := range p.Items {
|
||||
items = append(items, mailDeadLetterToWire(dl))
|
||||
}
|
||||
return mailDeadLetterListWire{
|
||||
Items: items,
|
||||
Page: p.Page,
|
||||
PageSize: p.PageSize,
|
||||
Total: p.Total,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/notification"
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AdminNotificationsHandlers groups the admin-side notification handlers
|
||||
// under `/api/v1/admin/notifications/*`. The wiring connects real bodies
|
||||
// backed by `*notification.Service`; tests that supply a nil service
|
||||
// fall back to the Stage-3 placeholder body so the contract test
|
||||
// continues to validate the OpenAPI envelope without booting Postgres.
|
||||
type AdminNotificationsHandlers struct {
|
||||
svc *notification.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewAdminNotificationsHandlers constructs the handler set. svc may be
|
||||
// nil — in that case every handler returns 501 not_implemented,
|
||||
// matching the pre-Stage-5.7 placeholder. logger may also be nil;
|
||||
// zap.NewNop is used in that case.
|
||||
func NewAdminNotificationsHandlers(svc *notification.Service, logger *zap.Logger) *AdminNotificationsHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &AdminNotificationsHandlers{svc: svc, logger: logger.Named("http.admin.notifications")}
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/admin/notifications.
|
||||
func (h *AdminNotificationsHandlers) List() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminNotificationsList")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
page := parsePositiveQueryInt(c.Query("page"), 1)
|
||||
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
||||
ctx := c.Request.Context()
|
||||
out, err := h.svc.AdminListNotifications(ctx, page, pageSize)
|
||||
if err != nil {
|
||||
respondNotificationError(c, h.logger, "admin notifications list", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, notificationListToWire(out))
|
||||
}
|
||||
}
|
||||
|
||||
// Get handles GET /api/v1/admin/notifications/{notification_id}.
|
||||
func (h *AdminNotificationsHandlers) Get() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminNotificationsGet")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
id, ok := parseNotificationIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
n, err := h.svc.AdminGetNotification(ctx, id)
|
||||
if err != nil {
|
||||
respondNotificationError(c, h.logger, "admin notifications get", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, notificationToWire(n))
|
||||
}
|
||||
}
|
||||
|
||||
// ListDeadLetters handles GET /api/v1/admin/notifications/dead-letters.
|
||||
func (h *AdminNotificationsHandlers) ListDeadLetters() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminNotificationsListDeadLetters")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
page := parsePositiveQueryInt(c.Query("page"), 1)
|
||||
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
||||
ctx := c.Request.Context()
|
||||
out, err := h.svc.AdminListDeadLetters(ctx, page, pageSize)
|
||||
if err != nil {
|
||||
respondNotificationError(c, h.logger, "admin notifications list dead-letters", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, notificationDeadLetterListToWire(out))
|
||||
}
|
||||
}
|
||||
|
||||
// ListMalformed handles GET /api/v1/admin/notifications/malformed.
|
||||
func (h *AdminNotificationsHandlers) ListMalformed() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminNotificationsListMalformed")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
page := parsePositiveQueryInt(c.Query("page"), 1)
|
||||
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
||||
ctx := c.Request.Context()
|
||||
out, err := h.svc.AdminListMalformed(ctx, page, pageSize)
|
||||
if err != nil {
|
||||
respondNotificationError(c, h.logger, "admin notifications list malformed", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, notificationMalformedListToWire(out))
|
||||
}
|
||||
}
|
||||
|
||||
// parseNotificationIDParam reads `notification_id` from the path. On
|
||||
// invalid input it writes the standard 400 envelope and returns
|
||||
// (uuid.Nil, false).
|
||||
func parseNotificationIDParam(c *gin.Context) (uuid.UUID, bool) {
|
||||
parsed, err := uuid.Parse(c.Param("notification_id"))
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "notification_id must be a valid UUID")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
// respondNotificationError translates the notification-domain sentinels
|
||||
// to HTTP. Any other error is logged and surfaced as 500 internal_error.
|
||||
func respondNotificationError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, notification.ErrNotificationNotFound):
|
||||
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "notification not found")
|
||||
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
|
||||
httperr.Abort(c, http.StatusServiceUnavailable, httperr.CodeServiceUnavailable, "request cancelled")
|
||||
default:
|
||||
logger.Error(op+" failed", zap.Error(err))
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "internal server error")
|
||||
}
|
||||
_ = ctx
|
||||
}
|
||||
|
||||
// Wire DTOs mirror the OpenAPI schemas in `backend/openapi.yaml`.
|
||||
|
||||
type notificationWire struct {
|
||||
NotificationID string `json:"notification_id"`
|
||||
Kind string `json:"kind"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
Payload map[string]any `json:"payload,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type notificationListWire struct {
|
||||
Items []notificationWire `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type notificationDeadLetterWire struct {
|
||||
DeadLetterID string `json:"dead_letter_id"`
|
||||
NotificationID string `json:"notification_id"`
|
||||
ArchivedAt string `json:"archived_at"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type notificationDeadLetterListWire struct {
|
||||
Items []notificationDeadLetterWire `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type notificationMalformedWire struct {
|
||||
ID string `json:"id"`
|
||||
ReceivedAt string `json:"received_at"`
|
||||
Payload map[string]any `json:"payload,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type notificationMalformedListWire struct {
|
||||
Items []notificationMalformedWire `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
func notificationToWire(n notification.Notification) notificationWire {
|
||||
out := notificationWire{
|
||||
NotificationID: n.NotificationID.String(),
|
||||
Kind: n.Kind,
|
||||
IdempotencyKey: n.IdempotencyKey,
|
||||
Payload: n.Payload,
|
||||
CreatedAt: n.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if n.UserID != nil {
|
||||
out.UserID = n.UserID.String()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func notificationListToWire(p notification.AdminListNotificationsPage) notificationListWire {
|
||||
items := make([]notificationWire, 0, len(p.Items))
|
||||
for _, n := range p.Items {
|
||||
items = append(items, notificationToWire(n))
|
||||
}
|
||||
return notificationListWire{
|
||||
Items: items,
|
||||
Page: p.Page,
|
||||
PageSize: p.PageSize,
|
||||
Total: p.Total,
|
||||
}
|
||||
}
|
||||
|
||||
func notificationDeadLetterToWire(dl notification.DeadLetter) notificationDeadLetterWire {
|
||||
return notificationDeadLetterWire{
|
||||
DeadLetterID: dl.DeadLetterID.String(),
|
||||
NotificationID: dl.NotificationID.String(),
|
||||
ArchivedAt: dl.ArchivedAt.UTC().Format(time.RFC3339Nano),
|
||||
Reason: dl.Reason,
|
||||
}
|
||||
}
|
||||
|
||||
func notificationDeadLetterListToWire(p notification.AdminListDeadLettersPage) notificationDeadLetterListWire {
|
||||
items := make([]notificationDeadLetterWire, 0, len(p.Items))
|
||||
for _, dl := range p.Items {
|
||||
items = append(items, notificationDeadLetterToWire(dl))
|
||||
}
|
||||
return notificationDeadLetterListWire{
|
||||
Items: items,
|
||||
Page: p.Page,
|
||||
PageSize: p.PageSize,
|
||||
Total: p.Total,
|
||||
}
|
||||
}
|
||||
|
||||
func notificationMalformedToWire(m notification.MalformedIntent) notificationMalformedWire {
|
||||
return notificationMalformedWire{
|
||||
ID: m.ID.String(),
|
||||
ReceivedAt: m.ReceivedAt.UTC().Format(time.RFC3339Nano),
|
||||
Payload: m.Payload,
|
||||
Reason: m.Reason,
|
||||
}
|
||||
}
|
||||
|
||||
func notificationMalformedListToWire(p notification.AdminListMalformedPage) notificationMalformedListWire {
|
||||
items := make([]notificationMalformedWire, 0, len(p.Items))
|
||||
for _, m := range p.Items {
|
||||
items = append(items, notificationMalformedToWire(m))
|
||||
}
|
||||
return notificationMalformedListWire{
|
||||
Items: items,
|
||||
Page: p.Page,
|
||||
PageSize: p.PageSize,
|
||||
Total: p.Total,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"galaxy/backend/internal/runtime"
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/telemetry"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AdminRuntimesHandlers groups the admin-side runtime handlers under
|
||||
// `/api/v1/admin/runtimes/*`. The implementation swaps the placeholder bodies
|
||||
// for real `*runtime.Service` calls; tests that omit the service fall
|
||||
// back to 501.
|
||||
type AdminRuntimesHandlers struct {
|
||||
svc *runtime.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewAdminRuntimesHandlers constructs the handler set. svc may be
|
||||
// nil — placeholders are returned in that case.
|
||||
func NewAdminRuntimesHandlers(svc *runtime.Service, logger *zap.Logger) *AdminRuntimesHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &AdminRuntimesHandlers{svc: svc, logger: logger.Named("http.admin.runtimes")}
|
||||
}
|
||||
|
||||
// Get handles GET /api/v1/admin/runtimes/{game_id}.
|
||||
func (h *AdminRuntimesHandlers) Get() gin.HandlerFunc {
|
||||
if h == nil || h.svc == nil {
|
||||
return handlers.NotImplemented("adminRuntimesGet")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
rec, err := h.svc.GetRuntime(ctx, gameID)
|
||||
if err != nil {
|
||||
respondRuntimeError(c, h.logger, "admin runtimes get", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, runtimeRecordToWire(rec))
|
||||
}
|
||||
}
|
||||
|
||||
// Restart handles POST /api/v1/admin/runtimes/{game_id}/restart.
|
||||
func (h *AdminRuntimesHandlers) Restart() gin.HandlerFunc {
|
||||
if h == nil || h.svc == nil {
|
||||
return handlers.NotImplemented("adminRuntimesRestart")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
op, err := h.svc.AdminRestart(ctx, gameID)
|
||||
if err != nil {
|
||||
respondRuntimeError(c, h.logger, "admin runtimes restart", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, runtimeOperationToWire(op))
|
||||
}
|
||||
}
|
||||
|
||||
// Patch handles POST /api/v1/admin/runtimes/{game_id}/patch.
|
||||
func (h *AdminRuntimesHandlers) Patch() gin.HandlerFunc {
|
||||
if h == nil || h.svc == nil {
|
||||
return handlers.NotImplemented("adminRuntimesPatch")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
var req runtimePatchRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
op, err := h.svc.AdminPatch(ctx, gameID, req.TargetVersion)
|
||||
if err != nil {
|
||||
respondRuntimeError(c, h.logger, "admin runtimes patch", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, runtimeOperationToWire(op))
|
||||
}
|
||||
}
|
||||
|
||||
// ForceNextTurn handles POST /api/v1/admin/runtimes/{game_id}/force-next-turn.
|
||||
func (h *AdminRuntimesHandlers) ForceNextTurn() gin.HandlerFunc {
|
||||
if h == nil || h.svc == nil {
|
||||
return handlers.NotImplemented("adminRuntimesForceNextTurn")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
op, err := h.svc.AdminForceNextTurn(ctx, gameID)
|
||||
if err != nil {
|
||||
respondRuntimeError(c, h.logger, "admin runtimes force-next-turn", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, runtimeOperationToWire(op))
|
||||
}
|
||||
}
|
||||
|
||||
func respondRuntimeError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, runtime.ErrNotFound):
|
||||
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "runtime record not found")
|
||||
case errors.Is(err, runtime.ErrInvalidInput),
|
||||
errors.Is(err, runtime.ErrPatchSemverIncompatible):
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error())
|
||||
case errors.Is(err, runtime.ErrConflict),
|
||||
errors.Is(err, runtime.ErrEngineVersionDisabled):
|
||||
httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, err.Error())
|
||||
case errors.Is(err, runtime.ErrJobQueueFull):
|
||||
httperr.Abort(c, http.StatusServiceUnavailable, httperr.CodeServiceUnavailable, "runtime worker queue full, retry later")
|
||||
default:
|
||||
logger.Error(op+" failed",
|
||||
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
|
||||
)
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "internal error")
|
||||
}
|
||||
}
|
||||
|
||||
// runtimeRecordWire mirrors `RuntimeRecord` from openapi.yaml. The
|
||||
// schema declares `additionalProperties: true`, so we serialise the
|
||||
// minimal documented shape.
|
||||
type runtimeRecordWire struct {
|
||||
GameID string `json:"game_id"`
|
||||
Status string `json:"status"`
|
||||
CurrentContainerID string `json:"current_container_id,omitempty"`
|
||||
ImageRef string `json:"image_ref,omitempty"`
|
||||
StartedAt *string `json:"started_at,omitempty"`
|
||||
LastObservedAt *string `json:"last_observed_at,omitempty"`
|
||||
}
|
||||
|
||||
func runtimeRecordToWire(r runtime.RuntimeRecord) runtimeRecordWire {
|
||||
out := runtimeRecordWire{
|
||||
GameID: r.GameID.String(),
|
||||
Status: r.Status,
|
||||
CurrentContainerID: r.CurrentContainerID,
|
||||
ImageRef: r.CurrentImageRef,
|
||||
}
|
||||
if r.StartedAt != nil {
|
||||
s := r.StartedAt.UTC().Format(timestampLayout)
|
||||
out.StartedAt = &s
|
||||
}
|
||||
if r.LastObservedAt != nil {
|
||||
s := r.LastObservedAt.UTC().Format(timestampLayout)
|
||||
out.LastObservedAt = &s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// runtimeOperationWire mirrors `RuntimeOperation` from openapi.yaml.
|
||||
type runtimeOperationWire struct {
|
||||
OperationID string `json:"operation_id"`
|
||||
GameID string `json:"game_id"`
|
||||
Op string `json:"op"`
|
||||
Status string `json:"status"`
|
||||
StartedAt string `json:"started_at"`
|
||||
FinishedAt *string `json:"finished_at,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func runtimeOperationToWire(op runtime.OperationLog) runtimeOperationWire {
|
||||
out := runtimeOperationWire{
|
||||
OperationID: op.OperationID.String(),
|
||||
GameID: op.GameID.String(),
|
||||
Op: op.Op,
|
||||
Status: op.Status,
|
||||
StartedAt: op.StartedAt.UTC().Format(timestampLayout),
|
||||
}
|
||||
if op.FinishedAt != nil {
|
||||
s := op.FinishedAt.UTC().Format(timestampLayout)
|
||||
out.FinishedAt = &s
|
||||
}
|
||||
if op.ErrorMessage != "" {
|
||||
out.Error = op.ErrorMessage
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// runtimePatchRequestWire mirrors `RuntimePatchRequest`.
|
||||
type runtimePatchRequestWire struct {
|
||||
TargetVersion string `json:"target_version"`
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/server/middleware/basicauth"
|
||||
"galaxy/backend/internal/user"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AdminUsersHandlers groups the admin-side user-management handlers
|
||||
// under `/api/v1/admin/users/*`. The current implementation ships real implementations
|
||||
// backed by `*user.Service`; tests that supply a nil service fall back
|
||||
// to the Stage-3 placeholder body so the contract test continues to
|
||||
// validate the OpenAPI envelope without booting a database.
|
||||
type AdminUsersHandlers struct {
|
||||
svc *user.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewAdminUsersHandlers constructs the handler set. svc may be nil — in
|
||||
// that case every handler returns 501 not_implemented, matching the
|
||||
// pre-Stage-5.2 placeholder. logger may also be nil; zap.NewNop is used
|
||||
// in that case.
|
||||
func NewAdminUsersHandlers(svc *user.Service, logger *zap.Logger) *AdminUsersHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &AdminUsersHandlers{svc: svc, logger: logger.Named("http.admin.users")}
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/admin/users.
|
||||
func (h *AdminUsersHandlers) List() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminUsersList")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
page := parsePositiveQueryInt(c.Query("page"), 1)
|
||||
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
||||
|
||||
ctx := c.Request.Context()
|
||||
result, err := h.svc.ListAccounts(ctx, page, pageSize)
|
||||
if err != nil {
|
||||
respondAccountError(c, h.logger, "admin users list", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, accountListToWire(result))
|
||||
}
|
||||
}
|
||||
|
||||
// Get handles GET /api/v1/admin/users/{user_id}.
|
||||
func (h *AdminUsersHandlers) Get() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminUsersGet")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := parseUserIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
account, err := h.svc.GetAccount(ctx, userID)
|
||||
if err != nil {
|
||||
respondAccountError(c, h.logger, "admin users get", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, accountResponseToWire(account))
|
||||
}
|
||||
}
|
||||
|
||||
// AddSanction handles POST /api/v1/admin/users/{user_id}/sanctions.
|
||||
func (h *AdminUsersHandlers) AddSanction() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminUsersAddSanction")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := parseUserIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req adminUserSanctionRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
expiresAt, err := parseTimePtr(req.ExpiresAt)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "expires_at must be RFC 3339")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
account, err := h.svc.ApplySanction(ctx, user.ApplySanctionInput{
|
||||
UserID: userID,
|
||||
SanctionCode: req.SanctionCode,
|
||||
Scope: req.Scope,
|
||||
ReasonCode: req.ReasonCode,
|
||||
Actor: wireToActorRef(req.Actor, c),
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
respondAccountError(c, h.logger, "admin users add sanction", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, accountResponseToWire(account))
|
||||
}
|
||||
}
|
||||
|
||||
// AddLimit handles POST /api/v1/admin/users/{user_id}/limits.
|
||||
func (h *AdminUsersHandlers) AddLimit() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminUsersAddLimit")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := parseUserIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req adminUserLimitRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
expiresAt, err := parseTimePtr(req.ExpiresAt)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "expires_at must be RFC 3339")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
account, err := h.svc.ApplyLimit(ctx, user.ApplyLimitInput{
|
||||
UserID: userID,
|
||||
LimitCode: req.LimitCode,
|
||||
Value: req.Value,
|
||||
ReasonCode: req.ReasonCode,
|
||||
Actor: wireToActorRef(req.Actor, c),
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
respondAccountError(c, h.logger, "admin users add limit", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, accountResponseToWire(account))
|
||||
}
|
||||
}
|
||||
|
||||
// AddEntitlement handles POST /api/v1/admin/users/{user_id}/entitlements.
|
||||
func (h *AdminUsersHandlers) AddEntitlement() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminUsersAddEntitlement")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := parseUserIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req adminUserEntitlementRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
startsAt, err := parseTimePtr(req.StartsAt)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "starts_at must be RFC 3339")
|
||||
return
|
||||
}
|
||||
endsAt, err := parseTimePtr(req.EndsAt)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "ends_at must be RFC 3339")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
account, err := h.svc.ApplyEntitlement(ctx, user.ApplyEntitlementInput{
|
||||
UserID: userID,
|
||||
Tier: req.Tier,
|
||||
Source: req.Source,
|
||||
Actor: wireToActorRef(req.Actor, c),
|
||||
ReasonCode: req.ReasonCode,
|
||||
StartsAt: startsAt,
|
||||
EndsAt: endsAt,
|
||||
})
|
||||
if err != nil {
|
||||
respondAccountError(c, h.logger, "admin users add entitlement", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, accountResponseToWire(account))
|
||||
}
|
||||
}
|
||||
|
||||
// SoftDelete handles POST /api/v1/admin/users/{user_id}/soft-delete.
|
||||
func (h *AdminUsersHandlers) SoftDelete() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminUsersSoftDelete")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := parseUserIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
username, _ := basicauth.UsernameFromContext(ctx)
|
||||
actor := user.ActorRef{Type: "admin", ID: username}
|
||||
if err := h.svc.SoftDelete(ctx, userID, actor); err != nil {
|
||||
if errors.Is(err, user.ErrAccountNotFound) {
|
||||
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "account not found")
|
||||
return
|
||||
}
|
||||
// Cascade errors do not mask the canonical state — the
|
||||
// account is soft-deleted in Postgres. Surface 204 with
|
||||
// the error logged so caller UI proceeds.
|
||||
h.logger.Warn("admin users soft-delete cascade returned error", zap.Error(err))
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// parseUserIDParam reads `user_id` from the path. On invalid input it
|
||||
// writes the standard 400 envelope and returns (uuid.Nil, false).
|
||||
func parseUserIDParam(c *gin.Context) (uuid.UUID, bool) {
|
||||
parsed, err := uuid.Parse(c.Param("user_id"))
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user_id must be a valid UUID")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
// parsePositiveQueryInt parses a non-negative integer query parameter.
|
||||
// Empty / non-numeric values fall back to fallback.
|
||||
func parsePositiveQueryInt(raw string, fallback int) int {
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil || parsed <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// wireToActorRef converts the wire-level ActorRef into the user-domain
|
||||
// type. The basic-auth context plumbing supplies a fallback id when the
|
||||
// client omits one, so admin actions always carry the operator
|
||||
// identity.
|
||||
func wireToActorRef(actor *actorRefWire, c *gin.Context) user.ActorRef {
|
||||
if actor == nil {
|
||||
username, _ := basicauth.UsernameFromContext(c.Request.Context())
|
||||
return user.ActorRef{Type: "admin", ID: username}
|
||||
}
|
||||
out := user.ActorRef{Type: actor.Type, ID: actor.ID}
|
||||
if out.ID == "" {
|
||||
if username, ok := basicauth.UsernameFromContext(c.Request.Context()); ok {
|
||||
out.ID = username
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// accountListToWire renders the AccountPage into the AdminUserList
|
||||
// schema declared in openapi.yaml.
|
||||
func accountListToWire(page user.AccountPage) adminUserListWire {
|
||||
out := adminUserListWire{
|
||||
Items: make([]accountWire, 0, len(page.Items)),
|
||||
Page: page.Page,
|
||||
PageSize: page.PageSize,
|
||||
Total: page.Total,
|
||||
}
|
||||
for _, a := range page.Items {
|
||||
out.Items = append(out.Items, accountToWire(a))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// adminUserSanctionRequestWire mirrors `AdminUserSanctionRequest`.
|
||||
type adminUserSanctionRequestWire struct {
|
||||
SanctionCode string `json:"sanction_code"`
|
||||
Scope string `json:"scope"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Actor *actorRefWire `json:"actor"`
|
||||
ExpiresAt *string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// adminUserLimitRequestWire mirrors `AdminUserLimitRequest`.
|
||||
type adminUserLimitRequestWire struct {
|
||||
LimitCode string `json:"limit_code"`
|
||||
Value int32 `json:"value"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Actor *actorRefWire `json:"actor"`
|
||||
ExpiresAt *string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// adminUserEntitlementRequestWire mirrors `AdminUserEntitlementRequest`.
|
||||
type adminUserEntitlementRequestWire struct {
|
||||
Tier string `json:"tier"`
|
||||
Source string `json:"source"`
|
||||
Actor *actorRefWire `json:"actor"`
|
||||
ReasonCode string `json:"reason_code,omitempty"`
|
||||
StartsAt *string `json:"starts_at,omitempty"`
|
||||
EndsAt *string `json:"ends_at,omitempty"`
|
||||
}
|
||||
|
||||
// adminUserListWire mirrors `AdminUserList`.
|
||||
type adminUserListWire struct {
|
||||
Items []accountWire `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"galaxy/backend/internal/auth"
|
||||
)
|
||||
|
||||
// ed25519PublicKeyLen is the fixed size of a raw Ed25519 public key. The
|
||||
// OpenAPI contract documents `client_public_key` as a "Standard
|
||||
// base64-encoded raw 32-byte Ed25519 public key"; the handler enforces
|
||||
// the length after decode so the auth service always operates on the
|
||||
// canonical shape.
|
||||
const ed25519PublicKeyLen = 32
|
||||
|
||||
// deviceSessionPayload is the JSON body the internal session endpoints
|
||||
// emit. It mirrors the `DeviceSession` schema in `openapi.yaml`.
|
||||
type deviceSessionPayload struct {
|
||||
DeviceSessionID string `json:"device_session_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Status string `json:"status"`
|
||||
ClientPublicKey string `json:"client_public_key,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
RevokedAt *string `json:"revoked_at,omitempty"`
|
||||
LastSeenAt *string `json:"last_seen_at,omitempty"`
|
||||
}
|
||||
|
||||
func deviceSessionToWire(s auth.Session) deviceSessionPayload {
|
||||
out := deviceSessionPayload{
|
||||
DeviceSessionID: s.DeviceSessionID.String(),
|
||||
UserID: s.UserID.String(),
|
||||
Status: s.Status,
|
||||
CreatedAt: s.CreatedAt.UTC().Format("2006-01-02T15:04:05.000Z07:00"),
|
||||
}
|
||||
if len(s.ClientPublicKey) > 0 {
|
||||
out.ClientPublicKey = base64.StdEncoding.EncodeToString(s.ClientPublicKey)
|
||||
}
|
||||
if s.RevokedAt != nil {
|
||||
formatted := s.RevokedAt.UTC().Format("2006-01-02T15:04:05.000Z07:00")
|
||||
out.RevokedAt = &formatted
|
||||
}
|
||||
if s.LastSeenAt != nil {
|
||||
formatted := s.LastSeenAt.UTC().Format("2006-01-02T15:04:05.000Z07:00")
|
||||
out.LastSeenAt = &formatted
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// validateEmail returns the trimmed value when raw parses as an
|
||||
// addr-spec, or "" when raw is malformed. Auth normalises to lowercase
|
||||
// internally; the handler only enforces the syntactic shape.
|
||||
func validateEmail(raw string) string {
|
||||
addr, err := mail.ParseAddress(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return addr.Address
|
||||
}
|
||||
|
||||
// decodeClientPublicKey decodes the wire-format client_public_key into
|
||||
// raw bytes and validates the length. Standard base64 (with padding) is
|
||||
// the canonical encoding documented in `openapi.yaml`.
|
||||
func decodeClientPublicKey(raw string) ([]byte, bool) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if len(decoded) != ed25519PublicKeyLen {
|
||||
return nil, false
|
||||
}
|
||||
return decoded, true
|
||||
}
|
||||
|
||||
// isDecimalCodeOfLength reports whether s is a string of exactly want
|
||||
// ASCII digits.
|
||||
func isDecimalCodeOfLength(s string, want int) bool {
|
||||
if len(s) != want {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] < '0' || s[i] > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"galaxy/backend/internal/auth"
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/telemetry"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// InternalSessionsHandlers groups the gateway-only session handlers
|
||||
// under `/api/v1/internal/sessions/*`. The current implementation ships real
|
||||
// implementations; nil *auth.Service falls back to the Stage-3
|
||||
// placeholder so the contract test continues to validate the OpenAPI
|
||||
// envelope without booting a database.
|
||||
type InternalSessionsHandlers struct {
|
||||
svc *auth.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewInternalSessionsHandlers constructs the handler set. svc may be
|
||||
// nil — in that case every handler returns 501 not_implemented, matching
|
||||
// the pre-Stage-5.1 placeholder. logger may also be nil; zap.NewNop is
|
||||
// used in that case.
|
||||
func NewInternalSessionsHandlers(svc *auth.Service, logger *zap.Logger) *InternalSessionsHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &InternalSessionsHandlers{svc: svc, logger: logger.Named("http.internal.sessions")}
|
||||
}
|
||||
|
||||
// Get handles GET /api/v1/internal/sessions/{device_session_id}.
|
||||
func (h *InternalSessionsHandlers) Get() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("internalSessionsGet")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
deviceSessionID, err := uuid.Parse(c.Param("device_session_id"))
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "device_session_id must be a valid UUID")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
sess, err := h.svc.GetSession(ctx, deviceSessionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrSessionNotFound) {
|
||||
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "device session not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("internal sessions get failed",
|
||||
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
|
||||
)
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, deviceSessionToWire(sess))
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke handles POST /api/v1/internal/sessions/{device_session_id}/revoke.
|
||||
func (h *InternalSessionsHandlers) Revoke() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("internalSessionsRevoke")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
deviceSessionID, err := uuid.Parse(c.Param("device_session_id"))
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "device_session_id must be a valid UUID")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
sess, err := h.svc.RevokeSession(ctx, deviceSessionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrSessionNotFound) {
|
||||
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "device session not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("internal sessions revoke failed",
|
||||
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
|
||||
)
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, deviceSessionToWire(sess))
|
||||
}
|
||||
}
|
||||
|
||||
// RevokeAllForUser handles POST /api/v1/internal/sessions/users/{user_id}/revoke-all.
|
||||
func (h *InternalSessionsHandlers) RevokeAllForUser() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("internalSessionsRevokeAllForUser")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, err := uuid.Parse(c.Param("user_id"))
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user_id must be a valid UUID")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
revoked, err := h.svc.RevokeAllForUser(ctx, userID)
|
||||
if err != nil {
|
||||
h.logger.Error("internal sessions revoke-all failed",
|
||||
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
|
||||
)
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user_id": userID.String(),
|
||||
"revoked_count": len(revoked),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/user"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// InternalUsersHandlers groups the gateway-only user-fetch handlers
|
||||
// under `/api/v1/internal/users/*`. The current implementation ships real
|
||||
// implementations backed by `*user.Service`; tests that supply a nil
|
||||
// service fall back to the Stage-3 placeholder body.
|
||||
type InternalUsersHandlers struct {
|
||||
svc *user.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewInternalUsersHandlers constructs the handler set. svc may be
|
||||
// nil — in that case every handler returns 501 not_implemented.
|
||||
// logger may also be nil; zap.NewNop is used in that case.
|
||||
func NewInternalUsersHandlers(svc *user.Service, logger *zap.Logger) *InternalUsersHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &InternalUsersHandlers{svc: svc, logger: logger.Named("http.internal.users")}
|
||||
}
|
||||
|
||||
// GetAccountInternal handles GET /api/v1/internal/users/{user_id}/account-internal.
|
||||
func (h *InternalUsersHandlers) GetAccountInternal() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("internalUsersGetAccountInternal")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := parseUserIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
account, err := h.svc.GetAccount(ctx, userID)
|
||||
if err != nil {
|
||||
respondAccountError(c, h.logger, "internal users get account", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, accountResponseToWire(account))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/auth"
|
||||
"galaxy/backend/internal/server/clientip"
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/telemetry"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// PublicAuthHandlers groups the public unauthenticated auth handlers
|
||||
// under `/api/v1/public/auth/*`. The current implementation ships the real challenge
|
||||
// issuance and confirmation flows; tests that supply a nil *auth.Service
|
||||
// fall back to the Stage-3 placeholder body so the contract test
|
||||
// continues to validate the OpenAPI envelope without booting a database.
|
||||
type PublicAuthHandlers struct {
|
||||
svc *auth.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewPublicAuthHandlers constructs the handler set. svc may be nil — in
|
||||
// that case every handler returns 501 not_implemented, matching the
|
||||
// pre-Stage-5.1 placeholder. logger may also be nil; zap.NewNop is used
|
||||
// in that case.
|
||||
func NewPublicAuthHandlers(svc *auth.Service, logger *zap.Logger) *PublicAuthHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &PublicAuthHandlers{svc: svc, logger: logger.Named("http.public.auth")}
|
||||
}
|
||||
|
||||
// SendEmailCode handles POST /api/v1/public/auth/send-email-code.
|
||||
func (h *PublicAuthHandlers) SendEmailCode() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("publicAuthSendEmailCode")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Locale string `json:"locale"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
email := validateEmail(req.Email)
|
||||
if email == "" {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "email is invalid")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
challengeID, err := h.svc.SendEmailCode(ctx, email, req.Locale, c.GetHeader("Accept-Language"), clientip.ExtractSourceIP(c))
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrEmailPermanentlyBlocked):
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "email is not allowed")
|
||||
default:
|
||||
h.logger.Error("send-email-code failed",
|
||||
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
|
||||
)
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"challenge_id": challengeID.String()})
|
||||
}
|
||||
}
|
||||
|
||||
// ConfirmEmailCode handles POST /api/v1/public/auth/confirm-email-code.
|
||||
func (h *PublicAuthHandlers) ConfirmEmailCode() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("publicAuthConfirmEmailCode")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
var req struct {
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
Code string `json:"code"`
|
||||
ClientPublicKey string `json:"client_public_key"`
|
||||
TimeZone string `json:"time_zone"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
challengeID, err := uuid.Parse(req.ChallengeID)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "challenge_id must be a valid UUID")
|
||||
return
|
||||
}
|
||||
if !isDecimalCodeOfLength(req.Code, auth.CodeLength) {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "code must be a 6-digit decimal string")
|
||||
return
|
||||
}
|
||||
clientPubKey, ok := decodeClientPublicKey(req.ClientPublicKey)
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "client_public_key must be a base64-encoded 32-byte Ed25519 key")
|
||||
return
|
||||
}
|
||||
if _, err := time.LoadLocation(req.TimeZone); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "time_zone must be a valid IANA zone")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
session, err := h.svc.ConfirmEmailCode(ctx, auth.ConfirmInputs{
|
||||
ChallengeID: challengeID,
|
||||
Code: req.Code,
|
||||
ClientPublicKey: clientPubKey,
|
||||
TimeZone: req.TimeZone,
|
||||
SourceIP: clientip.ExtractSourceIP(c),
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrChallengeNotFound):
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "challenge is not redeemable")
|
||||
case errors.Is(err, auth.ErrCodeMismatch):
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "code is incorrect")
|
||||
case errors.Is(err, auth.ErrTooManyAttempts):
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "too many attempts")
|
||||
default:
|
||||
h.logger.Error("confirm-email-code failed",
|
||||
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
|
||||
)
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"device_session_id": session.DeviceSessionID.String()})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/server/middleware/userid"
|
||||
"galaxy/backend/internal/telemetry"
|
||||
"galaxy/backend/internal/user"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// UserAccountHandlers groups the handlers under `/api/v1/user/account/*`.
|
||||
// The current implementation ships real implementations backed by `*user.Service`; tests
|
||||
// that supply a nil service fall back to the Stage-3 placeholder body
|
||||
// so the contract test continues to validate the OpenAPI envelope
|
||||
// without booting a database.
|
||||
type UserAccountHandlers struct {
|
||||
svc *user.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewUserAccountHandlers constructs the handler set. svc may be nil —
|
||||
// in that case every handler returns 501 not_implemented, matching the
|
||||
// pre-Stage-5.2 placeholder. logger may also be nil; zap.NewNop is
|
||||
// used in that case.
|
||||
func NewUserAccountHandlers(svc *user.Service, logger *zap.Logger) *UserAccountHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &UserAccountHandlers{svc: svc, logger: logger.Named("http.user.account")}
|
||||
}
|
||||
|
||||
// Get handles GET /api/v1/user/account.
|
||||
func (h *UserAccountHandlers) Get() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userAccountGet")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
account, err := h.svc.GetAccount(ctx, userID)
|
||||
if err != nil {
|
||||
respondAccountError(c, h.logger, "user account get", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, accountResponseToWire(account))
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateProfile handles PATCH /api/v1/user/account/profile.
|
||||
func (h *UserAccountHandlers) UpdateProfile() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userAccountUpdateProfile")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
var req updateProfileRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
account, err := h.svc.UpdateProfile(ctx, userID, user.UpdateProfileInput{
|
||||
DisplayName: req.DisplayName,
|
||||
})
|
||||
if err != nil {
|
||||
respondAccountError(c, h.logger, "user account update profile", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, accountResponseToWire(account))
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateSettings handles PATCH /api/v1/user/account/settings.
|
||||
func (h *UserAccountHandlers) UpdateSettings() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userAccountUpdateSettings")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
var req updateSettingsRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
account, err := h.svc.UpdateSettings(ctx, userID, user.UpdateSettingsInput{
|
||||
PreferredLanguage: req.PreferredLanguage,
|
||||
TimeZone: req.TimeZone,
|
||||
})
|
||||
if err != nil {
|
||||
respondAccountError(c, h.logger, "user account update settings", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, accountResponseToWire(account))
|
||||
}
|
||||
}
|
||||
|
||||
// Delete handles POST /api/v1/user/account/delete.
|
||||
func (h *UserAccountHandlers) Delete() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userAccountDelete")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
actor := user.ActorRef{Type: "user", ID: userID.String()}
|
||||
if err := h.svc.SoftDelete(ctx, userID, actor); err != nil {
|
||||
if errors.Is(err, user.ErrAccountNotFound) {
|
||||
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "account not found")
|
||||
return
|
||||
}
|
||||
h.logger.Warn("user account soft-delete returned cascade errors",
|
||||
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
|
||||
)
|
||||
// Cascade errors do not change the canonical state — the
|
||||
// account is soft-deleted in Postgres. Surface 204 so the
|
||||
// caller's UI proceeds to a logged-out state.
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// updateProfileRequestWire mirrors `UpdateProfileRequest` from openapi.yaml.
|
||||
type updateProfileRequestWire struct {
|
||||
DisplayName *string `json:"display_name,omitempty"`
|
||||
}
|
||||
|
||||
// updateSettingsRequestWire mirrors `UpdateSettingsRequest` from openapi.yaml.
|
||||
type updateSettingsRequestWire struct {
|
||||
PreferredLanguage *string `json:"preferred_language,omitempty"`
|
||||
TimeZone *string `json:"time_zone,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"galaxy/backend/internal/engineclient"
|
||||
"galaxy/backend/internal/runtime"
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/server/middleware/userid"
|
||||
"galaxy/backend/internal/telemetry"
|
||||
"galaxy/model/order"
|
||||
gamerest "galaxy/model/rest"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// UserGamesHandlers groups the engine-proxy handlers under
|
||||
// `/api/v1/user/games/{game_id}/*`. The wiring connects them through
|
||||
// `engineclient` against running engine containers.
|
||||
type UserGamesHandlers struct {
|
||||
runtime *runtime.Service
|
||||
engine *engineclient.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewUserGamesHandlers constructs the handler set. When runtime or
|
||||
// engine is nil, every handler returns 501 so the contract test still
|
||||
// passes against a partially-wired router.
|
||||
func NewUserGamesHandlers(rt *runtime.Service, engine *engineclient.Client, logger *zap.Logger) *UserGamesHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &UserGamesHandlers{runtime: rt, engine: engine, logger: logger.Named("http.user.games")}
|
||||
}
|
||||
|
||||
// Commands handles POST /api/v1/user/games/{game_id}/commands.
|
||||
func (h *UserGamesHandlers) Commands() gin.HandlerFunc {
|
||||
if h == nil || h.runtime == nil || h.engine == nil {
|
||||
return handlers.NotImplemented("userGamesCommands")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user id missing")
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body could not be read")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID)
|
||||
if err != nil {
|
||||
respondGameProxyError(c, h.logger, "user games commands", ctx, err)
|
||||
return
|
||||
}
|
||||
endpoint, err := h.runtime.EngineEndpoint(ctx, gameID)
|
||||
if err != nil {
|
||||
respondGameProxyError(c, h.logger, "user games commands", ctx, err)
|
||||
return
|
||||
}
|
||||
payload, err := rebindActor(body, mapping.RaceName)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be a JSON object")
|
||||
return
|
||||
}
|
||||
resp, err := h.engine.ExecuteCommands(ctx, endpoint, payload)
|
||||
if err != nil {
|
||||
respondEngineProxyError(c, h.logger, "user games commands", ctx, resp, err)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// Orders handles POST /api/v1/user/games/{game_id}/orders.
|
||||
func (h *UserGamesHandlers) Orders() gin.HandlerFunc {
|
||||
if h == nil || h.runtime == nil || h.engine == nil {
|
||||
return handlers.NotImplemented("userGamesOrders")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user id missing")
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body could not be read")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID)
|
||||
if err != nil {
|
||||
respondGameProxyError(c, h.logger, "user games orders", ctx, err)
|
||||
return
|
||||
}
|
||||
endpoint, err := h.runtime.EngineEndpoint(ctx, gameID)
|
||||
if err != nil {
|
||||
respondGameProxyError(c, h.logger, "user games orders", ctx, err)
|
||||
return
|
||||
}
|
||||
// Orders payload uses an updatedAt + commands shape; we don't
|
||||
// rewrite it here because the engine derives the actor from
|
||||
// the route, not the order body. We pass the body through
|
||||
// verbatim (per ARCHITECTURE.md §9: backend is the only
|
||||
// caller, so rewriting is unnecessary). Unused mapping is
|
||||
// kept in the lookup so 404 returns when no mapping exists.
|
||||
_ = mapping
|
||||
_ = order.Order{}
|
||||
resp, err := h.engine.PutOrders(ctx, endpoint, body)
|
||||
if err != nil {
|
||||
respondEngineProxyError(c, h.logger, "user games orders", ctx, resp, err)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// Report handles GET /api/v1/user/games/{game_id}/reports/{turn}.
|
||||
func (h *UserGamesHandlers) Report() gin.HandlerFunc {
|
||||
if h == nil || h.runtime == nil || h.engine == nil {
|
||||
return handlers.NotImplemented("userGamesReport")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
turnRaw := c.Param("turn")
|
||||
turn, err := strconv.Atoi(turnRaw)
|
||||
if err != nil || turn < 0 {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "turn must be a non-negative integer")
|
||||
return
|
||||
}
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user id missing")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID)
|
||||
if err != nil {
|
||||
respondGameProxyError(c, h.logger, "user games report", ctx, err)
|
||||
return
|
||||
}
|
||||
endpoint, err := h.runtime.EngineEndpoint(ctx, gameID)
|
||||
if err != nil {
|
||||
respondGameProxyError(c, h.logger, "user games report", ctx, err)
|
||||
return
|
||||
}
|
||||
body, err := h.engine.GetReport(ctx, endpoint, mapping.RaceName, turn)
|
||||
if err != nil {
|
||||
respondEngineProxyError(c, h.logger, "user games report", ctx, body, err)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", body)
|
||||
}
|
||||
}
|
||||
|
||||
// rebindActor decodes a JSON object from raw, sets `actor` to
|
||||
// raceName, and re-encodes. Backend never trusts the actor field
|
||||
// supplied by the client (per ARCHITECTURE.md §9).
|
||||
func rebindActor(raw []byte, raceName string) (json.RawMessage, error) {
|
||||
if len(raw) == 0 {
|
||||
// Empty body — synthesise a minimal envelope so the engine
|
||||
// receives a well-formed request.
|
||||
return json.Marshal(gamerest.Command{Actor: raceName})
|
||||
}
|
||||
var generic map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &generic); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actor, _ := json.Marshal(raceName)
|
||||
generic["actor"] = actor
|
||||
return json.Marshal(generic)
|
||||
}
|
||||
|
||||
func respondGameProxyError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, runtime.ErrNotFound):
|
||||
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "no runtime mapping for this user/game")
|
||||
case errors.Is(err, runtime.ErrConflict):
|
||||
httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, err.Error())
|
||||
default:
|
||||
logger.Error(op+" failed",
|
||||
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
|
||||
)
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "internal error")
|
||||
}
|
||||
}
|
||||
|
||||
func respondEngineProxyError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, body []byte, err error) {
|
||||
switch {
|
||||
case errors.Is(err, engineclient.ErrEngineValidation):
|
||||
if len(body) > 0 {
|
||||
c.Data(http.StatusBadRequest, "application/json", body)
|
||||
return
|
||||
}
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error())
|
||||
case errors.Is(err, engineclient.ErrEngineUnreachable):
|
||||
httperr.Abort(c, http.StatusServiceUnavailable, httperr.CodeServiceUnavailable, "engine is unreachable")
|
||||
case errors.Is(err, engineclient.ErrEngineProtocolViolation):
|
||||
logger.Error(op+" engine protocol violation",
|
||||
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
|
||||
)
|
||||
httperr.Abort(c, http.StatusBadGateway, httperr.CodeInternalError, "engine response was malformed")
|
||||
default:
|
||||
logger.Error(op+" failed",
|
||||
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
|
||||
)
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "internal error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/telemetry"
|
||||
"galaxy/backend/internal/user"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// timestampLayout matches the format used by other backend handlers
|
||||
// (The implementation deviceSession serialisation). UTC, millisecond precision.
|
||||
const timestampLayout = "2006-01-02T15:04:05.000Z07:00"
|
||||
|
||||
// respondAccountError maps user-package sentinels to the standard
|
||||
// JSON error envelope. Unknown errors land on a 500.
|
||||
func respondAccountError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, user.ErrAccountNotFound):
|
||||
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "account not found")
|
||||
case errors.Is(err, user.ErrInvalidInput),
|
||||
errors.Is(err, user.ErrInvalidActor):
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error())
|
||||
case errors.Is(err, user.ErrInvalidTier):
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "tier is not supported")
|
||||
case errors.Is(err, user.ErrInvalidSanctionCode):
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "sanction_code is not supported")
|
||||
default:
|
||||
logger.Error(op+" failed",
|
||||
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
|
||||
)
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
|
||||
}
|
||||
}
|
||||
|
||||
// accountResponseToWire renders the Account aggregate into the
|
||||
// `AccountResponse` shape declared in openapi.yaml.
|
||||
func accountResponseToWire(account user.Account) accountResponseWire {
|
||||
return accountResponseWire{Account: accountToWire(account)}
|
||||
}
|
||||
|
||||
func accountToWire(account user.Account) accountWire {
|
||||
out := accountWire{
|
||||
UserID: account.UserID.String(),
|
||||
Email: account.Email,
|
||||
UserName: account.UserName,
|
||||
DisplayName: account.DisplayName,
|
||||
PreferredLanguage: account.PreferredLanguage,
|
||||
TimeZone: account.TimeZone,
|
||||
DeclaredCountry: account.DeclaredCountry,
|
||||
Entitlement: entitlementSnapshotToWire(account.Entitlement),
|
||||
ActiveSanctions: activeSanctionsToWire(account.ActiveSanctions),
|
||||
ActiveLimits: activeLimitsToWire(account.ActiveLimits),
|
||||
CreatedAt: account.CreatedAt.UTC().Format(timestampLayout),
|
||||
UpdatedAt: account.UpdatedAt.UTC().Format(timestampLayout),
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func entitlementSnapshotToWire(snap user.EntitlementSnapshot) entitlementSnapshotWire {
|
||||
out := entitlementSnapshotWire{
|
||||
PlanCode: snap.Tier,
|
||||
IsPaid: snap.IsPaid,
|
||||
Source: snap.Source,
|
||||
Actor: actorRefToWire(snap.Actor),
|
||||
ReasonCode: snap.ReasonCode,
|
||||
StartsAt: snap.StartsAt.UTC().Format(timestampLayout),
|
||||
MaxRegisteredRaceNames: snap.MaxRegisteredRaceNames,
|
||||
UpdatedAt: snap.UpdatedAt.UTC().Format(timestampLayout),
|
||||
}
|
||||
if snap.EndsAt != nil {
|
||||
formatted := snap.EndsAt.UTC().Format(timestampLayout)
|
||||
out.EndsAt = &formatted
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func activeSanctionsToWire(items []user.ActiveSanction) []activeSanctionWire {
|
||||
out := make([]activeSanctionWire, 0, len(items))
|
||||
for _, s := range items {
|
||||
entry := activeSanctionWire{
|
||||
SanctionCode: s.SanctionCode,
|
||||
Scope: s.Scope,
|
||||
ReasonCode: s.ReasonCode,
|
||||
Actor: actorRefToWire(s.Actor),
|
||||
AppliedAt: s.AppliedAt.UTC().Format(timestampLayout),
|
||||
}
|
||||
if s.ExpiresAt != nil {
|
||||
formatted := s.ExpiresAt.UTC().Format(timestampLayout)
|
||||
entry.ExpiresAt = &formatted
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func activeLimitsToWire(items []user.ActiveLimit) []activeLimitWire {
|
||||
out := make([]activeLimitWire, 0, len(items))
|
||||
for _, l := range items {
|
||||
entry := activeLimitWire{
|
||||
LimitCode: l.LimitCode,
|
||||
Value: l.Value,
|
||||
ReasonCode: l.ReasonCode,
|
||||
Actor: actorRefToWire(l.Actor),
|
||||
AppliedAt: l.AppliedAt.UTC().Format(timestampLayout),
|
||||
}
|
||||
if l.ExpiresAt != nil {
|
||||
formatted := l.ExpiresAt.UTC().Format(timestampLayout)
|
||||
entry.ExpiresAt = &formatted
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func actorRefToWire(actor user.ActorRef) actorRefWire {
|
||||
return actorRefWire{Type: actor.Type, ID: actor.ID}
|
||||
}
|
||||
|
||||
// parseTimePtr converts a wire timestamp pointer into a time.Time
|
||||
// pointer. A nil or empty input yields nil. Invalid timestamps return
|
||||
// an error that handlers map to ErrInvalidInput.
|
||||
func parseTimePtr(raw *string) (*time.Time, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if *raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339Nano, *raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// accountResponseWire mirrors `AccountResponse` in openapi.yaml.
|
||||
type accountResponseWire struct {
|
||||
Account accountWire `json:"account"`
|
||||
}
|
||||
|
||||
// accountWire mirrors `Account` in openapi.yaml.
|
||||
type accountWire struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
UserName string `json:"user_name"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
PreferredLanguage string `json:"preferred_language"`
|
||||
TimeZone string `json:"time_zone"`
|
||||
DeclaredCountry string `json:"declared_country,omitempty"`
|
||||
Entitlement entitlementSnapshotWire `json:"entitlement"`
|
||||
ActiveSanctions []activeSanctionWire `json:"active_sanctions"`
|
||||
ActiveLimits []activeLimitWire `json:"active_limits"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type entitlementSnapshotWire struct {
|
||||
PlanCode string `json:"plan_code"`
|
||||
IsPaid bool `json:"is_paid"`
|
||||
Source string `json:"source"`
|
||||
Actor actorRefWire `json:"actor"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
StartsAt string `json:"starts_at"`
|
||||
EndsAt *string `json:"ends_at,omitempty"`
|
||||
MaxRegisteredRaceNames int32 `json:"max_registered_race_names"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type activeSanctionWire struct {
|
||||
SanctionCode string `json:"sanction_code"`
|
||||
Scope string `json:"scope"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Actor actorRefWire `json:"actor"`
|
||||
AppliedAt string `json:"applied_at"`
|
||||
ExpiresAt *string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
type activeLimitWire struct {
|
||||
LimitCode string `json:"limit_code"`
|
||||
Value int32 `json:"value"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Actor actorRefWire `json:"actor"`
|
||||
AppliedAt string `json:"applied_at"`
|
||||
ExpiresAt *string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
type actorRefWire struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/backend/internal/lobby"
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/server/middleware/userid"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// UserLobbyApplicationsHandlers groups the application-lifecycle handlers
|
||||
// under `/api/v1/user/lobby/games/{game_id}/applications/*`. The implementation // ships real implementations backed by `*lobby.Service`.
|
||||
type UserLobbyApplicationsHandlers struct {
|
||||
svc *lobby.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewUserLobbyApplicationsHandlers constructs the handler set. svc may
|
||||
// be nil — in that case every handler returns 501 not_implemented.
|
||||
func NewUserLobbyApplicationsHandlers(svc *lobby.Service, logger *zap.Logger) *UserLobbyApplicationsHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &UserLobbyApplicationsHandlers{svc: svc, logger: logger.Named("http.user.lobby.applications")}
|
||||
}
|
||||
|
||||
// Submit handles POST /api/v1/user/lobby/games/{game_id}/applications.
|
||||
func (h *UserLobbyApplicationsHandlers) Submit() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userLobbyApplicationsSubmit")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req lobbyApplicationSubmitRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
app, err := h.svc.SubmitApplication(ctx, lobby.SubmitApplicationInput{
|
||||
GameID: gameID,
|
||||
ApplicantUserID: userID,
|
||||
RaceName: req.RaceName,
|
||||
})
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user lobby applications submit", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, lobbyApplicationDetailToWire(app))
|
||||
}
|
||||
}
|
||||
|
||||
// Approve handles POST /api/v1/user/lobby/games/{game_id}/applications/{application_id}/approve.
|
||||
func (h *UserLobbyApplicationsHandlers) Approve() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userLobbyApplicationsApprove")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
applicationID, ok := parseApplicationIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
caller := userID
|
||||
updated, err := h.svc.ApproveApplication(ctx, &caller, false, gameID, applicationID)
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user lobby applications approve", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, lobbyApplicationDetailToWire(updated))
|
||||
}
|
||||
}
|
||||
|
||||
// Reject handles POST /api/v1/user/lobby/games/{game_id}/applications/{application_id}/reject.
|
||||
func (h *UserLobbyApplicationsHandlers) Reject() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userLobbyApplicationsReject")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
applicationID, ok := parseApplicationIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
caller := userID
|
||||
updated, err := h.svc.RejectApplication(ctx, &caller, false, gameID, applicationID)
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user lobby applications reject", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, lobbyApplicationDetailToWire(updated))
|
||||
}
|
||||
}
|
||||
|
||||
// lobbyApplicationSubmitRequestWire mirrors `LobbyApplicationSubmitRequest`.
|
||||
type lobbyApplicationSubmitRequestWire struct {
|
||||
RaceName string `json:"race_name"`
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/lobby"
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/server/middleware/userid"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// UserLobbyGamesHandlers groups the handlers under
|
||||
// `/api/v1/user/lobby/games/*`. The current implementation ships real implementations
|
||||
// backed by `*lobby.Service`; tests that supply a nil service fall back
|
||||
// to the Stage-3 placeholder body so the contract test continues to
|
||||
// validate the OpenAPI envelope without booting a database.
|
||||
type UserLobbyGamesHandlers struct {
|
||||
svc *lobby.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewUserLobbyGamesHandlers constructs the handler set. svc may be nil
|
||||
// — in that case every handler returns 501 not_implemented.
|
||||
func NewUserLobbyGamesHandlers(svc *lobby.Service, logger *zap.Logger) *UserLobbyGamesHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &UserLobbyGamesHandlers{svc: svc, logger: logger.Named("http.user.lobby.games")}
|
||||
}
|
||||
|
||||
func (h *UserLobbyGamesHandlers) callerUserID(c *gin.Context) (uuid.UUID, bool) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return userID, true
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/user/lobby/games.
|
||||
func (h *UserLobbyGamesHandlers) List() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userLobbyGamesList")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
page := parsePositiveQueryInt(c.Query("page"), 1)
|
||||
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
||||
ctx := c.Request.Context()
|
||||
result, err := h.svc.ListPublicGames(ctx, page, pageSize)
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user lobby games list", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gameSummaryPageToWire(result))
|
||||
}
|
||||
}
|
||||
|
||||
// Create handles POST /api/v1/user/lobby/games.
|
||||
func (h *UserLobbyGamesHandlers) Create() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userLobbyGamesCreate")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := h.callerUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req lobbyGameCreateRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
if req.Visibility != lobby.VisibilityPrivate {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user-facing /lobby/games only creates private games; admins use /api/v1/admin/games for public")
|
||||
return
|
||||
}
|
||||
enrollmentEndsAt, err := time.Parse(time.RFC3339Nano, req.EnrollmentEndsAt)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "enrollment_ends_at must be RFC 3339")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
owner := userID
|
||||
game, err := h.svc.CreateGame(ctx, lobby.CreateGameInput{
|
||||
OwnerUserID: &owner,
|
||||
Visibility: req.Visibility,
|
||||
GameName: req.GameName,
|
||||
Description: req.Description,
|
||||
MinPlayers: req.MinPlayers,
|
||||
MaxPlayers: req.MaxPlayers,
|
||||
StartGapHours: req.StartGapHours,
|
||||
StartGapPlayers: req.StartGapPlayers,
|
||||
EnrollmentEndsAt: enrollmentEndsAt,
|
||||
TurnSchedule: req.TurnSchedule,
|
||||
TargetEngineVersion: req.TargetEngineVersion,
|
||||
})
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user lobby games create", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, lobbyGameDetailToWire(game))
|
||||
}
|
||||
}
|
||||
|
||||
// Get handles GET /api/v1/user/lobby/games/{game_id}.
|
||||
func (h *UserLobbyGamesHandlers) Get() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userLobbyGamesGet")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
game, err := h.svc.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user lobby games get", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, lobbyGameDetailToWire(game))
|
||||
}
|
||||
}
|
||||
|
||||
// Update handles PATCH /api/v1/user/lobby/games/{game_id}.
|
||||
func (h *UserLobbyGamesHandlers) Update() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userLobbyGamesUpdate")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := h.callerUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req lobbyGameUpdateRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
ends, err := parseTimePtrField(req.EnrollmentEndsAt, "enrollment_ends_at")
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
caller := userID
|
||||
updated, err := h.svc.UpdateGame(ctx, &caller, false, gameID, lobby.UpdateGameInput{
|
||||
GameName: req.GameName,
|
||||
Description: req.Description,
|
||||
EnrollmentEndsAt: ends,
|
||||
TurnSchedule: req.TurnSchedule,
|
||||
TargetEngineVersion: req.TargetEngineVersion,
|
||||
MinPlayers: req.MinPlayers,
|
||||
MaxPlayers: req.MaxPlayers,
|
||||
StartGapHours: req.StartGapHours,
|
||||
StartGapPlayers: req.StartGapPlayers,
|
||||
})
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user lobby games update", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, lobbyGameDetailToWire(updated))
|
||||
}
|
||||
}
|
||||
|
||||
// transitionHandler is the shared shape for owner-driven state-machine
|
||||
// endpoints. fn captures the lobby Service method to invoke.
|
||||
func (h *UserLobbyGamesHandlers) transitionHandler(opName string, successStatus int, fn func(context.Context, *lobby.Service, *uuid.UUID, uuid.UUID) (lobby.GameRecord, error)) gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented(opName)
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := h.callerUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
caller := userID
|
||||
updated, err := fn(ctx, h.svc, &caller, gameID)
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user lobby games "+opName, ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(successStatus, lobbyGameStateChangeToWire(updated))
|
||||
}
|
||||
}
|
||||
|
||||
// OpenEnrollment handles POST /api/v1/user/lobby/games/{game_id}/open-enrollment.
|
||||
func (h *UserLobbyGamesHandlers) OpenEnrollment() gin.HandlerFunc {
|
||||
return h.transitionHandler("openEnrollment", http.StatusOK,
|
||||
func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) {
|
||||
return svc.OpenEnrollment(ctx, caller, false, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
// ReadyToStart handles POST /api/v1/user/lobby/games/{game_id}/ready-to-start.
|
||||
func (h *UserLobbyGamesHandlers) ReadyToStart() gin.HandlerFunc {
|
||||
return h.transitionHandler("readyToStart", http.StatusOK,
|
||||
func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) {
|
||||
return svc.ReadyToStart(ctx, caller, false, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
// Start handles POST /api/v1/user/lobby/games/{game_id}/start.
|
||||
func (h *UserLobbyGamesHandlers) Start() gin.HandlerFunc {
|
||||
return h.transitionHandler("start", http.StatusAccepted,
|
||||
func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) {
|
||||
return svc.Start(ctx, caller, false, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
// Pause handles POST /api/v1/user/lobby/games/{game_id}/pause.
|
||||
func (h *UserLobbyGamesHandlers) Pause() gin.HandlerFunc {
|
||||
return h.transitionHandler("pause", http.StatusOK,
|
||||
func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) {
|
||||
return svc.Pause(ctx, caller, false, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
// Resume handles POST /api/v1/user/lobby/games/{game_id}/resume.
|
||||
func (h *UserLobbyGamesHandlers) Resume() gin.HandlerFunc {
|
||||
return h.transitionHandler("resume", http.StatusOK,
|
||||
func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) {
|
||||
return svc.Resume(ctx, caller, false, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
// Cancel handles POST /api/v1/user/lobby/games/{game_id}/cancel.
|
||||
func (h *UserLobbyGamesHandlers) Cancel() gin.HandlerFunc {
|
||||
return h.transitionHandler("cancel", http.StatusOK,
|
||||
func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) {
|
||||
return svc.Cancel(ctx, caller, false, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
// RetryStart handles POST /api/v1/user/lobby/games/{game_id}/retry-start.
|
||||
func (h *UserLobbyGamesHandlers) RetryStart() gin.HandlerFunc {
|
||||
return h.transitionHandler("retryStart", http.StatusAccepted,
|
||||
func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) {
|
||||
return svc.RetryStart(ctx, caller, false, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
// lobbyGameCreateRequestWire mirrors `LobbyGameCreateRequest`.
|
||||
type lobbyGameCreateRequestWire struct {
|
||||
GameName string `json:"game_name"`
|
||||
Visibility string `json:"visibility"`
|
||||
Description string `json:"description"`
|
||||
MinPlayers int32 `json:"min_players"`
|
||||
MaxPlayers int32 `json:"max_players"`
|
||||
StartGapHours int32 `json:"start_gap_hours"`
|
||||
StartGapPlayers int32 `json:"start_gap_players"`
|
||||
EnrollmentEndsAt string `json:"enrollment_ends_at"`
|
||||
TurnSchedule string `json:"turn_schedule"`
|
||||
TargetEngineVersion string `json:"target_engine_version"`
|
||||
}
|
||||
|
||||
// lobbyGameUpdateRequestWire mirrors `LobbyGameUpdateRequest`. Optional
|
||||
// fields are pointers so the handler can distinguish "not supplied"
|
||||
// from "empty string".
|
||||
type lobbyGameUpdateRequestWire struct {
|
||||
GameName *string `json:"game_name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
EnrollmentEndsAt *string `json:"enrollment_ends_at,omitempty"`
|
||||
TurnSchedule *string `json:"turn_schedule,omitempty"`
|
||||
TargetEngineVersion *string `json:"target_engine_version,omitempty"`
|
||||
MinPlayers *int32 `json:"min_players,omitempty"`
|
||||
MaxPlayers *int32 `json:"max_players,omitempty"`
|
||||
StartGapHours *int32 `json:"start_gap_hours,omitempty"`
|
||||
StartGapPlayers *int32 `json:"start_gap_players,omitempty"`
|
||||
}
|
||||
|
||||
// gameSummaryPageWire mirrors `GameSummaryPage`.
|
||||
type gameSummaryPageWire struct {
|
||||
Items []gameSummaryWire `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
func gameSummaryPageToWire(page lobby.GamePage) gameSummaryPageWire {
|
||||
out := gameSummaryPageWire{
|
||||
Items: make([]gameSummaryWire, 0, len(page.Items)),
|
||||
Page: page.Page,
|
||||
PageSize: page.PageSize,
|
||||
Total: page.Total,
|
||||
}
|
||||
for _, g := range page.Items {
|
||||
out.Items = append(out.Items, gameSummaryToWire(g))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/lobby"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/telemetry"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// respondLobbyError maps lobby-package sentinel errors to the standard
|
||||
// JSON error envelope. Unknown errors land on a 500.
|
||||
func respondLobbyError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, lobby.ErrInvalidInput):
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error())
|
||||
case errors.Is(err, lobby.ErrNotFound):
|
||||
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "resource was not found")
|
||||
case errors.Is(err, lobby.ErrForbidden):
|
||||
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, err.Error())
|
||||
case errors.Is(err, lobby.ErrConflict),
|
||||
errors.Is(err, lobby.ErrInvalidStatus),
|
||||
errors.Is(err, lobby.ErrRaceNameTaken),
|
||||
errors.Is(err, lobby.ErrEntitlementExceeded),
|
||||
errors.Is(err, lobby.ErrPendingExpired):
|
||||
httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, err.Error())
|
||||
default:
|
||||
logger.Error(op+" failed",
|
||||
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
|
||||
)
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
|
||||
}
|
||||
}
|
||||
|
||||
// parseGameIDParam reads `game_id` from the path. Writes 400 envelope on
|
||||
// invalid input and returns false in that case.
|
||||
func parseGameIDParam(c *gin.Context) (uuid.UUID, bool) {
|
||||
parsed, err := uuid.Parse(c.Param("game_id"))
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "game_id must be a valid UUID")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func parseApplicationIDParam(c *gin.Context) (uuid.UUID, bool) {
|
||||
parsed, err := uuid.Parse(c.Param("application_id"))
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "application_id must be a valid UUID")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func parseInviteIDParam(c *gin.Context) (uuid.UUID, bool) {
|
||||
parsed, err := uuid.Parse(c.Param("invite_id"))
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "invite_id must be a valid UUID")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func parseMembershipIDParam(c *gin.Context) (uuid.UUID, bool) {
|
||||
parsed, err := uuid.Parse(c.Param("membership_id"))
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "membership_id must be a valid UUID")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
// gameSummaryWire mirrors `GameSummary` from openapi.yaml.
|
||||
type gameSummaryWire struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
GameType string `json:"game_type"`
|
||||
Status string `json:"status"`
|
||||
OwnerUserID *string `json:"owner_user_id,omitempty"`
|
||||
MinPlayers int32 `json:"min_players"`
|
||||
MaxPlayers int32 `json:"max_players"`
|
||||
EnrollmentEndsAt string `json:"enrollment_ends_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// lobbyGameDetailWire mirrors `LobbyGameDetail` from openapi.yaml.
|
||||
type lobbyGameDetailWire struct {
|
||||
gameSummaryWire
|
||||
Visibility string `json:"visibility"`
|
||||
Description string `json:"description,omitempty"`
|
||||
TurnSchedule string `json:"turn_schedule"`
|
||||
TargetEngineVersion string `json:"target_engine_version"`
|
||||
StartGapHours int32 `json:"start_gap_hours"`
|
||||
StartGapPlayers int32 `json:"start_gap_players"`
|
||||
CurrentTurn int32 `json:"current_turn"`
|
||||
RuntimeStatus string `json:"runtime_status"`
|
||||
EngineHealth string `json:"engine_health,omitempty"`
|
||||
StartedAt *string `json:"started_at,omitempty"`
|
||||
FinishedAt *string `json:"finished_at,omitempty"`
|
||||
}
|
||||
|
||||
func gameSummaryToWire(g lobby.GameRecord) gameSummaryWire {
|
||||
out := gameSummaryWire{
|
||||
GameID: g.GameID.String(),
|
||||
GameName: g.GameName,
|
||||
GameType: g.Visibility,
|
||||
Status: g.Status,
|
||||
MinPlayers: g.MinPlayers,
|
||||
MaxPlayers: g.MaxPlayers,
|
||||
EnrollmentEndsAt: g.EnrollmentEndsAt.UTC().Format(timestampLayout),
|
||||
CreatedAt: g.CreatedAt.UTC().Format(timestampLayout),
|
||||
UpdatedAt: g.UpdatedAt.UTC().Format(timestampLayout),
|
||||
}
|
||||
if g.OwnerUserID != nil {
|
||||
s := g.OwnerUserID.String()
|
||||
out.OwnerUserID = &s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func lobbyGameDetailToWire(g lobby.GameRecord) lobbyGameDetailWire {
|
||||
out := lobbyGameDetailWire{
|
||||
gameSummaryWire: gameSummaryToWire(g),
|
||||
Visibility: g.Visibility,
|
||||
Description: g.Description,
|
||||
TurnSchedule: g.TurnSchedule,
|
||||
TargetEngineVersion: g.TargetEngineVersion,
|
||||
StartGapHours: g.StartGapHours,
|
||||
StartGapPlayers: g.StartGapPlayers,
|
||||
CurrentTurn: g.RuntimeSnapshot.CurrentTurn,
|
||||
RuntimeStatus: g.RuntimeSnapshot.RuntimeStatus,
|
||||
EngineHealth: g.RuntimeSnapshot.EngineHealth,
|
||||
}
|
||||
if g.StartedAt != nil {
|
||||
s := g.StartedAt.UTC().Format(timestampLayout)
|
||||
out.StartedAt = &s
|
||||
}
|
||||
if g.FinishedAt != nil {
|
||||
s := g.FinishedAt.UTC().Format(timestampLayout)
|
||||
out.FinishedAt = &s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lobbyGameStateChangeWire mirrors `LobbyGameStateChange`.
|
||||
type lobbyGameStateChangeWire struct {
|
||||
GameID string `json:"game_id"`
|
||||
Status string `json:"status"`
|
||||
RuntimeStatus string `json:"runtime_status,omitempty"`
|
||||
}
|
||||
|
||||
func lobbyGameStateChangeToWire(g lobby.GameRecord) lobbyGameStateChangeWire {
|
||||
return lobbyGameStateChangeWire{
|
||||
GameID: g.GameID.String(),
|
||||
Status: g.Status,
|
||||
RuntimeStatus: g.RuntimeSnapshot.RuntimeStatus,
|
||||
}
|
||||
}
|
||||
|
||||
// lobbyApplicationDetailWire mirrors `LobbyApplicationDetail`.
|
||||
type lobbyApplicationDetailWire struct {
|
||||
ApplicationID string `json:"application_id"`
|
||||
GameID string `json:"game_id"`
|
||||
ApplicantUserID string `json:"applicant_user_id"`
|
||||
RaceName string `json:"race_name"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
DecidedAt *string `json:"decided_at,omitempty"`
|
||||
}
|
||||
|
||||
func lobbyApplicationDetailToWire(a lobby.Application) lobbyApplicationDetailWire {
|
||||
out := lobbyApplicationDetailWire{
|
||||
ApplicationID: a.ApplicationID.String(),
|
||||
GameID: a.GameID.String(),
|
||||
ApplicantUserID: a.ApplicantUserID.String(),
|
||||
RaceName: a.RaceName,
|
||||
Status: a.Status,
|
||||
CreatedAt: a.CreatedAt.UTC().Format(timestampLayout),
|
||||
}
|
||||
if a.DecidedAt != nil {
|
||||
s := a.DecidedAt.UTC().Format(timestampLayout)
|
||||
out.DecidedAt = &s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lobbyInviteDetailWire mirrors `LobbyInviteDetail`.
|
||||
type lobbyInviteDetailWire struct {
|
||||
InviteID string `json:"invite_id"`
|
||||
GameID string `json:"game_id"`
|
||||
InviterUserID string `json:"inviter_user_id"`
|
||||
InvitedUserID *string `json:"invited_user_id,omitempty"`
|
||||
Code *string `json:"code,omitempty"`
|
||||
RaceName string `json:"race_name"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
DecidedAt *string `json:"decided_at,omitempty"`
|
||||
}
|
||||
|
||||
func lobbyInviteDetailToWire(i lobby.Invite) lobbyInviteDetailWire {
|
||||
out := lobbyInviteDetailWire{
|
||||
InviteID: i.InviteID.String(),
|
||||
GameID: i.GameID.String(),
|
||||
InviterUserID: i.InviterUserID.String(),
|
||||
RaceName: i.RaceName,
|
||||
Status: i.Status,
|
||||
CreatedAt: i.CreatedAt.UTC().Format(timestampLayout),
|
||||
ExpiresAt: i.ExpiresAt.UTC().Format(timestampLayout),
|
||||
}
|
||||
if i.InvitedUserID != nil {
|
||||
s := i.InvitedUserID.String()
|
||||
out.InvitedUserID = &s
|
||||
}
|
||||
if i.Code != "" {
|
||||
c := i.Code
|
||||
out.Code = &c
|
||||
}
|
||||
if i.DecidedAt != nil {
|
||||
s := i.DecidedAt.UTC().Format(timestampLayout)
|
||||
out.DecidedAt = &s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lobbyMembershipDetailWire mirrors `LobbyMembershipDetail`.
|
||||
type lobbyMembershipDetailWire struct {
|
||||
MembershipID string `json:"membership_id"`
|
||||
GameID string `json:"game_id"`
|
||||
UserID string `json:"user_id"`
|
||||
RaceName string `json:"race_name"`
|
||||
CanonicalKey string `json:"canonical_key"`
|
||||
Status string `json:"status"`
|
||||
JoinedAt string `json:"joined_at"`
|
||||
RemovedAt *string `json:"removed_at,omitempty"`
|
||||
}
|
||||
|
||||
func lobbyMembershipDetailToWire(m lobby.Membership) lobbyMembershipDetailWire {
|
||||
out := lobbyMembershipDetailWire{
|
||||
MembershipID: m.MembershipID.String(),
|
||||
GameID: m.GameID.String(),
|
||||
UserID: m.UserID.String(),
|
||||
RaceName: m.RaceName,
|
||||
CanonicalKey: m.CanonicalKey,
|
||||
Status: m.Status,
|
||||
JoinedAt: m.JoinedAt.UTC().Format(timestampLayout),
|
||||
}
|
||||
if m.RemovedAt != nil {
|
||||
s := m.RemovedAt.UTC().Format(timestampLayout)
|
||||
out.RemovedAt = &s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// raceNameDetailWire mirrors `RaceNameDetail`.
|
||||
type raceNameDetailWire struct {
|
||||
Name string `json:"name"`
|
||||
Canonical string `json:"canonical"`
|
||||
Status string `json:"status"`
|
||||
OwnerUserID string `json:"owner_user_id"`
|
||||
GameID *string `json:"game_id,omitempty"`
|
||||
SourceGameID *string `json:"source_game_id,omitempty"`
|
||||
ReservedAt *string `json:"reserved_at,omitempty"`
|
||||
ExpiresAt *string `json:"expires_at,omitempty"`
|
||||
RegisteredAt *string `json:"registered_at,omitempty"`
|
||||
}
|
||||
|
||||
func raceNameDetailToWire(e lobby.RaceNameEntry) raceNameDetailWire {
|
||||
out := raceNameDetailWire{
|
||||
Name: e.Name,
|
||||
Canonical: string(e.Canonical),
|
||||
Status: e.Status,
|
||||
OwnerUserID: e.OwnerUserID.String(),
|
||||
}
|
||||
if e.GameID != (uuid.UUID{}) {
|
||||
s := e.GameID.String()
|
||||
out.GameID = &s
|
||||
}
|
||||
if e.SourceGameID != nil {
|
||||
s := e.SourceGameID.String()
|
||||
out.SourceGameID = &s
|
||||
}
|
||||
if e.ReservedAt != nil {
|
||||
s := e.ReservedAt.UTC().Format(timestampLayout)
|
||||
out.ReservedAt = &s
|
||||
}
|
||||
if e.ExpiresAt != nil {
|
||||
s := e.ExpiresAt.UTC().Format(timestampLayout)
|
||||
out.ExpiresAt = &s
|
||||
}
|
||||
if e.RegisteredAt != nil {
|
||||
s := e.RegisteredAt.UTC().Format(timestampLayout)
|
||||
out.RegisteredAt = &s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseTimePtrField parses a wire timestamp pointer into a time.Time
|
||||
// pointer. Empty / nil input yields nil. Invalid timestamps return an
|
||||
// error.
|
||||
func parseTimePtrField(raw *string, field string) (*time.Time, error) {
|
||||
if raw == nil || *raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339Nano, *raw)
|
||||
if err != nil {
|
||||
return nil, errors.New(field + " must be RFC 3339")
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/backend/internal/lobby"
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/server/middleware/userid"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// UserLobbyInvitesHandlers groups the invite-lifecycle handlers under
|
||||
// `/api/v1/user/lobby/games/{game_id}/invites/*`. The current implementation ships real
|
||||
// implementations backed by `*lobby.Service`.
|
||||
type UserLobbyInvitesHandlers struct {
|
||||
svc *lobby.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewUserLobbyInvitesHandlers constructs the handler set. svc may be
|
||||
// nil — in that case every handler returns 501 not_implemented.
|
||||
func NewUserLobbyInvitesHandlers(svc *lobby.Service, logger *zap.Logger) *UserLobbyInvitesHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &UserLobbyInvitesHandlers{svc: svc, logger: logger.Named("http.user.lobby.invites")}
|
||||
}
|
||||
|
||||
// Issue handles POST /api/v1/user/lobby/games/{game_id}/invites.
|
||||
func (h *UserLobbyInvitesHandlers) Issue() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userLobbyInvitesIssue")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req lobbyInviteIssueRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
var invitedID *uuid.UUID
|
||||
if req.InvitedUserID != nil && *req.InvitedUserID != "" {
|
||||
parsed, err := uuid.Parse(*req.InvitedUserID)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "invited_user_id must be a valid UUID")
|
||||
return
|
||||
}
|
||||
invitedID = &parsed
|
||||
}
|
||||
expires, err := parseTimePtrField(req.ExpiresAt, "expires_at")
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
raceName := ""
|
||||
if req.RaceName != nil {
|
||||
raceName = *req.RaceName
|
||||
}
|
||||
invite, err := h.svc.IssueInvite(ctx, lobby.IssueInviteInput{
|
||||
GameID: gameID,
|
||||
InviterUserID: userID,
|
||||
InvitedUserID: invitedID,
|
||||
RaceName: raceName,
|
||||
ExpiresAt: expires,
|
||||
})
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user lobby invites issue", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, lobbyInviteDetailToWire(invite))
|
||||
}
|
||||
}
|
||||
|
||||
// Redeem handles POST /api/v1/user/lobby/games/{game_id}/invites/{invite_id}/redeem.
|
||||
func (h *UserLobbyInvitesHandlers) Redeem() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userLobbyInvitesRedeem")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
inviteID, ok := parseInviteIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
invite, err := h.svc.RedeemInvite(ctx, userID, gameID, inviteID)
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user lobby invites redeem", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, lobbyInviteDetailToWire(invite))
|
||||
}
|
||||
}
|
||||
|
||||
// Decline handles POST /api/v1/user/lobby/games/{game_id}/invites/{invite_id}/decline.
|
||||
func (h *UserLobbyInvitesHandlers) Decline() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userLobbyInvitesDecline")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
inviteID, ok := parseInviteIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
invite, err := h.svc.DeclineInvite(ctx, userID, gameID, inviteID)
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user lobby invites decline", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, lobbyInviteDetailToWire(invite))
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke handles POST /api/v1/user/lobby/games/{game_id}/invites/{invite_id}/revoke.
|
||||
func (h *UserLobbyInvitesHandlers) Revoke() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userLobbyInvitesRevoke")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
inviteID, ok := parseInviteIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
caller := userID
|
||||
invite, err := h.svc.RevokeInvite(ctx, &caller, false, gameID, inviteID)
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user lobby invites revoke", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, lobbyInviteDetailToWire(invite))
|
||||
}
|
||||
}
|
||||
|
||||
// lobbyInviteIssueRequestWire mirrors `LobbyInviteIssueRequest`.
|
||||
type lobbyInviteIssueRequestWire struct {
|
||||
InvitedUserID *string `json:"invited_user_id,omitempty"`
|
||||
RaceName *string `json:"race_name,omitempty"`
|
||||
ExpiresAt *string `json:"expires_at,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/backend/internal/lobby"
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/server/middleware/userid"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// UserLobbyMembershipsHandlers groups the membership-lifecycle handlers
|
||||
// under `/api/v1/user/lobby/games/{game_id}/memberships/*`. The implementation // ships real implementations backed by `*lobby.Service`.
|
||||
type UserLobbyMembershipsHandlers struct {
|
||||
svc *lobby.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewUserLobbyMembershipsHandlers constructs the handler set. svc may
|
||||
// be nil — in that case every handler returns 501 not_implemented.
|
||||
func NewUserLobbyMembershipsHandlers(svc *lobby.Service, logger *zap.Logger) *UserLobbyMembershipsHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &UserLobbyMembershipsHandlers{svc: svc, logger: logger.Named("http.user.lobby.memberships")}
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/user/lobby/games/{game_id}/memberships.
|
||||
func (h *UserLobbyMembershipsHandlers) List() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userLobbyMembershipsList")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
items, err := h.svc.ListMembershipsForGame(ctx, gameID)
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user lobby memberships list", ctx, err)
|
||||
return
|
||||
}
|
||||
out := lobbyMembershipListWire{Items: make([]lobbyMembershipDetailWire, 0, len(items))}
|
||||
for _, m := range items {
|
||||
out.Items = append(out.Items, lobbyMembershipDetailToWire(m))
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove handles POST /api/v1/user/lobby/games/{game_id}/memberships/{membership_id}/remove.
|
||||
func (h *UserLobbyMembershipsHandlers) Remove() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userLobbyMembershipsRemove")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
membershipID, ok := parseMembershipIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
caller := userID
|
||||
updated, err := h.svc.RemoveMembership(ctx, &caller, false, gameID, membershipID)
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user lobby memberships remove", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, lobbyMembershipDetailToWire(updated))
|
||||
}
|
||||
}
|
||||
|
||||
// Block handles POST /api/v1/user/lobby/games/{game_id}/memberships/{membership_id}/block.
|
||||
func (h *UserLobbyMembershipsHandlers) Block() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userLobbyMembershipsBlock")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
membershipID, ok := parseMembershipIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
caller := userID
|
||||
updated, err := h.svc.BlockMembership(ctx, &caller, false, gameID, membershipID)
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user lobby memberships block", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, lobbyMembershipDetailToWire(updated))
|
||||
}
|
||||
}
|
||||
|
||||
// lobbyMembershipListWire mirrors `LobbyMembershipList`.
|
||||
type lobbyMembershipListWire struct {
|
||||
Items []lobbyMembershipDetailWire `json:"items"`
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/backend/internal/lobby"
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/server/middleware/userid"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// UserLobbyMyHandlers groups the caller-scoped lobby read endpoints
|
||||
// under `/api/v1/user/lobby/my/*`. The current implementation ships real implementations
|
||||
// backed by `*lobby.Service`.
|
||||
type UserLobbyMyHandlers struct {
|
||||
svc *lobby.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewUserLobbyMyHandlers constructs the handler set. svc may be nil —
|
||||
// in that case every handler returns 501 not_implemented.
|
||||
func NewUserLobbyMyHandlers(svc *lobby.Service, logger *zap.Logger) *UserLobbyMyHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &UserLobbyMyHandlers{svc: svc, logger: logger.Named("http.user.lobby.my")}
|
||||
}
|
||||
|
||||
// Games handles GET /api/v1/user/lobby/my/games.
|
||||
func (h *UserLobbyMyHandlers) Games() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userLobbyMyGames")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
games, err := h.svc.ListMyGames(ctx, userID)
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user lobby my games", ctx, err)
|
||||
return
|
||||
}
|
||||
out := myGamesListResponseWire{Items: make([]gameSummaryWire, 0, len(games))}
|
||||
for _, g := range games {
|
||||
out.Items = append(out.Items, gameSummaryToWire(g))
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// Applications handles GET /api/v1/user/lobby/my/applications.
|
||||
func (h *UserLobbyMyHandlers) Applications() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userLobbyMyApplications")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
items, err := h.svc.ListMyApplications(ctx, userID)
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user lobby my applications", ctx, err)
|
||||
return
|
||||
}
|
||||
out := lobbyApplicationListWire{Items: make([]lobbyApplicationDetailWire, 0, len(items))}
|
||||
for _, a := range items {
|
||||
out.Items = append(out.Items, lobbyApplicationDetailToWire(a))
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// Invites handles GET /api/v1/user/lobby/my/invites.
|
||||
func (h *UserLobbyMyHandlers) Invites() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userLobbyMyInvites")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
items, err := h.svc.ListMyInvites(ctx, userID)
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user lobby my invites", ctx, err)
|
||||
return
|
||||
}
|
||||
out := lobbyInviteListWire{Items: make([]lobbyInviteDetailWire, 0, len(items))}
|
||||
for _, i := range items {
|
||||
out.Items = append(out.Items, lobbyInviteDetailToWire(i))
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// RaceNames handles GET /api/v1/user/lobby/my/race-names.
|
||||
func (h *UserLobbyMyHandlers) RaceNames() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userLobbyMyRaceNames")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
items, err := h.svc.ListMyRaceNames(ctx, userID)
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user lobby my race-names", ctx, err)
|
||||
return
|
||||
}
|
||||
out := raceNameListWire{Items: make([]raceNameDetailWire, 0, len(items))}
|
||||
for _, e := range items {
|
||||
out.Items = append(out.Items, raceNameDetailToWire(e))
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// Wire envelopes for caller-scoped lists.
|
||||
|
||||
// myGamesListResponseWire mirrors `MyGamesListResponse`.
|
||||
type myGamesListResponseWire struct {
|
||||
Items []gameSummaryWire `json:"items"`
|
||||
}
|
||||
|
||||
// lobbyApplicationListWire mirrors `LobbyApplicationList`.
|
||||
type lobbyApplicationListWire struct {
|
||||
Items []lobbyApplicationDetailWire `json:"items"`
|
||||
}
|
||||
|
||||
// lobbyInviteListWire mirrors `LobbyInviteList`.
|
||||
type lobbyInviteListWire struct {
|
||||
Items []lobbyInviteDetailWire `json:"items"`
|
||||
}
|
||||
|
||||
// raceNameListWire mirrors `RaceNameList`.
|
||||
type raceNameListWire struct {
|
||||
Items []raceNameDetailWire `json:"items"`
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/backend/internal/lobby"
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/server/middleware/userid"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// UserLobbyRaceNamesHandlers groups the race-name-directory handlers
|
||||
// under `/api/v1/user/lobby/race-names/*`. The current implementation ships real
|
||||
// implementations backed by `*lobby.Service`.
|
||||
type UserLobbyRaceNamesHandlers struct {
|
||||
svc *lobby.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewUserLobbyRaceNamesHandlers constructs the handler set. svc may be
|
||||
// nil — in that case every handler returns 501 not_implemented.
|
||||
func NewUserLobbyRaceNamesHandlers(svc *lobby.Service, logger *zap.Logger) *UserLobbyRaceNamesHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &UserLobbyRaceNamesHandlers{svc: svc, logger: logger.Named("http.user.lobby.race_names")}
|
||||
}
|
||||
|
||||
// Register handles POST /api/v1/user/lobby/race-names/register.
|
||||
func (h *UserLobbyRaceNamesHandlers) Register() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userLobbyRaceNamesRegister")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
var req raceNameRegisterRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
entry, err := h.svc.RegisterRaceName(ctx, userID, req.Name)
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user lobby race-names register", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, raceNameDetailToWire(entry))
|
||||
}
|
||||
}
|
||||
|
||||
// raceNameRegisterRequestWire mirrors `RaceNameRegisterRequest`.
|
||||
type raceNameRegisterRequestWire struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Package httperr defines the standard JSON error envelope shared by every
|
||||
// backend HTTP middleware and handler.
|
||||
//
|
||||
// The envelope shape is fixed by `backend/openapi.yaml` (`ErrorResponse`) and
|
||||
// must remain identical across the public, user, admin, and internal route
|
||||
// groups so that callers can parse failures uniformly.
|
||||
package httperr
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Error code values that appear in the JSON envelope `error.code` field. They
|
||||
// are documented in `backend/openapi.yaml` and form the closed set of stable
|
||||
// machine-readable failure markers.
|
||||
const (
|
||||
CodeNotImplemented = "not_implemented"
|
||||
CodeInvalidRequest = "invalid_request"
|
||||
CodeUnauthorized = "unauthorized"
|
||||
CodeForbidden = "forbidden"
|
||||
CodeNotFound = "not_found"
|
||||
CodeConflict = "conflict"
|
||||
CodeMethodNotAllowed = "method_not_allowed"
|
||||
CodeInternalError = "internal_error"
|
||||
CodeServiceUnavailable = "service_unavailable"
|
||||
)
|
||||
|
||||
// Body stores the inner `error` object of the standard envelope.
|
||||
type Body struct {
|
||||
// Code is the stable machine-readable failure marker.
|
||||
Code string `json:"code"`
|
||||
|
||||
// Message is the human-readable client-safe failure description.
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Response wraps Body in the documented `{"error":{...}}` shape.
|
||||
type Response struct {
|
||||
Error Body `json:"error"`
|
||||
}
|
||||
|
||||
// Abort writes the standard JSON error envelope with statusCode and aborts the
|
||||
// gin handler chain. It is the single helper every middleware and handler must
|
||||
// use to emit a failure response.
|
||||
func Abort(c *gin.Context, statusCode int, code, message string) {
|
||||
c.AbortWithStatusJSON(statusCode, Response{
|
||||
Error: Body{
|
||||
Code: code,
|
||||
Message: message,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Package basicauth gates a route group behind HTTP Basic authentication.
|
||||
//
|
||||
// The middleware delegates the credential check to a Verifier.
|
||||
// Production wires `*admin.Service` (Postgres-backed, bcrypt cost 12).
|
||||
// The bundled StaticVerifier is a test utility — it accepts any
|
||||
// non-empty username together with a fixed password so the contract
|
||||
// test can exercise the admin route group without booting a database.
|
||||
// Production wiring never references StaticVerifier.
|
||||
package basicauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DefaultRealm is the realm advertised on `WWW-Authenticate` for the admin
|
||||
// surface.
|
||||
const DefaultRealm = "galaxy-admin"
|
||||
|
||||
// usernameContextKey is the unexported context key used to expose the
|
||||
// authenticated admin username to downstream handlers (e.g. for
|
||||
// soft-delete audit trails). The unexported value type prevents
|
||||
// accidental collisions with keys defined in unrelated packages.
|
||||
type usernameContextKey struct{}
|
||||
|
||||
// Verifier validates a username/password pair. Implementations must run in
|
||||
// constant time relative to the credential bytes.
|
||||
type Verifier interface {
|
||||
// Verify reports whether the supplied credentials are accepted. A non-nil
|
||||
// error indicates an unexpected verifier failure, distinct from a clean
|
||||
// rejection (false, nil).
|
||||
Verify(ctx context.Context, username, password string) (bool, error)
|
||||
}
|
||||
|
||||
// UsernameFromContext returns the authenticated admin username stored on
|
||||
// ctx by Middleware. The boolean reports whether a value was found.
|
||||
func UsernameFromContext(ctx context.Context) (string, bool) {
|
||||
if ctx == nil {
|
||||
return "", false
|
||||
}
|
||||
value, ok := ctx.Value(usernameContextKey{}).(string)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
// WithUsername stores username on ctx under the package-private context
|
||||
// key. Exposed for tests that need to build a context outside the
|
||||
// middleware.
|
||||
func WithUsername(ctx context.Context, username string) context.Context {
|
||||
return context.WithValue(ctx, usernameContextKey{}, username)
|
||||
}
|
||||
|
||||
// Middleware returns a gin middleware that enforces Basic authentication via
|
||||
// verifier. realm is advertised on `WWW-Authenticate`. A nil verifier behaves
|
||||
// as a deny-all verifier, suitable for the operating mode where the admin
|
||||
// surface must remain mounted but inaccessible.
|
||||
func Middleware(verifier Verifier, realm string) gin.HandlerFunc {
|
||||
if realm == "" {
|
||||
realm = DefaultRealm
|
||||
}
|
||||
challenge := `Basic realm="` + realm + `"`
|
||||
|
||||
return func(c *gin.Context) {
|
||||
username, password, ok := c.Request.BasicAuth()
|
||||
if !ok {
|
||||
c.Header("WWW-Authenticate", challenge)
|
||||
httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "basic authentication is required")
|
||||
return
|
||||
}
|
||||
|
||||
if verifier == nil {
|
||||
c.Header("WWW-Authenticate", challenge)
|
||||
httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "credentials were rejected")
|
||||
return
|
||||
}
|
||||
|
||||
accepted, err := verifier.Verify(c.Request.Context(), username, password)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "credential verification failed")
|
||||
return
|
||||
}
|
||||
if !accepted {
|
||||
c.Header("WWW-Authenticate", challenge)
|
||||
httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "credentials were rejected")
|
||||
return
|
||||
}
|
||||
|
||||
c.Request = c.Request.WithContext(WithUsername(c.Request.Context(), username))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// StaticVerifier accepts any non-empty username together with a
|
||||
// fixed shared password. It is a test-only utility: the OpenAPI
|
||||
// contract test wires it to exercise the admin route group without
|
||||
// booting a database. Production wiring uses the Postgres-backed
|
||||
// `*backend/internal/admin.Service`.
|
||||
type StaticVerifier struct {
|
||||
// Password is the shared secret. An empty value disables the verifier
|
||||
// (every request is rejected).
|
||||
Password string
|
||||
}
|
||||
|
||||
// NewStaticVerifier returns a StaticVerifier with the supplied password.
|
||||
func NewStaticVerifier(password string) StaticVerifier {
|
||||
return StaticVerifier{Password: password}
|
||||
}
|
||||
|
||||
// Verify accepts any non-empty username together with the configured password.
|
||||
// The password comparison runs in constant time. An empty configured password
|
||||
// rejects every request.
|
||||
func (v StaticVerifier) Verify(_ context.Context, username, password string) (bool, error) {
|
||||
if strings.TrimSpace(username) == "" {
|
||||
return false, nil
|
||||
}
|
||||
if v.Password == "" {
|
||||
return false, nil
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(password), []byte(v.Password)) != 1 {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Package geocounter exposes the gin middleware that records
|
||||
// `(user_id, country)` counters for every authenticated user-surface
|
||||
// request. The middleware sits one layer below `userid.Middleware` in
|
||||
// the route chain: it relies on the parsed user id already being on
|
||||
// the request context.
|
||||
//
|
||||
// The middleware never blocks: the underlying counter implementation
|
||||
// looks up the country synchronously (mmap read) and dispatches the
|
||||
// database upsert to a fire-and-forget goroutine. Errors from the
|
||||
// asynchronous path are logged inside the geo service, never surfaced
|
||||
// to the response.
|
||||
package geocounter
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"galaxy/backend/internal/server/clientip"
|
||||
"galaxy/backend/internal/server/middleware/userid"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Service is the narrow contract the middleware needs from the geo
|
||||
// package. It is satisfied by `*geo.Service` directly; tests inject a
|
||||
// recording stub. A nil Service is allowed and disables the
|
||||
// middleware's side effect.
|
||||
type Service interface {
|
||||
IncrementCounterAsync(ctx context.Context, userID uuid.UUID, sourceIP string)
|
||||
}
|
||||
|
||||
// Middleware returns a gin handler that, after the wrapped handler
|
||||
// chain has run, dispatches an `IncrementCounterAsync` call for the
|
||||
// authenticated user and the originating IP. svc may be nil, in which
|
||||
// case the middleware is a no-op pass-through.
|
||||
//
|
||||
// The middleware reads the user id from the request context populated
|
||||
// by `userid.Middleware`; routes that mount this middleware without
|
||||
// `userid.Middleware` ahead of it will silently skip the increment
|
||||
// because the user id is absent.
|
||||
func Middleware(svc Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok || userID == uuid.Nil {
|
||||
return
|
||||
}
|
||||
ip := clientip.ExtractSourceIP(c)
|
||||
if ip == "" {
|
||||
return
|
||||
}
|
||||
svc.IncrementCounterAsync(c.Request.Context(), userID, ip)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package geocounter_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"galaxy/backend/internal/server/middleware/geocounter"
|
||||
"galaxy/backend/internal/server/middleware/userid"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type recordingSvc struct {
|
||||
mu sync.Mutex
|
||||
calls []recordedCall
|
||||
}
|
||||
|
||||
type recordedCall struct {
|
||||
UserID uuid.UUID
|
||||
SourceIP string
|
||||
}
|
||||
|
||||
func (r *recordingSvc) IncrementCounterAsync(_ context.Context, userID uuid.UUID, sourceIP string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.calls = append(r.calls, recordedCall{UserID: userID, SourceIP: sourceIP})
|
||||
}
|
||||
|
||||
func (r *recordingSvc) snapshot() []recordedCall {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]recordedCall, len(r.calls))
|
||||
copy(out, r.calls)
|
||||
return out
|
||||
}
|
||||
|
||||
func newEngine(t *testing.T, svc geocounter.Service) *gin.Engine {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(userid.Middleware())
|
||||
r.Use(geocounter.Middleware(svc))
|
||||
r.GET("/probe", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func TestMiddlewareInvokesIncrementOnAuthenticatedRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := &recordingSvc{}
|
||||
r := newEngine(t, svc)
|
||||
|
||||
userID := uuid.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
|
||||
req.Header.Set(userid.Header, userID.String())
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.5")
|
||||
req.RemoteAddr = "10.0.0.1:1000"
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: want 200, got %d", rec.Code)
|
||||
}
|
||||
calls := svc.snapshot()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("calls: want 1, got %+v", calls)
|
||||
}
|
||||
if calls[0].UserID != userID {
|
||||
t.Errorf("user id: want %s, got %s", userID, calls[0].UserID)
|
||||
}
|
||||
if calls[0].SourceIP != "203.0.113.5" {
|
||||
t.Errorf("source ip: want 203.0.113.5, got %q", calls[0].SourceIP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareFallsBackToRemoteAddr(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := &recordingSvc{}
|
||||
r := newEngine(t, svc)
|
||||
|
||||
userID := uuid.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
|
||||
req.Header.Set(userid.Header, userID.String())
|
||||
req.RemoteAddr = "198.51.100.7:60000"
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
calls := svc.snapshot()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("calls: want 1, got %+v", calls)
|
||||
}
|
||||
if calls[0].SourceIP != "198.51.100.7" {
|
||||
t.Errorf("source ip: want 198.51.100.7, got %q", calls[0].SourceIP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareSkipsWhenNoSourceIP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := &recordingSvc{}
|
||||
r := newEngine(t, svc)
|
||||
|
||||
userID := uuid.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
|
||||
req.Header.Set(userid.Header, userID.String())
|
||||
req.RemoteAddr = ""
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if calls := svc.snapshot(); len(calls) != 0 {
|
||||
t.Fatalf("calls: want 0, got %+v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareSkipsWithoutUserContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := &recordingSvc{}
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
// No userid.Middleware on this chain.
|
||||
r.Use(geocounter.Middleware(svc))
|
||||
r.GET("/probe", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
|
||||
req.RemoteAddr = "203.0.113.5:1000"
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if calls := svc.snapshot(); len(calls) != 0 {
|
||||
t.Fatalf("calls: want 0, got %+v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareNilServiceIsPassThrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newEngine(t, nil)
|
||||
|
||||
userID := uuid.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
|
||||
req.Header.Set(userid.Header, userID.String())
|
||||
req.RemoteAddr = "203.0.113.5:1000"
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status with nil service: want 200, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Package logging emits a single info-level access log entry per HTTP request,
|
||||
// enriched with the active OpenTelemetry trace fields and the resolved request
|
||||
// id when present.
|
||||
package logging
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/server/middleware/requestid"
|
||||
"galaxy/backend/internal/telemetry"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Middleware returns the access-log gin middleware. The provided logger should
|
||||
// already carry the per-process service-name field; the middleware adds the
|
||||
// request method, matched route, status, latency, request id, and trace
|
||||
// fields.
|
||||
func Middleware(logger *zap.Logger) gin.HandlerFunc {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
duration := time.Since(start)
|
||||
|
||||
fields := make([]zap.Field, 0, 6)
|
||||
fields = append(fields,
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("path", c.FullPath()),
|
||||
zap.Int("status", c.Writer.Status()),
|
||||
zap.Duration("duration", duration),
|
||||
)
|
||||
if requestID, ok := requestid.FromGin(c); ok {
|
||||
fields = append(fields, zap.String("request_id", requestID))
|
||||
}
|
||||
fields = append(fields, telemetry.TraceFieldsFromContext(c.Request.Context())...)
|
||||
|
||||
logger.Info("http request", fields...)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Package metrics emits per-request OpenTelemetry counters and histograms
|
||||
// scoped by route group.
|
||||
//
|
||||
// The metric names are fixed by `backend/README.md` §15:
|
||||
//
|
||||
// - http_requests_total{group, method, route, status}
|
||||
// - http_request_duration_seconds{group, method, route, status}
|
||||
//
|
||||
// One Middleware instance per route group keeps the `group` attribute stable
|
||||
// across requests while allowing the gin router to share the same Meter.
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
)
|
||||
|
||||
// Group identifies the route family that emits the metric. The set is closed
|
||||
// and matches the prefixes registered by router.New.
|
||||
type Group string
|
||||
|
||||
const (
|
||||
// GroupRoot covers `/healthz`, `/readyz`, and unmatched routes.
|
||||
GroupRoot Group = "root"
|
||||
// GroupProbes covers the readiness/liveness probes when reported separately
|
||||
// from other root-level traffic.
|
||||
GroupProbes Group = "probes"
|
||||
// GroupPublic covers `/api/v1/public/*` endpoints.
|
||||
GroupPublic Group = "public"
|
||||
// GroupUser covers `/api/v1/user/*` endpoints.
|
||||
GroupUser Group = "user"
|
||||
// GroupAdmin covers `/api/v1/admin/*` endpoints.
|
||||
GroupAdmin Group = "admin"
|
||||
// GroupInternal covers `/api/v1/internal/*` endpoints.
|
||||
GroupInternal Group = "internal"
|
||||
)
|
||||
|
||||
// Instruments holds the shared metric instruments used by every Group-scoped
|
||||
// middleware. The instruments are constructed once per Meter; the
|
||||
// per-middleware closure binds them to the right `group` attribute.
|
||||
type Instruments struct {
|
||||
requestsTotal metric.Int64Counter
|
||||
requestDuration metric.Float64Histogram
|
||||
}
|
||||
|
||||
// NewInstruments builds the shared metric instruments from meter. A nil meter
|
||||
// returns nil instruments and disables metric emission.
|
||||
func NewInstruments(meter metric.Meter) (*Instruments, error) {
|
||||
if meter == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
requestsTotal, err := meter.Int64Counter(
|
||||
"http_requests_total",
|
||||
metric.WithDescription("Number of HTTP requests served by the backend, partitioned by route group, method, route, and response status."),
|
||||
metric.WithUnit("1"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
requestDuration, err := meter.Float64Histogram(
|
||||
"http_request_duration_seconds",
|
||||
metric.WithDescription("Duration of HTTP requests served by the backend, partitioned by route group, method, route, and response status."),
|
||||
metric.WithUnit("s"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Instruments{
|
||||
requestsTotal: requestsTotal,
|
||||
requestDuration: requestDuration,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Middleware returns a gin middleware that records request counters and
|
||||
// duration histograms with the `group` attribute fixed to group. A nil
|
||||
// instruments value yields a no-op middleware so that metric emission is
|
||||
// strictly opt-in.
|
||||
func Middleware(instruments *Instruments, group Group) gin.HandlerFunc {
|
||||
if instruments == nil {
|
||||
return func(c *gin.Context) { c.Next() }
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
duration := time.Since(start)
|
||||
|
||||
route := c.FullPath()
|
||||
if route == "" {
|
||||
route = "unmatched"
|
||||
}
|
||||
|
||||
attrs := metric.WithAttributes(
|
||||
attribute.String("group", string(group)),
|
||||
attribute.String("method", c.Request.Method),
|
||||
attribute.String("route", route),
|
||||
attribute.String("status", strconv.Itoa(c.Writer.Status())),
|
||||
)
|
||||
|
||||
instruments.requestsTotal.Add(c.Request.Context(), 1, attrs)
|
||||
instruments.requestDuration.Record(c.Request.Context(), duration.Seconds(), attrs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Package panicrecovery converts unrecovered panics into a structured 500
|
||||
// response and a single error-level log entry. It is wired exactly once at the
|
||||
// top of the gin middleware chain.
|
||||
package panicrecovery
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/server/middleware/requestid"
|
||||
"galaxy/backend/internal/telemetry"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Middleware returns a gin middleware that recovers from panics, logs the
|
||||
// failure with trace fields, and writes the standard 500 envelope.
|
||||
func Middleware(logger *zap.Logger) gin.HandlerFunc {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
|
||||
return gin.CustomRecovery(func(c *gin.Context, recovered any) {
|
||||
fields := []zap.Field{
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("path", c.FullPath()),
|
||||
zap.Any("panic", recovered),
|
||||
}
|
||||
if requestID, ok := requestid.FromGin(c); ok {
|
||||
fields = append(fields, zap.String("request_id", requestID))
|
||||
}
|
||||
fields = append(fields, telemetry.TraceFieldsFromContext(c.Request.Context())...)
|
||||
|
||||
logger.Error("http handler panicked", fields...)
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "internal server error")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Package requestid carries a per-request identifier across the gin handler
|
||||
// chain.
|
||||
//
|
||||
// The middleware reads the inbound `X-Request-ID` header, generates a UUIDv4
|
||||
// when absent, stores the value on the gin context, and reflects it on the
|
||||
// response. Downstream code retrieves the identifier through FromContext.
|
||||
package requestid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Header is the canonical case-correct header name carrying the request id.
|
||||
const Header = "X-Request-ID"
|
||||
|
||||
// ginContextKey is the gin.Context key under which the resolved request id is
|
||||
// stored. The value is a string. The key is exported in lowercase form so that
|
||||
// it never collides with handler-level keys; consumers should prefer
|
||||
// FromContext rather than reading the gin context directly.
|
||||
const ginContextKey = "backend.request_id"
|
||||
|
||||
// requestIDContextKey is the unexported context.Context key used when the
|
||||
// resolved request id is propagated outside gin (background goroutines,
|
||||
// downstream client calls). The unexported value type prevents accidental
|
||||
// collisions across packages.
|
||||
type requestIDContextKey struct{}
|
||||
|
||||
// Middleware returns the gin middleware that resolves and propagates the
|
||||
// request id.
|
||||
func Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
requestID := strings.TrimSpace(c.GetHeader(Header))
|
||||
if requestID == "" {
|
||||
requestID = uuid.NewString()
|
||||
}
|
||||
|
||||
c.Set(ginContextKey, requestID)
|
||||
c.Writer.Header().Set(Header, requestID)
|
||||
c.Request = c.Request.WithContext(WithValue(c.Request.Context(), requestID))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// FromContext returns the request id stored on ctx by Middleware. The boolean
|
||||
// reports whether an id was found. Consumers must always check the boolean
|
||||
// before using the returned string.
|
||||
func FromContext(ctx context.Context) (string, bool) {
|
||||
if ctx == nil {
|
||||
return "", false
|
||||
}
|
||||
value, ok := ctx.Value(requestIDContextKey{}).(string)
|
||||
if !ok || value == "" {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
// FromGin returns the request id stored on the gin context by Middleware. The
|
||||
// boolean reports whether an id was found.
|
||||
func FromGin(c *gin.Context) (string, bool) {
|
||||
if c == nil {
|
||||
return "", false
|
||||
}
|
||||
raw, ok := c.Get(ginContextKey)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
value, ok := raw.(string)
|
||||
if !ok || value == "" {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
// WithValue stores requestID on ctx under the package-private context key.
|
||||
// Exposed primarily for tests that build a context outside the middleware.
|
||||
func WithValue(ctx context.Context, requestID string) context.Context {
|
||||
return context.WithValue(ctx, requestIDContextKey{}, requestID)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Package userid extracts the calling user identifier from the trusted
|
||||
// `X-User-ID` header injected by gateway and exposes it through the request
|
||||
// context.
|
||||
//
|
||||
// Backend trusts the header value because the network segment between gateway
|
||||
// and backend is the trust boundary (see `ARCHITECTURE.md` §15). The
|
||||
// middleware therefore only validates the syntactic shape (UUID) and rejects
|
||||
// malformed or absent values with the standard `400 invalid_request` envelope.
|
||||
package userid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Header is the canonical case-correct header name carrying the trusted user
|
||||
// id forwarded by gateway.
|
||||
const Header = "X-User-ID"
|
||||
|
||||
// userIDContextKey is the unexported context key used to store the parsed
|
||||
// user id. The unexported value type prevents accidental collisions with
|
||||
// keys defined in unrelated packages.
|
||||
type userIDContextKey struct{}
|
||||
|
||||
// Middleware returns the gin middleware that requires a syntactically valid
|
||||
// `X-User-ID` header on every authenticated user request and stores the
|
||||
// parsed UUID on the request context.
|
||||
func Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
raw := strings.TrimSpace(c.GetHeader(Header))
|
||||
if raw == "" {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, Header+" header is required")
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, Header+" header must be a valid UUID")
|
||||
return
|
||||
}
|
||||
|
||||
c.Request = c.Request.WithContext(WithValue(c.Request.Context(), userID))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// FromContext returns the user id stored on ctx by Middleware. The boolean
|
||||
// reports whether a value was found.
|
||||
func FromContext(ctx context.Context) (uuid.UUID, bool) {
|
||||
if ctx == nil {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
value, ok := ctx.Value(userIDContextKey{}).(uuid.UUID)
|
||||
if !ok {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
// WithValue stores userID on ctx under the package-private context key.
|
||||
// Exposed for tests that need to build a context outside the middleware.
|
||||
func WithValue(ctx context.Context, userID uuid.UUID) context.Context {
|
||||
return context.WithValue(ctx, userIDContextKey{}, userID)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// statusResponse is the body shape returned by both probes.
|
||||
type statusResponse struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func handleHealthz(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, statusResponse{Status: "ok"})
|
||||
}
|
||||
|
||||
func handleReadyz(ready func() bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if ready != nil && !ready() {
|
||||
c.JSON(http.StatusServiceUnavailable, statusResponse{Status: "starting"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, statusResponse{Status: "ready"})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
// Package server hosts the backend HTTP listener and the route
|
||||
// configuration that wires the documented `backend/openapi.yaml`
|
||||
// contract against the per-domain handler sets.
|
||||
//
|
||||
// router.go is the single place where route groups, group-scoped
|
||||
// middleware, and per-domain handlers are mounted. Domain handlers
|
||||
// hold their own Service references; the routing layout is stable.
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/server/middleware/basicauth"
|
||||
"galaxy/backend/internal/server/middleware/geocounter"
|
||||
"galaxy/backend/internal/server/middleware/logging"
|
||||
"galaxy/backend/internal/server/middleware/metrics"
|
||||
"galaxy/backend/internal/server/middleware/panicrecovery"
|
||||
"galaxy/backend/internal/server/middleware/requestid"
|
||||
"galaxy/backend/internal/server/middleware/userid"
|
||||
"galaxy/backend/internal/telemetry"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
// otelServerName is the operation-name attribute attached to spans
|
||||
// produced by otelgin.
|
||||
otelServerName = "galaxy-backend"
|
||||
|
||||
// adminBasicAuthRealm is the realm advertised on `WWW-Authenticate`
|
||||
// responses from the admin surface.
|
||||
adminBasicAuthRealm = "galaxy-admin"
|
||||
)
|
||||
|
||||
var configureGinModeOnce sync.Once
|
||||
|
||||
// RouterDependencies aggregates every collaborator required to build the
|
||||
// backend HTTP handler chain.
|
||||
//
|
||||
// Logger, Telemetry, and Ready come from the process bootstrap.
|
||||
// AdminVerifier gates the admin surface; production wires
|
||||
// `*admin.Service`. The handler-set fields are allowed to be nil —
|
||||
// NewRouter substitutes a freshly-constructed placeholder set so
|
||||
// callers can supply only the slices they want to override.
|
||||
type RouterDependencies struct {
|
||||
Logger *zap.Logger
|
||||
Telemetry *telemetry.Runtime
|
||||
Ready func() bool
|
||||
AdminVerifier basicauth.Verifier
|
||||
|
||||
// GeoCounter, when non-nil, is mounted as middleware on the
|
||||
// `/api/v1/user/*` route group so that every authenticated request
|
||||
// dispatches a fire-and-forget counter increment. A nil value
|
||||
// leaves the route group untouched, which keeps existing tests
|
||||
// that build the router without geo wiring working as before.
|
||||
GeoCounter geocounter.Service
|
||||
|
||||
PublicAuth *PublicAuthHandlers
|
||||
UserAccount *UserAccountHandlers
|
||||
UserLobbyGames *UserLobbyGamesHandlers
|
||||
UserLobbyApplications *UserLobbyApplicationsHandlers
|
||||
UserLobbyInvites *UserLobbyInvitesHandlers
|
||||
UserLobbyMemberships *UserLobbyMembershipsHandlers
|
||||
UserLobbyMy *UserLobbyMyHandlers
|
||||
UserLobbyRaceNames *UserLobbyRaceNamesHandlers
|
||||
UserGames *UserGamesHandlers
|
||||
AdminAdminAccounts *AdminAdminAccountsHandlers
|
||||
AdminUsers *AdminUsersHandlers
|
||||
AdminGames *AdminGamesHandlers
|
||||
AdminRuntimes *AdminRuntimesHandlers
|
||||
AdminEngineVersions *AdminEngineVersionsHandlers
|
||||
AdminMail *AdminMailHandlers
|
||||
AdminNotifications *AdminNotificationsHandlers
|
||||
AdminGeo *AdminGeoHandlers
|
||||
InternalSessions *InternalSessionsHandlers
|
||||
InternalUsers *InternalUsersHandlers
|
||||
}
|
||||
|
||||
// NewRouter constructs the backend gin engine wired with the documented
|
||||
// middleware chain and every placeholder route from `backend/openapi.yaml`.
|
||||
// The returned handler is safe to pass into Server.NewServer.
|
||||
func NewRouter(deps RouterDependencies) (http.Handler, error) {
|
||||
configureGinModeOnce.Do(func() {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
})
|
||||
|
||||
if deps.Logger == nil {
|
||||
deps.Logger = zap.NewNop()
|
||||
}
|
||||
|
||||
deps = withDefaultHandlers(deps)
|
||||
|
||||
logger := deps.Logger.Named("http")
|
||||
|
||||
var instruments *metrics.Instruments
|
||||
if deps.Telemetry != nil {
|
||||
var err error
|
||||
instruments, err = metrics.NewInstruments(deps.Telemetry.MeterProvider().Meter(otelServerName))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.HandleMethodNotAllowed = true
|
||||
|
||||
router.Use(requestid.Middleware())
|
||||
router.Use(panicrecovery.Middleware(logger))
|
||||
router.Use(otelgin.Middleware(otelServerName))
|
||||
router.Use(logging.Middleware(logger))
|
||||
|
||||
router.GET("/healthz", metrics.Middleware(instruments, metrics.GroupProbes), handleHealthz)
|
||||
router.GET("/readyz", metrics.Middleware(instruments, metrics.GroupProbes), handleReadyz(deps.Ready))
|
||||
|
||||
registerPublicRoutes(router, instruments, deps)
|
||||
registerUserRoutes(router, instruments, deps)
|
||||
registerAdminRoutes(router, instruments, deps)
|
||||
registerInternalRoutes(router, instruments, deps)
|
||||
|
||||
router.NoMethod(func(c *gin.Context) {
|
||||
if allow := allowedMethodsForPath(c.Request.URL.Path); allow != "" {
|
||||
c.Header("Allow", allow)
|
||||
}
|
||||
httperr.Abort(c, http.StatusMethodNotAllowed, httperr.CodeMethodNotAllowed, "request method is not allowed for this route")
|
||||
})
|
||||
router.NoRoute(func(c *gin.Context) {
|
||||
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "resource was not found")
|
||||
})
|
||||
|
||||
return router, nil
|
||||
}
|
||||
|
||||
func withDefaultHandlers(deps RouterDependencies) RouterDependencies {
|
||||
if deps.PublicAuth == nil {
|
||||
deps.PublicAuth = NewPublicAuthHandlers(nil, deps.Logger)
|
||||
}
|
||||
if deps.UserAccount == nil {
|
||||
deps.UserAccount = NewUserAccountHandlers(nil, deps.Logger)
|
||||
}
|
||||
if deps.UserLobbyGames == nil {
|
||||
deps.UserLobbyGames = NewUserLobbyGamesHandlers(nil, deps.Logger)
|
||||
}
|
||||
if deps.UserLobbyApplications == nil {
|
||||
deps.UserLobbyApplications = NewUserLobbyApplicationsHandlers(nil, deps.Logger)
|
||||
}
|
||||
if deps.UserLobbyInvites == nil {
|
||||
deps.UserLobbyInvites = NewUserLobbyInvitesHandlers(nil, deps.Logger)
|
||||
}
|
||||
if deps.UserLobbyMemberships == nil {
|
||||
deps.UserLobbyMemberships = NewUserLobbyMembershipsHandlers(nil, deps.Logger)
|
||||
}
|
||||
if deps.UserLobbyMy == nil {
|
||||
deps.UserLobbyMy = NewUserLobbyMyHandlers(nil, deps.Logger)
|
||||
}
|
||||
if deps.UserLobbyRaceNames == nil {
|
||||
deps.UserLobbyRaceNames = NewUserLobbyRaceNamesHandlers(nil, deps.Logger)
|
||||
}
|
||||
if deps.UserGames == nil {
|
||||
deps.UserGames = NewUserGamesHandlers(nil, nil, deps.Logger)
|
||||
}
|
||||
if deps.AdminAdminAccounts == nil {
|
||||
deps.AdminAdminAccounts = NewAdminAdminAccountsHandlers(nil, deps.Logger)
|
||||
}
|
||||
if deps.AdminUsers == nil {
|
||||
deps.AdminUsers = NewAdminUsersHandlers(nil, deps.Logger)
|
||||
}
|
||||
if deps.AdminGames == nil {
|
||||
deps.AdminGames = NewAdminGamesHandlers(nil, deps.Logger)
|
||||
}
|
||||
if deps.AdminRuntimes == nil {
|
||||
deps.AdminRuntimes = NewAdminRuntimesHandlers(nil, deps.Logger)
|
||||
}
|
||||
if deps.AdminEngineVersions == nil {
|
||||
deps.AdminEngineVersions = NewAdminEngineVersionsHandlers(nil, deps.Logger)
|
||||
}
|
||||
if deps.AdminMail == nil {
|
||||
deps.AdminMail = NewAdminMailHandlers(nil, deps.Logger)
|
||||
}
|
||||
if deps.AdminNotifications == nil {
|
||||
deps.AdminNotifications = NewAdminNotificationsHandlers(nil, deps.Logger)
|
||||
}
|
||||
if deps.AdminGeo == nil {
|
||||
deps.AdminGeo = NewAdminGeoHandlers(nil, deps.Logger)
|
||||
}
|
||||
if deps.InternalSessions == nil {
|
||||
deps.InternalSessions = NewInternalSessionsHandlers(nil, deps.Logger)
|
||||
}
|
||||
if deps.InternalUsers == nil {
|
||||
deps.InternalUsers = NewInternalUsersHandlers(nil, deps.Logger)
|
||||
}
|
||||
return deps
|
||||
}
|
||||
|
||||
func registerPublicRoutes(router *gin.Engine, instruments *metrics.Instruments, deps RouterDependencies) {
|
||||
group := router.Group("/api/v1/public")
|
||||
group.Use(metrics.Middleware(instruments, metrics.GroupPublic))
|
||||
|
||||
auth := group.Group("/auth")
|
||||
auth.POST("/send-email-code", deps.PublicAuth.SendEmailCode())
|
||||
auth.POST("/confirm-email-code", deps.PublicAuth.ConfirmEmailCode())
|
||||
}
|
||||
|
||||
func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, deps RouterDependencies) {
|
||||
group := router.Group("/api/v1/user")
|
||||
group.Use(metrics.Middleware(instruments, metrics.GroupUser))
|
||||
group.Use(userid.Middleware())
|
||||
if deps.GeoCounter != nil {
|
||||
group.Use(geocounter.Middleware(deps.GeoCounter))
|
||||
}
|
||||
|
||||
account := group.Group("/account")
|
||||
account.GET("", deps.UserAccount.Get())
|
||||
account.PATCH("/profile", deps.UserAccount.UpdateProfile())
|
||||
account.PATCH("/settings", deps.UserAccount.UpdateSettings())
|
||||
account.POST("/delete", deps.UserAccount.Delete())
|
||||
|
||||
lobbyGroup := group.Group("/lobby")
|
||||
games := lobbyGroup.Group("/games")
|
||||
games.GET("", deps.UserLobbyGames.List())
|
||||
games.POST("", deps.UserLobbyGames.Create())
|
||||
games.GET("/:game_id", deps.UserLobbyGames.Get())
|
||||
games.PATCH("/:game_id", deps.UserLobbyGames.Update())
|
||||
games.POST("/:game_id/open-enrollment", deps.UserLobbyGames.OpenEnrollment())
|
||||
games.POST("/:game_id/ready-to-start", deps.UserLobbyGames.ReadyToStart())
|
||||
games.POST("/:game_id/start", deps.UserLobbyGames.Start())
|
||||
games.POST("/:game_id/pause", deps.UserLobbyGames.Pause())
|
||||
games.POST("/:game_id/resume", deps.UserLobbyGames.Resume())
|
||||
games.POST("/:game_id/cancel", deps.UserLobbyGames.Cancel())
|
||||
games.POST("/:game_id/retry-start", deps.UserLobbyGames.RetryStart())
|
||||
|
||||
games.POST("/:game_id/applications", deps.UserLobbyApplications.Submit())
|
||||
games.POST("/:game_id/applications/:application_id/approve", deps.UserLobbyApplications.Approve())
|
||||
games.POST("/:game_id/applications/:application_id/reject", deps.UserLobbyApplications.Reject())
|
||||
|
||||
games.POST("/:game_id/invites", deps.UserLobbyInvites.Issue())
|
||||
games.POST("/:game_id/invites/:invite_id/redeem", deps.UserLobbyInvites.Redeem())
|
||||
games.POST("/:game_id/invites/:invite_id/decline", deps.UserLobbyInvites.Decline())
|
||||
games.POST("/:game_id/invites/:invite_id/revoke", deps.UserLobbyInvites.Revoke())
|
||||
|
||||
games.GET("/:game_id/memberships", deps.UserLobbyMemberships.List())
|
||||
games.POST("/:game_id/memberships/:membership_id/remove", deps.UserLobbyMemberships.Remove())
|
||||
games.POST("/:game_id/memberships/:membership_id/block", deps.UserLobbyMemberships.Block())
|
||||
|
||||
my := lobbyGroup.Group("/my")
|
||||
my.GET("/games", deps.UserLobbyMy.Games())
|
||||
my.GET("/applications", deps.UserLobbyMy.Applications())
|
||||
my.GET("/invites", deps.UserLobbyMy.Invites())
|
||||
my.GET("/race-names", deps.UserLobbyMy.RaceNames())
|
||||
|
||||
raceNames := lobbyGroup.Group("/race-names")
|
||||
raceNames.POST("/register", deps.UserLobbyRaceNames.Register())
|
||||
|
||||
userGames := group.Group("/games")
|
||||
userGames.POST("/:game_id/commands", deps.UserGames.Commands())
|
||||
userGames.POST("/:game_id/orders", deps.UserGames.Orders())
|
||||
userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report())
|
||||
}
|
||||
|
||||
func registerAdminRoutes(router *gin.Engine, instruments *metrics.Instruments, deps RouterDependencies) {
|
||||
group := router.Group("/api/v1/admin")
|
||||
group.Use(metrics.Middleware(instruments, metrics.GroupAdmin))
|
||||
group.Use(basicauth.Middleware(deps.AdminVerifier, adminBasicAuthRealm))
|
||||
|
||||
adminAccounts := group.Group("/admin-accounts")
|
||||
adminAccounts.GET("", deps.AdminAdminAccounts.List())
|
||||
adminAccounts.POST("", deps.AdminAdminAccounts.Create())
|
||||
adminAccounts.GET("/:username", deps.AdminAdminAccounts.Get())
|
||||
adminAccounts.POST("/:username/disable", deps.AdminAdminAccounts.Disable())
|
||||
adminAccounts.POST("/:username/enable", deps.AdminAdminAccounts.Enable())
|
||||
adminAccounts.POST("/:username/reset-password", deps.AdminAdminAccounts.ResetPassword())
|
||||
|
||||
users := group.Group("/users")
|
||||
users.GET("", deps.AdminUsers.List())
|
||||
users.GET("/:user_id", deps.AdminUsers.Get())
|
||||
users.POST("/:user_id/sanctions", deps.AdminUsers.AddSanction())
|
||||
users.POST("/:user_id/limits", deps.AdminUsers.AddLimit())
|
||||
users.POST("/:user_id/entitlements", deps.AdminUsers.AddEntitlement())
|
||||
users.POST("/:user_id/soft-delete", deps.AdminUsers.SoftDelete())
|
||||
|
||||
games := group.Group("/games")
|
||||
games.GET("", deps.AdminGames.List())
|
||||
games.POST("", deps.AdminGames.Create())
|
||||
games.GET("/:game_id", deps.AdminGames.Get())
|
||||
games.POST("/:game_id/force-start", deps.AdminGames.ForceStart())
|
||||
games.POST("/:game_id/force-stop", deps.AdminGames.ForceStop())
|
||||
games.POST("/:game_id/ban-member", deps.AdminGames.BanMember())
|
||||
|
||||
runtimes := group.Group("/runtimes")
|
||||
runtimes.GET("/:game_id", deps.AdminRuntimes.Get())
|
||||
runtimes.POST("/:game_id/restart", deps.AdminRuntimes.Restart())
|
||||
runtimes.POST("/:game_id/patch", deps.AdminRuntimes.Patch())
|
||||
runtimes.POST("/:game_id/force-next-turn", deps.AdminRuntimes.ForceNextTurn())
|
||||
|
||||
engineVersions := group.Group("/engine-versions")
|
||||
engineVersions.GET("", deps.AdminEngineVersions.List())
|
||||
engineVersions.POST("", deps.AdminEngineVersions.Create())
|
||||
engineVersions.PATCH("/:id", deps.AdminEngineVersions.Update())
|
||||
engineVersions.POST("/:id/disable", deps.AdminEngineVersions.Disable())
|
||||
|
||||
mail := group.Group("/mail")
|
||||
mail.GET("/deliveries", deps.AdminMail.ListDeliveries())
|
||||
mail.GET("/deliveries/:delivery_id", deps.AdminMail.GetDelivery())
|
||||
mail.GET("/deliveries/:delivery_id/attempts", deps.AdminMail.ListDeliveryAttempts())
|
||||
mail.POST("/deliveries/:delivery_id/resend", deps.AdminMail.ResendDelivery())
|
||||
mail.GET("/dead-letters", deps.AdminMail.ListDeadLetters())
|
||||
|
||||
notifications := group.Group("/notifications")
|
||||
notifications.GET("", deps.AdminNotifications.List())
|
||||
notifications.GET("/dead-letters", deps.AdminNotifications.ListDeadLetters())
|
||||
notifications.GET("/malformed", deps.AdminNotifications.ListMalformed())
|
||||
notifications.GET("/:notification_id", deps.AdminNotifications.Get())
|
||||
|
||||
geo := group.Group("/geo")
|
||||
geo.GET("/users/:user_id/countries", deps.AdminGeo.ListUserCountries())
|
||||
}
|
||||
|
||||
func registerInternalRoutes(router *gin.Engine, instruments *metrics.Instruments, deps RouterDependencies) {
|
||||
group := router.Group("/api/v1/internal")
|
||||
group.Use(metrics.Middleware(instruments, metrics.GroupInternal))
|
||||
|
||||
sessions := group.Group("/sessions")
|
||||
sessions.POST("/users/:user_id/revoke-all", deps.InternalSessions.RevokeAllForUser())
|
||||
sessions.GET("/:device_session_id", deps.InternalSessions.Get())
|
||||
sessions.POST("/:device_session_id/revoke", deps.InternalSessions.Revoke())
|
||||
|
||||
users := group.Group("/users")
|
||||
users.GET("/:user_id/account-internal", deps.InternalUsers.GetAccountInternal())
|
||||
}
|
||||
|
||||
// allowedMethodsForPath returns the comma-separated list of methods
|
||||
// the gin router accepts on requestPath. Only the probe paths declare
|
||||
// a non-empty list so NoMethod can advertise a useful `Allow` header
|
||||
// on `/healthz` and `/readyz`. Other endpoints fall through to NoRoute.
|
||||
func allowedMethodsForPath(requestPath string) string {
|
||||
switch requestPath {
|
||||
case "/healthz", "/readyz":
|
||||
return http.MethodGet
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Package server is documented in router.go. server.go owns the HTTP listener
|
||||
// lifecycle: it binds the configured TCP listener, serves the supplied
|
||||
// http.Handler, and shuts down within the configured budget.
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/config"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Server owns the HTTP listener exposed by the backend.
|
||||
type Server struct {
|
||||
cfg config.HTTPConfig
|
||||
handler http.Handler
|
||||
logger *zap.Logger
|
||||
|
||||
stateMu sync.RWMutex
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
// NewServer constructs an HTTP server bound to cfg. handler is the prebuilt
|
||||
// http.Handler returned by NewRouter. A nil logger is replaced with zap.NewNop.
|
||||
func NewServer(cfg config.HTTPConfig, handler http.Handler, logger *zap.Logger) *Server {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
if handler == nil {
|
||||
handler = http.NotFoundHandler()
|
||||
}
|
||||
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
handler: handler,
|
||||
logger: logger.Named("http"),
|
||||
}
|
||||
}
|
||||
|
||||
// Run binds the listener and serves requests until Shutdown closes the server.
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("run backend HTTP server: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", s.cfg.Addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("run backend HTTP server: listen on %q: %w", s.cfg.Addr, err)
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Handler: s.handler,
|
||||
ReadTimeout: s.cfg.ReadTimeout,
|
||||
WriteTimeout: s.cfg.WriteTimeout,
|
||||
IdleTimeout: s.cfg.ReadTimeout,
|
||||
}
|
||||
|
||||
s.stateMu.Lock()
|
||||
s.server = server
|
||||
s.listener = listener
|
||||
s.stateMu.Unlock()
|
||||
|
||||
s.logger.Info("backend HTTP server started", zap.String("addr", listener.Addr().String()))
|
||||
|
||||
defer func() {
|
||||
s.stateMu.Lock()
|
||||
s.server = nil
|
||||
s.listener = nil
|
||||
s.stateMu.Unlock()
|
||||
}()
|
||||
|
||||
err = server.Serve(listener)
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, http.ErrServerClosed):
|
||||
s.logger.Info("backend HTTP server stopped")
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("run backend HTTP server: serve on %q: %w", s.cfg.Addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the HTTP server within ctx, applying the
|
||||
// configured per-listener shutdown timeout when it is shorter.
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("shutdown backend HTTP server: nil context")
|
||||
}
|
||||
|
||||
s.stateMu.RLock()
|
||||
server := s.server
|
||||
s.stateMu.RUnlock()
|
||||
|
||||
if server == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := boundedContext(ctx, s.cfg.ShutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := server.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("shutdown backend HTTP server: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boundedContext(parent context.Context, limit time.Duration) (context.Context, context.CancelFunc) {
|
||||
if limit <= 0 {
|
||||
return context.WithCancel(parent)
|
||||
}
|
||||
return context.WithTimeout(parent, limit)
|
||||
}
|
||||
Reference in New Issue
Block a user