Files
cmroubao/backend-api/internal/transport/httpapi/router.go
T

188 lines
4.3 KiB
Go

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