← Back to Tutorials
Python

Build a URL Router

Difficulty: Intermediate Est. Time: ~4 hours

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.

What You'll Build
  • A URL pattern matcher
  • HTTP method routing (GET, POST, PUT, DELETE)
  • Path parameter extraction
  • Query parameter parsing
  • Middleware chain support
  • Route grouping
What You'll Learn
  • 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