PaginationKeyset¶
A chat's messages by keyset, the key in plain sight: clients ask for what came after the last message they have, or page back through the history, newest first. The strategy: PaginationKeyset.
Run it¶
From a clone of the repository:
make deps # .venv, with every extra
source .venv/bin/activate
uvicorn examples.pagination.keyset:app
Try it¶
The first three messages, and the key of the last one in after:
$ curl -i "http://localhost:8000/messages?limit=3"
HTTP/1.1 200 OK
content-type: application/json
link: </messages?limit=3>; rel="first", </messages?after=3&limit=3>; rel="next"
etag: W/"27bea474cecbda7b21974e6e090dff20"
cache-status: Alcazaba; fwd=miss; stored
cache-control: max-age=300
{"items":[{"author":"leo","text":"Message 1","id":1},{"author":"eva","text":"Message 2","id":2},{"author":"ana","text":"Message 3","id":3}],"limit":3,"after":"3","has_more":true}
$ curl "http://localhost:8000/messages?after=3&limit=3"
{"items":[{"author":"leo","text":"Message 4","id":4},{"author":"eva","text":"Message 5","id":5},{"author":"ana","text":"Message 6","id":6}],"limit":3,"after":"6","has_more":true}
The last page says there's no more, and still where it ended:
$ curl -i "http://localhost:8000/messages?after=58"
HTTP/1.1 200 OK
content-type: application/json
link: </messages?limit=20>; rel="first"
etag: W/"b2c145cc80d08760326a4e2e28fade67"
cache-status: Alcazaba; fwd=miss; stored
cache-control: max-age=300
{"items":[{"author":"eva","text":"Message 59","id":59},{"author":"ana","text":"Message 60","id":60}],"limit":20,"after":"60","has_more":false}
A client that syncs keeps that after, and asks with it later for what came since:
$ curl -X POST http://localhost:8000/messages -H "Content-Type: application/json" -d '{"author": "ana", "text": "¿Comemos?"}'
{"author":"ana","text":"¿Comemos?","id":61}
$ curl -i "http://localhost:8000/messages?after=60"
HTTP/1.1 200 OK
content-type: application/json
link: </messages?limit=20>; rel="first"
etag: W/"9ac579890d7cc4d21627e6ab93c9e11d"
cache-status: Alcazaba; fwd=miss; stored
cache-control: max-age=300
{"items":[{"author":"ana","text":"¿Comemos?","id":61}],"limit":20,"after":"61","has_more":false}
The history read backwards, newest first:
$ curl -i "http://localhost:8000/messages/newest?limit=3"
HTTP/1.1 200 OK
content-type: application/json
link: </messages/newest?limit=3>; rel="first", </messages/newest?after=59&limit=3>; rel="next"
etag: W/"1403824bdb6e14ea4cf53f720ab18589"
cache-status: Alcazaba; fwd=miss; stored
cache-control: max-age=300
{"items":[{"author":"ana","text":"¿Comemos?","id":61},{"author":"ana","text":"Message 60","id":60},{"author":"eva","text":"Message 59","id":59}],"limit":3,"after":"59","has_more":true}
A key that isn't an id is refused before anything is queried:
$ curl -i "http://localhost:8000/messages?after=abc"
HTTP/1.1 400 Bad Request
content-type: application/json
{"detail":"Invalid after"}
What to look at¶
- The key, as it is:
afteris the id of the last message, as text; a key of several values, such as(created_at, id), would be a JSON array. - One row more: each query fetches
params.fetchrows (the limit and one), which tells whether there's a next page. - Syncing: the last page still gives its
after, so a client that has caught up asks for whatever came since with it. - Both orders:
/messages/newestreads the index the other way, andafterthere means the messages before it. - Cached page by page: each key and limit is an entry of its own, and a new message drops
them all (tag
messages).
The code¶
examples/pagination/keyset.py
"""A chat's messages paginated by keyset, the key in plain sight: clients ask for what came after
the last message they have, or page back through the history, newest first.
uvicorn examples.pagination.keyset:app
`/messages?after=42&limit=20` gives the messages after 42, in order: a client that keeps the
`after` of its last page gets only the new ones, however many arrive meanwhile. `/messages/newest`
pages back from the newest, `after` being the oldest message of the page before. The key is the
id, which clients read and may write; each page is cached under its own key, and a new message
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.keyset import KeysetPage, KeysetParams, PaginationKeyset
from fastapi_alcazaba.response_cache import cache
class NewMessage(BaseModel):
author: str
text: str
class Message(NewMessage):
id: int
class Database:
"""Stands in for the database (a session per request in a real app)."""
def __init__(self) -> None:
authors = ("ana", "leo", "eva")
self.messages = [
Message(id=i, author=authors[i % 3], text=f"Message {i}") for i in range(1, 61)
]
self.queries = 0
def after(self, params: KeysetParams) -> KeysetPage[Message]:
"""What a database does: WHERE id > :after ORDER BY id LIMIT :fetch."""
self.queries += 1
rows = self.messages
if isinstance(params.after, int):
rows = [message for message in rows if message.id > params.after]
return keyset.paginate(rows[: params.fetch], params, key=lambda m: m.id, queried=True)
def before(self, params: KeysetParams) -> KeysetPage[Message]:
"""What a database does: WHERE id < :after ORDER BY id DESC LIMIT :fetch."""
self.queries += 1
rows = self.messages[::-1]
if isinstance(params.after, int):
rows = [message for message in rows if message.id < params.after]
return keyset.paginate(
rows[: params.fetch],
params,
key=lambda m: m.id,
descending=True,
queried=True,
)
def add(self, message: NewMessage) -> Message:
saved = Message(id=len(self.messages) + 1, **message.model_dump())
self.messages.append(saved)
return saved
@asynccontextmanager
async def lifespan(application: FastAPI) -> AsyncIterator[None]:
FastAPICache.init(InMemoryBackend(), prefix="chat", 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)]
# Sorted by id, an int: ?after=42. Several values, such as (created_at, id), go as a JSON array.
keyset = PaginationKeyset(sort=int, default_limit=20, max_limit=100)
@app.get("/messages")
@cache(expire=300, tags=["messages"], exclude_params=["db"])
async def messages(
params: Annotated[KeysetParams, Depends(keyset)],
db: DatabaseDep,
) -> KeysetPage[Message]:
"""The messages after `after`, oldest first: for clients that sync."""
return db.after(params)
@app.get("/messages/newest")
@cache(expire=300, tags=["messages"], exclude_params=["db"])
async def newest_messages(
params: Annotated[KeysetParams, Depends(keyset)],
db: DatabaseDep,
) -> KeysetPage[Message]:
"""The messages before `after`, newest first: for a history read backwards."""
return db.before(params)
@app.post("/messages", status_code=status.HTTP_201_CREATED)
async def add_message(message: NewMessage, db: DatabaseDep) -> Message:
"""The last pages change: drop them all."""
saved = db.add(message)
await FastAPICache.invalidate_tags("messages")
return saved
if __name__ == "__main__":
uvicorn.run("keyset:app", reload=True)