Building a REST API¶
This tutorial walks you through building a complete REST API from scratch. By the end, you’ll have a working API with CRUD operations, request validation, error handling, and interactive documentation.
We’ll build a simple book catalog — a service that lets you create, read, update, and delete books.
Project Setup¶
Create a new file called app.py:
import responder
api = responder.API(
title="Book Catalog",
version="1.0",
openapi="3.0.2",
docs_route="/docs",
sessions=False,
)
We enable OpenAPI documentation from the start. openapi="3.0.2" serves the
machine-readable schema at /schema.yml, and docs_route="/docs" mounts
interactive Swagger UI on top of it — visit /docs at any point to explore
and exercise your API from the browser.
sessions=False tells Responder this service is stateless. Without it,
Responder’s secure-by-default sessions would mint a throwaway signing key on
startup and warn you about it; since we never touch req.session, we simply
switch them off. See Configuration for sessions and secret keys.
Define Your Models¶
We’ll use Pydantic to define our data models. Pydantic models serve double duty — they validate incoming data and generate OpenAPI schemas automatically:
from pydantic import BaseModel
class BookIn(BaseModel):
"""What the client sends when creating a book."""
title: str
author: str
year: int
isbn: str | None = None
class Book(BaseModel):
"""What the API returns."""
id: int
title: str
author: str
year: int
isbn: str | None = None
BookIn is the input model — it doesn’t have an id because the
server assigns that. Book is the output model — it includes
everything. This input/output separation is a common REST API pattern.
In-Memory Storage¶
For this tutorial, we’ll store books in a simple dict. In a real application, you’d use a database (see Using SQLAlchemy):
books_db: dict[int, dict] = {}
next_id = 1
List All Books¶
The first endpoint lists every book. It’s a GET request to /books,
with an optional ?author= filter wired up as a typed query parameter:
from responder import Query
@api.route("/books", methods=["GET"])
def list_books(req, resp, *, author: str | None = Query(None)) -> list[Book]:
books = list(books_db.values())
if author:
books = [b for b in books if b["author"] == author]
resp.media = books
Query(None) declares an optional query parameter — use Query(...) to
make one required, in which case a missing or mis-typed value returns a 422
automatically. Responder reads ?author=... from the query string, coerces
it to the annotated type, and passes it to your handler as a keyword argument.
The -> list[Book] return annotation documents the response shape.
In REST API design, GET requests should never modify data. They’re safe
and idempotent — calling them many times has the same effect as calling them
once.
Create a Book¶
To create a book, the client sends a POST request with a JSON body.
Annotate a parameter with your input model and Responder validates the body
for you, handing the handler a parsed BookIn — no manual req.media(),
and an automatic 422 with error details when the data is bad:
@api.route("/books", methods=["POST"], check_existing=False)
def create_book(req, resp, *, data: BookIn) -> Book:
global next_id
book = {"id": next_id, **data.model_dump()}
books_db[next_id] = book
next_id += 1
return book, 201
data arrives as a validated BookIn instance, so we call
data.model_dump() to turn it back into a plain dict. Returning
book, 201 is Flask-style shorthand: the first item becomes the response
body and the second the status code — here 201 Created, which tells the
client a new resource was created (more informative than a generic 200 OK).
The -> Book return annotation runs the outgoing payload through the Book
model, coercing types and stripping any field the model doesn’t declare.
Get a Single Book¶
Retrieve a book by its ID. The {book_id:int} converter ensures only
integer IDs match, so /books/abc 404s before your handler even runs:
@api.route("/books/{book_id:int}", methods=["GET"])
def get_book(req, resp, *, book_id) -> Book:
if book_id not in books_db:
responder.abort(404, detail=f"Book {book_id} not found")
resp.media = books_db[book_id]
responder.abort() raises a proper HTTP error from anywhere in your code —
no Starlette imports, no juggling resp.status_code by hand. Responder
renders framework-generated errors as application/problem+json by default.
Pass problem_details=False to API(...) to keep the legacy JSON/plain-text
error negotiation.
You can enrich every framework-generated problem payload with
problem_handler=. The handler receives (payload, request, exc) and may
mutate the payload in place or return a replacement dict:
def problem_handler(payload, request, exc):
payload["type"] = f"https://example.com/problems/{payload['status']}"
payload["instance"] = request.url.path
api = responder.API(problem_handler=problem_handler, request_id=True)
When request ID middleware or structured logging is enabled, problem payloads
also include request_id.
Update a Book¶
PUT replaces a resource entirely, so the client sends every field. The same
body injection and abort apply here:
@api.route("/books/{book_id:int}", methods=["PUT"], check_existing=False)
def update_book(req, resp, *, book_id, data: BookIn) -> Book:
if book_id not in books_db:
responder.abort(404, detail=f"Book {book_id} not found")
book = {"id": book_id, **data.model_dump()}
books_db[book_id] = book
return book
book_id comes from the URL and data from the request body — Responder
injects both as keyword arguments.
Delete a Book¶
DELETE removes a resource. The convention is to return 204 No Content
with an empty body on success:
@api.route("/books/{book_id:int}", methods=["DELETE"], check_existing=False)
def delete_book(req, resp, *, book_id):
if book_id not in books_db:
responder.abort(404, detail=f"Book {book_id} not found")
del books_db[book_id]
resp.status_code = 204
Error Handling¶
responder.abort() handles HTTP errors, but you can also map your own
exception types to clean responses. Here, any ValueError that escapes a
handler becomes a tidy 400 instead of a generic 500:
@api.exception_handler(ValueError)
async def handle_value_error(req, resp, exc):
resp.status_code = 400
resp.media = {"error": str(exc)}
Run It¶
Add the standard entry point at the bottom of your file:
if __name__ == "__main__":
api.run()
Start the server:
$ python app.py
Visit http://localhost:5042/docs to see your interactive API
documentation. You can test every endpoint directly from the browser.
Try It Out¶
Using curl:
# Create a book
$ curl -X POST http://localhost:5042/books \
-H "Content-Type: application/json" \
-d '{"title": "Dune", "author": "Frank Herbert", "year": 1965}'
# List all books
$ curl http://localhost:5042/books
# Filter by author
$ curl "http://localhost:5042/books?author=Frank+Herbert"
# Get a specific book
$ curl http://localhost:5042/books/1
# Update a book
$ curl -X PUT http://localhost:5042/books/1 \
-H "Content-Type: application/json" \
-d '{"title": "Dune", "author": "Frank Herbert", "year": 1965, "isbn": "978-0441172719"}'
# Delete a book
$ curl -X DELETE http://localhost:5042/books/1
What’s Next¶
This tutorial used in-memory storage. For a real application, you’ll want a database. See Using SQLAlchemy for integrating SQLAlchemy with Responder using the lifespan pattern and per-request dependency injection.
For the full picture on typed parameters, Pydantic validation, dependency injection, and OpenAPI generation, see the Feature Tour.