1 The Problem
Build a working HTTP server — the thing that sits behind every website and API — using only Python's standard library. No Flask, no Django, no installs. It serves a JSON REST API that a browser or another program can talk to.
GET /api/notes to read them and POST /api/notes to create one. This is the exact request/response pattern behind every app you use — learning it here, in ~60 lines, demystifies all of it.
By the end you'll understand how a server receives a request, decides what to do with it (routing), and sends back a structured response — and you'll be able to prove each part works with automated tests.
2 How to Think About It
Before writing code, reason about the shape of the problem. A server is a loop that does the same four things for every request:
GET /api/notes?) →
3. Act (read or change the data) →
4. Respond (status code + headers + body). Then wait for the next one.
So the design questions answer themselves: What's my data? (a list of notes in memory). What routes do I need? (GET and POST on /api/notes). What does each return? (JSON + the right status code — 200 for OK, 201 for created, 400 for bad input, 404 for unknown route). Decide these before touching http.server, and the code becomes a translation of the plan rather than a guess.
3 The Build — explained part by part
Here is the complete server. Read the explanation of each part below it — the goal is to understand why each line exists, not to memorise it.
"""
A minimal HTTP server using only Python's standard library.
Serves static files from ./www and a JSON REST API at /api/notes.
Usage: python3 web_server.py [port]
Then open: http://localhost:8000
"""
import json
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse
# In-memory data store (resets on restart)
NOTES = [
{"id": 1, "text": "Learn Python", "done": False},
{"id": 2, "text": "Build a web server", "done": True},
]
next_id = 3
class Handler(BaseHTTPRequestHandler):
def _send_json(self, data, status=200):
body = json.dumps(data).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
path = urlparse(self.path).path
if path == "/api/notes":
self._send_json(NOTES) # list all notes
elif path == "/":
self._send_json({"message": "Server is running"})
else:
self._send_json({"error": "Not found"}, 404)
def do_POST(self):
global next_id
path = urlparse(self.path).path
if path == "/api/notes":
length = int(self.headers.get("Content-Length", 0))
try:
payload = json.loads(self.rfile.read(length))
except json.JSONDecodeError:
return self._send_json({"error": "Invalid JSON"}, 400)
if "text" not in payload:
return self._send_json({"error": "Missing 'text'"}, 400)
note = {"id": next_id, "text": payload["text"], "done": False}
next_id += 1
NOTES.append(note)
self._send_json(note, 201) # created
else:
self._send_json({"error": "Not found"}, 404)
def main(port=8000):
server = HTTPServer(("", port), Handler)
print(f"Server running at http://localhost:{port}/")
try:
server.serve_forever()
except KeyboardInterrupt:
server.server_close()
if __name__ == "__main__":
main()
_send_json() — a helper that does the boring-but-critical response ritual every reply needs: status line,
Content-Type header, Content-Length, blank line, then the body. Writing it once keeps every response correct and consistent.do_GET / do_POST —
http.server calls these automatically based on the HTTP method. Inside, we urlparse the path and route to the right behaviour. Note the deliberate status codes: 201 for a created note, 400 for bad input, 404 for an unknown path.The POST validation — we never trust input: we catch invalid JSON and a missing
text field and return 400 instead of crashing. This is the difference between a toy and a real server.
4 Test & Prove Each Part
Code you can't prove is code you don't trust. Each test below validates one behaviour from the plan — this is how you know it works, and how you catch the day a change breaks it.
"""test_web_server.py — pytest suite. Run: pytest -v"""
import json, threading, urllib.request
from http.server import HTTPServer
import pytest
from web_server import Handler, NOTES
@pytest.fixture
def server():
"""Start the server on a random free port in a background thread."""
srv = HTTPServer(("localhost", 0), Handler)
port = srv.server_address[1]
t = threading.Thread(target=srv.serve_forever, daemon=True)
t.start()
yield f"http://localhost:{port}"
srv.shutdown()
def test_get_notes_returns_list(server):
"""GET /api/notes returns the notes as JSON."""
with urllib.request.urlopen(f"{server}/api/notes") as r:
assert r.status == 200
data = json.loads(r.read())
assert isinstance(data, list)
assert data[0]["text"] == "Learn Python"
def test_post_creates_note(server):
"""POST /api/notes adds a note and returns 201."""
body = json.dumps({"text": "New task"}).encode()
req = urllib.request.Request(f"{server}/api/notes", data=body,
method="POST")
with urllib.request.urlopen(req) as r:
assert r.status == 201
created = json.loads(r.read())
assert created["text"] == "New task"
assert created["done"] is False
def test_post_missing_text_is_rejected(server):
"""POST without 'text' returns 400, not a crash."""
body = json.dumps({"wrong": "field"}).encode()
req = urllib.request.Request(f"{server}/api/notes", data=body,
method="POST")
with pytest.raises(urllib.error.HTTPError) as exc:
urllib.request.urlopen(req)
assert exc.value.code == 400
def test_unknown_path_returns_404(server):
"""An unknown route returns 404."""
with pytest.raises(urllib.error.HTTPError) as exc:
urllib.request.urlopen(f"{server}/does-not-exist")
assert exc.value.code == 404
Run them with pytest -v. Each test starts the real server on a random port, makes a real HTTP request, and checks the real response — so passing tests mean the server genuinely works end to end.
5 API Documentation
Every API needs a contract — what endpoints exist, what they take, what they return. This is the same information a Swagger/OpenAPI UI shows, presented inline.
Response 200 — application/json
[
{ "id": 1, "text": "Learn Python", "done": false },
{ "id": 2, "text": "Build a web server", "done": true }
]Request body — application/json
{ "text": "Buy groceries" }Response 201 — created
{ "id": 3, "text": "Buy groceries", "done": false }Response 400 — missing "text" or invalid JSON
{ "error": "Missing 'text'" }This is a valid OpenAPI description in plain terms. To serve a live Swagger UI in production, you'd expose an openapi.json and point Swagger UI at it — but the contract above is the part that matters for understanding the API.
6 Run It & Automate It
Setup, run, and a CI pipeline that runs the tests automatically on every change.
python3 web_server.py → open http://localhost:8000/api/notesTest it:
curl -X POST http://localhost:8000/api/notes -d '{"text":"hello"}'
In a real project, you don't run tests by hand — a CI server (here, Jenkins) runs them automatically on every push and blocks the change if any test fails. That's how teams keep a server from breaking in production.
// Jenkinsfile — automated test pipeline.
// Jenkins runs this on every push: install deps, run tests, report.
pipeline {
agent any
stages {
stage('Checkout') {
steps { checkout scm }
}
stage('Set up Python') {
steps {
sh 'python3 -m venv venv'
sh '. venv/bin/activate && pip install pytest'
}
}
stage('Run tests') {
steps {
sh '. venv/bin/activate && pytest -v --junitxml=results.xml'
}
}
}
post {
always {
junit 'results.xml' // publish test results to Jenkins UI
}
success { echo 'All tests passed.' }
failure { echo 'Tests failed — see the report above.' }
}
}