Read Endpoints — List with Pagination and Single Resource

GET endpoints are the majority of API traffic — every page load, every list refresh, every detail view hits a read endpoint. Well-designed read endpoints are fast (indexes, eager loading), predictable (consistent pagination envelope), and defensive (404 for missing resources, 403 for unauthorised access). This lesson builds the production-quality GET endpoints for the blog application — a paginated list with filtering, a detail endpoint with nested related data, and an “own posts” endpoint that returns only the authenticated user’s posts.

Paginated List Endpoint

# app/routers/posts.py
from fastapi import APIRouter, Depends, Query, HTTPException, status
from sqlalchemy.orm import Session
from typing import Annotated, Literal
from app.dependencies import get_db
from app.schemas.post import PostResponse
from app.schemas.pagination import Page
from app.repositories import post_repo

router = APIRouter()

@router.get(
    "/",
    response_model     = Page[PostResponse],
    summary            = "List published posts",
    description        = "Returns a paginated list of published posts with optional filtering.",
)
def list_posts(
    page:      Annotated[int, Query(ge=1, description="Page number")] = 1,
    page_size: Annotated[int, Query(ge=1, le=100)] = 10,
    author_id: int | None = None,
    search:    Annotated[str | None, Query(max_length=200)] = None,
    sort_by:   Literal["created_at", "view_count", "title"] = "created_at",
    order:     Literal["asc", "desc"] = "desc",
    db:        Session = Depends(get_db),
):
    posts, total = post_repo.list_published_posts(
        db, page=page, page_size=page_size,
        author_id=author_id, search=search,
        sort_by=sort_by, order=order,
    )
    return Page.create(items=posts, total=total, page=page, page_size=page_size)
Note: The response_model=Page[PostResponse] handles two important jobs: it filters each post through PostResponse (removing sensitive fields like password_hash) and it documents the response shape in Swagger UI. FastAPI resolves the generic Page[PostResponse] at runtime and generates the correct OpenAPI schema showing items as an array of PostResponse objects. Always specify the concrete generic type in response_model, not the raw Page.
Tip: For list endpoints with full-text search, consider using PostgreSQL’s tsvector search covered in Chapter 19 rather than ILIKE '%term%'. The ILIKE pattern requires a sequential scan on the title and body columns, making it slow on large tables. A GIN index on the tsvector column makes search fast regardless of table size. Introduce this optimisation when the posts table exceeds ~100,000 rows and search latency becomes noticeable.
Warning: Never expose internal database IDs in URL paths if they are sequential integers and the resources are sensitive. GET /posts/42 lets clients enumerate all posts by trying 1, 2, 3… For public content like blog posts, sequential IDs are fine. For private resources (invoices, user data), use UUIDs in the public URL (GET /posts/a8098c1a-f86e-...) while keeping integer PKs for internal joins.

Single Resource with 404 Handling

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session, joinedload, selectinload
from sqlalchemy import select
from app.models.post import Post
from app.schemas.post import PostDetailResponse

@router.get(
    "/{post_id}",
    response_model = PostDetailResponse,
    summary        = "Get a single post by ID",
)
def get_post(post_id: int, db: Session = Depends(get_db)):
    post = db.scalars(
        select(Post)
        .options(
            joinedload(Post.author),
            selectinload(Post.tags),
            selectinload(Post.comments).joinedload(Comment.author),
        )
        .where(Post.id == post_id, Post.deleted_at.is_(None))
    ).first()

    if not post:
        raise HTTPException(
            status_code = status.HTTP_404_NOT_FOUND,
            detail      = f"Post {post_id} not found",
        )

    # Increment view count (fire-and-forget — do not fail on error)
    try:
        post.view_count += 1
        db.flush()
    except Exception:
        pass   # view count increment is best-effort

    return post

# ── Get by slug (public URL) ──────────────────────────────────────────────────
@router.get("/by-slug/{slug}", response_model=PostDetailResponse)
def get_post_by_slug(slug: str, db: Session = Depends(get_db)):
    post = db.scalars(
        select(Post)
        .options(joinedload(Post.author), selectinload(Post.tags))
        .where(Post.slug == slug, Post.status == "published",
               Post.deleted_at.is_(None))
    ).first()
    if not post:
        raise HTTPException(404, "Post not found")
    return post

My Posts — Authenticated User’s Own Resources

from app.dependencies import get_current_user
from app.models.user import User

@router.get(
    "/mine",
    response_model = Page[PostResponse],
    summary        = "List current user's posts (all statuses)",
)
def list_my_posts(
    page:      int = 1,
    page_size: int = 10,
    status:    str | None = None,  # filter by status
    db:        Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    stmt = (
        select(Post)
        .options(selectinload(Post.tags))
        .where(Post.author_id == current_user.id, Post.deleted_at.is_(None))
    )
    if status:
        stmt = stmt.where(Post.status == status)

    total = db.scalar(select(func.count()).select_from(stmt.subquery()))
    posts = db.scalars(
        stmt.order_by(Post.updated_at.desc())
            .offset((page - 1) * page_size)
            .limit(page_size)
    ).all()
    return Page.create(items=posts, total=total, page=page, page_size=page_size)

Common Mistakes

Mistake 1 — Not filtering deleted_at in read queries

❌ Wrong — soft-deleted posts appear in results:

posts = db.scalars(select(Post).where(Post.status == "published")).all()
# Returns deleted posts — soft delete is not applied!

✅ Correct — always filter deleted_at in every query:

posts = db.scalars(
    select(Post)
    .where(Post.status == "published", Post.deleted_at.is_(None))   # ✓
).all()

Mistake 2 — N+1 queries from lazy loading in list endpoint

❌ Wrong — author loaded lazily for each post (N+1):

posts = db.scalars(select(Post).limit(20)).all()
return posts   # each post.author access → extra query!

✅ Correct — eager load with joinedload/selectinload.

Mistake 3 — No 404 on missing resource (returns None, serialisation fails)

❌ Wrong — None returned, FastAPI cannot serialise it:

post = db.get(Post, post_id)
return post   # None → serialisation error or empty response

✅ Correct — always check and raise 404:

post = db.get(Post, post_id)
if not post:
    raise HTTPException(404, "Post not found")   # ✓
return post

Quick Reference

Pattern Code
Paginated list response_model=Page[PostResponse] + Page.create(...)
404 on missing if not post: raise HTTPException(404, ...)
Filter soft-deleted .where(Post.deleted_at.is_(None))
Eager load author .options(joinedload(Post.author))
Eager load tags .options(selectinload(Post.tags))
Count total db.scalar(select(func.count()).select_from(stmt.subquery()))

🧠 Test Yourself

A GET /posts endpoint returns published posts. Users have soft-deleted some posts. The endpoint correctly filters deleted_at IS NULL but still shows some deleted posts. What else might be wrong?