// 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 UserMail *UserMailHandlers UserSessions *UserSessionsHandlers 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.UserMail == nil { deps.UserMail = NewUserMailHandlers(nil, deps.Logger) } if deps.UserSessions == nil { deps.UserSessions = NewUserSessionsHandlers(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()) lobbyMail := lobbyGroup.Group("/mail") lobbyMail.GET("/unread-counts", deps.UserMail.UnreadCounts()) 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/orders", deps.UserGames.GetOrders()) userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report()) userGames.GET("/:game_id/battles/:turn/:battle_id", deps.UserGames.Battle()) userMail := userGames.Group("/:game_id/mail") userMail.POST("/messages", deps.UserMail.SendPersonal()) userMail.GET("/messages/:message_id", deps.UserMail.Get()) userMail.POST("/messages/:message_id/read", deps.UserMail.MarkRead()) userMail.DELETE("/messages/:message_id", deps.UserMail.Delete()) userMail.GET("/inbox", deps.UserMail.Inbox()) userMail.GET("/sent", deps.UserMail.Sent()) userSessions := group.Group("/sessions") userSessions.GET("", deps.UserSessions.List()) userSessions.POST("/revoke-all", deps.UserSessions.RevokeAll()) userSessions.POST("/:device_session_id/revoke", deps.UserSessions.Revoke()) } 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.GET("/:device_session_id", deps.InternalSessions.Get()) 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 "" } }