feat: backend service
This commit is contained in:
@@ -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 ""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user