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)) 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() } }