Index Ai Academy
Phase 8 · APIs · Building APIs

Build an API server from scratch

Routing, handlers and responses, with no framework.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Frameworks like FastAPI and Flask feel like magic until you've built the core yourself. It's smaller than you'd think: a loop that reads requests, a table of routes, and functions that return responses. Once you've written it, a framework's docs read like a list of conveniences on top of something you understand. You'll also know where to look when routing, errors or speed go wrong.

What you'll be able to do

  • Describe a web server as a loop around one function: request in, response out
  • Route requests by method and path, including path parameters like /campaigns/{id}
  • Return 404 and 405 correctly, with the Allow header
  • Explain WSGI and ASGI, the interfaces between servers and frameworks

What it covers

  1. A server is a loop around a function
  2. Requests and responses as objects
  3. 404 or 405?
  4. A complete tiny API
  5. WSGI and ASGI: the standard plug
  6. Why build one by hand at all
  7. What a real server adds

A server is a loop around a function

Picture a waiter in a small restaurant. They wait for someone to sit down and take the order. They pass it to the kitchen, bring back the food, then wait for the next person. They do that all day, and it never changes. The menu and the cooking are what make one restaurant different from another.

A web server does the same, forever:

  1. Wait for a client to connect.
  2. Read the request bytes and parse them. You wrote that parser last lesson.
  3. Call a function that turns the request into a response. That's the kitchen.
  4. Send the response bytes back, and go to 1.

Steps 1, 2 and 4 are plumbing, the same for every app. Here's what they look like with Python's socket module, the part of Python that talks to the network:

import socket

with socket.create_server(("127.0.0.1", 8000)) as server:
    while True:
        conn, addr = server.accept()           # 1. wait for a client
        with conn:
            raw = conn.recv(65536)             # 2. read the request (simplified)
            response = handle(parse_request(raw))   # 3. YOUR app
            conn.sendall(response)             # 4. send the answer

create_server opens a door on port 8000, and accept waits until someone knocks.

Real servers add a lot around this: many connections at once, time limits, TLS, and connections that stay open between requests. But step 3 is where your application lives, and it's an ordinary function. In the WSGI standard that Python servers follow, an application is exactly that: a function the server calls once per request with the request details (an environ dictionary) and a start_response callback, returning the body. That makes it easy to test. Call it with a request and check the response, with no network involved.

Note: The academy's Python can't open network ports, so this lesson builds and tests step 3 directly. On your own machine, the loop above serves it to a real browser at http://127.0.0.1:8000.

Try it now

Everything else in web frameworks is built around this loop.

Requests and responses as objects

Nobody wants to pick through raw bytes in every function. A kitchen works from a tidy order slip, and so should your code. So parse the bytes once, and pass around small objects. Dataclasses, from Phase 7, fit well:

from dataclasses import dataclass, field
import json

@dataclass
class Request:
    method: str
    path: str
    query: dict = field(default_factory=dict)
    headers: dict = field(default_factory=dict)
    body: bytes = b""

    def json(self):
        return json.loads(self.body or b"null")

@dataclass
class Response:
    status: int
    body: object = None
    headers: dict = field(default_factory=dict)

def hello(req):
    name = req.query.get("name", "world")
    return Response(200, {"message": f"Hello, {name}!"})

print(hello(Request("GET", "/hello", query={"name": "Ada"})))
It runs right here in your browser, with nothing to install.

A handler is a function from a Request to a Response: one dish on the menu. The rest of the server exists to pick the right handler and turn its Response into bytes.

Routing

Think of an office switchboard. A call comes in asking for sales, and the operator looks up the extension. The router does that job. Each route is a row in its table: a method, a path pattern and a handler.

A pattern can hold path parameters in braces, which work like blanks on a form. /campaigns/{id} matches /campaigns/cmp_103 and captures id = "cmp_103".

Matching works one segment at a time. A segment is the text between slashes, so /campaigns/cmp_103 has two. Compare them in pairs: campaigns must match exactly, and {id} accepts anything that isn't empty. Different numbers of segments can never match.

def match_pattern(pattern, path):
    p_parts = pattern.strip("/").split("/")
    parts = path.strip("/").split("/")
    if len(p_parts) != len(parts):
        return None
    params = {}
    for p, actual in zip(p_parts, parts):
        if p.startswith("{") and p.endswith("}"):
            if not actual:
                return None
            params[p[1:-1]] = actual
        elif p != actual:
            return None
    return params

print(match_pattern("/campaigns/{id}", "/campaigns/cmp_103"))
print(match_pattern("/campaigns/{id}", "/campaigns"))
print(match_pattern("/users/{uid}/orders/{oid}", "/users/7/orders/o9"))
Quick check
Does the pattern /campaigns/{id}/pause match the path /campaigns/cmp_1/pause, and if so what is captured?
  1. Yes, with id = "cmp_1"
  2. Yes, with id = "cmp_1/pause"
  3. No, because pause isn't a parameter
  4. No, patterns can't have text after a parameter
Show the answer

Yes, with id = "cmp_1"

Three segments each side. campaigns and pause match exactly, and {id} captures the middle segment.

404 or 405?

Say you walk down a street looking for a bakery. There are two different ways to be turned away. If there's no bakery on that street, that's one problem. If you find it and ask them to fix your car, that's another.

  • 404 Not Found is the missing bakery. No route's pattern matches the path at all. GET /campaings (a typo) is an example.
  • 405 Method Not Allowed is asking the wrong thing of the right place. The path matches a pattern, but not with this method. DELETE /campaigns when only GET and POST exist there is an example.

A 405 must include an Allow header listing the methods that do work, such as Allow: GET, POST. That's how the client learns what it can do instead. Getting this right is a small sign of a carefully built API.

Quick check
What does this print?
routes = {"/items": ["GET"]}
path, method = "/items", "DELETE"
if path not in routes: print(404)
elif method not in routes[path]: print(405)
else: print(200)
  1. DELETE
  2. 404
  3. 200
  4. 405
Show the answer

405

The path exists, but not with that method, so it's 405 rather than 404.

Quick check
Your server has a route for GET /campaigns. A client sends DELETE /campaigns. Which status fits?
  1. 200 OK
  2. 405 Method Not Allowed, with an Allow header listing GET
  3. 400 Bad Request
  4. 404 Not Found
Show the answer

405 Method Not Allowed, with an Allow header listing GET

The path exists, so it isn't 404. The method isn't supported there, and the Allow header tells the client what is.

Worked example

A complete tiny API

A router, three handlers and a test client, with no framework. The data lives in a dict, standing in for a database.

import json

CAMPAIGNS = {"cmp_1": {"id": "cmp_1", "name": "Spring sale", "budget": 500}}
ROUTES = []

def route(method, pattern):
    def register(handler):
        ROUTES.append((method, pattern, handler))
        return handler
    return register

def match_pattern(pattern, path):
    p_parts, parts = pattern.strip("/").split("/"), path.strip("/").split("/")
    if len(p_parts) != len(parts):
        return None
    params = {}
    for p, a in zip(p_parts, parts):
        if p.startswith("{"):
            params[p[1:-1]] = a
        elif p != a:
            return None
    return params

@route("GET", "/campaigns")
def list_campaigns(params, body):
    return 200, {"data": list(CAMPAIGNS.values())}

@route("GET", "/campaigns/{id}")
def get_campaign(params, body):
    c = CAMPAIGNS.get(params["id"])
    return (200, c) if c else (404, {"error": f"no campaign {params['id']}"})

@route("POST", "/campaigns")
def create_campaign(params, body):
    new_id = f"cmp_{len(CAMPAIGNS) + 1}"
    CAMPAIGNS[new_id] = {"id": new_id, **body}
    return 201, CAMPAIGNS[new_id]

def handle(method, path, body=None):
    allowed = []
    for m, pattern, handler in ROUTES:
        params = match_pattern(pattern, path)
        if params is None:
            continue
        if m == method:
            return handler(params, body)
        allowed.append(m)
    if allowed:
        return 405, {"error": "method not allowed", "allow": sorted(allowed)}
    return 404, {"error": "not found"}

print(handle("POST", "/campaigns", {"name": "Autumn", "budget": 300}))
print(handle("GET", "/campaigns/cmp_2"))
print(handle("DELETE", "/campaigns"))
print(handle("GET", "/campaings"))
In the lesson you can change this and run it.

@route(...) is a decorator: a function that runs when the handler is defined and adds it to the route table. The next lesson builds on exactly this. handle tries each route. A path match with the wrong method is noted for the 405, and no match at all is a 404.

WSGI and ASGI: the standard plug

Any lamp works in any wall socket, because everyone agreed on the shape of the plug. Python web servers and frameworks made the same kind of agreement about step 3. So any server can run any framework.

  • WSGI (Web Server Gateway Interface) is the classic plug. An app is a function app(environ, start_response). environ is a dict describing the request, and the app calls start_response(status, headers) and returns the body. Flask and Django speak WSGI, and servers such as Gunicorn run them.
  • ASGI is the newer one, for async code. Apps are async functions, so one process can hold thousands of open connections, including WebSockets and streams. It's like a waiter who takes one table's order and moves on, without standing there until the food is ready. FastAPI and Starlette speak ASGI, and Uvicorn runs them.
from wsgiref.util import setup_testing_defaults

def app(environ, start_response):
    body = f"You asked for {environ['PATH_INFO']}".encode()
    start_response("200 OK", [("Content-Type", "text/plain"), ("Content-Length", str(len(body)))])
    return [body]

environ = {"PATH_INFO": "/campaigns", "REQUEST_METHOD": "GET"}
setup_testing_defaults(environ)          # fill in the rest of a valid request
captured = {}
result = app(environ, lambda status, headers: captured.update(status=status, headers=headers))
print(captured["status"], b"".join(result))

That call is all a WSGI server does with your app, once per request. Because it's a plain function call, frameworks provide test clients that call the app directly in exactly this way.

Quick check
Why can Gunicorn run both Flask and Django apps?
  1. Gunicorn has special code for each framework
  2. Flask and Django are the same framework
  3. Gunicorn converts them to JavaScript
  4. Both speak WSGI, the standard interface between Python servers and apps
Show the answer

Both speak WSGI, the standard interface between Python servers and apps

WSGI (and ASGI for async apps) is the agreed plug shape, so any server can run any framework that speaks it.

Why build one by hand at all

You'll use a framework in real work. But having written this, you know a framework isn't magic. It's a loop, a parser, a route table and a function call. So when something strange happens, such as a header that goes missing or a request that hangs, you know where to look. HTTP doesn't require any framework, and that knowledge outlasts any one of them.

Getting the small things right

Answer 404 and 405 correctly. Getting it right tells a developer whether they've misspelled the URL or used the wrong verb, which saves them a long hunt.

Check your path patterns. A pattern like /items/<id> shouldn't match /items/. If it does, your handler receives an empty id, and you might end up listing or deleting the wrong thing. Test that case on purpose.

Test without a network. Your app is only a function that takes a request and returns a response. You can taste a dish without opening the restaurant, so call it directly:

def app(method, path):
    if path == "/items" and method == "GET":
        return 200, ["tent"]
    if path == "/items":
        return 405, "use GET"
    return 404, "not found"

print(app("GET", "/items"))
print(app("DELETE", "/items"))
print(app("GET", "/nope"))

Tests like these run thousands of times faster than starting a real server and sending requests. They can't fail because a port is busy, either. Keep a few real end-to-end tests, and do the rest this way.

What a real server adds

Your tiny server is one waiter, serving one table at a time. Production servers like Gunicorn and Uvicorn are the rest of the staff. They add many workers, time limits, limits on body size and protection from slow clients. You don't write those parts yourself. You plug your app into one, through the WSGI or ASGI interface you met in this lesson.

Practice what you just read

  • 6 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

What's the difference between a 404 and a 405?

  1. 404: no route matches the path. 405: the path exists but not with this method
  2. 404 is for GET requests that fail, and 405 is for POST requests that fail
  3. 405 means the server is down for now, and 404 means the page has gone for good
  4. They mean the same thing, and which one you get depends on the framework

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson