// SPDX-License-Identifier: EUPL-1.2 package api import ( "compress/gzip" "log/slog" "net/http" "time" "github.com/gin-contrib/cors" gingzip "github.com/gin-contrib/gzip" "github.com/gin-contrib/secure" ginslog "github.com/gin-contrib/slog" "github.com/gin-contrib/static" "github.com/gin-contrib/timeout" "github.com/gin-gonic/gin" ) // Option configures an Engine during construction. type Option func(*Engine) // WithAddr sets the listen address for the server. func WithAddr(addr string) Option { return func(e *Engine) { e.addr = addr } } // WithBearerAuth adds bearer token authentication middleware. // Requests to /health and paths starting with /swagger are exempt. func WithBearerAuth(token string) Option { return func(e *Engine) { skip := []string{"/health", "/swagger"} e.middlewares = append(e.middlewares, bearerAuthMiddleware(token, skip)) } } // WithRequestID adds middleware that assigns an X-Request-ID to every response. // Client-provided IDs are preserved; otherwise a random hex ID is generated. func WithRequestID() Option { return func(e *Engine) { e.middlewares = append(e.middlewares, requestIDMiddleware()) } } // WithCORS configures Cross-Origin Resource Sharing via gin-contrib/cors. // Pass "*" to allow all origins, or supply specific origin URLs. // Standard methods (GET, POST, PUT, PATCH, DELETE, OPTIONS) and common // headers (Authorization, Content-Type, X-Request-ID) are permitted. func WithCORS(allowOrigins ...string) Option { return func(e *Engine) { cfg := cors.Config{ AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, AllowHeaders: []string{"Authorization", "Content-Type", "X-Request-ID"}, MaxAge: 12 * time.Hour, } for _, o := range allowOrigins { if o == "*" { cfg.AllowAllOrigins = true break } } if !cfg.AllowAllOrigins { cfg.AllowOrigins = allowOrigins } e.middlewares = append(e.middlewares, cors.New(cfg)) } } // WithMiddleware appends arbitrary Gin middleware to the engine. func WithMiddleware(mw ...gin.HandlerFunc) Option { return func(e *Engine) { e.middlewares = append(e.middlewares, mw...) } } // WithStatic serves static files from the given root directory at urlPrefix. // Directory listing is disabled; only individual files are served. // Internally this uses gin-contrib/static as Gin middleware. func WithStatic(urlPrefix, root string) Option { return func(e *Engine) { e.middlewares = append(e.middlewares, static.Serve(urlPrefix, static.LocalFile(root, false))) } } // WithWSHandler registers a WebSocket handler at GET /ws. // Typically this wraps a go-ws Hub.Handler(). func WithWSHandler(h http.Handler) Option { return func(e *Engine) { e.wsHandler = h } } // WithAuthentik adds Authentik forward-auth middleware that extracts user // identity from X-authentik-* headers set by a trusted reverse proxy. // The middleware is permissive: unauthenticated requests are allowed through. func WithAuthentik(cfg AuthentikConfig) Option { return func(e *Engine) { e.middlewares = append(e.middlewares, authentikMiddleware(cfg)) } } // WithSwagger enables the Swagger UI at /swagger/. // The title, description, and version populate the OpenAPI info block. func WithSwagger(title, description, version string) Option { return func(e *Engine) { e.swaggerTitle = title e.swaggerDesc = description e.swaggerVersion = version e.swaggerEnabled = true } } // WithSecure adds security headers middleware via gin-contrib/secure. // Default policy sets HSTS (1 year, includeSubDomains), X-Frame-Options DENY, // X-Content-Type-Options nosniff, and Referrer-Policy strict-origin-when-cross-origin. // SSL redirect is not enabled so the middleware works behind a reverse proxy // that terminates TLS. func WithSecure() Option { return func(e *Engine) { e.middlewares = append(e.middlewares, secure.New(secure.Config{ STSSeconds: 31536000, STSIncludeSubdomains: true, FrameDeny: true, ContentTypeNosniff: true, ReferrerPolicy: "strict-origin-when-cross-origin", IsDevelopment: false, })) } } // WithGzip adds gzip response compression middleware via gin-contrib/gzip. // An optional compression level may be supplied (e.g. gzip.BestSpeed, // gzip.BestCompression). If omitted, gzip.DefaultCompression is used. func WithGzip(level ...int) Option { return func(e *Engine) { l := gzip.DefaultCompression if len(level) > 0 { l = level[0] } e.middlewares = append(e.middlewares, gingzip.Gzip(l)) } } // WithBrotli adds Brotli response compression middleware using andybalholm/brotli. // An optional compression level may be supplied (e.g. BrotliBestSpeed, // BrotliBestCompression). If omitted, BrotliDefaultCompression is used. func WithBrotli(level ...int) Option { return func(e *Engine) { l := BrotliDefaultCompression if len(level) > 0 { l = level[0] } e.middlewares = append(e.middlewares, newBrotliHandler(l).Handle) } } // WithSlog adds structured request logging middleware via gin-contrib/slog. // Each request is logged with method, path, status code, latency, and client IP. // If logger is nil, slog.Default() is used. func WithSlog(logger *slog.Logger) Option { return func(e *Engine) { if logger == nil { logger = slog.Default() } e.middlewares = append(e.middlewares, ginslog.SetLogger( ginslog.WithLogger(func(_ *gin.Context, l *slog.Logger) *slog.Logger { return logger }), )) } } // WithTimeout adds per-request timeout middleware via gin-contrib/timeout. // If a handler exceeds the given duration, the request is aborted with a // 504 Gateway Timeout carrying the standard error envelope: // // {"success":false,"error":{"code":"timeout","message":"Request timed out"}} // // A zero or negative duration effectively disables the timeout (the handler // runs without a deadline) — this is safe and will not panic. func WithTimeout(d time.Duration) Option { return func(e *Engine) { e.middlewares = append(e.middlewares, timeout.New( timeout.WithTimeout(d), timeout.WithResponse(timeoutResponse), )) } } // timeoutResponse writes a 504 Gateway Timeout with the standard error envelope. func timeoutResponse(c *gin.Context) { c.JSON(http.StatusGatewayTimeout, Fail("timeout", "Request timed out")) }