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",
)

We’re enabling OpenAPI documentation from the start. Visit /docs at any point to see interactive Swagger UI for your API.

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 — list all books. This is a GET request to /books:

@api.route("/books", methods=["GET"], response_model=list)
def list_books(req, resp):
    resp.media = list(books_db.values())

In REST API design, GET requests should never modify data. They’re safe and idempotent — calling them multiple 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. We use request_model=BookIn to validate the input automatically — if the client sends bad data, they get a 422 response with error details:

@api.route("/books", methods=["POST"], check_existing=False,
           request_model=BookIn, response_model=Book)
async def create_book(req, resp):
    global next_id
    data = await req.media()

    book = {"id": next_id, **data}
    books_db[next_id] = book
    next_id += 1

    resp.media = book
    resp.status_code = 201

Note resp.status_code = 201 — the HTTP 201 Created status code tells the client that a new resource was successfully created. This is more informative than a generic 200 OK.

Get a Single Book

Retrieve a specific book by its ID. The {book_id:int} route parameter ensures only integer IDs match — requests like /books/abc will 404:

@api.route("/books/{book_id:int}", methods=["GET"], response_model=Book)
def get_book(req, resp, *, book_id):
    if book_id not in books_db:
        resp.status_code = 404
        resp.media = {"error": f"Book {book_id} not found"}
        return

    resp.media = books_db[book_id]

Update a Book

PUT replaces a resource entirely. The client must send all fields:

@api.route("/books/{book_id:int}", methods=["PUT"], check_existing=False,
           request_model=BookIn, response_model=Book)
async def update_book(req, resp, *, book_id):
    if book_id not in books_db:
        resp.status_code = 404
        resp.media = {"error": f"Book {book_id} not found"}
        return

    data = await req.media()
    book = {"id": book_id, **data}
    books_db[book_id] = book
    resp.media = book

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:
        resp.status_code = 404
        resp.media = {"error": f"Book {book_id} not found"}
        return

    del books_db[book_id]
    resp.status_code = 204

Error Handling

Let’s add a custom error handler so any ValueError in our code returns a clean JSON response instead of a 500 error:

@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

# 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 how to integrate SQLAlchemy with Responder using the lifespan pattern.