Build a URL Router
Introduction
URL routing is the foundation of every web framework. It maps incoming URLs to specific handlers, extracts parameters, and manages middleware. In this tutorial, we'll build a complete URL router from scratch.
Routers are essential for building REST APIs, web applications, and microservices. Understanding how routing works internally will make you a better developer.
- A URL pattern matcher
- HTTP method routing (GET, POST, PUT, DELETE)
- Path parameter extraction
- Query parameter parsing
- Middleware chain support
- Route grouping
- How URL routing works internally
- Regular expressions for route matching
- Tree-based routing (Radix tree)
- Middleware patterns
- HTTP method handling
Core Concepts
Route Patterns
Routes can be static or dynamic. Static routes match exact paths like /about, while dynamic routes capture variables like /users/:id or /posts/{slug}.
Route Matching
The router needs to match incoming URLs against registered patterns. We can use regular expressions or build a specialized data structure like a radix tree for efficiency.
HTTP Methods
RESTful routing uses HTTP methods to indicate action: GET for reading, POST for creating, PUT/PATCH for updating, and DELETE for removing resources.
Middleware
Middleware are functions that execute before the route handler. They can modify requests, check authentication, log activity, or terminate the request early.
Project Overview
Our router will support:
| Feature | Example |
|---|---|
| Static routes | /about, /contact |
| Path parameters | /users/:id, /posts/{slug} |
| Query strings | /search?q=keyword |
| HTTP methods | GET, POST, PUT, DELETE |
| Middleware | Logging, auth, validation |
Prerequisites
- Python 3.8+ - Installed on your system
- Basic Python knowledge - Functions, classes, decorators
- Understanding of HTTP - Request/response cycle
Route Matching
Create router.py - the main router implementation:
import re
from typing import Callable, Dict, List, Optional, Any
from dataclasses import dataclass
from urllib.parse import urlparse, parse_qs
@dataclass
class Request:
method: str
path: str
headers: Dict[str, str] = None
body: Any = None
params: Dict[str, Any] = None
query: Dict[str, List[str]] = None
@dataclass
class Response:
status_code: int = 200
body: Any = None
headers: Dict[str, str] = None
class Route:
def __init__(self, method: str, pattern: str, handler: Callable):
self.method = method
self.pattern = pattern
self.handler = handler
self.regex = self._compile_pattern(pattern)
self.param_names = self._extract_param_names(pattern)
self.middleware: List[Callable] = []
def _compile_pattern(self, pattern: str) -> re.Pattern:
regex_pattern = pattern
param_names = []
for match in re.finditer(r':(\w+)|\{(\w+)\}', pattern):
param = match.group(1) or match.group(2)
param_names.append(param)
regex_pattern = regex_pattern.replace(match.group(0), r'(?P<{}>[^/]+)'.format(param))
regex_pattern = '^' + regex_pattern + '$'
return re.compile(regex_pattern)
def _extract_param_names(self, pattern: str) -> List[str]:
names = []
for match in re.finditer(r':(\w+)|\{(\w+)\}', pattern):
names.append(match.group(1) or match.group(2))
return names
def match(self, path: str) -> Optional[Dict[str, str]]:
match = self.regex.match(path)
if match:
return match.groupdict()
return None
class Router:
def __init__(self):
self.routes: Dict[str, List[Route]] = {
'GET': [],
'POST': [],
'PUT': [],
'PATCH': [],
'DELETE': [],
}
self.middleware: List[Callable] = []
self.not_found_handler: Optional[Callable] = None
def add_route(self, method: str, path: str, handler: Callable):
route = Route(method, path, handler)
self.routes[method.upper()].append(route)
return handler
def get(self, path: str):
return self._add_method('GET', path)
def post(self, path: str):
return self._add_method('POST', path)
def put(self, path: str):
return self._add_method('PUT', path)
def delete(self, path: str):
return self._add_method('DELETE', path)
def _add_method(self, method: str, path: str):
def decorator(handler: Callable):
self.add_route(method, path, handler)
return handler
return decorator
def use(self, middleware: Callable):
self.middleware.append(middleware)
return middleware
def route(self, path: str):
def decorator(handler: Callable):
for method in ['GET', 'POST', 'PUT', 'DELETE']:
self.add_route(method, path, handler)
return handler
return decorator
def find_route(self, method: str, path: str) -> Optional[tuple]:
routes = self.routes.get(method.upper(), [])
for route in routes:
params = route.match(path)
if params is not None:
return (route, params)
return None
def handle_request(self, method: str, path: str, **kwargs) -> Response:
parsed_url = urlparse(path)
query_params = parse_qs(parsed_url.query)
result = self.find_route(method, parsed_url.path)
if not result:
if self.not_found_handler:
return self.not_found_handler(method, path)
return Response(status_code=404, body="Not Found")
route, params = result
request = Request(
method=method,
path=parsed_url.path,
params=params,
query=query_params,
headers=kwargs.get('headers', {}),
body=kwargs.get('body')
)
for mw in self.middleware:
result = mw(request)
if result:
return result
for mw in route.middleware:
result = mw(request)
if result:
return result
return route.handler(request)
def serve(self, port: int = 8080):
import http.server
from socketserver import TCPServer
class RequestHandler(http.server.BaseHTTPRequestHandler):
def do_method(self):
method = self.command
path = self.path
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length) if content_length > 0 else None
response = router.handle_request(
method, path,
headers=dict(self.headers),
body=body
)
self.send_response(response.status_code)
if response.headers:
for key, value in response.headers.items():
self.send_header(key, value)
self.end_headers()
if response.body:
self.wfile.write(str(response.body).encode())
def do_GET(self): self.do_method()
def do_POST(self): self.do_method()
def do_PUT(self): self.do_method()
def do_DELETE(self): self.do_method()
with TCPServer(('localhost', port), RequestHandler) as httpd:
print(f"Server running on port {port}")
httpd.serve_forever()
Parameter Extraction
The router automatically extracts path parameters and query strings:
# Example: Path Parameters
@router.get('/users/:id')
def get_user(request):
user_id = request.params['id'] # From /users/123
return Response(body=f"User ID: {user_id}")
@router.get('/posts/:year/:month/:slug')
def get_post(request):
year = request.params['year'] # From /posts/2024/01/my-post
month = request.params['month']
slug = request.params['slug']
return Response(body=f"Post: {year}/{month}/{slug}")
# Example: Query Parameters
@router.get('/search')
def search(request):
query = request.query.get('q', [''])[0] # From /search?q=keyword
page = request.query.get('page', ['1'])[0]
return Response(body=f"Searching for: {query}, page: {page}")
Request object provides easy access to all data:
@router.post('/api/data')
def handle_data(request):
# Access all request data
print(request.method) # POST
print(request.path) # /api/data
print(request.params) # {'id': '123'}
print(request.query) # {'page': ['1']}
print(request.headers) # {'Content-Type': 'application/json'}
print(request.body) # Request body
return Response(body={"status": "ok"})
Middleware Support
Add middleware for cross-cutting concerns:
# Logging Middleware
def logging_middleware(request):
print(f"{request.method} {request.path}")
return None # Continue to next middleware
# Authentication Middleware
def auth_middleware(request):
token = request.headers.get('Authorization')
if not token:
return Response(status_code=401, body="Unauthorized")
# Verify token...
return None # Continue to next middleware
# CORS Middleware
def cors_middleware(request):
# Just add headers and continue
return None
# Add middleware to router
router.use(logging_middleware)
router.use(auth_middleware)
# Route-specific middleware
@router.get('/protected/:resource_id')
def protected_handler(request):
return Response(body="Protected content")
# Or add to specific routes
route = router.add_route('GET', '/admin', admin_handler)
route.middleware.append(admin_middleware)
Route Grouping
Group related routes with common prefixes:
class RouteGroup:
def __init__(self, router: Router, prefix: str):
self.router = router
self.prefix = prefix
self.middleware: List[Callable] = []
def route(self, path: str):
full_path = self.prefix + path
def decorator(handler: Callable):
self.router.add_route('GET', full_path, handler)
return handler
return decorator
def get(self, path: str):
return self.route('GET', path)
def post(self, path: str):
return self._add_method('POST', path)
def _add_method(self, method: str, path: str):
full_path = self.prefix + path
def decorator(handler: Callable):
route = Route(method, full_path, handler)
route.middleware.extend(self.middleware)
self.router.routes[method].append(route)
return handler
return decorator
def use(self, middleware: Callable):
self.middleware.append(middleware)
return middleware
# Usage
api = RouteGroup(router, '/api/v1')
@api.get('/users')
def list_users(request):
return Response(body=[])
@api.post('/users')
def create_user(request):
return Response(body={"id": 1})
@api.get('/users/:id')
def get_user(request):
return Response(body={"id": request.params['id']})
# Group with middleware
admin = RouteGroup(router, '/admin')
admin.use(auth_middleware)
@admin.get('/dashboard')
def admin_dashboard(request):
return Response(body="Admin Dashboard")
Testing the Router
Create a complete example application:
# app.py
from router import Router, Response
router = Router()
# Simple routes
@router.get('/')
def home(request):
return Response(body="Welcome to the API!")
@router.get('/about')
def about(request):
return Response(body="About Us Page")
# Dynamic routes
@router.get('/users/:user_id')
def get_user(request):
user_id = request.params['user_id']
return Response(body=f"User: {user_id}")
@router.get('/posts/:category/:slug')
def get_post(request):
return Response(body=f"Category: {request.params['category']}, Post: {request.params['slug']}")
# Query parameters
@router.get('/search')
def search(request):
query = request.query.get('q', [''])[0]
return Response(body=f"Search results for: {query}")
# RESTful API
@router.get('/api/posts')
def list_posts(request):
return Response(body=[
{"id": 1, "title": "Hello World"},
{"id": 2, "title": "Second Post"}
])
@router.post('/api/posts')
def create_post(request):
return Response(body={"status": "created", "id": 123})
@router.get('/api/posts/:id')
def get_post(request):
return Response(body={"id": request.params['id'], "title": "Sample Post"})
@router.put('/api/posts/:id')
def update_post(request):
return Response(body={"status": "updated", "id": request.params['id']})
@router.delete('/api/posts/:id')
def delete_post(request):
return Response(body={"status": "deleted", "id": request.params['id']})
# Run the server
if __name__ == '__main__':
router.serve(8080)
Test with curl:
curl http://localhost:8080/
curl http://localhost:8080/users/123
curl "http://localhost:8080/search?q=test"
curl http://localhost:8080/api/posts
curl -X POST http://localhost:8080/api/posts -d '{"title": "New Post"}'
Summary
Congratulations! You've built a complete URL router. Here's what you learned:
- Route Matching - How to match URLs against patterns using regex
- Parameter Extraction - How to extract path and query parameters
- HTTP Methods - How to handle different HTTP methods
- Middleware - How to add cross-cutting concerns
- Route Grouping - How to organize routes with prefixes
Possible Extensions
- Add route caching for performance
- Implement rate limiting middleware
- Add request validation
- Implement response serialization
- Add WebSocket support