163 lines
4.2 KiB
Go
163 lines
4.2 KiB
Go
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()
|
|
}
|
|
}
|