package httpapi import ( "context" "crypto/rand" "encoding/binary" "errors" "fmt" "net/http" "sync/atomic" "time" "github.com/gin-gonic/gin" ) type DatabasePinger interface { PingContext(context.Context) error } type EventLogger func(string) type RouteRegistrar func(gin.IRoutes) error type RouterDependencies struct { Database DatabasePinger RegisterPublicRoutes RouteRegistrar RegisterAdminRoutes RouteRegistrar AdminSessions AdminAuthenticator LogEvent EventLogger } type AdminWeb interface { RegisterProtected(gin.IRoutes) } func NewAdminRouteRegistrar( services AdminServices, web AdminWeb, ) (RouteRegistrar, error) { if err := services.validate(); err != nil { return nil, err } if web == nil { return nil, errors.New("admin web handler is required") } return func(routes gin.IRoutes) error { if err := registerAdminAPI(routes, services); err != nil { return err } web.RegisterProtected(routes) return nil }, nil } func NewRouter(dependencies RouterDependencies) (http.Handler, error) { if dependencies.Database == nil { return nil, errors.New("database pinger is required") } if dependencies.RegisterAdminRoutes == nil { return nil, errors.New("admin route registrar is required") } if dependencies.RegisterPublicRoutes == nil { return nil, errors.New("public route registrar is required") } if dependencies.AdminSessions == nil { return nil, errors.New("admin authenticator is required") } if dependencies.LogEvent == nil { return nil, errors.New("event logger is required") } gin.SetMode(gin.ReleaseMode) router := gin.New() router.Use(requestIDMiddleware()) router.Use(safeRecovery(dependencies.LogEvent)) router.HandleMethodNotAllowed = true if err := router.SetTrustedProxies(nil); err != nil { return nil, err } router.GET("/healthz", healthHandler(dependencies.Database)) if err := dependencies.RegisterPublicRoutes(router); err != nil { return nil, err } adminRoutes := router.Group("") adminRoutes.Use(requireAdminSession(dependencies.AdminSessions)) if err := dependencies.RegisterAdminRoutes(adminRoutes); err != nil { return nil, err } router.NoRoute(func(ctx *gin.Context) { ctx.JSON(http.StatusNotFound, errorResponse( ctx, "NOT_FOUND", "resource not found", )) }) router.NoMethod(func(ctx *gin.Context) { ctx.JSON(http.StatusMethodNotAllowed, errorResponse( ctx, "METHOD_NOT_ALLOWED", "method not allowed", )) }) return router, nil } func safeRecovery(logEvent EventLogger) gin.HandlerFunc { return func(ctx *gin.Context) { defer func() { if recover() != nil { logEvent("HTTP handler panic recovered") ctx.AbortWithStatusJSON( http.StatusInternalServerError, errorResponse( ctx, "INTERNAL_ERROR", "internal server error", ), ) } }() ctx.Next() } } func requestIDMiddleware() gin.HandlerFunc { return func(ctx *gin.Context) { requestID := newRequestID() ctx.Set(requestIDContextKey, requestID) ctx.Header(requestIDHeader, requestID) ctx.Next() } } func healthHandler(database DatabasePinger) gin.HandlerFunc { return func(ctx *gin.Context) { pingContext, cancel := context.WithTimeout(ctx.Request.Context(), time.Second) defer cancel() ctx.Header("Cache-Control", "no-store") if err := database.PingContext(pingContext); err != nil { ctx.JSON(http.StatusServiceUnavailable, gin.H{ "status": "unavailable", }) return } ctx.JSON(http.StatusOK, gin.H{"status": "ok"}) } } func errorResponse(ctx *gin.Context, code, message string) gin.H { requestID, _ := ctx.Get(requestIDContextKey) return gin.H{ "error": gin.H{ "code": code, "message": message, "retryable": false, "details": gin.H{}, }, "request_id": requestID, } } func newRequestID() string { var value [16]byte if _, err := rand.Read(value[:]); err != nil { now := uint64(time.Now().UnixNano()) binary.BigEndian.PutUint64(value[:8], now) binary.BigEndian.PutUint64(value[8:], fallbackRequestID.Add(1)) } value[6] = (value[6] & 0x0f) | 0x40 value[8] = (value[8] & 0x3f) | 0x80 return fmt.Sprintf( "%08x-%04x-%04x-%04x-%012x", value[0:4], value[4:6], value[6:8], value[8:10], value[10:16], ) } const ( requestIDContextKey = "request_id" requestIDHeader = "X-Request-ID" ) var fallbackRequestID atomic.Uint64