Adding Routes
Look up the request path in a routes table, and answer 404 Not Found when nothing matches.
Our server can read a request and send a response, but it gives the same answer to everyone.
Ask for /about, /favicon.ico or /does-not-exist and you always get the same heading with 200 OK.
That last one is a lie. A 200 says "here is what you asked for", and we have nothing of the kind.
In this page, we teach the server to look at the target and decide what to send.
A route is a rule that connects a path, such as /about, to a response. When no route matches, we answer with a real 404 Not Found.
Add a Routes Table
The target is the second value on the request line. We have been printing it since before and it can look like this:
/
/about
/about?lang=enEverything before the ? is the path. Everything after it is the query string, extra information sent along with the request.
We route by the path only, so /about and /about?lang=en should both reach the same page.
Cutting the target at the first ? gives us the path, so:
path = target.split("?", 1)[0]If there is no ?, split returns the whole target as the only item, so this works for both cases.
The simplest way to connect paths to pages is a dictionary. The key is the path, and the value is the page to send.
Add ROUTES and route() below build_response():
ROUTES = {
"/": "Home",
"/about": "About",
}
def route(target: str) -> tuple[int, str, bytes]:
path = target.split("?", 1)[0]
page = ROUTES.get(path)
if page is None:
return 404, "Not Found", b"404 Not Found"
return 200, "OK", page.encode("utf-8")Let's walk through it:
- Find the path. : We cut off the query string, as we saw above.
- Look it up. :
ROUTES.get(path)returns the page for that path, orNoneif the path is not in the table. - No match. : We return the status
404with the reasonNot Found, and a small page that says so. - Match. We return
200 OKand the page.
The function gives back three values:
- the status
- the reason and
- the body.
These are exactly what
build_response()needs, so the two fit together. In the next pages, we will keep this shape and only change howroute()finds the body.
Use It in main
In main(), replace the hard-coded status and page with a call to route().
Only the end of the with conn: block changes:
print(f"{method} {target} {version}")
for name, value in headers.items():
print(f" {name}: {value}")
status, reason, body = route(target)
print(f" -> {status} {reason}")
response = build_response(status, reason, body)
conn.sendall(response)The status is no longer written by hand. route() chooses it, and we print it so the server's terminal shows what each request got.
HEAD Requests
In the introduction, we said our server handles GET and HEAD.
A HEAD request asks for the same response as GET, but without the body.
Clients use it to check that a page exists and how big it is, without downloading it.
The important detail is that Content-Length must still show the size of the body we would have sent. So we cannot just pass an empty body. Instead, we teach build_response() to leave the body out.
Update build_response():
def build_response(
status: int,
reason: str,
body: bytes,
content_type: str = "text/html; charset=utf-8",
head_only: bool = False,
) -> bytes:
head = (
f"HTTP/1.1 {status} {reason}\r\n"
f"Content-Type: {content_type}\r\n"
f"Content-Length: {len(body)}\r\n"
"Connection: close\r\n"
"\r\n"
).encode("iso-8859-1")
return head if head_only else head + bodyThe length is still computed from body, but with head_only we send only the head. Then, in main(), pass it in:
response = build_response(
status, reason, body, head_only=(method == "HEAD")
)Run The Server
Here is the full code:
import socket
HOST = "127.0.0.1"
PORT = 8000
def read_request(conn: socket.socket) -> bytes:
data = b""
while b"\r\n\r\n" not in data:
chunk = conn.recv(1024)
if not chunk: # the client closed the connection
break
data += chunk
return data
def parse_request(raw: bytes) -> tuple[str, str, str, dict[str, str]]:
if b"\r\n\r\n" not in raw:
raise ValueError("incomplete request")
head = raw.split(b"\r\n\r\n", 1)[0].decode("iso-8859-1")
lines = head.split("\r\n")
parts = lines[0].split(" ")
if len(parts) != 3:
raise ValueError("malformed request line")
method, target, version = parts
headers = {}
for line in lines[1:]:
name, sep, value = line.partition(":")
if not sep:
raise ValueError("malformed header")
headers[name.strip().lower()] = value.strip()
return method, target, version, headers
def build_response(
status: int,
reason: str,
body: bytes,
content_type: str = "text/html; charset=utf-8",
head_only: bool = False,
) -> bytes:
head = (
f"HTTP/1.1 {status} {reason}\r\n"
f"Content-Type: {content_type}\r\n"
f"Content-Length: {len(body)}\r\n"
"Connection: close\r\n"
"\r\n"
).encode("iso-8859-1")
return head if head_only else head + body
ROUTES = {
"/": "Home",
"/about": "About",
}
def route(target: str) -> tuple[int, str, bytes]:
path = target.split("?", 1)[0]
page = ROUTES.get(path)
if page is None:
return 404, "Not Found", b"404 Not Found"
return 200, "OK", page.encode("utf-8")
def main() -> None:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((HOST, PORT))
server.listen()
print(f"listening on http://{HOST}:{PORT} (Press Ctrl+c to stop)")
try:
while True:
conn, addr = server.accept()
with conn:
print(f"connection from {addr}")
raw = read_request(conn)
try:
method, target, version, headers = parse_request(raw)
except ValueError as exc:
print(f"bad request: {exc}")
continue
print(f"{method} {target} {version}")
for name, value in headers.items():
print(f" {name}: {value}")
status, reason, body = route(target)
print(f" -> {status} {reason}")
response = build_response(status, reason, body, head_only=(method == "HEAD"))
conn.sendall(response)
except KeyboardInterrupt:
print("\nbye")
if __name__ == "__main__":
main()Start the server and from a second terminal, ask for a page that exists:
curl -i http://127.0.0.1:8000/Expected output:
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 4
Connection: close
HomeNow ask for one that doesn't:
curl -i http://127.0.0.1:8000/does-not-existExpected output:
HTTP/1.1 404 Not Found
Content-Type: text/html; charset=utf-8
Content-Length: 13
Connection: close
404 Not FoundA query string doesn't change the route, so this still returns 200 OK:
curl -i "http://127.0.0.1:8000/about?lang=en"Finally, send a HEAD request. The -I option makes curl use HEAD method:
curl -I http://127.0.0.1:8000/Expected output:
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 4
Connection: closeThe headers match the GET response, including Content-Length: 4, but no body follows.
Our server can now choose what to send, but every page lives inside our Python code as a string. A real site has dozens of pages, images and stylesheets, and nobody wants to write them as strings. Next, we teach the server to find real files on disk and send them.