kereru-forms/backend/internal/api/router.go

166 lines
4.3 KiB
Go
Raw Normal View History

package api
import (
"context"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/rs/zerolog"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
"kereru.forms/internal/logging"
)
// RouterOptions configures cross-cutting HTTP behaviour.
type RouterOptions struct {
CORSOrigins []string
ServiceName string
// StaticDir, when set, serves a built frontend with SPA fallback.
StaticDir string
}
// NewRouter builds the Gin engine with all middleware and API routes.
func NewRouter(server *Server, logger zerolog.Logger, opts RouterOptions) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
serviceName := opts.ServiceName
if serviceName == "" {
serviceName = "kereru-forms"
}
r := gin.New()
r.RedirectTrailingSlash = false
// Ensure *gin.Context behaves as the request context for derived values.
r.ContextWithFallback = true
r.Use(otelgin.Middleware(serviceName))
r.Use(gin.Recovery())
r.Use(requestIDMiddleware())
r.Use(withRequestMetadata())
r.Use(logging.GinMiddleware(logger))
r.Use(corsMiddleware(opts.CORSOrigins))
2026-10-23 19:36:02 +00:00
if server.auth != nil {
r.Use(authMiddleware(server.auth))
}
strict := NewStrictHandler(server, nil)
// The API is exposed at the root (for probes and direct clients) and under
// /api so the SPA can use a stable same-origin prefix in every deployment.
RegisterHandlers(r, strict)
RegisterHandlers(r.Group("/api"), strict)
if opts.StaticDir != "" {
serveStatic(r, opts.StaticDir, logger)
}
return r
}
var apiPrefixes = []string{"/health", "/forms", "/submissions", "/api"}
func isAPIPath(path string) bool {
for _, prefix := range apiPrefixes {
if path == prefix || strings.HasPrefix(path, prefix+"/") {
return true
}
}
return false
}
// serveStatic serves files from dir and falls back to index.html for client
// side routes so the SPA can be hosted by the backend binary.
func serveStatic(r *gin.Engine, dir string, logger zerolog.Logger) {
index := filepath.Join(dir, "index.html")
r.NoRoute(func(c *gin.Context) {
if isAPIPath(c.Request.URL.Path) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found", "code": "not_found"})
return
}
clean := filepath.Clean(strings.TrimPrefix(c.Request.URL.Path, "/"))
candidate := filepath.Join(dir, clean)
if clean != "." && !strings.HasPrefix(candidate, filepath.Clean(dir)) {
c.Status(http.StatusNotFound)
return
}
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
c.File(candidate)
return
}
if _, err := os.Stat(index); err != nil {
logger.Warn().Err(err).Str("dir", dir).Msg("static frontend not found")
c.Status(http.StatusNotFound)
return
}
c.File(index)
})
}
func requestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
id := strings.TrimSpace(c.GetHeader("X-Request-ID"))
if id == "" {
id = uuid.NewString()
}
c.Header("X-Request-ID", id)
logging.SetRequestID(c, id)
c.Next()
}
}
func withRequestMetadata() gin.HandlerFunc {
return func(c *gin.Context) {
meta := requestMetadata{}
if ip := strings.TrimSpace(c.ClientIP()); ip != "" {
meta.ip = &ip
}
if ua := strings.TrimSpace(c.Request.UserAgent()); ua != "" {
meta.userAgent = &ua
}
c.Set(metaKey, meta)
ctx := context.WithValue(c.Request.Context(), metaKey, meta)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
func corsMiddleware(origins []string) gin.HandlerFunc {
allowAll := false
allowed := make(map[string]struct{}, len(origins))
for _, o := range origins {
o = strings.TrimSpace(o)
if o == "*" {
allowAll = true
continue
}
if o != "" {
allowed[o] = struct{}{}
}
}
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin != "" {
if allowAll {
c.Header("Access-Control-Allow-Origin", "*")
} else if _, ok := allowed[origin]; ok {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Vary", "Origin")
}
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID")
c.Header("Access-Control-Expose-Headers", "X-Request-ID")
c.Header("Access-Control-Max-Age", "600")
}
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}