32.4. Building the REST API
Table of Contents
Overview
In the final project you already planned the application, the architecture, and the database. In this chapter you will turn that plan into a working REST API.
You will not learn REST or FastAPI from scratch here. Instead, you will focus on how to design, organize, and implement a real, production‑grade API using the concepts you already know.
This chapter uses Python and FastAPI in examples, but the structure and ideas apply to any backend framework.
Key idea: In a production project, your REST API should be:
- predictable,
- consistent,
- well structured,
- easy to extend,
- well validated,
- and well documented.
Translating Requirements into Endpoints
You already defined what your application must do in the planning chapter. Now you map those features to REST resources and endpoints.
From features to resources
Start by listing the core entities in your domain. For example, in a generic production app you might have:
UserProjectTaskCommentAuth(login, registration, tokens)
Each entity usually becomes one or more REST resources. A resource is typically represented by a URL path prefix, such as:
/users/projects/tasks/auth
Create a table for your project that maps business features to resources:
| Feature | Resource(s) | Notes |
|---|---|---|
| User registration | /auth/register | Returns created user or minimal profile |
| Login and token issuance | /auth/login | Returns access and refresh tokens |
| List current user tasks | /tasks | Filter by assignee_id = current user |
| Create new project | /projects | Only for authenticated users |
| Add task to a project | /projects/{id}/tasks | Or just /tasks with project_id field |
| Comment on a task | /tasks/{id}/comments | Nested resource |
Write this mapping explicitly before coding. It prevents you from creating random paths that are difficult to maintain later.
RESTful URL Design for the Project
In this step you refine URLs, HTTP methods, and path parameters using REST principles you already learned in the REST and FastAPI chapters.
Basic URL patterns
Use the standard resource patterns:
- Collection:
/resources - Single resource:
/resources/{id}
Examples:
GET /projectslist projectsPOST /projectscreate projectGET /projects/{project_id}retrieve projectPATCH /projects/{project_id}update project partiallyDELETE /projects/{project_id}delete project
Rule: Use plural resource names for collections (for example /users, /orders),
and avoid verbs in paths (for example prefer /tasks over /createTask).
Example URL design for a task management style app
| Resource | HTTP method | URL | Purpose |
|---|---|---|---|
| Tasks | GET | /tasks | List tasks |
| Tasks | POST | /tasks | Create a task |
| Tasks | GET | /tasks/{task_id} | Get task detail |
| Tasks | PATCH | /tasks/{task_id} | Update part of a task |
| Tasks | PUT | /tasks/{task_id} | Replace a task |
| Tasks | DELETE | /tasks/{task_id} | Delete a task |
| Tasks | GET | /projects/{project_id}/tasks | List tasks in a project |
| Comments | POST | /tasks/{task_id}/comments | Add comment to a task |
| Comments | GET | /tasks/{task_id}/comments | List task comments |
Nested URLs such as /tasks/{task_id}/comments are useful when the relationship is very tight and you always access a child through its parent.
Query parameters vs path parameters
Use path parameters for resource identity, and query parameters for filtering and pagination.
Examples:
- Path parameter, identity:
/tasks/{task_id}/users/{user_id}- Query parameters, filtering and options:
/tasks?status=completed&assignee_id=123/projects?limit=20&offset=40/tasks?search=backend&sort=-created_at
Rule:
- Use path parameters for unique resource identity.
- Use query parameters for search, filters, sorting, and pagination.
Designing Request and Response Models
In a production API you almost never work directly with raw dictionaries. You define schemas or models for input and output.
In FastAPI you usually use Pydantic models for this.
Separate models for create, update, and response
You often need:
- A Create model (fields required to create a resource).
- An Update model (all optional fields for partial update).
- A Response model (what you actually return to the client).
Example for a Task resource:
from datetime import datetime
from typing import Optional, Literal
from pydantic import BaseModel, Field
TaskStatus = Literal["todo", "in_progress", "done"]
class TaskCreate(BaseModel):
title: str = Field(..., max_length=200)
description: Optional[str] = None
project_id: int
assignee_id: Optional[int] = None
status: TaskStatus = "todo"
class TaskUpdate(BaseModel):
title: Optional[str] = Field(None, max_length=200)
description: Optional[str] = None
assignee_id: Optional[int] = None
status: Optional[TaskStatus] = None
class TaskBase(BaseModel):
id: int
title: str
description: Optional[str]
status: TaskStatus
project_id: int
assignee_id: Optional[int]
created_at: datetime
updated_at: datetime
class Config:
orm_mode = True
class TaskResponse(TaskBase):
# you can extend this later, for example with nested user or project data
pass
Rule:
Never expose internal fields that should stay secret, such as password hashes, internal IDs, or debug fields, in your response models.
Example: User models
A very common pattern:
class UserCreate(BaseModel):
email: str
password: str
full_name: Optional[str] = None
class UserResponse(BaseModel):
id: int
email: str
full_name: Optional[str] = None
is_active: bool
class Config:
orm_mode = True
Notice that UserResponse does not include password. You only use password in input models.
Implementing CRUD Endpoints
Now that you have URL patterns and models, you can implement CRUD logic.
In this project you should not put all logic directly in the FastAPI route functions. Use your architecture: service layer, repositories, and models. The route should be thin.
Folder and module structure for the API
A practical structure for a production FastAPI project:
app/
main.py
api/
v1/
__init__.py
router.py
endpoints/
__init__.py
auth.py
users.py
projects.py
tasks.py
core/
config.py
security.py
db/
base.py
session.py
models/
user.py
project.py
task.py
schemas/
user.py
project.py
task.py
services/
user_service.py
project_service.py
task_service.pyExample: router registration
# app/api/v1/router.py
from fastapi import APIRouter
from .endpoints import auth, users, projects, tasks
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(projects.router, prefix="/projects", tags=["projects"])
api_router.include_router(tasks.router, prefix="/tasks", tags=["tasks"])
Then in main.py:
from fastapi import FastAPI
from app.api.v1.router import api_router
app = FastAPI(title="Production Backend API")
app.include_router(api_router, prefix="/api/v1")Example: create and read endpoints
Tasks endpoint implementation
# app/api/v1/endpoints/tasks.py
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.task import TaskCreate, TaskResponse, TaskUpdate
from app.services import task_service
from app.core.security import get_current_user
from app.models.user import User
router = APIRouter()
@router.post(
"",
response_model=TaskResponse,
status_code=status.HTTP_201_CREATED,
)
def create_task(
task_in: TaskCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# You can enforce ownership rules here if needed
task = task_service.create_task(db=db, task_in=task_in, creator_id=current_user.id)
return task
@router.get("", response_model=List[TaskResponse])
def list_tasks(
project_id: int | None = None,
status: str | None = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tasks = task_service.list_tasks(
db=db,
current_user=current_user,
project_id=project_id,
status=status,
)
return tasks
@router.get("/{task_id}", response_model=TaskResponse)
def get_task(
task_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
task = task_service.get_task_by_id_for_user(db, task_id=task_id, user=current_user)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
@router.patch("/{task_id}", response_model=TaskResponse)
def update_task(
task_id: int,
task_in: TaskUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
task = task_service.update_task_for_user(
db=db,
task_id=task_id,
task_in=task_in,
user=current_user,
)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(
task_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
deleted = task_service.delete_task_for_user(
db=db,
task_id=task_id,
user=current_user,
)
if not deleted:
raise HTTPException(status_code=404, detail="Task not found")
The endpoints are very slim. Most logic is kept in task_service.
Example: service layer for tasks
# app/services/task_service.py
from typing import List, Optional
from sqlalchemy.orm import Session
from app import models
from app.schemas.task import TaskCreate, TaskUpdate
from app.models.user import User
def create_task(db: Session, task_in: TaskCreate, creator_id: int) -> models.Task:
task = models.Task(
title=task_in.title,
description=task_in.description,
status=task_in.status,
project_id=task_in.project_id,
assignee_id=task_in.assignee_id,
creator_id=creator_id,
)
db.add(task)
db.commit()
db.refresh(task)
return task
def list_tasks(
db: Session,
current_user: User,
project_id: Optional[int] = None,
status: Optional[str] = None,
) -> List[models.Task]:
query = db.query(models.Task)
# simple example, you can add more complex permissions later
query = query.filter(
models.Task.assignee_id == current_user.id
| (models.Task.creator_id == current_user.id)
)
if project_id is not None:
query = query.filter(models.Task.project_id == project_id)
if status is not None:
query = query.filter(models.Task.status == status)
return query.all()
def get_task_by_id_for_user(
db: Session,
task_id: int,
user: User,
) -> Optional[models.Task]:
return (
db.query(models.Task)
.filter(
models.Task.id == task_id,
(
(models.Task.assignee_id == user.id)
| (models.Task.creator_id == user.id)
),
)
.first()
)
def update_task_for_user(
db: Session,
task_id: int,
task_in: TaskUpdate,
user: User,
) -> Optional[models.Task]:
task = get_task_by_id_for_user(db, task_id=task_id, user=user)
if not task:
return None
data = task_in.dict(exclude_unset=True)
for field, value in data.items():
setattr(task, field, value)
db.add(task)
db.commit()
db.refresh(task)
return task
def delete_task_for_user(
db: Session,
task_id: int,
user: User,
) -> bool:
task = get_task_by_id_for_user(db, task_id=task_id, user=user)
if not task:
return False
db.delete(task)
db.commit()
return TrueValidation and Error Handling in the API
Your REST API is the external contract of your backend. It must validate input strictly and return clear error messages.
Input validation with Pydantic
Use field types, constraints, and custom validators in your schemas.
Example:
from pydantic import BaseModel, Field, EmailStr, validator
class RegisterUser(BaseModel):
email: EmailStr
password: str = Field(..., min_length=8, max_length=128)
full_name: str = Field(..., min_length=2, max_length=100)
@validator("password")
def password_must_have_digit(cls, v: str) -> str:
if not any(ch.isdigit() for ch in v):
raise ValueError("Password must contain at least one digit")
return v
If a client sends invalid data, FastAPI will automatically return a 422 Unprocessable Entity response that includes validation errors.
Rule:
Validate at the boundary of your system. Do not trust client input. Use schemas to filter and validate everything that comes into your API.
Standard error response structure
Define a consistent error shape. For example:
{
"detail": "Task not found",
"code": "TASK_NOT_FOUND"
}or with additional metadata:
{
"detail": "Task not found",
"code": "TASK_NOT_FOUND",
"status": 404
}
In FastAPI, you can raise HTTPException:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task not found",
)For more advanced handling, you can create custom exception classes and global exception handlers.
Example of a domain exception:
class NotFoundError(Exception):
def __init__(self, code: str, message: str):
self.code = code
self.message = messageAnd a handler:
from fastapi import Request
from fastapi.responses import JSONResponse
@app.exception_handler(NotFoundError)
async def not_found_exception_handler(request: Request, exc: NotFoundError):
return JSONResponse(
status_code=404,
content={"detail": exc.message, "code": exc.code},
)Then in your services:
from app.core.exceptions import NotFoundError
def get_project_or_raise(db: Session, project_id: int) -> models.Project:
project = db.query(models.Project).get(project_id)
if not project:
raise NotFoundError(code="PROJECT_NOT_FOUND", message="Project not found")
return projectPagination, Filtering, and Sorting
In a production API you rarely return all records. You implement pagination and basic filters to keep responses fast and predictable.
Pagination patterns
There are different strategies. The simplest is offset pagination using limit and offset.
GET /tasks?limit=20&offset=40This means "skip 40 items, then return the next 20."
from typing import List
from fastapi import Query
@router.get("", response_model=List[TaskResponse])
def list_tasks(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
):
tasks = task_service.list_tasks_paginated(
db=db,
current_user=current_user,
limit=limit,
offset=offset,
)
return tasksService implementation:
def list_tasks_paginated(
db: Session,
current_user: User,
limit: int,
offset: int,
) -> List[models.Task]:
query = (
db.query(models.Task)
.filter(
(models.Task.assignee_id == current_user.id)
| (models.Task.creator_id == current_user.id)
)
.order_by(models.Task.created_at.desc())
)
return query.offset(offset).limit(limit).all()You can also return metadata:
class PaginatedTasks(BaseModel):
total: int
items: List[TaskResponse]
limit: int
offset: intThen your endpoint can respond with pagination info.
Rule:
Always limit the maximum number of items returned by list endpoints. This protects your database and avoids huge responses.
Filtering
Add query parameters for fields that are commonly filtered.
Examples:
/tasks?status=done/tasks?project_id=10/tasks?assignee_id=123
In FastAPI:
@router.get("", response_model=List[TaskResponse])
def list_tasks(
status: TaskStatus | None = None,
project_id: int | None = None,
assignee_id: int | None = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tasks = task_service.list_tasks_filtered(
db=db,
current_user=current_user,
status=status,
project_id=project_id,
assignee_id=assignee_id,
)
return tasksIn the service:
from sqlalchemy import and_
def list_tasks_filtered(
db: Session,
current_user: User,
status: Optional[str],
project_id: Optional[int],
assignee_id: Optional[int],
) -> List[models.Task]:
filters = [
(models.Task.assignee_id == current_user.id)
| (models.Task.creator_id == current_user.id)
]
if status is not None:
filters.append(models.Task.status == status)
if project_id is not None:
filters.append(models.Task.project_id == project_id)
if assignee_id is not None:
filters.append(models.Task.assignee_id == assignee_id)
return db.query(models.Task).filter(and_(*filters)).all()Sorting
Use a sort query parameter. Common pattern:
sort=created_atascendingsort=-created_atdescending (minus sign for descending)
Example parsing:
from fastapi import Query
@router.get("", response_model=List[TaskResponse])
def list_tasks(
sort: str = Query("-created_at"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tasks = task_service.list_tasks_sorted(
db=db,
current_user=current_user,
sort=sort,
)
return tasksService:
from sqlalchemy import asc, desc
def list_tasks_sorted(
db: Session,
current_user: User,
sort: str,
) -> List[models.Task]:
field_name = sort.lstrip("-")
direction = desc if sort.startswith("-") else asc
field_map = {
"created_at": models.Task.created_at,
"updated_at": models.Task.updated_at,
"title": models.Task.title,
"status": models.Task.status,
}
order_field = field_map.get(field_name, models.Task.created_at)
query = (
db.query(models.Task)
.filter(
(models.Task.assignee_id == current_user.id)
| (models.Task.creator_id == current_user.id)
)
.order_by(direction(order_field))
)
return query.all()Versioning and URL Structure
You already saw the "/api/v1" prefix in earlier examples. This is a simple and popular way to version your API.
Rule:
Always version your public API from the beginning. Use a prefix like /api/v1 to avoid breaking existing clients in the future.
Common approaches:
| Approach | Example | Notes |
|---|---|---|
| URL version | /api/v1/tasks | Simple and visible, often recommended |
| Header | Accept: application/vnd.myapp.v1+json | More complex, usually for advanced APIs |
| Query param | /tasks?version=1 | Not recommended for long term main version |
In a typical FastAPI project you group routes by version folder, as seen earlier:
# app/api/v1/router.py
# app/api/v2/router.pyWhen you need a new major version, you can:
- Copy or refactor the existing routes into
v2. - Start exposing new behavior under
/api/v2. - Keep
/api/v1for some time for older clients.
Documentation and OpenAPI in the Final Project
FastAPI automatically generates an OpenAPI schema and interactive docs, but you still must design your endpoints and descriptions so that the documentation is useful.
Tags, summaries, and descriptions
Use tags and docstrings so that /docs and /redoc are readable.
router = APIRouter(prefix="/tasks", tags=["tasks"])
@router.post(
"",
response_model=TaskResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a new task",
description="Create a new task in a project. The current user becomes the creator.",
)
def create_task(...):
"""
Detailed description for API users.
- **title**: Task title, max 200 characters.
- **description**: Optional task details.
- **status**: One of `todo`, `in_progress`, `done`.
"""
...This appears in Swagger UI and helps other developers understand how to use your API.
Configuring metadata in the FastAPI app
In main.py:
app = FastAPI(
title="Production Backend API",
description="Backend for the Final Project, providing tasks, projects, and authentication.",
version="1.0.0",
contact={
"name": "Backend Team",
"url": "https://example.com",
"email": "backend@example.com",
},
license_info={
"name": "MIT",
"url": "https://opensource.org/licenses/MIT",
},
)FastAPI will expose:
- OpenAPI JSON at
/openapi.json - Swagger UI at
/docs - ReDoc at
/redoc
You can then use this OpenAPI spec for:
- Generating client SDKs.
- Validating API changes.
- Communicating the contract with frontend developers.
Putting It All Together
In this chapter you focused on how to build the REST API layer of your production project without re‑explaining basic REST or FastAPI concepts.
Your main tasks for your own project are:
- List all features and map them to resources and endpoints.
- Design URLs using RESTful patterns, correct HTTP methods, and clear use of path and query parameters.
- Create Pydantic schemas for requests and responses, with strict validation.
- Implement CRUD endpoints with a thin routing layer and a clean service or repository layer underneath.
- Add pagination, filtering, and sorting to your list endpoints.
- Define consistent error handling and response shapes.
- Version your API using a clear prefix such as
/api/v1. - Use OpenAPI documentation features to make the API easy to understand and consume.
With these steps, you will have a well structured REST API that fits into the production architecture of the final project and is ready for authentication, PostgreSQL, Redis, background workers, and deployment in the following chapters.
Views: 7
KAHIBARO