Skip to content

PaginationKeyset

PaginationKeyset Client to Your app: GET /messages?after=58. Your app to Database: WHERE id > 58 LIMIT 21. Your app to Client: 200, 59 60, after: 60. Database: message 61 is written. Client to Your app: GET /messages?after=60. Your app to Client: 200, 61 alone. Client to Your app: GET /messages?after=abc. Your app to Client: 400, invalid after. The key of the last item seen: rows written meanwhile shift nothing. Client Your app Database GET /messages?after=58 WHERE id > 58 LIMIT 21 200, 59 60, after: 60 message 61 is written GET /messages?after=60 200, 61 alone GET /messages?after=abc 400, invalid after The key of the last item seen: rows written meanwhile shift nothing
What it's for: clients that keep the key of the last item they have, and ask for what came after it: syncing, chats, exports.

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: after is 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.fetch rows (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/newest reads the index the other way, and after there 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)