🐍 Python Course · Stage 5 · Lesson 87 of 89 · Simple Web Server
Course Overview →

🛠 Read the Walkthrough tab fully, then try the Full Code tab, then attempt the Extend It challenges.

← Data Analyser Python Playground →
thecodex.expert · The Codex Family of Knowledge
Project 9 of 14

Simple Web Server

A minimal HTTP server in Python using only the standard library — serves static files and a JSON REST API with GET and POST endpoints.

Advanced 🕐 2 hours HTTPREST APIstdlib only Python 3.x

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.

Real use case: You're building a notes app. The browser needs to fetch the list of notes and add new ones. That requires a server exposing two endpoints: 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:

The mental model — every server is this loop
1. Receive a request (method + path + maybe a body) → 2. Route it (which code handles 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.

GET /api/notes

POST /api/notes

route by path

route by path

read

append

JSON 200

JSON 201

Browser

Handler

do_GET

do_POST

NOTES list

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.

Pythonweb_server.py
"""
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()
What each part does
NOTES + next_id — our "database": a plain list held in memory. Simple on purpose; swap it for a real DB later.

_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_POSThttp.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.

GET returns the notes list as JSON (200)
POST creates a note and returns it (201)
POST with missing data is rejected (400) — not a crash
An unknown route returns 404
Pythontest_web_server.py
"""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.

GET /api/notes List all notes
Response 200 — application/json
[
  { "id": 1, "text": "Learn Python", "done": false },
  { "id": 2, "text": "Build a web server", "done": true }
]
POST /api/notes Create a note
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.

Run locally
python3 web_server.py → open http://localhost:8000/api/notes
Test 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.

GroovyJenkinsfile
// 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.' }
    }
}
What you learned
The request→route→act→respond loop, HTTP status codes, JSON APIs, input validation, automated testing, API documentation, and CI. These are the foundations of every backend. Related reference: http.server, json, testing.