Building Your First API
Set up a FastAPI project and build your first REST endpoints with proper request/response handling
Hands-On API Development
FastAPI is a modern, high-performance Python web framework for building APIs. It provides automatic API documentation, request validation, and async support out of the box. In this lesson, you'll build your first API from scratch and understand the fundamentals of route handling and data validation.
Python Web Frameworks Overview
Python offers several excellent frameworks for building web applications and APIs. Each has its strengths, trade-offs, and ideal use cases. Understanding these options helps you choose the right tool for your project.
A lightweight, minimalist micro-framework that gives you the essentials to build web applications without imposing structure. Flask is simple, flexible, and easy to learn, making it perfect for small to medium projects and prototypes.
When to Use
- Small to medium-sized APIs
- Prototypes and MVPs
- Learning web development
- Maximum flexibility needed
- Simple CRUD applications
When to Avoid
- Large enterprise applications
- Need for built-in admin panel
- Complex ORM requirements
- High-performance async needed
- Automatic API documentation required
- Simple and easy to learn
- Minimal boilerplate code
- Excellent documentation
- Large ecosystem of extensions
- Very flexible and unopinionated
- No built-in data validation
- Manual async setup required
- No automatic API docs
- More decisions to make
- Slower than async frameworks
A batteries-included, high-level framework that follows the "don't repeat yourself" (DRY) principle. Django provides everything you need for building complex web applications: ORM, admin interface, authentication, and more. Django REST Framework (DRF) extends Django for building powerful APIs.
When to Use
- Large enterprise applications
- Need built-in admin interface
- Complex data models with ORM
- Authentication/authorization needed
- Full-stack web applications
When to Avoid
- Small microservices
- Simple REST APIs only
- Need maximum flexibility
- High-performance async required
- Learning first web framework
- Everything included (batteries-included)
- Powerful ORM and migrations
- Built-in admin interface
- Strong security features
- Mature ecosystem and community
- DRF provides powerful API tools
- Steeper learning curve
- More opinionated structure
- Heavier than microframeworks
- Overkill for simple APIs
- Async support still maturing
FastAPI
fastapi.tiangolo.comA modern, high-performance framework specifically designed for building APIs with Python 3.8+ type hints. FastAPI provides automatic API documentation, data validation via Pydantic, and native async support, making it one of the fastest Python frameworks available.
When to Use
- Modern REST APIs
- Microservices architecture
- High-performance requirements
- Automatic documentation needed
- Type safety and validation important
When to Avoid
- Full-stack web apps with templates
- Need built-in admin panel
- Using older Python versions
- Prefer more established frameworks
- Automatic interactive API docs
- Built-in data validation (Pydantic)
- Native async/await support
- Excellent performance
- Type hints for IDE support
- Easy to learn and use
- Younger ecosystem
- No built-in ORM
- Fewer extensions than Flask
- Requires Python 3.8+
- Not ideal for server-side templates
Other Notable Frameworks
🌪️ Tornadotornadoweb.org
Asynchronous networking framework ideal for long-lived connections, WebSockets, and applications requiring thousands of concurrent connections.
🚀 Sanicsanic.dev
Flask-like async framework built for speed. Similar API to Flask but with async/await support and better performance.
🔷 Pyramidtrypyramid.com
Flexible framework that scales from small to large applications. Not opinionated, allowing you to use your preferred tools and patterns.
🍾 Bottlebottlepy.org
Single-file micro-framework with no dependencies. Perfect for learning, prototyping, or building small applications with minimal overhead.
| Framework | Best For | Performance | Learning Curve | Async Support |
|---|---|---|---|---|
| Flask | Small APIs, Prototypes | ⭐⭐⭐ | Easy | Manual setup |
| Django + DRF | Full-stack, Enterprise | ⭐⭐⭐ | Moderate | Improving |
| FastAPI | Modern APIs, Microservices | ⭐⭐⭐⭐⭐ | Easy-Moderate | Native |
| Tornado | WebSockets, Real-time | ⭐⭐⭐⭐ | Moderate | Native |
| Sanic | High-performance APIs | ⭐⭐⭐⭐⭐ | Easy | Native |
Choosing the Right Framework
For this course, we'll use FastAPI because it combines ease of use with modern features like automatic documentation, type safety, and excellent performance. It's perfect for learning API development patterns that apply across all frameworks.
The concepts you learn (REST principles, validation, authentication, etc.) are transferable to any framework. FastAPI just makes these patterns easier to implement and understand.
Why FastAPI?
FastAPI is one of the fastest Python web frameworks available, built on top of Starlette and Pydantic. It's designed for building modern APIs with automatic documentation and type safety.
⚡ FastAPI Advantages
- Automatic interactive API docs (Swagger UI + ReDoc)
- Built-in data validation with Pydantic
- Native async/await support
- Type hints for better IDE support
- High performance (comparable to NodeJS/Go)
- Modern Python 3.8+ features
🐍 Flask Advantages
- Simpler learning curve for beginners
- More mature ecosystem
- Greater flexibility (less opinionated)
- Larger community and resources
- Better for full-stack web apps (with templates)
- More third-party extensions available
When to Choose FastAPI
Use FastAPI when building modern REST APIs, especially if you need automatic documentation, data validation, or high performance. Use Flask for simpler projects, prototypes, or when you need full-stack features like template rendering.
Project Setup
Step 1: Create a Virtual Environment
Always use a virtual environment to isolate your project dependencies.
# Create a new directory for your project mkdir todo-api cd todo-api # Create a virtual environment python -m venv venv # Activate the virtual environment # On macOS/Linux: source venv/bin/activate # On Windows: venv\Scripts\activate
(venv) in your terminal prompt, indicating the virtual environment is active.Step 2: Install FastAPI and Uvicorn
# Install FastAPI and uvicorn (ASGI server) pip install "fastapi[all]" # Or install individually: pip install fastapi uvicorn[standard]
Step 3: Create Your First API File
Create a file called main.py with a simple "Hello World" API:
# main.py
from fastapi import FastAPI
# Create a FastAPI instance
app = FastAPI()
# Define a root endpoint
@app.get("/")
async def root():
return {"message": "Hello World"}
# Define a health check endpoint
@app.get("/health")
async def health_check():
return {"status": "healthy"}@app.get() decorator defines a GET endpoint. We use async def for async support.Step 4: Run Your API
# Start the development server uvicorn main:app --reload # Output: # INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) # INFO: Started reloader process # INFO: Started server process # INFO: Waiting for application startup. # INFO: Application startup complete.
http://localhost:8000. The --reload flag auto-restarts on code changes.Test Your API
Open your browser and visit:
http://localhost:8000- Returns:{"message": "Hello World"}http://localhost:8000/health- Returns:{"status": "healthy"}http://localhost:8000/docs- Interactive Swagger UI documentationhttp://localhost:8000/redoc- Alternative ReDoc documentation
Path Parameters
Path parameters allow you to capture values from the URL path. They're used to identify specific resources.
Basic Path Parameter
@app.get("/users/{user_id}")
async def get_user(user_id: int):
return {
"user_id": user_id,
"name": f"User {user_id}"
}
# Usage:
# GET /users/123
# Returns: {"user_id": 123, "name": "User 123"}user_id is automatically converted to an integer. If you pass a non-integer, FastAPI returns a 422 validation error.Multiple Path Parameters
@app.get("/users/{user_id}/posts/{post_id}")
async def get_user_post(user_id: int, post_id: int):
return {
"user_id": user_id,
"post_id": post_id,
"message": f"Post {post_id} by User {user_id}"
}
# Usage:
# GET /users/42/posts/999
# Returns: {"user_id": 42, "post_id": 999, "message": "Post 999 by User 42"}Path Parameter with Validation
from fastapi import Path
@app.get("/items/{item_id}")
async def get_item(
item_id: int = Path(..., ge=1, le=1000, description="The ID of the item")
):
return {"item_id": item_id}
# Usage:
# GET /items/50 → ✅ {"item_id": 50}
# GET /items/1001 → ❌ 422 Error: "ensure this value is less than or equal to 1000"
# GET /items/0 → ❌ 422 Error: "ensure this value is greater than or equal to 1"Query Parameters
Query parameters are optional parameters that appear after ? in the URL. They're used for filtering, pagination, and configuration.
Basic Query Parameters
@app.get("/items")
async def list_items(skip: int = 0, limit: int = 10):
return {
"skip": skip,
"limit": limit,
"message": f"Showing items {skip} to {skip + limit}"
}
# Usage:
# GET /items → {"skip": 0, "limit": 10, "message": "Showing items 0 to 10"}
# GET /items?skip=20 → {"skip": 20, "limit": 10, "message": "Showing items 20 to 30"}
# GET /items?skip=5&limit=20 → {"skip": 5, "limit": 20, "message": "Showing items 5 to 25"}Required Query Parameters
from typing import Optional
@app.get("/search")
async def search_items(
q: str, # Required (no default value)
category: Optional[str] = None, # Optional
max_price: Optional[float] = None # Optional
):
result = {"query": q}
if category is not None:
result["category"] = category
if max_price is not None:
result["max_price"] = max_price
return result
# Usage:
# GET /search → ❌ 422 Error: "field required" (missing q)
# GET /search?q=laptop → ✅ {"query": "laptop"}
# GET /search?q=laptop&category=electronics → ✅ {"query": "laptop", "category": "electronics"}
# GET /search?q=laptop&max_price=1000.50 → ✅ {"query": "laptop", "max_price": 1000.5}Query Parameter Validation
from fastapi import Query
@app.get("/items/search")
async def search_with_validation(
q: str = Query(..., min_length=3, max_length=50),
limit: int = Query(10, ge=1, le=100)
):
return {"query": q, "limit": limit}
# Usage:
# GET /items/search?q=ab → ❌ 422 Error: "ensure this value has at least 3 characters"
# GET /items/search?q=laptop → ✅ {"query": "laptop", "limit": 10}
# GET /items/search?q=laptop&limit=150 → ❌ 422 Error: "ensure this value is less than or equal to 100"Request Body with Pydantic Models
Pydantic models define the structure and validation rules for request bodies. They provide automatic validation, serialization, and documentation.
Defining a Pydantic Model
from pydantic import BaseModel, EmailStr, Field
from typing import Optional
class User(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
email: EmailStr # Validates email format
age: Optional[int] = Field(None, ge=0, le=150)
is_active: bool = True
model_config = {
"json_schema_extra": {
"examples": [{
"name": "Alice Johnson",
"email": "alice@example.com",
"age": 30,
"is_active": True
}]
}
}Field(...) means required. The model_config dict provides example data for API docs (Pydantic v2).Using the Model in an Endpoint
@app.post("/users")
async def create_user(user: User):
return {
"message": "User created successfully",
"user": user
}
# Valid Request:
# POST /users
# Body: {"name": "Alice", "email": "alice@example.com", "age": 30}
# Response: 200 OK
# {
# "message": "User created successfully",
# "user": {
# "name": "Alice",
# "email": "alice@example.com",
# "age": 30,
# "is_active": true
# }
# }
# Invalid Request (bad email):
# POST /users
# Body: {"name": "Alice", "email": "not-an-email", "age": 30}
# Response: 422 Unprocessable Entity
# {
# "detail": [
# {
# "loc": ["body", "email"],
# "msg": "value is not a valid email address",
# "type": "value_error.email"
# }
# ]
# }Response Models
class UserResponse(BaseModel):
id: int
name: str
email: str
# Note: password is NOT included in response
class UserCreate(BaseModel):
name: str
email: EmailStr
password: str # This will be hashed, not returned
@app.post("/users", response_model=UserResponse, status_code=201)
async def create_user(user: UserCreate):
# In production: hash password, save to database
new_user = {
"id": 123,
"name": user.name,
"email": user.email,
"password": "hashed_password" # This won't be in response
}
return new_user
# Request:
# POST /users
# Body: {"name": "Alice", "email": "alice@example.com", "password": "secret123"}
# Response: 201 Created
# {
# "id": 123,
# "name": "Alice",
# "email": "alice@example.com"
# }
# Note: password is excluded from responseresponse_model filters the response to only include specified fields, protecting sensitive data.Combining Path, Query, and Body Parameters
You can mix all three types of parameters in a single endpoint. FastAPI automatically determines which is which based on the function signature.
class UpdateUser(BaseModel):
name: Optional[str] = None
email: Optional[EmailStr] = None
@app.patch("/users/{user_id}")
async def update_user(
user_id: int, # Path parameter
updates: UpdateUser, # Request body
send_email: bool = False # Query parameter
):
result = {
"user_id": user_id,
"updates": updates.model_dump(exclude_unset=True),
"email_notification": send_email
}
return result
# Usage:
# PATCH /users/123?send_email=true
# Body: {"name": "Alice Updated"}
# Response:
# {
# "user_id": 123,
# "updates": {"name": "Alice Updated"},
# "email_notification": true
# }Parameter Recognition Rules
- Path parameter: Declared in the path string with
{param_name} - Request body: Declared with a Pydantic model type
- Query parameter: Singular types (int, str, bool, etc.) not in the path
Building a Complete Todo API
Let's build a complete CRUD API for managing todo items, applying everything we've learned.
# todo_api.py
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
app = FastAPI(
title="Todo API",
description="A simple API for managing todo items",
version="1.0.0"
)
# Models
class TodoCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
description: Optional[str] = Field(None, max_length=1000)
priority: int = Field(1, ge=1, le=5)
model_config = {
"json_schema_extra": {
"examples": [{
"title": "Buy groceries",
"description": "Milk, eggs, bread",
"priority": 2
}]
}
}
class TodoUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = None
priority: Optional[int] = Field(None, ge=1, le=5)
completed: Optional[bool] = None
class Todo(BaseModel):
id: int
title: str
description: Optional[str]
priority: int
completed: bool
created_at: datetime
updated_at: datetime
# In-memory database (use real database in production)
todos_db = {}
todo_id_counter = 1
# Endpoints
@app.get("/", tags=["Root"])
async def root():
"""Welcome endpoint"""
return {
"message": "Welcome to Todo API",
"docs": "/docs",
"total_todos": len(todos_db)
}
@app.get("/todos", response_model=List[Todo], tags=["Todos"])
async def list_todos(
skip: int = 0,
limit: int = 10,
completed: Optional[bool] = None,
min_priority: int = 1
):
"""
List all todos with pagination and filtering.
- **skip**: Number of items to skip (for pagination)
- **limit**: Maximum number of items to return
- **completed**: Filter by completion status (optional)
- **min_priority**: Filter by minimum priority (1-5)
"""
all_todos = list(todos_db.values())
# Apply filters
if completed is not None:
all_todos = [t for t in all_todos if t["completed"] == completed]
all_todos = [t for t in all_todos if t["priority"] >= min_priority]
# Apply pagination
return all_todos[skip:skip + limit]
@app.post("/todos", response_model=Todo, status_code=status.HTTP_201_CREATED, tags=["Todos"])
async def create_todo(todo: TodoCreate):
"""Create a new todo item"""
global todo_id_counter
now = datetime.now()
new_todo = {
"id": todo_id_counter,
"title": todo.title,
"description": todo.description,
"priority": todo.priority,
"completed": False,
"created_at": now,
"updated_at": now
}
todos_db[todo_id_counter] = new_todo
todo_id_counter += 1
return new_todo
@app.get("/todos/{todo_id}", response_model=Todo, tags=["Todos"])
async def get_todo(todo_id: int):
"""Get a specific todo by ID"""
if todo_id not in todos_db:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Todo with id {todo_id} not found"
)
return todos_db[todo_id]
@app.put("/todos/{todo_id}", response_model=Todo, tags=["Todos"])
async def replace_todo(todo_id: int, todo: TodoCreate):
"""Replace an entire todo item"""
if todo_id not in todos_db:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Todo with id {todo_id} not found"
)
updated_todo = {
"id": todo_id,
"title": todo.title,
"description": todo.description,
"priority": todo.priority,
"completed": todos_db[todo_id]["completed"],
"created_at": todos_db[todo_id]["created_at"],
"updated_at": datetime.now()
}
todos_db[todo_id] = updated_todo
return updated_todo
@app.patch("/todos/{todo_id}", response_model=Todo, tags=["Todos"])
async def update_todo(todo_id: int, updates: TodoUpdate):
"""Partially update a todo item"""
if todo_id not in todos_db:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Todo with id {todo_id} not found"
)
todo = todos_db[todo_id]
# Update only provided fields
update_data = updates.model_dump(exclude_unset=True)
for field, value in update_data.items():
todo[field] = value
todo["updated_at"] = datetime.now()
todos_db[todo_id] = todo
return todo
@app.delete("/todos/{todo_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Todos"])
async def delete_todo(todo_id: int):
"""Delete a todo item"""
if todo_id not in todos_db:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Todo with id {todo_id} not found"
)
del todos_db[todo_id]
return None
@app.get("/stats", tags=["Stats"])
async def get_stats():
"""Get statistics about todos"""
total = len(todos_db)
completed = sum(1 for t in todos_db.values() if t["completed"])
pending = total - completed
return {
"total": total,
"completed": completed,
"pending": pending,
"completion_rate": (completed / total * 100) if total > 0 else 0
}
# Run with: uvicorn todo_api:app --reloadWhat We've Implemented
- ✅ CRUD operations (Create, Read, Update, Delete)
- ✅ Pagination with skip/limit
- ✅ Filtering by completion status and priority
- ✅ Input validation with Pydantic
- ✅ Proper HTTP status codes
- ✅ Error handling with HTTPException
- ✅ Statistics endpoint
- ✅ Tags for organized documentation
Testing the Todo API
# 1. Create a todo
curl -X POST http://localhost:8000/todos \
-H "Content-Type: application/json" \
-d '{"title": "Learn FastAPI", "description": "Complete the tutorial", "priority": 5}'
# Response: 201 Created
# {"id": 1, "title": "Learn FastAPI", "description": "Complete the tutorial",
# "priority": 5, "completed": false, "created_at": "2024-01-20T10:00:00", ...}
# 2. List all todos
curl http://localhost:8000/todos
# Response: 200 OK
# [{"id": 1, "title": "Learn FastAPI", ...}]
# 3. Get specific todo
curl http://localhost:8000/todos/1
# Response: 200 OK
# {"id": 1, "title": "Learn FastAPI", ...}
# 4. Update todo (mark as complete)
curl -X PATCH http://localhost:8000/todos/1 \
-H "Content-Type: application/json" \
-d '{"completed": true}'
# Response: 200 OK
# {"id": 1, "title": "Learn FastAPI", "completed": true, ...}
# 5. Delete todo
curl -X DELETE http://localhost:8000/todos/1
# Response: 204 No ContentAutomatic Interactive Documentation
One of FastAPI's killer features is automatic, interactive API documentation generated from your code.
📘 Swagger UI
Visit http://localhost:8000/docs
- Interactive interface to test endpoints
- See request/response models
- Try requests directly in browser
- View validation rules and examples
📗 ReDoc
Visit http://localhost:8000/redoc
- Clean, three-panel layout
- Better for reading documentation
- Code examples in multiple languages
- Professional appearance for public APIs
What's Included Automatically
- All endpoints organized by tags
- Request and response schemas from Pydantic models
- Validation rules (min/max, patterns, etc.)
- Example values from model_config json_schema_extra
- Docstrings as endpoint descriptions
- OpenAPI JSON schema at
/openapi.json
Production-Ready Project Structure
While the examples above are great for learning, production applications need better organization. A well-structured project separates concerns, improves maintainability, and scales with your team.
Problem with Single-File Approach
Putting everything in main.py works for tutorials but becomes unmaintainable as your API grows:
- Hard to find specific endpoints
- Difficult to test individual components
- Can't organize code by feature/domain
- Merge conflicts with team members
- No clear separation of concerns
Recommended Structure
Here's a production-ready structure that scales from small APIs to large applications:
my-fastapi-project/ ├── project/ │ ├── __init__.py │ └── service_api/ │ ├── __init__.py │ ├── main.py # Application factory │ ├── requirements.txt # Service-specific dependencies │ ├── routers/ # Route definitions │ │ ├── __init__.py # Router registration │ │ ├── health.py # Health check endpoint │ │ ├── users.py # User-related endpoints │ │ └── products.py # Product-related endpoints │ ├── controllers/ # Business logic │ │ ├── __init__.py │ │ ├── user_controller.py │ │ └── product_controller.py │ ├── models/ # Pydantic models │ │ ├── __init__.py │ │ ├── user.py # User request/response models │ │ └── product.py # Product request/response models │ ├── database/ # Database layer │ │ ├── __init__.py │ │ ├── connection.py # DB connection setup │ │ ├── models.py # SQLAlchemy/ORM models │ │ └── repositories/ # Data access layer │ │ ├── __init__.py │ │ ├── user_repository.py │ │ └── product_repository.py │ └── utils/ # Utility functions │ ├── __init__.py │ ├── auth.py # Authentication helpers │ └── validators.py # Custom validators ├── tests/ │ ├── __init__.py │ └── unit/ │ ├── __init__.py │ ├── service_api/ │ │ ├── __init__.py │ │ ├── base.py # Test base classes │ │ └── test_health.py # Health endpoint tests │ └── test_users.py # User endpoint tests ├── manager.py # CLI commands (run, test, etc.) ├── requirements.txt # Global dependencies ├── .env # Environment variables ├── .gitignore └── README.md
Key Components Explained
Creates and configures the FastAPI application instance:
# project/service_api/main.py
from fastapi import FastAPI
from project.service_api.routers import routers
def create_app(api_title: str, debug: bool = False):
app = FastAPI(title=api_title, debug=debug)
# Register all routers
for prefix, router in routers:
app.include_router(router, prefix=prefix)
return appEach file contains related endpoints. __init__.py registers all routers:
# project/service_api/routers/__init__.py
from fastapi import APIRouter
from project.service_api.routers.health import health_router
from project.service_api.routers.users import users_router
API_BASE_PATH = "/api"
routers = [
(API_BASE_PATH, health_router),
(API_BASE_PATH, users_router),
]
# project/service_api/routers/users.py
from fastapi import APIRouter
from project.service_api.controllers.user_controller import UserController
users_router = APIRouter(tags=["users"])
@users_router.get("/users")
async def list_users():
return await UserController.list_users()
@users_router.post("/users")
async def create_user(user: CreateUserRequest):
return await UserController.create_user(user)Separates business logic from route handlers. Makes code testable and reusable:
# project/service_api/controllers/user_controller.py
from project.service_api.database.repositories.user_repository import UserRepository
class UserController:
@staticmethod
async def list_users(skip: int = 0, limit: int = 20):
# Business logic here
users = await UserRepository.get_all(skip, limit)
# Transform data if needed
return {
"users": users,
"total": await UserRepository.count()
}
@staticmethod
async def create_user(user_data: dict):
# Validation, processing, etc.
if await UserRepository.exists_by_email(user_data["email"]):
raise HTTPException(409, "Email already exists")
return await UserRepository.create(user_data)Define request and response structures separately from database models:
# project/service_api/models/user.py
from pydantic import BaseModel, EmailStr
from typing import Optional
class UserBase(BaseModel):
email: EmailStr
username: str
full_name: Optional[str] = None
class CreateUserRequest(UserBase):
password: str
class UpdateUserRequest(BaseModel):
full_name: Optional[str] = None
email: Optional[EmailStr] = None
class UserResponse(UserBase):
id: int
is_active: bool
class Config:
from_attributes = True # For SQLAlchemy modelsSeparates database operations from business logic. Repositories handle all data access:
# project/service_api/database/repositories/user_repository.py
from sqlalchemy.ext.asyncio import AsyncSession
from project.service_api.database.models import User
class UserRepository:
@staticmethod
async def get_all(skip: int = 0, limit: int = 20):
# Database query here
pass
@staticmethod
async def get_by_id(user_id: int):
# Query by ID
pass
@staticmethod
async def create(user_data: dict):
# Insert into database
pass
@staticmethod
async def exists_by_email(email: str) -> bool:
# Check existence
passCentral command-line interface for running the app, tests, migrations, etc:
# manager.py
import click
from uvicorn import run
@click.group()
def cli():
pass
@cli.command()
def run_api():
"""Run the API service"""
from project.service_api.main import create_app
run(
app=create_app("My API", debug=True),
host="0.0.0.0",
port=8000
)
@cli.command()
def run_tests():
"""Run test suite"""
import pytest
pytest.main(["-v", "tests/"])
if __name__ == "__main__":
cli()Benefits of This Structure
Maintainability
- Easy to locate specific code
- Clear responsibility for each module
- Changes are isolated to relevant files
Testability
- Controllers can be tested independently
- Mock repositories easily
- Unit tests don't need full app setup
Scalability
- Add features without touching existing code
- Multiple developers work simultaneously
- Grows from small to enterprise size
Reusability
- Controllers shared across endpoints
- Repositories used by multiple services
- Models consistent throughout app
Production-Ready Templates
ByteCode Solutions provides battle-tested project templates that follow these best practices. Start your next project with a solid foundation:
⚡ FastAPI Blueprint
Production-ready FastAPI project structure with testing, CLI commands, and modular architecture.
View Template🎸 Django Blueprint
Full-featured Django project with Django REST Framework, organized for enterprise applications.
View TemplateWhen to Refactor
Start simple, refactor when needed:
- Keep everything in
main.pyfor your first 5-10 endpoints - When you notice duplication or files getting too large (>300 lines), start organizing
- Move to this structure when working with a team or planning to maintain long-term
- Don't over-architect early - let structure emerge from needs
URI Design Best Practices and API Versioning
How you design URIs and handle versioning significantly impacts your API's maintainability and client experience. While many approaches exist, some are more aligned with REST principles than others.
URI Design Principles
Good URI Design
/users- Collection of users/users/123- Specific user/users/123/orders- User's orders/products- Collection of products/orders/456/items- Items in order
Principle: URIs identify resources, not actions or versions.
Poor URI Design
/getUser- Verb in URI/user-list- Inconsistent naming/api/v1/users- Version in URI*/createOrder- Action in URI/users.json- Format in URI
Problem: Violates REST principles, harder to maintain.
The API Versioning Debate
One of the most controversial topics in API design is versioning strategy. While URI versioning (/api/v1/users) is popular and used by major companies, it violates REST principles.
Why URI Versioning is NOT Recommended
Including versions in URIs (/api/v1/users, /api/v2/users) breaks a fundamental REST principle: URIs should identify resources, not representations.
- Same resource has different URIs
- Breaks caching mechanisms
- Links become version-specific
- Clients must update all URIs
- Creates resource duplication
- Violates HATEOAS principle
# Same user, different URIs GET /api/v1/users/123 GET /api/v2/users/123 # Which is the "real" URI? # Cache invalidation problems # Bookmarks break # API clients need updates
Real-world analogy: Imagine if every time a person got older, their home address changed. That's what URI versioning does to your resources.
Why do big companies use it? Legacy decisions, organizational constraints, and prioritizing developer convenience over REST principles. Just because it's common doesn't make it correct.
Recommended: Header-Based Versioning
Use Accept Header for Versioning (Content Negotiation)
The Accept header tells the server which representation of the resource you want. The URI stays constant, representing the resource itself.
# Same URI, different versions via Accept header
# Request version 1
GET /api/users/123
Accept: application/vnd.myapi.v1+json
# Request version 2
GET /api/users/123
Accept: application/vnd.myapi.v2+json
# Default to latest version
GET /api/users/123
Accept: application/json
# Response includes version info
HTTP/1.1 200 OK
Content-Type: application/vnd.myapi.v2+json
{
"id": 123,
"name": "John Doe",
"email": "john@example.com"
}- ✅ URI represents the resource, not the version
- ✅ Same resource has one canonical URI
- ✅ Proper REST content negotiation
- ✅ Caching works correctly
- ✅ Links remain stable
- ✅ Follows HTTP specification
Implementing Header-Based Versioning in FastAPI
from fastapi import FastAPI, Request, HTTPException, Response
from typing import Optional
app = FastAPI()
def get_api_version(request: Request) -> str:
"""Extract version from Accept header"""
accept = request.headers.get("accept", "application/json")
# Parse Accept header for version
# application/vnd.myapi.v1+json -> v1
# application/vnd.myapi.v2+json -> v2
if "vnd.myapi.v1" in accept: # For convenience using if/else,
return "v1" # but, in production services let's use strategy pattern.
elif "vnd.myapi.v2" in accept:
return "v2"
else:
return "v2" # Default to latest
@app.get("/api/users/{user_id}")
async def get_user(user_id: int, request: Request, response: Response):
version = get_api_version(request)
# Fetch user data
user = await db.get_user(user_id)
# Return different representations based on version
if version == "v1":
response.headers["Content-Type"] = "application/vnd.myapi.v1+json"
return {
"id": user.id,
"name": user.name,
"email": user.email
}
elif version == "v2":
response.headers["Content-Type"] = "application/vnd.myapi.v2+json"
return {
"id": user.id,
"full_name": user.name, # Renamed field
"email": user.email,
"created_at": user.created_at, # New field
"profile_url": f"/users/{user.id}" # HATEOAS link
}
# Alternative: Use custom header
@app.get("/api/products/{product_id}")
async def get_product(
product_id: int,
api_version: Optional[str] = Header("2", alias="X-API-Version")
):
if api_version == "1":
# Return v1 format
pass
else:
# Return v2 format (default)
pass/api/users/123 always points to the same user, but clients can request different representations via headers.Comparison of Versioning Strategies
| Strategy | Example | Pros | Cons | REST Compliant |
|---|---|---|---|---|
| URI Versioning | /api/v1/users | Simple, visible, easy to test | Breaks REST, multiple URIs per resource | ❌ No |
| Accept Header | Accept: vnd.api.v1+json | REST compliant, single URI, proper negotiation | Harder to test, invisible in browser | ✅ Yes |
| Custom Header | X-API-Version: 1 | Simple, keeps URI clean | Not standard, bypasses HTTP negotiation | ⚠️ Partial |
| Query Parameter | /users?version=1 | Easy to test, visible | Pollutes query space, breaks caching | ❌ No |
Best Practices Summary
✅ DO This
- Use nouns for resources
- Keep URIs stable over time
- Version via Accept header
- Use consistent naming
- Make URIs readable
- Support content negotiation
❌ AVOID This
- Verbs in URIs (
/getUser) - Versions in URIs (
/v1/users) - File extensions (
/users.json) - Underscores (use hyphens)
- Actions in paths
- Exposing implementation details
⚠️ Consider Trade-offs
- Team familiarity vs REST purity
- Testing convenience vs correctness
- Public API vs internal API
- Migration path from legacy
- Documentation clarity
- Client developer experience
Pragmatic Approach
If you must use URI versioning:
- Document that it's for convenience, not REST compliance
- Consider it a routing prefix, not part of the resource identifier
- Still support content negotiation for format (JSON, XML, etc.)
- Plan migration to header-based versioning eventually
For new projects: Start with header-based versioning. It's more work initially but pays off in maintainability, cacheability, and REST compliance.
Further Reading
- RFC 7231 (HTTP/1.1): Content negotiation specification
- Roy Fielding's Thesis: Original REST architectural style definition
- Martin Fowler: "Richardson Maturity Model" - explains REST levels
- API Design Patterns: Common patterns for versioning and evolution
Key Takeaways
- FastAPI is fast and modern. Built on Starlette and Pydantic for high performance and great developer experience.
- Type hints drive everything. They enable validation, documentation, and IDE support automatically.
- Pydantic models are powerful. They provide validation, serialization, and clear contracts for your API.
- Three parameter types: path, query, body. FastAPI automatically determines which is which.
- Documentation is free. Swagger UI and ReDoc are generated automatically from your code.
- Start simple, grow complex. You can build production APIs with minimal boilerplate code.
Bonus: Production Deployment on AWS
Ready to take your FastAPI or Flask application to production? Learn how to deploy a scalable, production-grade Python web application on AWS infrastructure with enterprise-level architecture.
The article covers:
- Amazon Machine Image (AMI) creation - Build custom, pre-configured server images
- Infrastructure as Code (IaC) with Terraform - Define and provision AWS resources programmatically
- Flask application deployment - Production setup with Gunicorn (WSGI server) and Nginx (reverse proxy)
- Classic AWS architecture - Elastic Load Balancer (ELB), Auto Scaling Group (ASG), and EC2 instances for high availability
Practice Exercises
- Extend the Todo API with a
due_datefield (datetime type) - Add a
GET /todos/high-priorityendpoint that returns todos with priority ≥ 4 - Implement a
POST /todos/bulkendpoint that accepts a list of todos - Add tags/categories to todos (use a List[str] field)
- Create a search endpoint that filters by title using query parameters