Views: 5
To build your first real endpoints in FastAPI, you define asynchronous functions decorated with HTTP methods like @app.get() or @app.post(). These endpoints utilize path parameters for unique resource identification and query parameters for filtering or pagination. By integrating Pydantic models, FastAPI automatically handles request body validation and generates interactive API documentation (Swagger UI), ensuring your backend is both robust and developer-friendly from the start.
Learn to build FastAPI endpoints using HTTP methods, path and query parameters, and Pydantic models for automatic data validation and API documentation.
🎓 What You’ll Learn
By the end of this tutorial, you’ll understand:
- HTTP methods and RESTful API design principles
- Path parameters vs Query parameters (and when to use each)
- Request and Response models with Pydantic
- Data validation and automatic documentation
- Building a complete CRUD endpoint (without database yet)
📖 Understanding HTTP Methods & REST APIs
What is REST?
REST stands for REpresentational State Transfer. It’s a set of rules for building web APIs.
Real-world analogy: Think of a library:
- GET: “Show me book #42” (retrieve information)
- POST: “Add this new book to the library” (create new item)
- PUT: “Replace everything in book #42 with this new version” (full update)
- PATCH: “Just fix the typo on page 10 of book #42” (partial update)
- DELETE: “Remove book #42 from the library” (delete item)
HTTP Methods Explained
| Method | Purpose | Has Body? | Idempotent?* | Safe?** |
|---|---|---|---|---|
| GET | Retrieve data | No | Yes | Yes |
| POST | Create new resource | Yes | No | No |
| PUT | Update/Replace entire resource | Yes | Yes | No |
| PATCH | Partial update | Yes | No | No |
| DELETE | Remove resource | No | Yes | No |
Idempotent*: Calling it multiple times has the same effect as calling it once
DELETE /users/5→ Deleting 5 times = deleting once (user is gone either way)POST /users→ Creating 5 times = 5 new users (NOT idempotent)
Safe: Doesn’t modify data on the server
GET /users→ Just reads, doesn’t change anythingPOST /users→ Creates something new (NOT safe)
🛠️ Step-by-Step Implementation
Step 1: Understanding Path Parameters
Path parameters are variables embedded in the URL path.
Example URLs:
/users/123 → user_id = 123
/posts/456/comments → post_id = 456
/books/python-guide → book_id = "python-guide"
Create a new file called ep_02.py with these contents:
"""
Blog Post 2: FastAPI HTTP Methods and Parameters
Exploring path parameters, query parameters, and request/response models
"""
from fastapi import FastAPI
from typing import Optional
app = FastAPI(
title="FastAPI Learning - Blog Post 2",
description="Understanding HTTP methods and parameters",
version="0.2.0"
)
# ============================================
# PART 1: PATH PARAMETERS
# ============================================
@app.get("/users/{user_id}")
async def get_user(user_id: int):
"""
Get a user by their ID (path parameter)
Args:
user_id (int): The user's unique identifier
Returns:
dict: User information
"""
return {
"user_id": user_id,
"username": f"user_{user_id}",
"message": f"Retrieved user with ID: {user_id}"
}
🔍 Code Breakdown:
{user_id}: Curly braces define a path parameteruser_id: int: Type hint – FastAPI validates it’s an integer- Automatic validation: If you visit
/users/abc, FastAPI returns an error automatically!
Test it:
# Run the server
uvicorn blog_post_2:app --reload
# Visit in browser:
# http://127.0.0.1:8000/users/42
# http://127.0.0.1:8000/users/invalid ← See the error!
Step 2: Path Parameters with Multiple Types
# Add this to ep_02.py
@app.get("/items/{item_id}")
async def get_item(item_id: str):
"""
Get an item by ID (string path parameter)
Note: This accepts ANY string, so 'abc123' and '456' both work
"""
return {
"item_id": item_id,
"type": type(item_id).__name__
}
@app.get("/products/{category}/{product_id}")
async def get_product(category: str, product_id: int):
"""
Multiple path parameters example
URL: /products/electronics/42
"""
return {
"category": category,
"product_id": product_id,
"full_path": f"{category}/{product_id}"
}
Test URLs:
http://127.0.0.1:8000/items/laptop-xyz
http://127.0.0.1:8000/products/electronics/42
http://127.0.0.1:8000/products/books/123
Step 3: Understanding Query Parameters
Query parameters come after ? in the URL and are used for filtering, sorting, pagination.
Example URLs:
/search?q=fastapi → q = "fastapi"
/search?q=python&limit=10 → q = "python", limit = 10
/users?active=true&sort=name → active = true, sort = "name"
When to use Path vs Query parameters?
| Use Path Parameters | Use Query Parameters |
|---|---|
Resource identifier (/users/123) | Optional filters (?active=true) |
| Required for the endpoint | Optional parameters |
| Part of resource hierarchy | Modify how data is returned |
| SEO-friendly URLs | Sorting, pagination, filtering |
# Add this to ep_02.py
# ============================================
# PART 2: QUERY PARAMETERS
# ============================================
@app.get("/search")
async def search_items(
q: str, # Required query parameter
limit: int = 10, # Optional with default value
skip: int = 0, # Optional with default value
sort: Optional[str] = None # Optional, can be None
):
"""
Search items with query parameters
Args:
q: Search query (required)
limit: Maximum number of results (default: 10)
skip: Number of results to skip for pagination (default: 0)
sort: Sort field (optional)
Example URLs:
/search?q=python
/search?q=python&limit=5
/search?q=python&limit=5&skip=10&sort=date
"""
return {
"query": q,
"limit": limit,
"skip": skip,
"sort": sort,
"results": f"Searching for '{q}' with limit={limit}, skip={skip}"
}
@app.get("/filter")
async def filter_items(
min_price: Optional[float] = None,
max_price: Optional[float] = None,
in_stock: bool = True,
category: Optional[str] = None
):
"""
Filter items with multiple optional query parameters
Example: /filter?min_price=10&max_price=100&category=electronics
"""
filters = {
"min_price": min_price,
"max_price": max_price,
"in_stock": in_stock,
"category": category
}
# Remove None values for cleaner response
active_filters = {k: v for k, v in filters.items() if v is not None}
return {
"active_filters": active_filters,
"message": "Filtering items with the specified criteria"
}
Test URLs:
http://127.0.0.1:8000/search?q=python
http://127.0.0.1:8000/search?q=fastapi&limit=5&skip=10
http://127.0.0.1:8000/filter?min_price=10&max_price=100&category=electronics
http://127.0.0.1:8000/filter?in_stock=false
Step 4: Combining Path and Query Parameters
# Add this to ep_02.py
@app.get("/users/{user_id}/posts")
async def get_user_posts(
user_id: int, # Path parameter
limit: int = 10, # Query parameter
published: Optional[bool] = None # Query parameter
):
"""
Get posts by a specific user with filtering
Combines path and query parameters
Example: /users/42/posts?limit=5&published=true
"""
return {
"user_id": user_id,
"limit": limit,
"published_filter": published,
"message": f"Getting up to {limit} posts for user {user_id}"
}
🎯 Introduction to Pydantic Models
What is Pydantic?
Pydantic is a data validation library that uses Python type hints. It’s the foundation of FastAPI’s automatic validation.
Why use Pydantic models?
- ✅ Validation: Automatic type checking and data validation
- ✅ Documentation: Auto-generates API docs with examples
- ✅ Serialization: Converts between Python objects and JSON
- ✅ Type Safety: Catches errors before they reach production
- ✅ IDE Support: Better autocomplete and error detection
Step 5: Creating Your First Pydantic Model
# Add this to ep_02.py
from pydantic import BaseModel, Field, EmailStr
from typing import Optional, List
from datetime import datetime
from enum import Enum
# ============================================
# PART 3: PYDANTIC MODELS
# ============================================
# Enum for user roles (limited choices)
class UserRole(str, Enum):
"""User role enumeration"""
ADMIN = "admin"
USER = "user"
GUEST = "guest"
class User(BaseModel):
"""
User model with validation
This defines the structure of a User object
"""
id: int = Field(..., description="Unique user identifier", gt=0)
username: str = Field(..., min_length=3, max_length=50, description="Username")
email: EmailStr = Field(..., description="User email address")
full_name: Optional[str] = Field(None, description="User's full name")
role: UserRole = Field(default=UserRole.USER, description="User role")
is_active: bool = Field(default=True, description="Is the user active?")
created_at: datetime = Field(default_factory=datetime.now, description="Creation timestamp")
tags: List[str] = Field(default=[], description="User tags")
class Config:
"""Pydantic configuration"""
json_schema_extra = {
"example": {
"id": 1,
"username": "johndoe",
"email": "john@example.com",
"full_name": "John Doe",
"role": "user",
"is_active": True,
"tags": ["developer", "python"]
}
}
class UserCreate(BaseModel):
"""
Model for creating a new user (without id and created_at)
"""
username: str = Field(..., min_length=3, max_length=50)
email: EmailStr
full_name: Optional[str] = None
role: UserRole = UserRole.USER
password: str = Field(..., min_length=8, description="User password")
class Config:
json_schema_extra = {
"example": {
"username": "johndoe",
"email": "john@example.com",
"full_name": "John Doe",
"password": "securepass123"
}
}
class UserUpdate(BaseModel):
"""
Model for updating a user (all fields optional)
"""
username: Optional[str] = Field(None, min_length=3, max_length=50)
email: Optional[EmailStr] = None
full_name: Optional[str] = None
role: Optional[UserRole] = None
is_active: Optional[bool] = None
tags: Optional[List[str]] = None
class UserResponse(BaseModel):
"""
Model for user response (without sensitive data like password)
"""
id: int
username: str
email: EmailStr
full_name: Optional[str]
role: UserRole
is_active: bool
created_at: datetime
tags: List[str]
class Config:
json_schema_extra = {
"example": {
"id": 1,
"username": "johndoe",
"email": "john@example.com",
"full_name": "John Doe",
"role": "user",
"is_active": True,
"created_at": "2024-01-15T10:30:00",
"tags": ["developer", "python"]
}
}
🔍 Field Breakdown:
Field(...): The...(Ellipsis) means “required”gt=0: Greater than 0 (id must be positive)min_length,max_length: String length validationEmailStr: Special type that validates email format (requirespip install pydantic[email])default_factory: Function called to generate default valueConfig.json_schema_extra: Provides example for API documentation
Step 6: Install Email Validation
pip install pydantic[email]
pip freeze > requirements.txt
Step 7: Using Models in Endpoints (POST Request)
# Add this to ep_02.py
# In-memory database (just a list for now)
fake_users_db: List[User] = []
next_user_id = 1
@app.post("/users", response_model=UserResponse, status_code=201)
async def create_user(user: UserCreate):
"""
Create a new user
Args:
user: User data from request body
Returns:
UserResponse: Created user (without password)
"""
global next_user_id
# Check if username already exists
if any(u.username == user.username for u in fake_users_db):
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="Username already exists")
# Create new user (in real app, hash the password!)
new_user = User(
id=next_user_id,
username=user.username,
email=user.email,
full_name=user.full_name,
role=user.role,
is_active=True,
created_at=datetime.now(),
tags=[]
)
fake_users_db.append(new_user)
next_user_id += 1
return new_user
🔍 Important Concepts:
response_model=UserResponse: Tells FastAPI what structure to return- Automatically excludes
passwordfield - Validates the response matches the model
- Automatically excludes
status_code=201: HTTP 201 = “Created” (proper REST convention)user: UserCreate: FastAPI automatically:- Parses JSON from request body
- Validates all fields
- Returns detailed errors if validation fails
Step 8: GET Endpoint with Response Model
# Add this to ep_02.py
from fastapi import HTTPException
@app.get("/users", response_model=List[UserResponse])
async def get_all_users(
skip: int = 0,
limit: int = 10,
role: Optional[UserRole] = None
):
"""
Get all users with optional filtering and pagination
Args:
skip: Number of records to skip
limit: Maximum number of records to return
role: Filter by user role
Returns:
List of users
"""
users = fake_users_db
# Filter by role if specified
if role:
users = [u for u in users if u.role == role]
# Pagination
return users[skip : skip + limit]
@app.get("/users/{user_id}", response_model=UserResponse)
async def get_user_by_id(user_id: int):
"""
Get a specific user by ID
Args:
user_id: User identifier
Returns:
User data
Raises:
HTTPException: 404 if user not found
"""
for user in fake_users_db:
if user.id == user_id:
return user
# User not found
raise HTTPException(
status_code=404,
detail=f"User with ID {user_id} not found"
)
Step 9: PUT Endpoint (Full Update)
# Add this to ep_02.py
@app.put("/users/{user_id}", response_model=UserResponse)
async def update_user(user_id: int, user_update: UserCreate):
"""
Update (replace) an entire user
PUT replaces ALL fields, so you must provide complete data
Args:
user_id: User to update
user_update: New user data
Returns:
Updated user
"""
for idx, user in enumerate(fake_users_db):
if user.id == user_id:
# Replace entire user (keep id and created_at)
updated_user = User(
id=user.id,
username=user_update.username,
email=user_update.email,
full_name=user_update.full_name,
role=user_update.role,
is_active=True,
created_at=user.created_at, # Keep original
tags=[]
)
fake_users_db[idx] = updated_user
return updated_user
raise HTTPException(status_code=404, detail="User not found")
Step 10: PATCH Endpoint (Partial Update)
# Add this to ep_02.py
@app.patch("/users/{user_id}", response_model=UserResponse)
async def partial_update_user(user_id: int, user_update: UserUpdate):
"""
Partially update a user
PATCH only updates the fields you provide, leaves others unchanged
Args:
user_id: User to update
user_update: Fields to update (all optional)
Returns:
Updated user
"""
for user in fake_users_db:
if user.id == user_id:
# Update only provided fields
update_data = user_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(user, field, value)
return user
raise HTTPException(status_code=404, detail="User not found")
🔍 Key Concept: exclude_unset=True
- Only includes fields that were actually provided in the request
- Fields not in the request are ignored (not set to None)
Example:
PATCH /users/1
{
"full_name": "Jane Doe"
}
Only updates full_name, leaves everything else unchanged.
Step 11: DELETE Endpoint
# Add this to ep_02.py
@app.delete("/users/{user_id}", status_code=204)
async def delete_user(user_id: int):
"""
Delete a user
Args:
user_id: User to delete
Returns:
204 No Content (successful deletion returns nothing)
"""
for idx, user in enumerate(fake_users_db):
if user.id == user_id:
fake_users_db.pop(idx)
return # 204 returns no content
raise HTTPException(status_code=404, detail="User not found")
🔍 HTTP 204: “No Content” – successful deletion, no response body needed
🧪 Testing Your API
Method 1: Interactive Docs (Recommended for Beginners)
- Visit: http://127.0.0.1:8000/docs
- You’ll see all your endpoints organized beautifully
- Click on any endpoint → “Try it out”
- Fill in the request body, click “Execute”
Method 2: curl (Command Line)
# Create a user
curl -X POST "http://127.0.0.1:8000/users" \
-H "Content-Type: application/json" \
-d '{
"username": "johndoe",
"email": "john@example.com",
"full_name": "John Doe",
"password": "securepass123"
}'
# Get all users
curl "http://127.0.0.1:8000/users"
# Get specific user
curl "http://127.0.0.1:8000/users/1"
# Update user (partial)
curl -X PATCH "http://127.0.0.1:8000/users/1" \
-H "Content-Type: application/json" \
-d '{"full_name": "John Smith"}'
# Delete user
curl -X DELETE "http://127.0.0.1:8000/users/1"
Method 3: Python Requests Library
Create test_api.py with these contents:
"""
Simple script to test our API endpoints
"""
import requests
import json
BASE_URL = "http://127.0.0.1:8000"
def test_create_user():
"""Test creating a new user"""
user_data = {
"username": "alice",
"email": "alice@example.com",
"full_name": "Alice Johnson",
"password": "secure123",
"role": "user"
}
response = requests.post(f"{BASE_URL}/users", json=user_data)
print(f"Status Code: {response.status_code}")
print(f"Response: {json.dumps(response.json(), indent=2)}")
return response.json()
def test_get_users():
"""Test getting all users"""
response = requests.get(f"{BASE_URL}/users")
print(f"\nAll Users: {json.dumps(response.json(), indent=2)}")
def test_get_user(user_id: int):
"""Test getting a specific user"""
response = requests.get(f"{BASE_URL}/users/{user_id}")
if response.status_code == 200:
print(f"\nUser {user_id}: {json.dumps(response.json(), indent=2)}")
else:
print(f"\nError: {response.status_code} - {response.json()}")
def test_update_user(user_id: int):
"""Test partial update"""
update_data = {"full_name": "Alice Smith"}
response = requests.patch(f"{BASE_URL}/users/{user_id}", json=update_data)
print(f"\nUpdated User: {json.dumps(response.json(), indent=2)}")
def test_delete_user(user_id: int):
"""Test deleting a user"""
response = requests.delete(f"{BASE_URL}/users/{user_id}")
print(f"\nDelete Status: {response.status_code}")
if __name__ == "__main__":
# Run tests
print("=== Creating User ===")
user = test_create_user()
print("\n=== Getting All Users ===")
test_get_users()
print("\n=== Getting Specific User ===")
test_get_user(user['id'])
print("\n=== Updating User ===")
test_update_user(user['id'])
print("\n=== Deleting User ===")
test_delete_user(user['id'])
print("\n=== Verifying Deletion ===")
test_get_users()
Install requests library:
pip install requests
pip freeze > requirements.txt
Run the test:
python test_api.py
📊 HTTP Status Codes Reference
| Code | Meaning | When to Use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST (resource created) |
| 204 | No Content | Successful DELETE |
| 400 | Bad Request | Validation error, bad input |
| 401 | Unauthorized | Authentication required |
| 403 | Forbidden | Authenticated but not authorized |
| 404 | Not Found | Resource doesn’t exist |
| 409 | Conflict | Duplicate resource (username exists) |
| 422 | Unprocessable Entity | FastAPI validation errors |
| 500 | Internal Server Error | Server-side error |
🎯 Key Concepts Summary
1. HTTP Methods
@app.get() # Retrieve data
@app.post() # Create new resource
@app.put() # Replace entire resource
@app.patch() # Partial update
@app.delete() # Remove resource
2. Parameters
# Path parameter (required, in URL)
@app.get("/users/{user_id}")
async def get_user(user_id: int):
...
# Query parameter (optional, after ?)
@app.get("/search")
async def search(q: str, limit: int = 10):
...
3. Pydantic Models
class User(BaseModel):
name: str # Required
age: int = 18 # Optional with default
email: Optional[str] = None # Optional, can be None
tags: List[str] = [] # List with default
4. Response Models
@app.post("/users", response_model=UserResponse)
async def create_user(user: UserCreate):
# Automatically validates and filters response
return created_user
🧪 Hands-On Exercises
Exercise 1: Add a Product Model
Create a Product model with:
id,name,price,description,in_stock- Implement CRUD endpoints
- Add search and filter capabilities
Exercise 2: Validation Challenge
Add these validations to UserCreate:
- Username: no special characters, lowercase only
- Password: must contain uppercase, lowercase, and number
- Age: between 13 and 120
Hint: Use Pydantic’s Field with regex parameter!
Exercise 3: Complex Relationships
Create a Post model that references a user:
GET /users/{user_id}/posts– Get user’s postsPOST /users/{user_id}/posts– Create post for userGET /postswith filtering by author
🐛 Debugging Tips
1. Check Auto-Generated Docs
Always visit /docs – it shows exactly what FastAPI expects!
2. Read Validation Errors
FastAPI gives detailed errors:
{
"detail": [
{
"loc": ["body", "email"],
"msg": "value is not a valid email address",
"type": "value_error.email"
}
]
}
3. Use Type Hints
Your IDE will catch errors before you run the code!
4. Test Each Endpoint
Use the test script or /docs to verify each endpoint works.
📚 Additional Resources
Official Docs:
Next Learning:
- Dependency injection
- Database integration (SQLAlchemy)
- Authentication & security
- Background tasks
🎉 What You’ve Accomplished!
✅ Understood REST API principles and HTTP methods
✅ Mastered path and query parameters
✅ Created Pydantic models with validation
✅ Built a complete CRUD API
✅ Learned automatic documentation
✅ Implemented proper error handling
✅ Tested your API multiple ways
🚀 Coming Up in Blog Post 3
In the next tutorial, we’ll cover:
- Project Structure: Organizing code for scalability
- Configuration Management: Environment variables, settings
- Routers: Separating endpoints into modules
- Middleware: Request/response processing
- Folder Structure: Production-ready organization
This will transform our single-file app into a professional, maintainable codebase!
Leave a Reply