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
- What REST is: resources, verbs, statelessness
- Designing the Papyrus API
GET /api/books: the list (withjsonifyandasdict)GET /api/books/<title>: the detail and the 404 witherrorhandlerPOST /api/books: create with validation (201 or 400)PUTandDELETE: update stock and delete- Testing with
curl - Testing with the pytest test client
- Real world:
/api/v1versioning and pagination - 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/<title><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 eachBookdataclass into a dictionary:{"title": "The Odyssey", "price": 12.5, "stock": 4}.- The list comprehension (M4) does it for the whole catalog.
jsonify(...)(M6'sjson.dumpson steroids) serializes to JSON and also assembles the complete HTTP response: theContent-Type: application/jsonheader 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
404with an explanatory JSON. - Your
errors.pyhierarchy gains new value: since they all inherit fromPapyrusError, you could register a generic handler for the whole family. M7 design, M10 dividends.
Response for GET /api/books/Moby-Dick:
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)), 201Let's break down the decisions:
methods=["POST"]: by default a route only accepts GET; here we declare the verb. The same URL/api/bookshas 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, orNoneif 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 aValueErrorif"price": "free"comes in, and aKeyErroriftitleis 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 todata/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 sayLook 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/v2without 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/booksreturns 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/jsonon the client:request.get_json()returnsNoneand your code blows up further down. Withsilent=True+ aNonecheck 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). Checkmethods=[...]— 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
curlURLs:The Odysseymust travel asThe%20Odysseyand the URL must be quoted; otherwisecurlthinks you're passing it two arguments. - Under-validating the POST: "the client will validate" is the 10-01 trap — anyone with
curlbypasses your form. The backend always validates: type, ranges, duplicates, and responds 400 with the reason. - Catching
Exceptionin theerrorhandler: register handlers for specific exceptions (BookNotFoundError). AnExceptionhandler that answers 404 to everything hides genuine bugs that should be 500s and show up inpapyrus.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"] == 9The 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
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
