Thursday

19-06-2025 Vol 19

Implementing FastAPI from Scratch Using Only Pure Python

Implementing FastAPI from Scratch Using Only Pure Python: A Comprehensive Guide

FastAPI, a modern, high-performance web framework for building APIs with Python 3.7+, is known for its ease of use, speed, and automatic data validation. While FastAPI itself relies on libraries like Pydantic and Starlette, understanding its core principles and how it functions under the hood can be incredibly valuable. This article guides you through building a simplified FastAPI-like framework from scratch using only pure Python, helping you grasp the fundamentals of web framework design and the power of Python’s built-in libraries.

Table of Contents

  1. Introduction: Why Build a Framework from Scratch?
  2. Understanding the Basics: WSGI and ASGI
    • What is WSGI?
    • What is ASGI?
    • Choosing the Right Protocol
  3. Setting Up the Project Structure
  4. Core Components: The Router
    • Creating the Router Class
    • Registering Routes
    • Handling Different HTTP Methods (GET, POST, etc.)
  5. Request Handling: Parsing and Processing
    • Parsing Request Headers
    • Reading Request Body
    • Query Parameter Handling
  6. Response Generation: Building HTTP Responses
    • Setting Status Codes
    • Setting Headers
    • Returning Data as JSON
  7. Middleware Implementation
    • Creating a Simple Middleware Class
    • Applying Middleware to the Application
  8. Exception Handling
    • Defining Custom Exceptions
    • Implementing Exception Handlers
  9. Data Validation (Simplified)
    • Creating a Basic Data Validation Class
    • Validating Request Data
  10. Running the Application with a WSGI/ASGI Server
    • Using wsgiref for WSGI
    • Using uvicorn or hypercorn for ASGI
  11. Example Application: A Simple To-Do API
  12. Limitations and Next Steps
  13. Conclusion

1. Introduction: Why Build a Framework from Scratch?

You might wonder, why bother building a web framework from scratch when robust frameworks like FastAPI, Flask, and Django already exist? There are several compelling reasons:

  • Deeper Understanding: Building a framework forces you to understand the underlying mechanics of web servers, routing, request handling, and response generation. This knowledge is invaluable for debugging, optimizing, and extending existing frameworks.
  • Customization: When you control the entire stack, you can tailor the framework to your specific needs. This can be useful in niche scenarios where existing frameworks are overkill or don’t quite fit.
  • Educational Exercise: It’s an excellent learning exercise to improve your Python skills and your understanding of software architecture principles.
  • Debugging Skills: By understanding the inner workings, debugging becomes significantly easier. You can trace the flow of execution step-by-step.
  • Appreciation for Existing Frameworks: You’ll gain a greater appreciation for the complexities handled by frameworks like FastAPI and understand why they are so powerful and efficient.

This article focuses on building a simplified framework, omitting many features of a production-ready framework for clarity and brevity. The goal is to illustrate the core concepts, not to create a fully functional alternative to FastAPI.

2. Understanding the Basics: WSGI and ASGI

Before diving into the code, it’s crucial to understand the underlying protocols that power web applications: WSGI and ASGI.

What is WSGI?

WSGI (Web Server Gateway Interface) is a standard interface between web servers (like Apache or Gunicorn) and Python web applications. It defines a simple calling convention that allows the server to pass requests to the application and receive responses in a standardized format.

In essence, a WSGI application is a callable (usually a function) that takes two arguments:

  1. environ: A dictionary containing information about the request, such as headers, method, and URL.
  2. start_response: A callable that the application uses to set the HTTP status code and headers.

The application then returns an iterable (usually a list) of byte strings representing the response body.

What is ASGI?

ASGI (Asynchronous Server Gateway Interface) is a successor to WSGI, designed to handle asynchronous operations and long-lived connections like WebSockets. ASGI allows your application to handle multiple requests concurrently without blocking, leading to better performance and scalability.

ASGI applications are also callables, but their signature is slightly different and they operate asynchronously. They typically use async def to define asynchronous functions and leverage await to handle asynchronous operations.

Choosing the Right Protocol

For this simplified framework, we’ll focus on ASGI as it’s more modern and supports both synchronous and asynchronous operations. We will implement a minimal ASGI application capable of handling HTTP requests.

3. Setting Up the Project Structure

Let’s create a basic project structure to organize our code:


my_framework/
├── app.py       # Main application file
├── router.py    # Routing logic
├── request.py   # Request handling
├── response.py  # Response generation
├── middleware.py # Middleware implementation
└── exceptions.py # Custom exceptions

4. Core Components: The Router

The router is responsible for mapping incoming HTTP requests to the appropriate handler functions. It’s the heart of our framework.

Creating the Router Class

Create a file named router.py and add the following code:

“`python
# router.py
class Router:
def __init__(self):
self.routes = {}

def add_route(self, path: str, methods: list, handler):
if path not in self.routes:
self.routes[path] = {}
for method in methods:
self.routes[path][method.upper()] = handler

def get(self, path: str):
def decorator(handler):
self.add_route(path, [“GET”], handler)
return handler
return decorator

def post(self, path: str):
def decorator(handler):
self.add_route(path, [“POST”], handler)
return handler
return decorator

def put(self, path: str):
def decorator(handler):
self.add_route(path, [“PUT”], handler)
return handler
return decorator

def delete(self, path: str):
def decorator(handler):
self.add_route(path, [“DELETE”], handler)
return handler
return decorator

def patch(self, path: str):
def decorator(handler):
self.add_route(path, [“PATCH”], handler)
return handler
return decorator

def route(self, path: str, methods: list):
def decorator(handler):
self.add_route(path, methods, handler)
return handler
return decorator

async def handle_request(self, scope, receive, send):
method = scope[“method”]
path = scope[“path”]

if path in self.routes and method in self.routes[path]:
handler = self.routes[path][method]
await handler(scope, receive, send)
else:
await self.default_response(scope, receive, send)

async def default_response(self, scope, receive, send):
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”,
})
“`

Registering Routes

The add_route method registers a handler function for a specific path and HTTP method. The convenience methods (get, post, put, delete, patch) provide a more declarative way to define routes using decorators.

Example usage:

“`python
# In app.py (example, assuming you’ve imported the Router class)
from router import Router

router = Router()

@router.get(“/”)
async def index_handler(scope, receive, send):
await send({
“type”: “http.response.start”,
“status”: 200,
“headers”: [
[b”content-type”, b”text/plain”],
],
})
await send({
“type”: “http.response.body”,
“body”: b”Hello, world!”,
})

@router.post(“/items”)
async def create_item_handler(scope, receive, send):
# Handle the request body to create an item
await send({
“type”: “http.response.start”,
“status”: 201,
“headers”: [
[b”content-type”, b”text/plain”],
],
})
await send({
“type”: “http.response.body”,
“body”: b”Item Created”,
})
“`

Handling Different HTTP Methods (GET, POST, etc.)

The Router class provides decorators for common HTTP methods. Each decorator calls add_route with the appropriate method name to register the handler.

5. Request Handling: Parsing and Processing

Handling incoming requests involves parsing headers, reading the body, and extracting query parameters. Create a file named request.py for this purpose.

“`python
# request.py
import json
from urllib.parse import urlparse, parse_qs

class Request:
def __init__(self, scope, receive):
self.scope = scope
self.receive = receive
self._body = None
self._parsed_body = None
self._query_params = None

@property
def method(self) -> str:
return self.scope[“method”]

@property
def path(self) -> str:
return self.scope[“path”]

@property
def headers(self) -> dict:
headers = {}
for name, value in self.scope[“headers”]:
headers[name.decode(“utf-8”)] = value.decode(“utf-8″)
return headers

async def body(self) -> bytes:
if self._body is None:
body = b””
more_body = True
while more_body:
message = await self.receive()
body += message.get(“body”, b””)
more_body = message.get(“more_body”, False)
self._body = body
return self._body

async def json(self) -> dict:
if self._parsed_body is None:
body = await self.body()
try:
self._parsed_body = json.loads(body.decode(“utf-8”))
except json.JSONDecodeError:
self._parsed_body = {} # Or raise an exception
return self._parsed_body

@property
def query_params(self) -> dict:
if self._query_params is None:
query_string = self.scope.get(“query_string”, b””).decode(“utf-8”)
self._query_params = parse_qs(query_string)
return self._query_params

“`

Parsing Request Headers

The headers property extracts headers from the scope dictionary provided by ASGI.

Reading Request Body

The body method asynchronously reads the request body from the receive callable. It accumulates the body in chunks until the more_body flag is False. The json method then parses the body as JSON.

Query Parameter Handling

The query_params property parses the query string from the scope dictionary using urllib.parse.

Usage example:

“`python
# In your route handler
from request import Request

async def my_handler(scope, receive, send):
request = Request(scope, receive)
data = await request.json()
print(f”Received data: {data}”)
# … process the data …
“`

6. Response Generation: Building HTTP Responses

Generating HTTP responses involves setting the status code, headers, and body. Create a file named response.py:

“`python
# response.py
import json

class Response:
def __init__(self, content: str = “”, status_code: int = 200, headers: dict = None, media_type: str = “text/plain”):
self.content = content
self.status_code = status_code
self.headers = headers or {}
self.media_type = media_type

async def __call__(self, scope, receive, send):
headers = [[b”content-type”, self.media_type.encode(“utf-8”)]]
for name, value in self.headers.items():
headers.append([name.encode(“utf-8”), value.encode(“utf-8”)])

await send({
“type”: “http.response.start”,
“status”: self.status_code,
“headers”: headers,
})
await send({
“type”: “http.response.body”,
“body”: self.content.encode(“utf-8″),
})

class JSONResponse(Response):
def __init__(self, content: dict, status_code: int = 200, headers: dict = None):
super().__init__(
content=json.dumps(content),
status_code=status_code,
headers=headers,
media_type=”application/json”,
)
“`

Setting Status Codes

The status_code attribute sets the HTTP status code of the response.

Setting Headers

The headers attribute is a dictionary that allows you to set custom HTTP headers.

Returning Data as JSON

The JSONResponse class is a specialized response class that automatically serializes a dictionary to JSON and sets the Content-Type header to application/json.

Usage example:

“`python
# In your route handler
from response import Response, JSONResponse

async def my_handler(scope, receive, send):
return JSONResponse(content={“message”: “Hello, world!”}, status_code=200)(scope, receive, send)

async def another_handler(scope, receive, send):
return Response(content=”Plain text response”, status_code=200)(scope, receive, send)
“`

7. Middleware Implementation

Middleware allows you to intercept and process requests and responses globally. This is useful for tasks like authentication, logging, and request modification. Create a file named middleware.py:

“`python
# middleware.py
class Middleware:
def __init__(self, app):
self.app = app

async def __call__(self, scope, receive, send):
# Pre-processing logic (before the request reaches the handler)
print(“Middleware: Request received”)

async def wrapped_send(message):
# Post-processing logic (before the response is sent)
if message[“type”] == “http.response.start”:
print(“Middleware: Response status:”, message[“status”])
await send(message)

await self.app(scope, receive, wrapped_send)
“`

Creating a Simple Middleware Class

The Middleware class takes the application as an argument in its constructor. The __call__ method is the entry point for the middleware. It receives the scope, receive, and send callables.

Applying Middleware to the Application

To apply middleware, you wrap the application instance with the middleware class:

“`python
# In app.py
from middleware import Middleware

# … your router and route definitions …

app = router.handle_request # or whatever you named the method to handle requests
app = Middleware(app)
“`

The middleware intercepts the request before it reaches the handler, executes its pre-processing logic, calls the next application in the chain (in this case, the route handler), and then executes its post-processing logic before sending the response.

8. Exception Handling

Robust exception handling is crucial for building reliable applications. Create a file named exceptions.py:

“`python
# exceptions.py
class HTTPException(Exception):
def __init__(self, status_code: int, detail: str = None):
self.status_code = status_code
self.detail = detail

“`

Defining Custom Exceptions

The HTTPException class is a custom exception that represents an HTTP error. It takes a status_code and an optional detail message as arguments.

Implementing Exception Handlers

You can implement global exception handlers to catch exceptions and return appropriate HTTP responses:

“`python
# In app.py
from exceptions import HTTPException
from response import JSONResponse

async def app(scope, receive, send):
try:
# … your route handling logic …
await router.handle_request(scope, receive, send)
except HTTPException as e:
await JSONResponse(content={“detail”: e.detail}, status_code=e.status_code)(scope, receive, send)
except Exception as e:
# Handle unexpected errors
await JSONResponse(content={“detail”: “Internal Server Error”}, status_code=500)(scope, receive, send)

“`

This example catches HTTPException and returns a JSON response with the error details. It also catches other exceptions and returns a generic “Internal Server Error” response.

9. Data Validation (Simplified)

Data validation ensures that incoming data conforms to the expected format and constraints. This example provides a very basic implementation.

Creating a Basic Data Validation Class

“`python
# In app.py or a separate file
class ValidationError(Exception):
def __init__(self, message):
self.message = message

class Validator:
def __init__(self, schema: dict):
self.schema = schema

def validate(self, data: dict):
for field, expected_type in self.schema.items():
if field not in data:
raise ValidationError(f”Missing field: {field}”)
if not isinstance(data[field], expected_type):
raise ValidationError(f”Invalid type for field {field}. Expected {expected_type}, got {type(data[field])}”)
return data
“`

Validating Request Data

Usage within a route handler:

“`python
from response import JSONResponse
from request import Request

async def create_item_handler(scope, receive, send):
request = Request(scope, receive)
try:
data = await request.json()
schema = {“name”: str, “description”: str, “price”: (int, float)}
validated_data = Validator(schema).validate(data)
# Process validated data here
await JSONResponse(content={“message”: “Item created”, “data”: validated_data}, status_code=201)(scope, receive, send)
except ValidationError as e:
await JSONResponse(content={“detail”: e.message}, status_code=400)(scope, receive, send)
except Exception as e:
await JSONResponse(content={“detail”: “Invalid JSON”}, status_code=400)(scope, receive, send)
“`

This example defines a schema for the request data and validates that the required fields are present and have the correct types. If validation fails, it raises a ValidationError and returns a 400 Bad Request response.

10. Running the Application with a WSGI/ASGI Server

Now that we have the basic framework components, let’s run the application using a WSGI/ASGI server.

Using wsgiref for WSGI

While our framework is primarily ASGI, we can use wsgiref (Python’s built-in WSGI server) for simple testing. This requires adapting our ASGI application to a WSGI-compatible one.

First, install asgiref (if you don’t have it already):

pip install asgiref

Then, modify app.py:

“`python
# app.py
import asyncio
from wsgiref.simple_server import make_server
from asgiref.wsgi import WsgiToAsgi
from router import Router

router = Router()

@router.get(“/”)
async def index_handler(scope, receive, send):
await send({
“type”: “http.response.start”,
“status”: 200,
“headers”: [
[b”content-type”, b”text/plain”],
],
})
await send({
“type”: “http.response.body”,
“body”: b”Hello, world!”,
})

app = router.handle_request
asgi_app = app # This is already an ASGI app, but we’re showing the variable for clarity

# Adapt ASGI app to WSGI using asgiref
wsgi_app = WsgiToAsgi(asgi_app)

if __name__ == “__main__”:
with make_server(“”, 8000, wsgi_app) as httpd:
print(“Serving on port 8000…”)
httpd.serve_forever()
“`

Run this script using python app.py and access http://localhost:8000 in your browser.

Using uvicorn or hypercorn for ASGI

For proper ASGI support and better performance, use uvicorn or hypercorn:

pip install uvicorn
# or
pip install hypercorn

Modify your app.py to be minimal:

“`python
# app.py
from router import Router

router = Router()

@router.get(“/”)
async def index_handler(scope, receive, send):
await send({
“type”: “http.response.start”,
“status”: 200,
“headers”: [
[b”content-type”, b”text/plain”],
],
})
await send({
“type”: “http.response.body”,
“body”: b”Hello, world!”,
})

app = router.handle_request
“`

Then, run the application using:

uvicorn app:app --reload
# or
hypercorn app:app --reload

Access http://localhost:8000 in your browser. The --reload flag enables automatic reloading on code changes.

11. Example Application: A Simple To-Do API

Let’s create a simple To-Do API using our framework:

“`python
# app.py
import asyncio
import json
from router import Router
from request import Request
from response import JSONResponse, Response
from exceptions import HTTPException

router = Router()

todos = []
todo_id_counter = 1

@router.get(“/todos”)
async def list_todos(scope, receive, send):
return JSONResponse(content=todos)(scope, receive, send)

@router.post(“/todos”)
async def create_todo(scope, receive, send):
global todo_id_counter
request = Request(scope, receive)
try:
data = await request.json()
if not isinstance(data, dict) or “task” not in data:
raise HTTPException(status_code=400, detail=”Invalid todo data. ‘task’ field required.”)
new_todo = {“id”: todo_id_counter, “task”: data[“task”], “completed”: False}
todos.append(new_todo)
todo_id_counter += 1
return JSONResponse(content=new_todo, status_code=201)(scope, receive, send)
except HTTPException as e:
return JSONResponse(content={“detail”: e.detail}, status_code=e.status_code)(scope, receive, send)
except Exception as e:
return JSONResponse(content={“detail”: “Invalid JSON data”}, status_code=400)(scope, receive, send)

@router.get(“/todos/{todo_id}”)
async def get_todo(scope, receive, send):
try:
todo_id = int(scope[“path”].split(“/”)[-1])
todo = next((t for t in todos if t[“id”] == todo_id), None)
if todo:
return JSONResponse(content=todo)(scope, receive, send)
else:
raise HTTPException(status_code=404, detail=”Todo not found”)
except ValueError:
return JSONResponse(content={“detail”: “Invalid todo ID”}, status_code=400)(scope, receive, send)
except HTTPException as e:
return JSONResponse(content={“detail”: e.detail}, status_code=e.status_code)(scope, receive, send)

@router.put(“/todos/{todo_id}”)
async def update_todo(scope, receive, send):
request = Request(scope, receive)
try:
todo_id = int(scope[“path”].split(“/”)[-1])
data = await request.json()
if not isinstance(data, dict) or “task” not in data:
raise HTTPException(status_code=400, detail=”Invalid todo data. ‘task’ field required.”)

todo_index = next((i for i, t in enumerate(todos) if t[“id”] == todo_id), None)

if todo_index is None:
raise HTTPException(status_code=404, detail=”Todo not found”)
else:
todos[todo_index][“task”] = data[“task”]
return JSONResponse(content=todos[todo_index])(scope, receive, send)

except ValueError:
return JSONResponse(content={“detail”: “Invalid todo ID”}, status_code=400)(scope, receive, send)
except HTTPException as e:
return JSONResponse(content={“detail”: e.detail}, status_code=e.status_code)(scope, receive, send)
except Exception as e:
return JSONResponse(content={“detail”: “Invalid JSON data”}, status_code=400)(scope, receive, send)

@router.delete(“/todos/{todo_id}”)
async def delete_todo(scope, receive, send):
try:
todo_id = int(scope[“path”].split(“/”)[-1])
todo_index = next((i for i, t in enumerate(todos) if t[“id”] == todo_id), None)

if todo_index is None:
raise HTTPException(status_code=404, detail=”Todo not found”)
else:
del todos[todo_index]
return Response(status_code=204)(scope, receive, send)

except ValueError:
return JSONResponse(content={“detail”: “Invalid todo ID”}, status_code=400)(scope, receive, send)
except HTTPException as e:
return JSONResponse(content={“detail”: e.detail}, status_code=e.status_code)(scope, receive, send)

app = router.handle_request
“`

This example demonstrates:

  • Listing all to-dos (GET /todos)
  • Creating a new to-do (POST /todos)
  • Retrieving a specific to-do (GET /todos/{todo_id})
  • Updating a to-do (PUT /todos/{todo_id})
  • Deleting a to-do (DELETE /todos/{todo_id})
  • Basic error handling using custom exceptions.

12. Limitations and Next Steps

This simplified framework has several limitations:

  • Limited Functionality: It lacks many features of a production-ready framework, such as:
    • Automatic data validation (using Pydantic, for example)
    • Dependency injection
    • Authentication and authorization
    • Testing framework
    • More robust exception handling
    • Background tasks
    • WebSockets support
    • Static file serving
  • Security: It doesn’t include security features like CSRF protection or input sanitization.
  • Performance: It’s not optimized for performance and may not scale well under heavy load.
  • No Documentation: This implementation is not documented, making it harder to maintain and extend.

Next steps for improving the framework could include:

  • Implementing more robust data validation using a library like Pydantic.
  • Adding support for dependency injection.
  • Implementing authentication and authorization mechanisms.
  • Adding a testing framework.
  • Optimizing performance using techniques like caching and connection pooling.
  • Adding WebSocket support.

13. Conclusion

Building a web framework from scratch is a challenging but rewarding exercise. It provides a deep understanding of the underlying principles of web application development and helps you appreciate the complexities handled by existing frameworks like FastAPI. While this simplified framework is not intended for production use, it serves as a valuable learning tool for aspiring web developers and software architects.

“`

omcoding

Leave a Reply

Your email address will not be published. Required fields are marked *