Skip to content

PaginationRange

PaginationRange Client to Your app: GET …?start=0&end=24. Your app to Database: count, LIMIT 25 OFFSET 0. Your app to Client: 200, 25 items. Your app: Content-Range: items 0-24/319. Client to Your app: GET …?start=319. Your app to Client: 416, items */319. Client to Your app: GET …?start=10&end=5. Your app to Client: 400, end before start. The rows by index, and the total in Content-Range. Client Your app Database GET …?start=0&end=24 count, LIMIT 25 OFFSET 0 200, 25 items Content-Range: items 0-24/319 GET …?start=319 416, items */319 GET …?start=10&end=5 400, end before start The rows by index, and the total in Content-Range
What it's for: data grids that ask for the rows they show by index, and read which ones they got, and how many there are, from Content-Range.

A contact list for a data grid, which asks for the rows it shows by the indexes of the first and the last, and reads which ones it got, and how many there are in all, from Content-Range. The strategy: PaginationRange.

Run it

From a clone of the repository:

make deps                 # .venv, with every extra
source .venv/bin/activate
uvicorn examples.pagination.ranges:app

Try it

The first three contacts, items 0 to 2 of 319:

$ curl -i "http://localhost:8000/contacts?start=0&end=2"
HTTP/1.1 200 OK
content-type: application/json
content-range: items 0-2/319
link: </contacts?start=0&end=2>; rel="first", </contacts?start=3&end=5>; rel="next", </contacts?start=318&end=320>; rel="last"
etag: W/"76667f0812a81129bb32cdab8752ca68"
cache-status: Alcazaba; fwd=miss; stored
cache-control: max-age=300

{"items":[{"name":"Contact 001","email":"contact1@example.com","id":1},{"name":"Contact 002","email":"contact2@example.com","id":2},{"name":"Contact 003","email":"contact3@example.com","id":3}],"start":0,"end":2,"total":319,"limit":3}

The last range of five holds four: end and Content-Range say which items came, while the links keep the size asked for. Asked again, it comes from the cache, Content-Range and links included:

$ curl -i "http://localhost:8000/contacts?start=315&end=319"
HTTP/1.1 200 OK
content-type: application/json
content-range: items 315-318/319
link: </contacts?start=0&end=4>; rel="first", </contacts?start=310&end=314>; rel="prev", </contacts?start=315&end=319>; rel="last"
etag: W/"dfb6651db798fb4f78bdba3a7f653b34"
cache-status: Alcazaba; fwd=miss; stored
cache-control: max-age=300

{"items":[{"name":"Contact 316","email":"contact316@example.com","id":316},{"name":"Contact 317","email":"contact317@example.com","id":317},{"name":"Contact 318","email":"contact318@example.com","id":318},{"name":"Contact 319","email":"contact319@example.com","id":319}],"start":315,"end":318,"total":319,"limit":5}

$ curl -si "http://localhost:8000/contacts?start=315&end=319" | grep -E "cache-status|content-range"
cache-status: Alcazaba; hit; ttl=300
content-range: items 315-318/319

A range that starts past the last contact is a 416, which says how many there are:

$ curl -i "http://localhost:8000/contacts?start=319"
HTTP/1.1 416 Range Not Satisfiable
content-range: items */319
content-type: application/json

{"detail":"Range Not Satisfiable"}

A range that ends before it starts, or holds more than 100 contacts, is a 400:

$ curl -i "http://localhost:8000/contacts?start=10&end=5"
HTTP/1.1 400 Bad Request
content-type: application/json

{"detail":"Invalid range: end is before start"}

$ curl -i "http://localhost:8000/contacts?start=0&end=499"
HTTP/1.1 400 Bad Request
content-type: application/json

{"detail":"Invalid range: more than 100 items"}

What to look at

  • Both ends included, from 0: start=0&end=24 is 25 contacts; without end, a range holds default_items (25 here). For the database, params.offset and params.limit.
  • The total twice: in the body (total) and in Content-Range: items 0-24/319, where a grid that reads headers finds it; items */319 when the range holds nothing.
  • 400 or 416: a range that can't be one (end before start, or too big) is the client's mistake, a 400 before anything is queried. One that starts past the end is a 416, after the count: the list may have shrunk since the client last read its total.
  • Cached with its headers: each range is an entry, and a hit gets its Content-Range and links back. A new contact (POST /contacts) changes the total, so it drops every range.

The code

examples/pagination/ranges.py
"""A contact list for a data grid, paginated by range: the grid asks for the rows it shows by their
indexes, and reads which ones it got, and how many there are, from `Content-Range`.

    uvicorn examples.pagination.ranges:app

`/contacts?start=0&end=24` answers the first 25 contacts with `Content-Range: items 0-24/319`. A
range that starts past the last contact is a 416, with `Content-Range: items */319`. Each range
is cached under its own key, and a new contact drops them all.
"""

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Annotated

import uvicorn
from fastapi import Depends, FastAPI, Request, status
from pydantic import BaseModel

from fastapi_alcazaba import FastAPICache
from fastapi_alcazaba.backends.inmemory import InMemoryBackend
from fastapi_alcazaba.pagination.ranges import PaginationRange, RangePage, RangeParams
from fastapi_alcazaba.response_cache import cache


class NewContact(BaseModel):
    name: str
    email: str


class Contact(NewContact):
    id: int


class Database:
    """Stands in for the database (a session per request in a real app)."""

    def __init__(self) -> None:
        self.contacts = [
            Contact(id=i, name=f"Contact {i:03}", email=f"contact{i}@example.com")
            for i in range(1, 320)
        ]
        self.queries = 0

    def page(self, params: RangeParams) -> RangePage[Contact]:
        """What a database does: count, and fetch only the range (ORDER BY name LIMIT :limit
        OFFSET :offset)."""
        self.queries += 1
        by_name = sorted(self.contacts, key=lambda contact: contact.name)
        rows = by_name[params.offset : params.offset + params.limit]
        return ranges.paginate(rows, params, total=len(by_name))

    def add(self, contact: NewContact) -> Contact:
        saved = Contact(id=len(self.contacts) + 1, **contact.model_dump())
        self.contacts.append(saved)
        return saved


@asynccontextmanager
async def lifespan(application: FastAPI) -> AsyncIterator[None]:
    FastAPICache.init(InMemoryBackend(), prefix="contacts", vary_headers=())
    application.state.db = Database()
    yield


app = FastAPI(lifespan=lifespan)


def get_db(request: Request) -> Database:
    db: Database = request.app.state.db
    return db


DatabaseDep = Annotated[Database, Depends(get_db)]

ranges = PaginationRange(default_items=25, max_items=100)


@app.get("/contacts")
@cache(expire=300, tags=["contacts"], exclude_params=["db"])
async def contacts(
    params: Annotated[RangeParams, Depends(ranges)],
    db: DatabaseDep,
) -> RangePage[Contact]:
    """A range of contacts, by name: the cache gives a hit its Content-Range too."""
    return db.page(params)


@app.post("/contacts", status_code=status.HTTP_201_CREATED)
async def add_contact(contact: NewContact, db: DatabaseDep) -> Contact:
    """The total changes, and the new contact may land in any range: drop them all."""
    saved = db.add(contact)
    await FastAPICache.invalidate_tags("contacts")
    return saved


if __name__ == "__main__":
    uvicorn.run("ranges:app", reload=True)