// 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 }