Build a Mini-FastAPI from Scratch: Learn ASGI & Routing Internals
Leapcell

Leapcell @leapcell

About: leapcell.io: serverless web hosting / async task / redis

Location:
California
Joined:
Jul 31, 2024

Build a Mini-FastAPI from Scratch: Learn ASGI & Routing Internals

Publish Date: Aug 6
0 0

Leapcell: The Best of Serverless Web Hosting

Building a Simplified FastAPI from Scratch: Understanding ASGI and Core Routing

Introduction: Why Reinvent This Wheel?

When we talk about Python asynchronous web frameworks, FastAPI is undoubtedly the brightest star in recent years. It has gained widespread acclaim for its impressive performance, automatic API documentation generation, and type hint support. But have you ever wondered: what magic lies behind this powerful framework?

Today, we'll build a simplified version of FastAPI from scratch, focusing on understanding two core concepts: the ASGI protocol and the routing system. By constructing it with our own hands, you'll grasp the working principles of modern asynchronous web frameworks. This won't just help you use FastAPI better—it'll enable you to quickly identify the root cause when problems arise.

What is ASGI? Why is it More Advanced than WSGI?

Before we start coding, we need to understand ASGI (Asynchronous Server Gateway Interface)—the foundation that allows FastAPI to achieve high-performance asynchronous processing.

Limitations of WSGI

If you've used Django or Flask, you've probably heard of WSGI (Web Server Gateway Interface). WSGI is a synchronous interface specification between Python web applications and servers, but it has obvious flaws:

  • Can only handle one request at a time, no concurrency
  • Doesn't support long-lived connections (like WebSocket)
  • Can't fully leverage the advantages of asynchronous I/O

Advantages of ASGI

ASGI was created to solve these problems:

  • Fully asynchronous, supporting concurrent processing of multiple requests
  • Compatible with WebSocket and HTTP/2
  • Allows middleware to work in asynchronous environments
  • Supports asynchronous events throughout the request lifecycle

Simply put, ASGI defines a standard interface that allows asynchronous web applications to communicate with servers (like Uvicorn). Next, we'll implement a minimalist ASGI server.

Step 1: Implement a Basic ASGI Server

An ASGI application is essentially a callable object (function or class) that receives three parameters: scope, receive, and send.

# asgi_server.py
import socket
import asyncio
import json
from typing import Callable, Awaitable, Dict, Any

# ASGI application type definition
ASGIApp = Callable[[Dict[str, Any], Callable[[], Awaitable[Dict]]], Awaitable[None]]

class ASGIServer:
    def __init__(self, host: str = "127.0.0.1", port: int = 8000):
        self.host = host
        self.port = port
        self.app: ASGIApp = self.default_app  # Default application

    async def default_app(self, scope: Dict[str, Any], receive: Callable, send: Callable):
        """Default application: returns 404 response"""
        if scope["type"] == "http":
            await send({
                "type": "http.response.start",
                "status": 404,
                "headers": [(b"content-type", b"text/plain")]
            })
            await send({
                "type": "http.response.body",
                "body": b"Not Found"
            })

    async def handle_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
        """Handles new connections, parses HTTP requests and passes to ASGI application"""
        data = await reader.read(1024)
        request = data.decode().split("\r\n")
        method, path, _ = request[0].split()

        # Build ASGI scope
        scope = {
            "type": "http",
            "method": method,
            "path": path,
            "headers": []
        }

        # Parse request headers
        for line in request[1:]:
            if line == "":
                break
            key, value = line.split(":", 1)
            scope["headers"].append((key.strip().lower().encode(), value.strip().encode()))

        # Define receive and send methods
        async def receive() -> Dict:
            """Simulates receiving messages (simplified version)"""
            return {"type": "http.request", "body": b""}

        async def send(message: Dict):
            """Sends response to client"""
            if message["type"] == "http.response.start":
                status = message["status"]
                status_line = f"HTTP/1.1 {status} OK\r\n"
                headers = "".join([f"{k.decode()}: {v.decode()}\r\n" for k, v in message["headers"]])
                writer.write(f"{status_line}{headers}\r\n".encode())

            if message["type"] == "http.response.body":
                writer.write(message["body"])
                await writer.drain()
                writer.close()

        # Call ASGI application
        await self.app(scope, receive, send)

    async def run(self):
        """Starts the server"""
        server = await asyncio.start_server(
            self.handle_connection, self.host, self.port
        )
        print(f"Server running on http://{self.host}:{self.port}")
        async with server:
            await server.serve_forever()

# Run the server
if __name__ == "__main__":
    server = ASGIServer()
    asyncio.run(server.run())
Enter fullscreen mode Exit fullscreen mode

This simplified ASGI server can handle basic HTTP requests and return responses. Test it out: after running the script, visit http://127.0.0.1:8000 and you'll see "Not Found" because we haven't defined any routes yet.

Step 2: Implement the Routing System

One of FastAPI's most intuitive features is its elegant route definition, like:

@app.get("/items/{item_id}")
async def read_item(item_id: int, q: str = None):
    return {"item_id": item_id, "q": q}
Enter fullscreen mode Exit fullscreen mode

Let's implement similar routing functionality.

Routing Core Component Design

We need three core components:

  • Router: Manages all routing rules
  • Decorators: @get, @post, etc., for registering routes
  • Path matching: Handles dynamic path parameters (like /items/{item_id})
# router.py
from typing import Callable, Awaitable, Dict, Any, List, Tuple, Pattern
import re
from functools import wraps

# Route type definition
RouteHandler = Callable[[Dict[str, Any]], Awaitable[Dict[str, Any]]]

class Route:
    def __init__(self, path: str, methods: List[str], handler: RouteHandler):
        self.path = path
        self.methods = [m.upper() for m in methods]
        self.handler = handler
        self.path_pattern, self.param_names = self.compile_path(path)

    def compile_path(self, path: str) -> Tuple[Pattern, List[str]]:
        """Converts path to regular expression and extracts parameter names"""
        param_names = []
        pattern = re.sub(r"{(\w+)}", lambda m: (param_names.append(m.group(1)), r"(\w+)")[1], path)
        return re.compile(f"^{pattern}$"), param_names

    def match(self, path: str, method: str) -> Tuple[bool, Dict[str, Any]]:
        """Matches path and method, returns parameters"""
        if method not in self.methods:
            return False, {}

        match = self.path_pattern.match(path)
        if not match:
            return False, {}

        params = dict(zip(self.param_names, match.groups()))
        return True, params

class Router:
    def __init__(self):
        self.routes: List[Route] = []

    def add_route(self, path: str, methods: List[str], handler: RouteHandler):
        """Adds a route"""
        self.routes.append(Route(path, methods, handler))

    def route(self, path: str, methods: List[str]):
        """Route decorator"""
        def decorator(handler: RouteHandler):
            self.add_route(path, methods, handler)
            @wraps(handler)
            async def wrapper(*args, **kwargs):
                return await handler(*args, **kwargs)
            return wrapper
        return decorator

    # Shortcut methods
    def get(self, path: str):
        return self.route(path, ["GET"])

    def post(self, path: str):
        return self.route(path, ["POST"])

    async def handle(self, scope: Dict[str, Any], receive: Callable) -> Dict[str, Any]:
        """Handles requests, finds matching route and executes it"""
        path = scope["path"]
        method = scope["method"]

        for route in self.routes:
            matched, params = route.match(path, method)
            if matched:
                # Parse query parameters
                query_params = self.parse_query_params(scope)
                # Merge path parameters and query parameters
                request_data = {** params, **query_params}
                # Call handler function
                return await route.handler(request_data)

        # No route found
        return {"status": 404, "body": {"detail": "Not Found"}}

    def parse_query_params(self, scope: Dict[str, Any]) -> Dict[str, Any]:
        """Parses query parameters (simplified version)"""
        # In actual ASGI, query parameters are in scope["query_string"]
        query_string = scope.get("query_string", b"").decode()
        params = {}
        if query_string:
            for pair in query_string.split("&"):
                if "=" in pair:
                    key, value = pair.split("=", 1)
                    params[key] = value
        return params
Enter fullscreen mode Exit fullscreen mode

Integrating Routing with the ASGI Server

Now we need to modify our ASGI server to use our routing system:

# Add routing support to ASGIServer class
class ASGIServer:
    def __init__(self, host: str = "127.0.0.1", port: int = 8000):
        self.host = host
        self.port = port
        self.router = Router()  # Instantiate router
        self.app = self.asgi_app  # Use routing-enabled ASGI application

    async def asgi_app(self, scope: Dict[str, Any], receive: Callable, send: Callable):
        """ASGI application with routing functionality"""
        if scope["type"] == "http":
            # Handle request
            response = await self.router.handle(scope, receive)
            status = response.get("status", 200)
            body = json.dumps(response.get("body", {})).encode()

            # Send response
            await send({
                "type": "http.response.start",
                "status": status,
                "headers": [(b"content-type", b"application/json")]
            })
            await send({
                "type": "http.response.body",
                "body": body
            })
Enter fullscreen mode Exit fullscreen mode

Step 3: Implement Parameter Parsing and Type Conversion

One of FastAPI's highlights is its automatic parameter parsing and type conversion. Let's implement this feature:

# Add type conversion to Router's handle method
async def handle(self, scope: Dict[str, Any], receive: Callable) -> Dict[str, Any]:
    # ... previous code ...

    if matched:
        # Parse query parameters
        query_params = self.parse_query_params(scope)
        # Merge path parameters and query parameters
        raw_data = {** params, **query_params}

        # Get parameter type annotations from handler function
        handler_params = route.handler.__annotations__

        # Type conversion
        request_data = {}
        for key, value in raw_data.items():
            if key in handler_params:
                target_type = handler_params[key]
                try:
                    # Attempt type conversion
                    request_data[key] = target_type(value)
                except (ValueError, TypeError):
                    return {
                        "status": 400,
                        "body": {"detail": f"Invalid type for {key}, expected {target_type}"}
                    }
            else:
                request_data[key] = value

        # Call handler function
        return await route.handler(request_data)
Enter fullscreen mode Exit fullscreen mode

Now our framework can automatically convert parameters to the types specified by the function annotations!

Step 4: Implement Request Body Parsing (POST Support)

Next, we'll add support for POST request bodies, enabling JSON data parsing:

# Add request body parsing to Router
async def handle(self, scope: Dict[str, Any], receive: Callable) -> Dict[str, Any]:
    # ... previous code ...

    # If it's a POST request, parse the request body
    request_body = {}
    if method == "POST":
        # Get request body from receive
        message = await receive()
        if message["type"] == "http.request" and "body" in message:
            try:
                request_body = json.loads(message["body"].decode())
            except json.JSONDecodeError:
                return {
                    "status": 400,
                    "body": {"detail": "Invalid JSON"}
                }

    # Merge all parameters
    raw_data = {** params, **query_params,** request_body}
    # ... type conversion and handler function call ...
Enter fullscreen mode Exit fullscreen mode

Step 5: Build a Complete Example Application

Now we can use our framework just like FastAPI:

# main.py
from asgi_server import ASGIServer
import asyncio

# Create server instance (includes router)
app = ASGIServer()
router = app.router

# Define routes
@router.get("/")
async def root():
    return {"message": "Hello, World!"}

@router.get("/items/{item_id}")
async def read_item(item_id: int, q: str = None):
    return {"item_id": item_id, "q": q}

@router.post("/items/")
async def create_item(name: str, price: float):
    return {"item": {"name": name, "price": price, "id": 42}}

# Run the application
if __name__ == "__main__":
    asyncio.run(app.run())
Enter fullscreen mode Exit fullscreen mode

Test this application:

Differences from FastAPI and Optimization Directions

Our simplified version implements FastAPI's core functionality, but the real FastAPI has many advanced features:

  • Dependency injection system: FastAPI's dependency injection is very powerful, supporting nested dependencies, global dependencies, etc.
  • Automatic documentation: FastAPI can automatically generate Swagger and ReDoc documentation
  • More data type support: Including Pydantic model validation, form data, file uploads, etc.
  • Middleware system: More complete middleware support
  • WebSocket support: Full implementation of ASGI's WebSocket specification
  • Asynchronous database tools: Deep integration with tools like SQLAlchemy

Summary: What Have We Learned?

Through this hands-on practice, we've understood:

  • The basic working principles of the ASGI protocol: the three elements of scope, receive, and send
  • The core of the routing system: path matching, parameter parsing, and handler function mapping
  • How type conversion is implemented: using function annotations for automatic conversion
  • The request handling process: the complete lifecycle from receiving a request to returning a response

This knowledge applies not only to FastAPI but also to all ASGI frameworks (like Starlette, Quart, etc.). When you encounter problems using these frameworks, recalling the simplified version we built today will help resolve many confusions.

Finally, remember: the best way to learn is through hands-on practice. Try extending our simplified framework—like adding dependency injection or more complete error handling. This will take your understanding of web frameworks to the next level!

Leapcell: The Best of Serverless Web Hosting

Finally, here's a platform ideal for deploying Python services: Leapcell

🚀 Build with Your Favorite Language

Develop effortlessly in JavaScript, Python, Go, or Rust.

🌍 Deploy Unlimited Projects for Free

Only pay for what you use—no requests, no charges.

⚡ Pay-as-You-Go, No Hidden Costs

No idle fees, just seamless scalability.

📖 Explore Our Documentation

🔹 Follow us on Twitter: @LeapcellHQ

Comments 0 total

    Add comment