forked from aegis/pyserveX
396 lines
9.0 KiB
Go
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
|
|
}
|