8.12 Building a Complete REST API
Table of Contents
Planning the Complete REST API
Before writing any code, always start by deciding what your API will do. For this chapter, we will build a complete REST API for a simple “Tasks” application, similar to a mini version of a task management tool.
We will:
- Use FastAPI and Pydantic
- Use an in‑memory “database” first, but structure code so that a real database can be plugged in later
- Implement full CRUD operations
- Add validation, error handling, and basic pagination
- Structure the project in a way that can grow
Think of this chapter as a complete walkthrough from “empty folder” to “working REST API” using FastAPI.
Our example domain will have:
- A
Userwho can own tasks (we will not implement real authentication here) - A
Taskwith: id: integertitle: stringdescription: optional stringcompleted: booleancreated_at: datetimeupdated_at: datetimeowner_id: integer, id of the user who owns the task
We will focus on API design and FastAPI usage, not database specifics or authentication details, since those belong to other chapters.
Goal: By the end of this chapter you should be able to create a non‑trivial FastAPI application with:
- Clear project structure
- CRUD endpoints for a resource
- Pydantic models for requests and responses
- Validation and error handling
- Pagination and simple filtering
Project Structure
You can start simple, but even for a small app, a bit of structure helps a lot.
A reasonable layout for this example:
task_api/
app/
__init__.py
main.py
api/
__init__.py
v1/
__init__.py
tasks.py
models/
__init__.py
task.py
schemas/
__init__.py
task.py
core/
__init__.py
config.py
services/
__init__.py
tasks.py
requirements.txtExplanation:
app/main.py
Creates the FastAPI instance and includes routers.app/api/v1/tasks.py
Contains the API endpoints for tasks.v1is our API version.app/models/task.py
“Database model” representation of a task. In real life this might use SQLAlchemy. Here we will use a simple class and an in‑memory store.app/schemas/task.py
Pydantic models for request and response bodies.app/services/tasks.py
Business logic for tasks, independent of HTTP details.app/core/config.py
Configuration values such as app name or version, possibly environment‑based later.
Starting with structure that separates “API”, “schemas”, “models”, and “services” makes it easier to grow the project without turning main.py into a giant file.
Setting Up the FastAPI Application
Create app/main.py:
from fastapi import FastAPI
from app.api.v1.tasks import router as tasks_router
app = FastAPI(
title="Task Management API",
version="1.0.0",
)
app.include_router(tasks_router, prefix="/api/v1", tags=["tasks"])Explanation:
FastAPI(...)sets some metadata such as title and version.include_routeradds all routes defined intasks_routerunder/api/v1.- Using
tagsgroups endpoints in the interactive docs (Swagger UI) under “tasks”.
To run the app:
uvicorn app.main:app --reloadVisit:
http://127.0.0.1:8000/docsfor Swagger UIhttp://127.0.0.1:8000/redocfor ReDoc UI
Defining Task Schemas with Pydantic
The schemas define how data is sent to and from the API. They are not database models, but shapes of data you accept or return.
Create app/schemas/task.py:
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class TaskBase(BaseModel):
title: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=1000)
completed: bool = False
class TaskCreate(TaskBase):
# Additional fields for creation can be added here
pass
class TaskUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=1000)
completed: Optional[bool] = None
class TaskInDBBase(TaskBase):
id: int
owner_id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True # for ORM objects later
class Task(TaskInDBBase):
"""Public response model for a single task."""
pass
class TaskList(BaseModel):
"""Response model for a list of tasks with pagination."""
total: int
page: int
size: int
items: list[Task]Common patterns:
- Separate models for create, update, and read:
TaskCreatefor POST bodyTaskUpdatefor PATCH/PUT bodyTaskfor responses- Validation uses
Field: min_length,max_length- default values
- A list response wrapper like
TaskListadds pagination metadata.
Simple In‑Memory Data Model
In a real application you would use an ORM and a real database. For this chapter we will simulate data storage with an in‑memory store but still keep a “model” layer.
Create app/models/task.py:
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class TaskModel:
id: int
title: str
description: str | None
completed: bool
owner_id: int
created_at: datetime = field(default_factory=datetime.utcnow)
updated_at: datetime = field(default_factory=datetime.utcnow)
This TaskModel is a simple dataclass to store attribute values. We will keep data in a Python dictionary.
We also need some storage and ID generation. For a small example, we can keep this in the service layer.
Business Logic in a Service Layer
We want to keep HTTP details (FastAPI, Request, Response) separate from business logic. That way we can change the API without breaking the core rules, or test logic without HTTP.
Create app/services/tasks.py:
from collections.abc import Sequence
from datetime import datetime
from typing import Optional
from app.models.task import TaskModel
from app.schemas.task import TaskCreate, TaskUpdate
# In-memory "database"
_TASKS: dict[int, TaskModel] = {}
_NEXT_ID: int = 1We will add functions to perform CRUD:
def _get_next_id() -> int:
global _NEXT_ID
next_id = _NEXT_ID
_NEXT_ID += 1
return next_idCreate a task:
def create_task(data: TaskCreate, owner_id: int) -> TaskModel:
task_id = _get_next_id()
now = datetime.utcnow()
task = TaskModel(
id=task_id,
title=data.title,
description=data.description,
completed=data.completed,
owner_id=owner_id,
created_at=now,
updated_at=now,
)
_TASKS[task_id] = task
return taskGet a single task:
def get_task(task_id: int) -> Optional[TaskModel]:
return _TASKS.get(task_id)List tasks with simple pagination and filtering:
def list_tasks(
owner_id: Optional[int] = None,
completed: Optional[bool] = None,
page: int = 1,
size: int = 10,
) -> tuple[int, list[TaskModel]]:
"""
Returns (total_count, items_for_this_page).
"""
tasks: Sequence[TaskModel] = list(_TASKS.values())
if owner_id is not None:
tasks = [t for t in tasks if t.owner_id == owner_id]
if completed is not None:
tasks = [t for t in tasks if t.completed == completed]
# sort by created_at for consistency
tasks = sorted(tasks, key=lambda t: t.created_at)
total = len(tasks)
# simple pagination
start = (page - 1) * size
end = start + size
page_items = list(tasks)[start:end]
return total, page_itemsUpdate a task (partial update):
def update_task(task: TaskModel, data: TaskUpdate) -> TaskModel:
updated = False
if data.title is not None:
task.title = data.title
updated = True
if data.description is not None:
task.description = data.description
updated = True
if data.completed is not None:
task.completed = data.completed
updated = True
if updated:
task.updated_at = datetime.utcnow()
return taskDelete a task:
def delete_task(task_id: int) -> None:
_TASKS.pop(task_id, None)Note:
- The service returns and accepts
TaskModel, not Pydantic models. - Pagination returns a tuple
(total, list_of_tasks)so the API layer can package it intoTaskList.
Implementing the Tasks API Router
Now we connect our business logic to HTTP endpoints using FastAPI.
Create app/api/v1/tasks.py:
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, status
from app.schemas.task import Task, TaskCreate, TaskList, TaskUpdate
from app.services import tasks as task_service
router = APIRouter()Creating Tasks (POST /tasks)
@router.post(
"/tasks",
response_model=Task,
status_code=status.HTTP_201_CREATED,
)
def create_task(task_in: TaskCreate) -> Task:
# In a real app, owner_id would come from the authenticated user
fake_owner_id = 1
task_model = task_service.create_task(task_in, owner_id=fake_owner_id)
return task_modelListing Tasks with Pagination and Filtering (GET /tasks)
We want:
GET /tasksto support:pagequery parameter (default 1)sizequery parameter (default 10)completedfilter (optional)owner_idfilter (optional, but often from authentication in real apps)
@router.get(
"/tasks",
response_model=TaskList,
)
def list_tasks(
page: int = Query(1, ge=1, description="Page number, starting from 1"),
size: int = Query(10, ge=1, le=100, description="Page size"),
completed: Optional[bool] = Query(
None,
description="Filter by completion status",
),
owner_id: Optional[int] = Query(
None,
ge=1,
description="Filter by owner id (for demo only)",
),
) -> TaskList:
total, items = task_service.list_tasks(
owner_id=owner_id,
completed=completed,
page=page,
size=size,
)
return TaskList(
total=total,
page=page,
size=size,
items=items,
)Examples:
GET /api/v1/tasks
Get first page of tasks.GET /api/v1/tasks?page=2&size=5
Get second page, 5 tasks per page.GET /api/v1/tasks?completed=true
Get completed tasks only.
Getting a Single Task (GET /tasks/{task_id})
@router.get(
"/tasks/{task_id}",
response_model=Task,
)
def get_task(task_id: int) -> Task:
task = task_service.get_task(task_id)
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
return taskUpdating a Task (PATCH /tasks/{task_id})
We use PATCH for partial updates. All fields are optional in TaskUpdate.
@router.patch(
"/tasks/{task_id}",
response_model=Task,
)
def update_task(task_id: int, task_in: TaskUpdate) -> Task:
task = task_service.get_task(task_id)
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
updated_task = task_service.update_task(task, task_in)
return updated_task
You could also implement PUT for full replacement, but PATCH for partial fields is often more convenient for clients.
Deleting a Task (DELETE /tasks/{task_id})
@router.delete(
"/tasks/{task_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
def delete_task(task_id: int) -> None:
task = task_service.get_task(task_id)
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
task_service.delete_task(task_id)
# 204 means "No Content", so we return nothingSummary of main endpoints:
| HTTP Method | Path | Description | Request Body | Response |
|---|---|---|---|---|
| POST | /api/v1/tasks | Create a task | TaskCreate | 201 Created, body Task |
| GET | /api/v1/tasks | List tasks (paginated) | None | 200 OK, body TaskList |
| GET | /api/v1/tasks/{id} | Get a single task | None | 200 OK, body Task or 404 |
| PATCH | /api/v1/tasks/{id} | Update a task (partial) | TaskUpdate | 200 OK, body Task or 404 |
| DELETE | /api/v1/tasks/{id} | Delete a task | None | 204 No Content or 404 |
Validation and Error Handling in the API
Validation happens at multiple levels:
- Pydantic models
For example,TaskCreate.titlehasmin_length=1,max_length=100.
If a client sends an empty title, FastAPI returns HTTP 422 automatically. - Query parameter constraints
We usedQuery(1, ge=1)forpageandQuery(10, ge=1, le=100)forsize.
If a client sendspage=0, FastAPI will return a 422 validation error. - Business rules
You can add further checks in the service layer or API layer. For example, you may restrict that title cannot be “test” in production. - Error responses using HTTPException
We usedHTTPExceptionfor 404 Not Found when a task is missing.
Rule: Always validate input at the boundary of your system, and always return clear HTTP status codes:
- 201 for resource creation
- 200 for successful retrieval or update
- 204 for successful deletion without content
- 400 or 422 for invalid input
- 404 when a resource does not exist
Example of adding a simple business rule:
from fastapi import HTTPException
@router.post("/tasks", response_model=Task, status_code=status.HTTP_201_CREATED)
def create_task(task_in: TaskCreate) -> Task:
if task_in.title.lower() == "forbidden":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This title is not allowed",
)
fake_owner_id = 1
task_model = task_service.create_task(task_in, owner_id=fake_owner_id)
return task_modelAdding Simple Dependency Injection
FastAPI has a powerful dependency system. Even with an in‑memory store, we can get a taste of how it works.
Imagine we want a common function that fetches a task or raises 404. Instead of repeating this logic in every endpoint, we can make a dependency.
In app/api/v1/tasks.py:
from fastapi import Depends
from app.models.task import TaskModelDefine the dependency:
def get_task_or_404(task_id: int) -> TaskModel:
task = task_service.get_task(task_id)
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)
return taskUse it in endpoints:
@router.get("/tasks/{task_id}", response_model=Task)
def get_task(task: TaskModel = Depends(get_task_or_404)) -> Task:
return task
@router.patch("/tasks/{task_id}", response_model=Task)
def update_task(
task_in: TaskUpdate,
task: TaskModel = Depends(get_task_or_404),
) -> Task:
updated_task = task_service.update_task(task, task_in)
return updated_task
@router.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task: TaskModel = Depends(get_task_or_404)) -> None:
task_service.delete_task(task.id)Benefits:
- Single place for “find task or 404” logic
- Easier to replace later, for example when you move from in‑memory storage to a database session
Basic Filtering, Sorting, and Pagination
Our list_tasks endpoint already has:
- Pagination with
pageandsize - Filtering with
completedandowner_id - Sorting by
created_at
This is a common pattern for REST APIs.
The pagination formula is:
$$
\text{start} = (\text{page} - 1) \times \text{size}
$$
$$
\text{end} = \text{start} + \text{size}
$$
We then slice the list:
page_items = tasks[start:end]Rule: For any non‑trivial list endpoint, provide:
- Pagination parameters, usually
pageandsize - A
totalfield in the response, to let clients know how many items exist - Stable sorting, typically by created_at or id
You can extend filters easily:
def list_tasks(
owner_id: Optional[int] = None,
completed: Optional[bool] = None,
search: Optional[str] = None,
page: int = 1,
size: int = 10,
) -> tuple[int, list[TaskModel]]:
tasks: Sequence[TaskModel] = list(_TASKS.values())
if owner_id is not None:
tasks = [t for t in tasks if t.owner_id == owner_id]
if completed is not None:
tasks = [t for t in tasks if t.completed == completed]
if search:
lowered = search.lower()
tasks = [
t for t in tasks
if lowered in t.title.lower()
or (t.description and lowered in t.description.lower())
]
tasks = sorted(tasks, key=lambda t: t.created_at)
total = len(tasks)
start = (page - 1) * size
end = start + size
page_items = list(tasks)[start:end]
return total, page_itemsAnd in the router:
@router.get("/tasks", response_model=TaskList)
def list_tasks(
page: int = Query(1, ge=1),
size: int = Query(10, ge=1, le=100),
completed: Optional[bool] = Query(None),
owner_id: Optional[int] = Query(None, ge=1),
search: Optional[str] = Query(None, min_length=1),
) -> TaskList:
total, items = task_service.list_tasks(
owner_id=owner_id,
completed=completed,
search=search,
page=page,
size=size,
)
return TaskList(total=total, page=page, size=size, items=items)Testing the API Manually
With the API running via Uvicorn, you can test each endpoint.
- Create a task
- Method: POST
- URL:
http://127.0.0.1:8000/api/v1/tasks - Body:
{
"title": "Learn FastAPI",
"description": "Build a complete REST API",
"completed": false
}- Expected:
201 Createdand a JSON body with anid,owner_id,created_atetc.
- List tasks
- Method: GET
- URL:
http://127.0.0.1:8000/api/v1/tasks?page=1&size=5 - Expected:
200 OKand aTaskListobject:
{
"total": 1,
"page": 1,
"size": 5,
"items": [
{
"id": 1,
"title": "Learn FastAPI",
"description": "Build a complete REST API",
"completed": false,
"owner_id": 1,
"created_at": "2024-01-01T12:00:00",
"updated_at": "2024-01-01T12:00:00"
}
]
}- Get a single task
- Method: GET
- URL:
http://127.0.0.1:8000/api/v1/tasks/1 - Update a task
- Method: PATCH
- URL:
http://127.0.0.1:8000/api/v1/tasks/1 - Body:
{
"completed": true
}- Delete a task
- Method: DELETE
- URL:
http://127.0.0.1:8000/api/v1/tasks/1
Evolving This API Further
The example so far is fully functional, but uses an in‑memory store and a fake owner_id. In a real project, you would extend it by:
- Replacing the in‑memory dictionary with a real database, using an ORM
- Using real authentication to get
owner_idfrom the current user - Adding more resources, for example “projects” with many “tasks”
- Adding proper error formatting and global exception handlers
- Splitting routers by domain (tasks, users, projects) and possibly modules
The important part is the pattern:
- Schemas for input and output
- Models (ORM or otherwise) for persistent data
- Services that contain the actual logic
- Routers that translate HTTP requests to service calls and back
- Main app that ties everything together and exposes a consistent REST API
Key idea: A “complete REST API” in FastAPI is not just a collection of routes. It is:
- Clear resource definitions and URLs
- Thoughtful request and response models
- Proper HTTP methods and status codes
- Validation and error handling
- A structure that can grow as your application grows
With this structure and these patterns, you can confidently build more complex REST APIs on top of FastAPI.
Views: 7
KAHIBARO