Write endpoints — POST to create, PUT to fully replace, PATCH to partially update — require more defensive programming than read endpoints. Each write must validate ownership (you can only edit your own posts), handle database constraint violations gracefully (duplicate slug → 409), and return the correct status code (201 for create, 200 for update). Using separate PostCreate, PostUpdate, and PostResponse schemas for each operation (as established in Chapter 23) keeps each endpoint focused and prevents clients from setting fields they should not.
Create Endpoint (POST)
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from app.schemas.post import PostCreate, PostResponse
from app.models.post import Post
router = APIRouter()
@router.post(
"/",
response_model = PostResponse,
status_code = status.HTTP_201_CREATED,
summary = "Create a new blog post",
)
def create_post(
post_in: PostCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
try:
post = Post(
**post_in.model_dump(),
author_id = current_user.id,
)
db.add(post)
db.flush()
db.refresh(post)
return post
except IntegrityError as e:
db.rollback()
if "posts_slug_key" in str(e.orig) or "unique" in str(e.orig).lower():
raise HTTPException(
status_code = status.HTTP_409_CONFLICT,
detail = "A post with this slug already exists",
)
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Database error")
db.flush() writes the new post to the database within the current transaction, making the auto-generated id and created_at (from server_default) available. db.refresh(post) then reloads those server-generated values into the Python object. Without the flush + refresh, returning the post immediately after db.add() would give you an object with id=None and no created_at. The actual commit happens when the get_db dependency exits successfully.IntegrityError specifically for expected constraint violations (duplicate slug, duplicate email) and let unexpected exceptions propagate. Wrapping every database operation in a generic except Exception hides real bugs. Match the specific constraint name ("posts_slug_key") or check for the word “unique” in the error message to differentiate duplicate key violations from foreign key violations or NOT NULL violations.author_id from the request body — always set it from the authenticated user. If a client sends {"author_id": 1, "title": "..."}) with the intention of creating a post attributed to another user, your endpoint must ignore that field and use current_user.id. This is why PostCreate should never include author_id as a field.Full Update Endpoint (PUT)
from app.schemas.post import PostCreate, PostResponse
# PUT replaces the entire resource — client sends all fields
@router.put(
"/{post_id}",
response_model = PostResponse,
summary = "Replace a post (full update)",
)
def replace_post(
post_id: int,
post_in: PostCreate, # full PostCreate — all required fields
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
post = db.get(Post, post_id)
if not post or post.deleted_at:
raise HTTPException(404, "Post not found")
if post.author_id != current_user.id and current_user.role != "admin":
raise HTTPException(403, "Not authorised to edit this post")
try:
for field, value in post_in.model_dump().items():
setattr(post, field, value)
db.flush()
db.refresh(post)
return post
except IntegrityError:
db.rollback()
raise HTTPException(409, "Slug already in use by another post")
Partial Update Endpoint (PATCH)
from app.schemas.post import PostUpdate
# PATCH updates only the provided fields — client sends subset
@router.patch(
"/{post_id}",
response_model = PostResponse,
summary = "Partially update a post",
)
def update_post(
post_id: int,
update_in: PostUpdate, # all fields optional
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
post = db.get(Post, post_id)
if not post or post.deleted_at:
raise HTTPException(404, "Post not found")
if post.author_id != current_user.id and current_user.role != "admin":
raise HTTPException(403, "Not authorised")
# Only update fields that were explicitly provided
changes = update_in.model_dump(exclude_none=True)
if not changes:
return post # nothing to update — return unchanged
try:
for field, value in changes.items():
setattr(post, field, value)
db.flush()
db.refresh(post)
return post
except IntegrityError:
db.rollback()
raise HTTPException(409, "Slug already in use")
Common Mistakes
Mistake 1 — Trusting author_id from request body
❌ Wrong — client can set any author:
post = Post(**post_in.model_dump()) # model_dump() includes any author_id the client sent!
✅ Correct — always override with authenticated user:
post = Post(**post_in.model_dump(), author_id=current_user.id) # ✓
Mistake 2 — PATCH using model_dump() without exclude_none
❌ Wrong — sets unmentioned fields to None:
for field, value in update_in.model_dump().items():
setattr(post, field, value) # body=None if client only sent title!
✅ Correct:
for field, value in update_in.model_dump(exclude_none=True).items():
setattr(post, field, value) # ✓ only sent fields updated
Mistake 3 — No ownership check on update (IDOR vulnerability)
❌ Wrong — any user can update any post:
post = db.get(Post, post_id)
if not post: raise HTTPException(404)
# No check: who owns this post?
for field, value in update.items(): setattr(post, field, value)
✅ Correct — check ownership before modifying:
if post.author_id != current_user.id and current_user.role != "admin":
raise HTTPException(403, "Not your post") # ✓
Quick Reference
| Endpoint | Schema | Status | Key Concern |
|---|---|---|---|
| POST /posts | PostCreate | 201 | Set author from auth, handle duplicate slug (409) |
| PUT /posts/{id} | PostCreate | 200 | Ownership check, full replace, handle 409 |
| PATCH /posts/{id} | PostUpdate | 200 | Ownership check, exclude_none=True, handle 409 |