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 Created from a create endpoint with status_code and response_model
  • Raise correct 404 Not Found errors with HTTPException when a task is missing
  • Return a 204 No Content from a delete with a genuinely empty body
  • Accept a file upload with UploadFile and 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 = 1

TaskCreate 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 None

Three 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 --reload

Then 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 report 201.
  • response_model=Task — shape and validate the outgoing response.
  • HTTPException — raise a precise error like 404 with a detail message.
  • status.HTTP_204_NO_CONTENT — success with a deliberately empty body; return None.
  • UploadFile — accept a file upload; use await 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.