PaginationRange¶
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=24is 25 contacts; withoutend, a range holdsdefault_items(25 here). For the database,params.offsetandparams.limit. - The total twice: in the body (
total) and inContent-Range: items 0-24/319, where a grid that reads headers finds it;items */319when the range holds nothing. - 400 or 416: a range that can't be one (
endbeforestart, 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-Rangeand 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)