Lesson 5 - Guided Project: Validated Tasks API

Welcome to the Validated Tasks API

In Module 1 you built a read-only Task Manager: it could hand tasks out, but it could not take any in. This guided project is the second version of that course-long app, and it grows up in one big way: clients can now create tasks, and every new task is checked before it is stored. You will put together everything Module 2 taught you. A TaskCreate input model with Field constraints and a custom validator guards what comes in. A Task output model shapes what goes back out. An in-memory store holds the tasks, and a small set of endpoints ties it all together. Nothing here is new in isolation; the skill is assembling the pieces into one clean, trustworthy API.

By the end of this project, you will be able to:

  • Separate an input model (TaskCreate) from an output model (Task)
  • Enforce field rules with Field(min_length=..., max_length=...) and a custom @field_validator
  • Use response_model so your endpoints return exactly the shape you promise
  • Build POST, GET list, and GET one endpoints with a proper 404, and verify them with TestClient

We will build in stages and run the code at each step. Let’s start with the models.


Stage 1: The Input and Output Models

A create endpoint has two different shapes of data to think about. The data the client sends is not the same as the data your API returns. When a client creates a task, it should not invent an id — your server assigns that. So we use two models: one for input, one for output.

TaskCreate describes what a client is allowed to send. title is required and must be a non-empty string of at most 100 characters, enforced with Field(min_length=1, max_length=100). done defaults to False, and priority defaults to "medium". The priority needs more than a type check — it must be one of three specific words — so we add a custom @field_validator that raises a ValueError for anything else.

Task describes what the API gives back. It is the same data plus the id the server assigned.

from pydantic import BaseModel, Field, field_validator


class TaskCreate(BaseModel):
    title: str = Field(min_length=1, max_length=100)
    done: bool = False
    priority: str = "medium"

    @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 Task(BaseModel):
    id: int
    title: str
    done: bool = False
    priority: str = "medium"

TaskCreate has no id field at all — a client literally cannot send one. Task has an id, which only our code will fill in. Keeping these two models apart is what makes the rest of the project clean: the gate that checks input and the contract that describes output are different things, so we give them different classes.


Stage 2: Creating Tasks with POST /tasks

Now the create endpoint. It takes a TaskCreate body, so FastAPI validates the incoming JSON against all those rules before our function runs. Inside, we assign the next id, build a Task, store it, and return it. The response_model=Task on the decorator tells FastAPI to send the response back through the Task model, so the output shape is guaranteed.

We need a place to keep tasks. For now that is a plain Python list and a counter for the next id — both live in memory.

from fastapi import FastAPI, HTTPException

app = FastAPI()

tasks: list[Task] = []
next_id = 1


@app.post("/tasks", response_model=Task)
def create_task(payload: TaskCreate):
    global next_id
    task = Task(id=next_id, **payload.model_dump())
    tasks.append(task)
    next_id += 1
    return task

The flow is: validate (FastAPI does this against TaskCreate), assign an id, store, return. payload.model_dump() turns the validated input into a dict, which we spread into a new Task along with the fresh id. Because response_model=Task is set, the client always gets back an object with an id — even though it never sent one.


Stage 3: Reading Tasks with GET (and a Proper 404)

Creating is only useful if you can read back what you made. We add two read endpoints, both using response_model so their output matches the same Task shape.

GET /tasks returns the whole list. GET /tasks/{task_id} returns a single task by its id — and when no task has that id, it raises an HTTPException with status 404, which is the correct way to say “that thing does not exist.”

@app.get("/tasks", response_model=list[Task])
def list_tasks():
    return tasks


@app.get("/tasks/{task_id}", response_model=Task)
def get_task(task_id: int):
    for task in tasks:
        if task.id == task_id:
            return task
    raise HTTPException(status_code=404, detail=f"Task {task_id} not found")

response_model=list[Task] promises a list of tasks; response_model=Task promises a single one. The loop walks the store looking for a match. If it finds one, it returns it; if it falls through, raise HTTPException(status_code=404, detail=...) sends a clean 404 with a helpful message instead of crashing or returning null.


Stage 4: Verifying the Whole Project

Here is the full main.py, every stage assembled into one file.

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field, field_validator

app = FastAPI()


class TaskCreate(BaseModel):
    title: str = Field(min_length=1, max_length=100)
    done: bool = False
    priority: str = "medium"

    @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 Task(BaseModel):
    id: int
    title: str
    done: bool = False
    priority: str = "medium"


tasks: list[Task] = []
next_id = 1


@app.post("/tasks", response_model=Task)
def create_task(payload: TaskCreate):
    global next_id
    task = Task(id=next_id, **payload.model_dump())
    tasks.append(task)
    next_id += 1
    return task


@app.get("/tasks", response_model=list[Task])
def list_tasks():
    return tasks


@app.get("/tasks/{task_id}", response_model=Task)
def get_task(task_id: int):
    for task in tasks:
        if task.id == task_id:
            return task
    raise HTTPException(status_code=404, detail=f"Task {task_id} not found")

Rather than start a server, we’ll drive the app with FastAPI’s TestClient, which calls the endpoints in-process — the same approach used throughout the course. We create two tasks, list them, fetch one, ask for one that doesn’t exist, and finally send two invalid creates to confirm validation rejects them.

from fastapi.testclient import TestClient
import json

client = TestClient(app)

# Create two tasks
r1 = client.post("/tasks", json={"title": "Write the report", "priority": "high"})
print(r1.status_code, json.dumps(r1.json()))

r2 = client.post("/tasks", json={"title": "Email the team"})
print(r2.status_code, json.dumps(r2.json()))

# List all
r3 = client.get("/tasks")
print(r3.status_code, json.dumps(r3.json()))

# Get one
r4 = client.get("/tasks/1")
print(r4.status_code, json.dumps(r4.json()))

# Get a missing one -> 404
r5 = client.get("/tasks/999")
print(r5.status_code, json.dumps(r5.json()))

# Invalid create: bad priority -> 422
r6 = client.post("/tasks", json={"title": "X", "priority": "urgent"})
print(r6.status_code, json.dumps(r6.json()["detail"][0]["msg"]))

# Invalid create: empty title -> 422
r7 = client.post("/tasks", json={"title": "", "priority": "low"})
print(r7.status_code, json.dumps(r7.json()["detail"][0]["type"]))
200 {"id": 1, "title": "Write the report", "done": false, "priority": "high"}
200 {"id": 2, "title": "Email the team", "done": false, "priority": "medium"}
200 [{"id": 1, "title": "Write the report", "done": false, "priority": "high"}, {"id": 2, "title": "Email the team", "done": false, "priority": "medium"}]
200 {"id": 1, "title": "Write the report", "done": false, "priority": "high"}
404 {"detail": "Task 999 not found"}
422 "Value error, priority must be one of ['high', 'low', 'medium']"
422 "string_too_short"

Read the output top to bottom. The two creates each came back with a server-assigned id (1, then 2) — the client never sent one, and the defaults filled in done and priority where they were omitted. The list shows both tasks in the Task shape, and GET /tasks/1 returns exactly that one. Asking for task 999 gives a clean 404 with our message. The last two creates are rejected with 422 before any task is stored: "urgent" fails the custom priority validator, and an empty "" title fails the min_length=1 constraint (Pydantic labels it string_too_short). Validation in, shaped data out — the project works end to end.

To run it for real, save the full file as main.py and start a server:

uvicorn main:app --reload

Then open http://127.0.0.1:8000/docs. The interactive docs let you expand POST /tasks, click “Try it out,” send a body, and watch the validated Task come back — and you can try a bad priority to see the 422 for yourself.

The data lives in memory (for now)

The tasks list and next_id counter are ordinary Python variables, so everything resets the moment you stop the server. That is fine while you focus on request and response shapes. Module 5 swaps this in-memory store for a real database, and the endpoints barely change — precisely because the input model (TaskCreate) and output model (Task) already keep your data shapes clean and separate from where the data is stored.


Extend the Project

Exercise 1: Add a created_at timestamp to the output

Right now a task has no record of when it was made. Add a created_at field to the output so each task reports its creation time. Should this field live on TaskCreate, on Task, or both?

Hint

It belongs on Task only — the server sets it, so a client should not send it. Add created_at: datetime to Task, import from datetime import datetime, and pass created_at=datetime.now() when you build the task in create_task. Leave TaskCreate untouched.

Exercise 2: Reject titles that are only whitespace

The min_length=1 constraint accepts a title like " " because three spaces are technically one or more characters. Add a custom @field_validator("title") on TaskCreate that rejects a title which is empty after stripping whitespace.

Hint

Follow the same pattern as check_priority: if not value.strip(): raise ValueError("title cannot be blank"). Returning value.strip() instead also trims surrounding spaces before storing. A body like {"title": " "} should now come back as a 422.

Exercise 3: Foreshadow an update endpoint

Sketch (on paper or in comments) a PUT /tasks/{task_id} endpoint that replaces an existing task. What status should it return when the id does not exist, and which model would the request body use?

Hint

It would accept a TaskCreate body (the same validated input), find the task by id, and replace its fields — keeping the original id. If no task matches, raise the same HTTPException(status_code=404, ...) you used in get_task. You will build endpoints like this fully in a later module.


Summary

You combined the whole of Module 2 into one working API. Two models do two jobs: TaskCreate is the input model that validates what clients send, with a Field(min_length=1, max_length=100) constraint on title and a custom @field_validator that limits priority to low, medium, or high. Task is the output model that adds the server-assigned id and is used as the response_model so every endpoint returns exactly the shape it promises. A POST endpoint validates, assigns an id, stores, and returns; GET endpoints list all tasks and fetch one, raising a 404 via HTTPException when an id is missing. You verified all of it with TestClient: real created tasks with ids, a list, a 404 body, and 422s for invalid creates.

Key Concepts

  • Input vs output modelsTaskCreate guards what comes in; Task shapes what goes out.
  • Field constraintsmin_length and max_length enforce rules without manual checks.
  • Custom @field_validator — express logic types alone can’t, like “priority must be one of these.”
  • response_model — guarantees the response shape and adds it to the docs.
  • HTTPException(status_code=404, ...) — the correct, clean way to report a missing resource.

Why This Matters

A create endpoint is where untrusted data meets your system, so it is exactly where validation earns its keep. By splitting input from output and letting Pydantic enforce the rules, you wrote almost no checking code yet rejected every bad request before it touched your store. This separation is not just tidy — it is what lets you later swap the in-memory list for a database without rewriting your endpoints, because the data shapes are already defined independently of where they live.


Next Steps

Continue to Module 3 - HTTP Done Right

Add proper status codes, real error handling, and file uploads so your API speaks HTTP correctly and handles the messy cases well.

Back to Module Overview

Return to the Request Bodies and Pydantic module overview


Continue Building Your Skills

Your Task Manager can now create, list, and fetch tasks — and it refuses anything that does not fit the rules. Next, in Module 3, you’ll make it speak HTTP fluently: returning the right status codes for each action, handling errors deliberately, and accepting form data and file uploads. The data you accept will keep getting richer; the way you respond is about to get sharper.