apigateway / services /base_service /route_matcher.py
jebin2's picture
refactor
bcc8074
"""
Route Matcher - URL pattern matching for service configuration.
Provides flexible URL matching capabilities for services to define
which routes require auth, credits, etc.
Supported patterns:
- Exact match: "/api/users"
- Prefix match: "/api/*" (matches /api/anything)
- Wildcard match: "/api/users/*/posts" (matches /api/users/123/posts)
- Deep wildcard: "/api/**" (matches /api/users/123/posts/456)
- Regex match: "^/api/v[0-9]+/.*$"
Usage:
matcher = RouteMatcher(["/api/*", "/admin/**"])
if matcher.matches("/api/users"):
# Route requires auth
pass
"""
import re
import logging
from typing import List, Set, Optional, Pattern
from fnmatch import fnmatch
logger = logging.getLogger(__name__)
class RouteMatcher:
"""
Flexible URL pattern matcher for route configuration.
Supports exact matches, glob patterns, and regex patterns.
"""
def __init__(self, patterns: List[str]):
"""
Initialize route matcher with patterns.
Args:
patterns: List of URL patterns to match
"""
self.patterns = patterns
self._exact_matches: Set[str] = set()
self._prefix_patterns: List[str] = []
self._glob_patterns: List[str] = []
self._regex_patterns: List[Pattern] = []
# Classify patterns for performance
self._classify_patterns()
def _classify_patterns(self) -> None:
"""
Classify patterns by type for optimal matching performance.
Order of matching:
1. Exact matches (fastest - O(1))
2. Prefix patterns (fast - string startswith)
3. Glob patterns (medium - fnmatch)
4. Regex patterns (slowest - regex matching)
"""
for pattern in self.patterns:
# Empty pattern
if not pattern:
continue
# Regex pattern (starts with ^)
if pattern.startswith("^"):
try:
compiled = re.compile(pattern)
self._regex_patterns.append(compiled)
logger.debug(f"Classified as regex: {pattern}")
except re.error as e:
logger.warning(f"Invalid regex pattern '{pattern}': {e}")
continue
# Glob pattern (contains * or ?)
if "*" in pattern or "?" in pattern:
# Simple prefix wildcard: /api/*
if pattern.endswith("/*") and "*" not in pattern[:-2]:
prefix = pattern[:-2] # Remove /*
self._prefix_patterns.append(prefix)
logger.debug(f"Classified as prefix: {prefix}")
else:
# Complex glob: /api/*/users or /api/**
self._glob_patterns.append(pattern)
logger.debug(f"Classified as glob: {pattern}")
continue
# Exact match
self._exact_matches.add(pattern)
logger.debug(f"Classified as exact: {pattern}")
def matches(self, path: str) -> bool:
"""
Check if a URL path matches any configured pattern.
Args:
path: URL path to check (e.g., "/api/users/123")
Returns:
True if path matches any pattern, False otherwise
"""
# Strip query parameters and fragments
path = path.split("?")[0].split("#")[0]
# Normalize path (remove trailing slash unless it's just "/")
if path != "/" and path.endswith("/"):
path = path.rstrip("/")
# 1. Exact match (O(1))
if path in self._exact_matches:
return True
# 2. Prefix match (O(n) but fast)
for prefix in self._prefix_patterns:
if path.startswith(prefix + "/") or path == prefix:
return True
# 3. Glob match (O(n))
for pattern in self._glob_patterns:
if fnmatch(path, pattern):
return True
# 4. Regex match (O(n) but slower)
for regex in self._regex_patterns:
if regex.match(path):
return True
return False
def get_matching_pattern(self, path: str) -> Optional[str]:
"""
Get the first pattern that matches the given path.
Useful for debugging or determining which rule matched.
Args:
path: URL path to check
Returns:
Matching pattern string or None
"""
# Strip query parameters and fragments
path = path.split("?")[0].split("#")[0]
# Normalize path
if path != "/" and path.endswith("/"):
path = path.rstrip("/")
# Exact match
if path in self._exact_matches:
return path
# Prefix match
for prefix in self._prefix_patterns:
if path.startswith(prefix + "/") or path == prefix:
return prefix + "/*"
# Glob match
for pattern in self._glob_patterns:
if fnmatch(path, pattern):
return pattern
# Regex match
for regex in self._regex_patterns:
if regex.match(path):
return regex.pattern
return None
def __repr__(self) -> str:
"""String representation for debugging."""
return (
f"RouteMatcher("
f"exact={len(self._exact_matches)}, "
f"prefix={len(self._prefix_patterns)}, "
f"glob={len(self._glob_patterns)}, "
f"regex={len(self._regex_patterns)})"
)
class RouteConfig:
"""
Route configuration helper for services.
Manages multiple route lists (required, optional, public) with
precedence and exclusion logic.
"""
def __init__(
self,
required: List[str] = None,
optional: List[str] = None,
public: List[str] = None,
):
"""
Initialize route configuration.
Args:
required: Routes that REQUIRE the service (e.g., auth required)
optional: Routes where service is OPTIONAL (e.g., auth optional)
public: Routes that are PUBLIC (e.g., no auth needed)
Precedence: public > required > optional (for conflict resolution)
"""
self.required_matcher = RouteMatcher(required or [])
self.optional_matcher = RouteMatcher(optional or [])
self.public_matcher = RouteMatcher(public or [])
def is_required(self, path: str) -> bool:
"""
Check if service is REQUIRED for this path.
Returns False if path is public (public takes precedence).
"""
if self.is_public(path):
return False
return self.required_matcher.matches(path)
def is_optional(self, path: str) -> bool:
"""
Check if service is OPTIONAL for this path.
Returns False if path is public or required.
"""
if self.is_public(path):
return False
if self.required_matcher.matches(path):
return False
return self.optional_matcher.matches(path)
def is_public(self, path: str) -> bool:
"""
Check if path is PUBLIC (service not needed).
Public takes highest precedence.
"""
return self.public_matcher.matches(path)
def requires_service(self, path: str) -> bool:
"""
Check if service is needed (required OR optional) for this path.
Returns False if path is not matched by any configuration.
"""
return self.is_required(path) or self.is_optional(path)
__all__ = [
'RouteMatcher',
'RouteConfig',
]