Илья Глазунов 8f5b9a5cd1 go implementation
2025-12-11 16:52:13 +03:00

396 lines
9.0 KiB
Go

// Package routing provides HTTP routing with regex support
package routing
import (
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"github.com/konduktor/konduktor/internal/config"
"github.com/konduktor/konduktor/internal/logging"
)
// RouteMatch represents a matched route with captured parameters
type RouteMatch struct {
Config map[string]interface{}
Params map[string]string
}
// RegexRoute represents a compiled regex route
type RegexRoute struct {
Pattern *regexp.Regexp
Config map[string]interface{}
CaseSensitive bool
OriginalExpr string
}
// Router handles HTTP routing with exact, regex, and default routes
type Router struct {
config *config.Config
logger *logging.Logger
mux *http.ServeMux
staticDir string
exactRoutes map[string]map[string]interface{}
regexRoutes []*RegexRoute
defaultRoute map[string]interface{}
mu sync.RWMutex
}
// New creates a new router from config
func New(cfg *config.Config, logger *logging.Logger) *Router {
staticDir := "./static"
if cfg != nil && cfg.HTTP.StaticDir != "" {
staticDir = cfg.HTTP.StaticDir
}
r := &Router{
config: cfg,
logger: logger,
mux: http.NewServeMux(),
staticDir: staticDir,
exactRoutes: make(map[string]map[string]interface{}),
regexRoutes: make([]*RegexRoute, 0),
}
r.setupRoutes()
return r
}
// NewRouter creates a router without config (for testing)
func NewRouter(opts ...RouterOption) *Router {
r := &Router{
mux: http.NewServeMux(),
staticDir: "./static",
exactRoutes: make(map[string]map[string]interface{}),
regexRoutes: make([]*RegexRoute, 0),
}
for _, opt := range opts {
opt(r)
}
return r
}
// RouterOption is a functional option for Router
type RouterOption func(*Router)
// WithStaticDir sets the static directory
func WithStaticDir(dir string) RouterOption {
return func(r *Router) {
r.staticDir = dir
}
}
// StaticDir returns the static directory path
func (r *Router) StaticDir() string {
return r.staticDir
}
// Routes returns the regex routes (for testing)
func (r *Router) Routes() []*RegexRoute {
r.mu.RLock()
defer r.mu.RUnlock()
return r.regexRoutes
}
// ExactRoutes returns the exact routes (for testing)
func (r *Router) ExactRoutes() map[string]map[string]interface{} {
r.mu.RLock()
defer r.mu.RUnlock()
return r.exactRoutes
}
// DefaultRoute returns the default route (for testing)
func (r *Router) DefaultRoute() map[string]interface{} {
r.mu.RLock()
defer r.mu.RUnlock()
return r.defaultRoute
}
// AddRoute adds a route with the given pattern and config
// Pattern formats:
// - "=/path" - exact match
// - "~regex" - case-sensitive regex
// - "~*regex" - case-insensitive regex
// - "__default__" - default/fallback route
func (r *Router) AddRoute(pattern string, routeConfig map[string]interface{}) {
r.mu.Lock()
defer r.mu.Unlock()
switch {
case pattern == "__default__":
r.defaultRoute = routeConfig
case strings.HasPrefix(pattern, "="):
// Exact match route
path := strings.TrimPrefix(pattern, "=")
r.exactRoutes[path] = routeConfig
case strings.HasPrefix(pattern, "~*"):
// Case-insensitive regex
expr := strings.TrimPrefix(pattern, "~*")
re, err := regexp.Compile("(?i)" + expr)
if err != nil {
if r.logger != nil {
r.logger.Error("Invalid regex pattern", "pattern", pattern, "error", err)
}
return
}
r.regexRoutes = append(r.regexRoutes, &RegexRoute{
Pattern: re,
Config: routeConfig,
CaseSensitive: false,
OriginalExpr: expr,
})
case strings.HasPrefix(pattern, "~"):
// Case-sensitive regex
expr := strings.TrimPrefix(pattern, "~")
re, err := regexp.Compile(expr)
if err != nil {
if r.logger != nil {
r.logger.Error("Invalid regex pattern", "pattern", pattern, "error", err)
}
return
}
r.regexRoutes = append(r.regexRoutes, &RegexRoute{
Pattern: re,
Config: routeConfig,
CaseSensitive: true,
OriginalExpr: expr,
})
}
}
// Match finds the best matching route for a path
// Priority: exact match > regex match > default
func (r *Router) Match(path string) *RouteMatch {
r.mu.RLock()
defer r.mu.RUnlock()
// 1. Check exact routes
if cfg, ok := r.exactRoutes[path]; ok {
return &RouteMatch{
Config: cfg,
Params: make(map[string]string),
}
}
// 2. Check regex routes
for _, route := range r.regexRoutes {
match := route.Pattern.FindStringSubmatch(path)
if match != nil {
params := make(map[string]string)
// Extract named groups
names := route.Pattern.SubexpNames()
for i, name := range names {
if i > 0 && name != "" && i < len(match) {
params[name] = match[i]
}
}
return &RouteMatch{
Config: route.Config,
Params: params,
}
}
}
// 3. Check default route
if r.defaultRoute != nil {
return &RouteMatch{
Config: r.defaultRoute,
Params: make(map[string]string),
}
}
return nil
}
// setupRoutes configures the routes from config
func (r *Router) setupRoutes() {
// Health check endpoint
r.mux.HandleFunc("/health", r.healthHandler)
// Setup redirect instructions from config
if r.config != nil {
for from, to := range r.config.Server.RedirectInstructions {
fromPath := from
toPath := to
r.mux.HandleFunc(fromPath, func(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, toPath, http.StatusMovedPermanently)
})
}
}
// Default handler for all other routes
r.mux.HandleFunc("/", r.defaultHandler)
}
// ServeHTTP implements http.Handler
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
r.mux.ServeHTTP(w, req)
}
// healthHandler handles health check requests
func (r *Router) healthHandler(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
// defaultHandler handles requests that don't match other routes
func (r *Router) defaultHandler(w http.ResponseWriter, req *http.Request) {
path := req.URL.Path
// Try to match against configured routes
match := r.Match(path)
if match != nil {
r.handleRouteMatch(w, req, match)
return
}
// Try to serve static file
if r.staticDir != "" {
filePath := filepath.Join(r.staticDir, path)
// Prevent directory traversal
if !strings.HasPrefix(filepath.Clean(filePath), filepath.Clean(r.staticDir)) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Check if file exists
info, err := os.Stat(filePath)
if err == nil {
if info.IsDir() {
// Try index.html
indexPath := filepath.Join(filePath, "index.html")
if _, err := os.Stat(indexPath); err == nil {
http.ServeFile(w, req, indexPath)
return
}
} else {
http.ServeFile(w, req, filePath)
return
}
}
}
// 404 Not Found
http.NotFound(w, req)
}
// handleRouteMatch handles a matched route
func (r *Router) handleRouteMatch(w http.ResponseWriter, req *http.Request, match *RouteMatch) {
cfg := match.Config
// Handle "return" directive
if ret, ok := cfg["return"].(string); ok {
parts := strings.SplitN(ret, " ", 2)
statusCode := 200
body := "OK"
if len(parts) >= 1 {
switch parts[0] {
case "200":
statusCode = 200
case "201":
statusCode = 201
case "301":
statusCode = 301
case "302":
statusCode = 302
case "400":
statusCode = 400
case "404":
statusCode = 404
case "500":
statusCode = 500
}
}
if len(parts) >= 2 {
body = parts[1]
}
if ct, ok := cfg["content_type"].(string); ok {
w.Header().Set("Content-Type", ct)
} else {
w.Header().Set("Content-Type", "text/plain")
}
w.WriteHeader(statusCode)
w.Write([]byte(body))
return
}
// Handle static files with root
if root, ok := cfg["root"].(string); ok {
path := req.URL.Path
if indexFile, ok := cfg["index_file"].(string); ok {
if path == "/" || strings.HasSuffix(path, "/") {
path = "/" + indexFile
}
}
filePath := filepath.Join(root, path)
if cacheControl, ok := cfg["cache_control"].(string); ok {
w.Header().Set("Cache-Control", cacheControl)
}
if headers, ok := cfg["headers"].([]interface{}); ok {
for _, h := range headers {
if header, ok := h.(string); ok {
parts := strings.SplitN(header, ": ", 2)
if len(parts) == 2 {
w.Header().Set(parts[0], parts[1])
}
}
}
}
http.ServeFile(w, req, filePath)
return
}
// Handle SPA fallback
if spaFallback, ok := cfg["spa_fallback"].(bool); ok && spaFallback {
root := r.staticDir
if rt, ok := cfg["root"].(string); ok {
root = rt
}
indexFile := "index.html"
if idx, ok := cfg["index_file"].(string); ok {
indexFile = idx
}
filePath := filepath.Join(root, indexFile)
http.ServeFile(w, req, filePath)
return
}
http.NotFound(w, req)
}
// CreateRouterFromConfig creates a router from extension config
func CreateRouterFromConfig(cfg map[string]interface{}) *Router {
router := NewRouter()
if locations, ok := cfg["regex_locations"].(map[string]interface{}); ok {
for pattern, routeCfg := range locations {
if rc, ok := routeCfg.(map[string]interface{}); ok {
router.AddRoute(pattern, rc)
}
}
}
return router
}