Module 5: WebSockets and File Uploads
Project: Real-time Notifications and File Uploads
Project overview
In this project you add two fundamental capabilities to the Task Manager API you've been building since Module 1: real-time notifications via WebSocket and file uploads to attach files to tasks.
The result: any user connected to the WebSocket will instantly see when someone creates, updates, or completes a task. And every task can have attached files (PDFs, images, documents) with type and size validation.
These two features turn your API from "a CRUD that only answers when asked" into "a system that keeps everyone in sync in real time and handles multimedia content."
Project objective
By the time you complete this project:
- ✅ Your API has a WebSocket endpoint that notifies events in real time
- ✅ A ConnectionManager handles multiple simultaneous connections
- ✅ Creating, updating, and completing tasks generates WebSocket notifications
- ✅ Tasks support file uploads with type and size validation
- ✅ Files are saved to disk with unique names
- ✅ There's an endpoint to download attached files
- ✅ WebSocket + background tasks work together for notifications
Technical specifications
Stack
- Python 3.9+
- FastAPI with uvicorn
- python-multipart (for uploads)
- websockets (for testing)
Updated structure
task-manager-api/
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── models.py
│ ├── data.py
│ ├── dependencies.py
│ ├── websocket_manager.py ← NEW
│ ├── routers/
│ │ ├── __init__.py
│ │ ├── tasks.py ← Updated with WS notifications
│ │ ├── websocket.py ← NEW
│ │ └── uploads.py ← NEW
│ └── uploads/ ← Directory for files
├── requirements.txt
└── README.md
Setup
pip install python-multipart
mkdir -p app/uploads
Step 1: ConnectionManager
Create app/websocket_manager.py:
from fastapi import WebSocket
from datetime import datetime
class ConnectionManager:
def __init__(self):
self.active_connections: list[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections:
self.active_connections.remove(websocket)
async def send_personal(self, data: dict, websocket: WebSocket):
await websocket.send_json(data)
async def broadcast(self, data: dict):
disconnected = []
for connection in self.active_connections:
try:
await connection.send_json(data)
except Exception:
disconnected.append(connection)
for conn in disconnected:
self.disconnect(conn)
async def broadcast_event(self, event_type: str, payload: dict):
message = {
"type": event_type,
"data": payload,
"timestamp": datetime.now().isoformat(),
"connections": len(self.active_connections)
}
await self.broadcast(message)
@property
def connection_count(self) -> int:
return len(self.active_connections)
ws_manager = ConnectionManager()
Step 2: The WebSocket router
Create app/routers/websocket.py:
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.websocket_manager import ws_manager
router = APIRouter(tags=["WebSocket"])
@router.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
await ws_manager.connect(ws)
await ws_manager.send_personal(
{
"type": "connected",
"message": "Connected to the Task Manager event stream",
"active_connections": ws_manager.connection_count
},
ws
)
try:
while True:
data = await ws.receive_json()
if data.get("action") == "ping":
await ws_manager.send_personal({"type": "pong"}, ws)
elif data.get("action") == "status":
await ws_manager.send_personal(
{
"type": "status",
"connections": ws_manager.connection_count
},
ws
)
except WebSocketDisconnect:
ws_manager.disconnect(ws)
await ws_manager.broadcast_event("user_disconnected", {
"connections": ws_manager.connection_count
})
Step 3: Update the tasks router with notifications
Modify app/routers/tasks.py to add WebSocket notifications to every CRUD operation:
from fastapi import APIRouter, HTTPException, Depends
from app.models import TaskCreate, TaskUpdate, TaskPatch, TaskResponse
from app.data import tasks_db, generate_id, get_current_timestamp
from app.dependencies import get_task_or_404, pagination_params
from app.websocket_manager import ws_manager
router = APIRouter(prefix="/tasks", tags=["Tasks"])
@router.post("/", status_code=201, response_model=TaskResponse)
async def create_task(task_data: TaskCreate):
new_task = {
"id": generate_id(),
"title": task_data.title,
"description": task_data.description,
"status": task_data.status.value if hasattr(task_data.status, 'value') else task_data.status,
"priority": task_data.priority.value if hasattr(task_data.priority, 'value') else task_data.priority,
"created_at": get_current_timestamp(),
"updated_at": get_current_timestamp(),
"attachments": []
}
tasks_db.append(new_task)
await ws_manager.broadcast_event("task_created", {
"task_id": new_task["id"],
"title": new_task["title"],
"priority": new_task["priority"]
})
return new_task
@router.get("/", response_model=list[TaskResponse])
async def list_tasks(pagination: dict = Depends(pagination_params)):
skip = pagination["skip"]
limit = pagination["limit"]
return tasks_db[skip:skip + limit]
@router.get("/{task_id}", response_model=TaskResponse)
async def get_task(task: dict = Depends(get_task_or_404)):
return task
@router.put("/{task_id}", response_model=TaskResponse)
async def update_task(task_data: TaskUpdate, task: dict = Depends(get_task_or_404)):
task["title"] = task_data.title
task["description"] = task_data.description
task["status"] = task_data.status.value if hasattr(task_data.status, 'value') else task_data.status
task["priority"] = task_data.priority.value if hasattr(task_data.priority, 'value') else task_data.priority
task["updated_at"] = get_current_timestamp()
await ws_manager.broadcast_event("task_updated", {
"task_id": task["id"],
"title": task["title"],
"status": task["status"]
})
return task
@router.patch("/{task_id}", response_model=TaskResponse)
async def patch_task(task_data: TaskPatch, task: dict = Depends(get_task_or_404)):
update_data = task_data.model_dump(exclude_unset=True)
old_status = task.get("status")
for field, value in update_data.items():
if hasattr(value, 'value'):
task[field] = value.value
else:
task[field] = value
task["updated_at"] = get_current_timestamp()
new_status = task.get("status")
if old_status != new_status and new_status == "completed":
await ws_manager.broadcast_event("task_completed", {
"task_id": task["id"],
"title": task["title"],
"completed_at": task["updated_at"]
})
else:
await ws_manager.broadcast_event("task_patched", {
"task_id": task["id"],
"fields_updated": list(update_data.keys())
})
return task
@router.delete("/{task_id}", status_code=204)
async def delete_task(task: dict = Depends(get_task_or_404)):
tasks_db.remove(task)
await ws_manager.broadcast_event("task_deleted", {
"task_id": task["id"],
"title": task["title"]
})
Step 4: The upload router
Create app/routers/uploads.py:
import uuid
from pathlib import Path
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends
from fastapi.responses import FileResponse
from app.data import tasks_db
from app.dependencies import get_task_or_404
from app.websocket_manager import ws_manager
router = APIRouter(prefix="/tasks", tags=["Attachments"])
UPLOAD_DIR = Path("app/uploads")
UPLOAD_DIR.mkdir(exist_ok=True)
ALLOWED_TYPES = {
"image/jpeg", "image/png", "image/gif", "image/webp",
"application/pdf",
"text/plain", "text/csv",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
def generate_safe_filename(original: str) -> str:
ext = Path(original).suffix.lower()
return f"{uuid.uuid4().hex[:12]}{ext}"
@router.post("/{task_id}/attachments")
async def upload_attachment(
task_id: int,
file: UploadFile = File(...),
description: str = Form(default=""),
task: dict = Depends(get_task_or_404)
):
if file.content_type not in ALLOWED_TYPES:
raise HTTPException(400, detail={
"error": "File type not allowed",
"received": file.content_type,
"allowed": list(ALLOWED_TYPES)
})
content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(413, detail={
"error": "File too large",
"max_mb": MAX_FILE_SIZE / (1024 * 1024),
"received_mb": round(len(content) / (1024 * 1024), 2)
})
safe_name = generate_safe_filename(file.filename)
file_path = UPLOAD_DIR / safe_name
with open(file_path, "wb") as f:
f.write(content)
attachment_info = {
"filename": safe_name,
"original_name": file.filename,
"description": description,
"content_type": file.content_type,
"size_bytes": len(content)
}
if "attachments" not in task:
task["attachments"] = []
task["attachments"].append(attachment_info)
await ws_manager.broadcast_event("attachment_added", {
"task_id": task_id,
"task_title": task["title"],
"filename": file.filename,
"size_kb": round(len(content) / 1024, 1)
})
return {
"task_id": task_id,
"attachment": attachment_info,
"total_attachments": len(task["attachments"])
}
@router.get("/{task_id}/attachments")
async def list_attachments(task: dict = Depends(get_task_or_404)):
return {
"task_id": task["id"],
"attachments": task.get("attachments", []),
"count": len(task.get("attachments", []))
}
@router.get("/{task_id}/attachments/{filename}")
async def download_attachment(
filename: str,
task: dict = Depends(get_task_or_404)
):
attachments = task.get("attachments", [])
attachment = next(
(a for a in attachments if a["filename"] == filename),
None
)
if not attachment:
raise HTTPException(404, detail=f"Attachment {filename} not found on this task")
file_path = UPLOAD_DIR / filename
if not file_path.exists():
raise HTTPException(404, detail="File not found on disk")
return FileResponse(
path=file_path,
filename=attachment["original_name"],
media_type=attachment["content_type"]
)
Step 5: Update main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.routers import tasks, websocket, uploads
@asynccontextmanager
async def lifespan(app):
print("Task Manager API with WebSocket and Uploads started")
yield
print("Task Manager API stopped")
app = FastAPI(
title="Task Manager API",
description="An advanced API with real-time notifications and file uploads",
version="2.0.0",
lifespan=lifespan
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(tasks.router)
app.include_router(uploads.router)
app.include_router(websocket.router)
@app.get("/health")
async def health():
from app.websocket_manager import ws_manager
return {
"status": "healthy",
"websocket_connections": ws_manager.connection_count
}
Verification
Preparation
# Terminal 1: the server
uvicorn app.main:app --reload
# Terminal 2: the WebSocket client
python -c "
import asyncio, websockets, json
async def listen():
async with websockets.connect('ws://localhost:8000/ws') as ws:
print('Connected to the WebSocket')
while True:
msg = json.loads(await ws.recv())
print(f'[{msg[\"type\"]}] {json.dumps(msg[\"data\"], indent=2)}')
asyncio.run(listen())
"
Tests
1. Check the WebSocket connection:
The client in Terminal 2 should show: [connected] ...
2. Create a task (Terminal 3):
curl -s -X POST http://localhost:8000/tasks/ \
-H "Content-Type: application/json" \
-d '{"title": "Test task", "description": "Testing WS", "priority": "high"}' | python -m json.tool
Terminal 2 should show: [task_created] {"task_id": ..., "title": "Test task", ...}
3. Complete the task:
curl -s -X PATCH http://localhost:8000/tasks/1 \
-H "Content-Type: application/json" \
-d '{"status": "completed"}' | python -m json.tool
Terminal 2 should show: [task_completed] ...
4. Upload a file:
echo "Test content" > /tmp/test.txt
curl -s -X POST http://localhost:8000/tasks/1/attachments \
-F "file=@/tmp/test.txt" \
-F "description=Test file" | python -m json.tool
Terminal 2 should show: [attachment_added] ...
5. List the attachments:
curl -s http://localhost:8000/tasks/1/attachments | python -m json.tool
6. Download the file:
# Use the filename from step 4
curl -s http://localhost:8000/tasks/1/attachments/{filename} -o downloaded.txt
cat downloaded.txt
7. Type validation:
# Try uploading an .exe (it should fail with 400)
echo "fake" > /tmp/test.exe
curl -s -X POST http://localhost:8000/tasks/1/attachments \
-F "file=@/tmp/test.exe;type=application/x-msdownload" | python -m json.tool
8. Health check with the WS count:
curl -s http://localhost:8000/health | python -m json.tool
9. Delete the task:
curl -s -X DELETE http://localhost:8000/tasks/1 -w "%{http_code}"
Terminal 2 should show: [task_deleted] ...
Completeness checklist
- ConnectionManager with connect/disconnect/broadcast
- A working WebSocket endpoint at
/ws - WebSocket notification on POST /tasks/
- WebSocket notification on PUT /tasks/{id}
- WebSocket notification on PATCH /tasks/{id}
- A special notification when status changes to "completed"
- WebSocket notification on DELETE /tasks/{id}
- Upload endpoint with MIME type validation
- Upload endpoint with size validation (5MB)
- Files saved with unique names
- An endpoint to list a task's attachments
- An endpoint to download an attachment
- WebSocket notification when a file is uploaded
- Health check shows the WS connection count
- Multiple WS clients receive the broadcast simultaneously
Troubleshooting
The WebSocket disconnects immediately
Check that no middleware is interfering. CORSMiddleware doesn't block WebSocket, but proxies like nginx need special configuration.
ModuleNotFoundError: No module named 'multipart'
Install it: pip install python-multipart. FastAPI requires it for UploadFile and Form().
The files aren't being saved
Check that the app/uploads/ directory exists and has write permissions. mkdir -p app/uploads.
The broadcast doesn't reach every client
Check that there's a single instance of ws_manager (imported from app.websocket_manager). If you create another instance, the clients end up in separate managers.
422 Unprocessable Entity on upload
When you use File() and Form(), the Content-Type has to be multipart/form-data, not application/json. With curl, use -F instead of -d.
file.size returns None
Read the content first: content = await file.read(), then use len(content).
The server hangs with large files
Use chunks instead of a full await file.read() for files larger than 10MB.
A WebSocket disconnection isn't detected
Make sure you're catching WebSocketDisconnect in the try/except. Without it, the error propagates and can crash things.
Resources
- FastAPI WebSockets — The official docs
- FastAPI Request Files — UploadFile
- FastAPI Forms and Files — Combining them
- Starlette WebSocket — The base API
- python-multipart — The parser
- FileResponse — Serving files
What's next?
In Module 6 you integrate EVERYTHING you learned in modules 1-5 into a complete final project: the Task Manager API with dependency injection, a modular APIRouter, advanced response models, background tasks, WebSockets, and file uploads. It's your portfolio piece that proves you've mastered advanced FastAPI.