The website from the previous lesson is perfect for Luis and his browser, but Julia wants a mobile app, and a mobile app doesn't want HTML tables: it wants raw data it can render its own way. That's the job of a REST API: the same routes and HTTP verbs as always, but with JSON in the body — the format you've mastered since M6. In this lesson you'll build the complete Papyrus API: list the catalog, look up a book (and translate BookNotFoundError into a clean 404, closing the circle we opened in M7), add books with serious validation, update stock and delete. You'll test it from the terminal with curl and from pytest with the test client, and you'll walk away with the API contract documented the way a professional would.

Contents

  1. What REST is: resources, verbs, statelessness
  2. Designing the Papyrus API
  3. GET /api/books: the list (with jsonify and asdict)
  4. GET /api/books/<title>: the detail and the 404 with errorhandler
  5. POST /api/books: create with validation (201 or 400)
  6. PUT and DELETE: update stock and delete
  7. Testing with curl
  8. Testing with the pytest test client
  9. Real world: /api/v1 versioning and pagination
  10. The API contract, documented

What REST is: resources, verbs, statelessness

REST (Representational State Transfer) isn't a technology but a style: a way of designing APIs so that any developer understands them without reading the manual. Its practical principles:

Principle What it means In Papyrus
Everything is a resource with its own URL URLs name things (nouns), not actions /api/books, /api/books/Hamlet
The HTTP verbs are the actions GET reads, POST creates, PUT updates, DELETE removes — never /api/deleteBook DELETE /api/books/Hamlet
Stateless Each request is self-sufficient; the server doesn't remember the previous one Two consecutive GETs don't depend on each other
Representations The resource travels in an agreed format, almost always JSON {"title": "Hamlet", ...}
Meaningful status codes The response describes itself: 200, 201, 400, 404... The final table in this lesson

Statelessness is the one that feels strangest at first and the most valuable: since the server keeps no memory of conversations, any identical server can handle the next request — that's how you scale to millions of users. The price: everything a request needs must travel inside it.

Designing the Papyrus API

Before coding, you design. The resource is the book; the collection, the books. The whole map follows from that:

flowchart LR
    A["/api/books<br/>(collection)"] -->|GET| B["List catalog → 200"]
    A -->|POST| C["Create book → 201 / 400"]
    D["/api/books/&lt;title&gt;<br/>(resource)"] -->|GET| E["Detail → 200 / 404"]
    D -->|PUT| F["Update stock → 200 / 400 / 404"]
    D -->|DELETE| G["Delete → 204 / 404"]

We build the API into the same app.py from 10-02 (the HTML site and the API coexist just fine), with the /api prefix to keep them separate.

GET /api/books: the list (with jsonify and asdict)

from dataclasses import asdict
from flask import Flask, jsonify, request
from papyrus.warehouse import load_catalog, get_book, save_catalog
from papyrus.models import Book
from papyrus.errors import BookNotFoundError

app = Flask(__name__)
catalog = load_catalog("data/catalog.json")

@app.route("/api/books")
def api_list():
    return jsonify([asdict(book) for book in catalog.values()])

Three modules of the course get welded together in one line here:

  • asdict(book) (M5) turns each Book dataclass into a dictionary: {"title": "The Odyssey", "price": 12.5, "stock": 4}.
  • The list comprehension (M4) does it for the whole catalog.
  • jsonify(...) (M6's json.dumps on steroids) serializes to JSON and also assembles the complete HTTP response: the Content-Type: application/json header and a 200 code.

The response Julia's app will receive:

[
  {"title": "The Odyssey", "price": 12.5, "stock": 4},
  {"title": "Hamlet", "price": 9.95, "stock": 6},
  {"title": "Don Quixote", "price": 15.9, "stock": 8},
  {"title": "Faust", "price": 21.0, "stock": 10}
]

GET /api/books/<title>: the detail and the 404 with errorhandler

For the detail we use get_book() — the M7 variant that raises BookNotFoundError instead of returning None. And who catches that exception? We could put a try/except in every view... or tell Flask once:

@app.errorhandler(BookNotFoundError)
def book_not_found(error):
    return jsonify({"error": str(error)}), 404

@app.route("/api/books/<title>")
def api_detail(title):
    book = get_book(catalog, title)   # may raise BookNotFoundError
    return jsonify(asdict(book))

This is the moment M7 and 10-01 shake hands, and it deserves to be savored:

  • @app.errorhandler(BookNotFoundError) — another registration decorator (08-02, that's three now) — declares: "whenever any view lets this exception escape, respond like this".
  • The view stays clean: it asks for the book and returns it. The error case doesn't even appear: the exception climbs up (M7: exceptions travel upward until someone catches them) and the handler translates it into a 404 with an explanatory JSON.
  • Your errors.py hierarchy gains new value: since they all inherit from PapyrusError, you could register a generic handler for the whole family. M7 design, M10 dividends.

Response for GET /api/books/Moby-Dick:

{"error": "'Moby-Dick' is not in the catalog"}

with a 404 Not Found code. Julia's app looks at the code, sees 404, and shows "book not available" — no guessing required.

POST /api/books: create with validation (201 or 400)

Creating requires receiving data. The client sends it as JSON in the body and Flask hands it over with request.get_json():

@app.route("/api/books", methods=["POST"])
def api_create():
    data = request.get_json(silent=True)
    if data is None:
        return jsonify({"error": "Expected a JSON body"}), 400
    try:
        book = Book(title=str(data["title"]),
                    price=float(data["price"]),
                    stock=int(data.get("stock", 0)))
    except (KeyError, TypeError, ValueError) as exc:
        return jsonify({"error": f"Invalid data: {exc}"}), 400
    if book.price <= 0:
        return jsonify({"error": "Price must be positive"}), 400
    if book.title in catalog:
        return jsonify({"error": f"'{book.title}' already exists"}), 400
    catalog[book.title] = book
    save_catalog(catalog, "data/catalog.json")
    return jsonify(asdict(book)), 201

Let's break down the decisions:

  • methods=["POST"]: by default a route only accepts GET; here we declare the verb. The same URL /api/books has two views — GET lists, POST creates — and that's REST in its purest form.
  • request.get_json(silent=True) returns the dict (M4) from the body, or None if the body isn't valid JSON — which we turn into a polite 400 instead of an embarrassing 500.
  • The dataclass validates the shape: building Book(...) with explicit conversions (float, int) triggers a ValueError if "price": "free" comes in, and a KeyError if title is missing. We catch the trio and respond 400 with the reason — a helpful error saves hours for whoever uses your API (M7's error-message lesson, now facing the public).
  • Business validations (positive price, duplicates) → also 400.
  • Success → 201 Created (not 200: created is information) and the freshly created book as the body, so the client can confirm how it turned out. save_catalog (M6) persists the change to data/catalog.json.

PUT and DELETE: update stock and delete

With the patterns already in place, the two remaining operations write themselves:

@app.route("/api/books/<title>", methods=["PUT"])
def api_update(title):
    book = get_book(catalog, title)             # automatic 404 if it doesn't exist
    data = request.get_json(silent=True) or {}
    if "stock" not in data:
        return jsonify({"error": "Missing 'stock' field"}), 400
    try:
        new_stock = int(data["stock"])
    except (TypeError, ValueError):
        return jsonify({"error": "'stock' must be an integer"}), 400
    if new_stock < 0:
        return jsonify({"error": "Stock cannot be negative"}), 400
    book.stock = new_stock
    save_catalog(catalog, "data/catalog.json")
    return jsonify(asdict(book))                # 200 with the final state

@app.route("/api/books/<title>", methods=["DELETE"])
def api_delete(title):
    get_book(catalog, title)                    # validates existence → 404
    del catalog[title]
    save_catalog(catalog, "data/catalog.json")
    return "", 204                              # no content: nothing left to say

Look at the return on investment from the errorhandler: both views start with get_book and do not handle the "doesn't exist" case — M7's exception and the handler take care of it. The 204 No Content on DELETE is the canonical code for "done, and there's no body to return".

Testing with curl

curl is the universal terminal HTTP client: with it you poke your API without writing a single line of frontend. With the server running (flask --app app run --debug):

# List the catalog
curl http://127.0.0.1:5000/api/books

# Detail (URL with spaces: quoted and encoded as %20)
curl "http://127.0.0.1:5000/api/books/The%20Odyssey"

# A 404 with a helpful message
curl -i http://127.0.0.1:5000/api/books/Moby-Dick

# Create a book (POST with a JSON body)
curl -X POST http://127.0.0.1:5000/api/books \
     -H "Content-Type: application/json" \
     -d '{"title": "Faust II", "price": 18.50, "stock": 3}'

# Update stock (PUT)
curl -X PUT http://127.0.0.1:5000/api/books/Hamlet \
     -H "Content-Type: application/json" \
     -d '{"stock": 9}'

# Delete (DELETE)
curl -X DELETE "http://127.0.0.1:5000/api/books/Faust%20II"

Cheat sheet: -X sets the verb, -H adds a header (declaring Content-Type: application/json is mandatory for get_json() to work without surprises), -d is the body, -i also shows the response headers and status code. Spaces in URLs are encoded as %20: The Odyssey travels as The%20Odyssey, and curl http://127.0.0.1:5000/api/books/Don%20Quixote fetches Don Quixote. Run all six and watch the codes: 200, 200, 404, 201, 200, 204 — your API tells the truth.

Testing with the pytest test client

And as with everything in Papyrus since M9: if it has no test, it doesn't exist. Flask includes a test client that simulates requests without starting a server, and it slots straight into your suite:

# tests/test_api.py
import pytest
from app import app

@pytest.fixture
def client():
    app.config["TESTING"] = True
    return app.test_client()

def test_list_returns_the_catalog(client):
    response = client.get("/api/books")
    assert response.status_code == 200
    titles = [book["title"] for book in response.get_json()]
    assert "The Odyssey" in titles

def test_missing_book_gives_404_with_message(client):
    response = client.get("/api/books/Moby-Dick")
    assert response.status_code == 404
    assert "Moby-Dick" in response.get_json()["error"]

The pieces are M9's — fixture, asserts, pytest in green — applied to HTTP: client.get(...) makes the request in memory and response.get_json() unpacks the JSON. We won't dig deeper (M9 already gave you the craft), but let the seed be planted: your API's contracts — status codes included — are protected with regression tests just like the member prices.

Real world: /api/v1 versioning and pagination

Two practices you'll see in every professional API and which we only name here, honestly:

  • Versioning: public APIs prefix the version — /api/v1/books — so they can launch an incompatible /api/v2 without breaking the apps that already use v1. With four books and one client (Julia) we don't need it; with real clients, it's the first thing you decide.
  • Pagination: GET /api/books returns the entire catalog. With 4 books, perfect; with 40,000, it would be a poisoned gift. Real APIs return pages (/api/books?page=2&per_page=50) plus navigation metadata. Remember this the day your collection grows.

The API contract, documented

Every API deserves its contract in writing. This is Papyrus v1's — the table you'd hand to whoever builds Julia's app:

Route Verb Input body Success Errors
/api/books GET 200 + list of books
/api/books POST {"title", "price", "stock"?} 201 + created book 400 invalid data or duplicate
/api/books/<title> GET 200 + book 404 doesn't exist
/api/books/<title> PUT {"stock": integer ≥ 0} 200 + updated book 400 invalid stock, 404 doesn't exist
/api/books/<title> DELETE 204 no body 404 doesn't exist

All errors share a format: {"error": "readable message"}. That consistency is part of the contract too.

Common Mistakes and Tips

  • Forgetting Content-Type: application/json on the client: request.get_json() returns None and your code blows up further down. With silent=True + a None check you respond with a clear 400 instead of a mysterious 500.
  • 405 Method Not Allowed: you POSTed to a route that only declares GET (or the reverse). Check methods=[...] — it's the most frequent oversight in this lesson.
  • Returning 200 on errors: 10-01's cardinal sin, now with real consequences — automated clients look at the code before the body. Code and body must tell the same story.
  • Spaces in curl URLs: The Odyssey must travel as The%20Odyssey and the URL must be quoted; otherwise curl thinks you're passing it two arguments.
  • Under-validating the POST: "the client will validate" is the 10-01 trap — anyone with curl bypasses your form. The backend always validates: type, ranges, duplicates, and responds 400 with the reason.
  • Catching Exception in the errorhandler: register handlers for specific exceptions (BookNotFoundError). An Exception handler that answers 404 to everything hides genuine bugs that should be 500s and show up in papyrus.log.

Exercises

Exercise 1: a sale endpoint

Add POST /api/books/<title>/sale that receives {"units": n, "member": bool} and calls M7's sell() (it returns the amount and decrements stock). Respond 200 with {"title", "units", "amount"}. InsufficientStockError must become a 409 Conflict (the code for "the request is valid but clashes with the current state") through a new errorhandler — no try/except in the view.

Exercise 2: the contract under test

Write two tests with the test client: (a) POST /api/books without the price field responds 400 and the message mentions the problem; (b) after a PUT that sets Hamlet's stock to 9, a detail GET returns stock == 9.

Exercise 3: availability filter

Extend GET /api/books with the optional parameter ?available=yes that filters using in_stock() (M5). Without the parameter, current behavior stays intact — making sure a change doesn't break the existing contract is half the profession.

Solutions

Exercise 1:

from papyrus.errors import InsufficientStockError
from papyrus.warehouse import sell

@app.errorhandler(InsufficientStockError)
def out_of_stock(error):
    return jsonify({"error": str(error)}), 409

@app.route("/api/books/<title>/sale", methods=["POST"])
def api_sell(title):
    data = request.get_json(silent=True) or {}
    units = int(data.get("units", 1))
    amount = sell(catalog, title, units)   # automatic 404 or 409
    save_catalog(catalog, "data/catalog.json")
    return jsonify({"title": title, "units": units,
                    "amount": round(amount, 2)})

The view doesn't contain a single try: BookNotFoundError → 404 and InsufficientStockError → 409 are resolved by the handlers. Your M7 exceptions have become the API's error system.

Exercise 2:

def test_post_without_price_gives_400(client):
    response = client.post("/api/books", json={"title": "The Iliad"})
    assert response.status_code == 400
    assert "price" in response.get_json()["error"]

def test_put_updates_the_stock(client):
    client.put("/api/books/Hamlet", json={"stock": 9})
    response = client.get("/api/books/Hamlet")
    assert response.get_json()["stock"] == 9

The test client's json= argument serializes the body and sets the Content-Type for you. (For the M9 purist: these tests mutate the shared catalog; a fixture restoring it with tmp_path would be the next refinement.)

Exercise 3:

@app.route("/api/books")
def api_list():
    books = catalog.values()
    if request.args.get("available") == "yes":
        books = [book for book in books if book.in_stock()]
    return jsonify([asdict(book) for book in books])

Parameter absent → the new branch never runs: the contract in the final table remains true word for word.

Conclusion

Papyrus now speaks both languages of the web: HTML for people (10-02) and JSON for programs. The API covers the book resource's full lifecycle — list, detail, create, update, delete — with the right verbs and codes, and the best part is how much old course material holds up the new: M5's asdict() feeds jsonify, M6's JSON is the body of every response, and M7's BookNotFoundError translates into a 404 through a single-piece errorhandler — the 08-02 decorator at work once again. You tested the contract with curl from outside and with the pytest test client from inside, and you left documented the table any client needs. Flask has taught you the pieces one at a time: route, view, template, JSON, error. The natural question is what happens when the project grows and you want someone to hand you the repetitive pieces ready-made — real persistence, an admin panel, forms with automatic validation, users. That opposite and complementary philosophy has a name: Django, the batteries-included framework. In the next lesson we start a Django project from scratch and you'll see your Book dataclass reborn as a model with persistence thrown in for free.

Python Programming Course

Module 1: Introduction to Python

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved