Lesson 5 - Guided Project: Robust Task Endpoints
Welcome to Robust Task Endpoints
This is the third version of the Task Manager you’ve been building throughout the course. In Module 1 you stood up the endpoints; in Module 2 you gave them validated input and output with Pydantic models. The data is clean now — but the API still doesn’t speak HTTP the way a professional service should. A create that returns plain 200, a missing task that returns who-knows-what, a delete that sends back a body it shouldn’t: these are exactly the rough edges Module 3 teaches you to file down. In this project you’ll harden every endpoint so the API tells clients precisely what happened with the right status code, raises clean 404s, and even accepts a file attachment.
By the end of this project, you will be able to:
- Return
201 Createdfrom a create endpoint withstatus_codeandresponse_model - Raise correct
404 Not Founderrors withHTTPExceptionwhen a task is missing - Return a
204 No Contentfrom a delete with a genuinely empty body - Accept a file upload with
UploadFileand record its details on a task
We’ll build it in stages and verify the whole thing with TestClient at the end. Let’s begin.
Stage 1: Recap the Models and Store
We start from where Module 2 left off: a TaskCreate input model, a Task output model, and an in-memory dictionary acting as our database. The only addition for this project is a small Attachment model and an optional attachment field on Task — so a task can carry the details of an uploaded file.
from fastapi import FastAPI, HTTPException, status, UploadFile
from pydantic import BaseModel, Field, field_validator
app = FastAPI()
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=100)
priority: str = "medium"
done: bool = False
@field_validator("priority")
@classmethod
def check_priority(cls, value):
allowed = {"low", "medium", "high"}
if value not in allowed:
raise ValueError(f"priority must be one of {sorted(allowed)}")
return value
class Attachment(BaseModel):
filename: str
size: int
class Task(BaseModel):
id: int
title: str
priority: str
done: bool
attachment: Attachment | None = None
tasks: dict[int, dict] = {}
next_id = 1TaskCreate is what clients send: a title between 1 and 100 characters, a priority restricted to low/medium/high by the validator, and a done flag defaulting to False. Task is what we send back: it adds the server-assigned id and an attachment that’s None until a file is uploaded. The tasks dictionary stores each task by id, and next_id hands out the next available id.
Stage 2: Create with 201, Read and Delete with Real Errors
Now the endpoints. The create endpoint declares two things on its decorator: status_code=status.HTTP_201_CREATED so a successful create reports 201 instead of 200, and response_model=Task so the response is shaped (and validated) as a Task. The read and delete endpoints both check the store first and raise HTTPException(status_code=404, ...) when the task isn’t there. Delete uses status.HTTP_204_NO_CONTENT and returns None.
@app.post("/tasks", status_code=status.HTTP_201_CREATED, response_model=Task)
def create_task(task: TaskCreate):
global next_id
record = {"id": next_id, **task.model_dump(), "attachment": None}
tasks[next_id] = record
next_id += 1
return record
@app.get("/tasks/{task_id}", response_model=Task)
def get_task(task_id: int):
if task_id not in tasks:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return tasks[task_id]
@app.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task_id: int):
if task_id not in tasks:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
del tasks[task_id]
return NoneThree different outcomes, three different signals. Creating a task answers 201 — “I made something new.” Asking for a task that doesn’t exist answers 404 with a clear detail message — a client error, not a server crash. Deleting answers 204 — “done, and there’s deliberately nothing to send back.” Notice the delete function returns None: a 204 must have an empty body, so there’s nothing to return.
Stage 3: A File-Attachment Endpoint
The new feature for this version is attachments. We add a POST /tasks/{task_id}/attachment endpoint that accepts an uploaded file. FastAPI represents an upload with the UploadFile type — declare a parameter as file: UploadFile and FastAPI streams the upload into it. Because reading a file is an I/O operation, this endpoint is async and we await file.read() to get the bytes. Just like the read and delete endpoints, it 404s first if the task is missing.
@app.post("/tasks/{task_id}/attachment", response_model=Task)
async def add_attachment(task_id: int, file: UploadFile):
if task_id not in tasks:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
content = await file.read()
tasks[task_id]["attachment"] = {"filename": file.filename, "size": len(content)}
return tasks[task_id]An UploadFile gives you handy attributes: file.filename is the original name the client uploaded, and await file.read() returns the raw bytes, whose length is the file’s size. We store both on the task’s attachment field and return the updated task. We don’t keep the file’s contents in this version — just enough to prove the upload worked and describe what was attached.
Stage 4: Verify the Whole Project
Time to run every path. We use TestClient, which exercises the app in-process — no server needed. We’ll create a task (expect 201), read it, hit two 404s, upload an attachment, try an attachment on a missing task, delete the task (expect 204 with an empty body), and confirm it’s gone.
from fastapi.testclient import TestClient
client = TestClient(app)
print("== 201 create ==")
r = client.post("/tasks", json={"title": "Write the report", "priority": "high"})
print(r.status_code, r.json())
print("\n== GET existing ==")
r = client.get("/tasks/1")
print(r.status_code, r.json())
print("\n== 404 get missing ==")
r = client.get("/tasks/999")
print(r.status_code, r.json())
print("\n== 404 delete missing ==")
r = client.delete("/tasks/999")
print(r.status_code, r.json())
print("\n== attachment upload ==")
r = client.post("/tasks/1/attachment", files={"file": ("notes.txt", b"hello fastapi files", "text/plain")})
print(r.status_code, r.json())
print("\n== 404 attachment on missing task ==")
r = client.post("/tasks/999/attachment", files={"file": ("notes.txt", b"hello fastapi files", "text/plain")})
print(r.status_code, r.json())
print("\n== 204 delete ==")
r = client.delete("/tasks/1")
print(r.status_code, repr(r.text))
print("\n== confirm gone ==")
r = client.get("/tasks/1")
print(r.status_code, r.json())== 201 create ==
201 {'id': 1, 'title': 'Write the report', 'priority': 'high', 'done': False, 'attachment': None}
== GET existing ==
200 {'id': 1, 'title': 'Write the report', 'priority': 'high', 'done': False, 'attachment': None}
== 404 get missing ==
404 {'detail': 'Task 999 not found'}
== 404 delete missing ==
404 {'detail': 'Task 999 not found'}
== attachment upload ==
200 {'id': 1, 'title': 'Write the report', 'priority': 'high', 'done': False, 'attachment': {'filename': 'notes.txt', 'size': 19}}
== 404 attachment on missing task ==
404 {'detail': 'Task 999 not found'}
== 204 delete ==
204 ''
== confirm gone ==
404 {'detail': 'Task 1 not found'}Every signal is correct. The create returns 201 with the new task and its server-assigned id. Both missing-task requests return 404 with a clean detail message. The upload records notes.txt and its 19-byte size on the task. The delete returns 204 with a truly empty body (''), and the follow-up GET confirms the task is gone with another 404. That’s an API that says exactly what happened, every time.
To run it as a real server, save the models and endpoints from Stages 1-3 into a file called main.py and start it with:
uvicorn main:app --reloadThen open http://127.0.0.1:8000/docs and try it from the interactive docs: create a task, fetch it, use the file-picker on the attachment endpoint to upload a real file, and watch the status codes come back. The --reload flag restarts the server automatically whenever you edit the file.
The store is in-memory — and so are the attachments
Both the tasks and their attachment details live in a Python dictionary that resets every time the server restarts. Even the file you upload isn’t saved to disk in this version — we read its bytes only to record the name and size. That’s fine for learning, but a real app would persist tasks in a database and store uploaded files somewhere durable. Module 5 adds a real database. What carries forward no matter where the data lives is the HTTP behavior you built here: correct status codes and clean errors are what make the API predictable to build against.
Extend the Project
Exercise 1: Reject duplicate titles with 409
In create_task, before adding the task, check whether any existing task already has the same title. If one does, raise a 409 Conflict instead of creating a duplicate.
Hint
Loop over tasks.values() and compare titles: if any(t["title"] == task.title for t in tasks.values()):. Then raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="A task with that title already exists"). 409 is the right code because the request clashes with the current state of the server.
Exercise 2: Restrict attachment content types
Only allow text or image uploads. In add_attachment, check file.content_type before storing, and reject anything else.
Hint
UploadFile exposes file.content_type (for example "text/plain" or "image/png"). Define allowed = {"text/plain", "image/png", "image/jpeg"} and raise a 400 Bad Request when file.content_type not in allowed: raise HTTPException(status_code=400, detail=f"Unsupported file type: {file.content_type}").
Exercise 3: Return the attachment as a download
Store the uploaded bytes (not just the size) and add a GET /tasks/{task_id}/attachment endpoint that returns the file so a client can download it.
Hint
Save content to a temp path or keep the bytes in your store, then use from fastapi.responses import FileResponse and return FileResponse(path, filename=...), or Response(content=..., media_type=...) for in-memory bytes. Remember to 404 if the task has no attachment yet.
Summary
You took the validated Tasks API from Module 2 and gave it professional HTTP behavior. Creating a task now returns 201 Created and is shaped by response_model=Task. Reads and deletes raise a clean 404 Not Found with HTTPException when the task is missing, and delete returns a proper 204 No Content with an empty body. You also added a file-attachment endpoint using UploadFile, reading the uploaded bytes to record the filename and size on the task. Every path was verified end-to-end with TestClient.
Key Concepts
status_code=status.HTTP_201_CREATED— make a create endpoint report201.response_model=Task— shape and validate the outgoing response.HTTPException— raise a precise error like404with adetailmessage.status.HTTP_204_NO_CONTENT— success with a deliberately empty body; returnNone.UploadFile— accept a file upload; useawait file.read(),file.filename,file.content_type.
Why This Matters
Clean input and output models (Module 2) make your data trustworthy, but the protocol is what clients actually program against. A frontend shows a “created!” toast on 201, displays a “not found” message on 404, and stops asking for a resource it just deleted because of the 204. When every endpoint returns the right code and a clear error, the API becomes predictable — easy to build against, easy to debug, and easy to test. That predictability is exactly what separates a hobby script from a production service, and it’s the foundation the next module builds on as you structure the app into routers and dependencies.
Next Steps
Continue to Module 4 - Structure, Dependencies, and Middleware
Organize the growing app with dependency injection and routers, and add cross-cutting behavior with middleware.
Back to Module Overview
Return to the HTTP Done Right module overview
Continue Building Your Skills
Your Task Manager now behaves like a real HTTP service: it returns the right status code for every outcome, raises clean errors, and accepts file uploads. As it grows, though, keeping everything in one main.py gets unwieldy. In the next module you’ll learn to organize the application — splitting endpoints into routers, sharing logic with dependency injection, and adding middleware — so the project stays clean as it scales.