DEV Community

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

Posted on

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

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

Top comments (0)