mirror of
https://github.com/kennethreitz/responder.git
synced 2026-10-24 05:59:12 +00:00
266 lines
8.4 KiB
ReStructuredText
266 lines
8.4 KiB
ReStructuredText
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 :doc:`guide-config` for sessions and secret keys.
|
|
|
|
|
|
.. _tutorial-rest-models:
|
|
|
|
Define Your Models
|
|
------------------
|
|
|
|
We'll use `Pydantic <https://docs.pydantic.dev/>`_ 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 :doc:`tutorial-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]
|
|
return 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]`` annotation validates and serializes the response, places
|
|
the same shape in OpenAPI, and types generated clients.
|
|
|
|
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, status_code=201
|
|
)
|
|
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
|
|
|
|
``data`` arrives as a validated ``BookIn`` instance, so we call
|
|
``data.model_dump()`` to turn it back into a plain dict. Returning
|
|
``status_code=201`` declares the success status — ``201 Created`` 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.no_content()
|
|
|
|
|
|
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 :doc:`tutorial-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 :doc:`tour`.
|