Ep.02 FastAPI Basics – Building Your First Real Endpoints

Views: 2

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

MethodPurposeHas Body?Idempotent?*Safe?**
GETRetrieve dataNoYesYes
POSTCreate new resourceYesNoNo
PUTUpdate/Replace entire resourceYesYesNo
PATCHPartial updateYesNoNo
DELETERemove resourceNoYesNo

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 anything
  • POST /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:

  1. {user_id}: Curly braces define a path parameter
  2. user_id: int: Type hint – FastAPI validates it’s an integer
  3. 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 ParametersUse Query Parameters
Resource identifier (/users/123)Optional filters (?active=true)
Required for the endpointOptional parameters
Part of resource hierarchyModify how data is returned
SEO-friendly URLsSorting, 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 validation
  • EmailStr: Special type that validates email format (requires pip install pydantic[email])
  • default_factory: Function called to generate default value
  • Config.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:

  1. response_model=UserResponse: Tells FastAPI what structure to return
    • Automatically excludes password field
    • Validates the response matches the model
  2. status_code=201: HTTP 201 = “Created” (proper REST convention)
  3. 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)

  1. Visit: http://127.0.0.1:8000/docs
  2. You’ll see all your endpoints organized beautifully
  3. Click on any endpoint โ†’ “Try it out”
  4. 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

CodeMeaningWhen to Use
200OKSuccessful GET, PUT, PATCH
201CreatedSuccessful POST (resource created)
204No ContentSuccessful DELETE
400Bad RequestValidation error, bad input
401UnauthorizedAuthentication required
403ForbiddenAuthenticated but not authorized
404Not FoundResource doesn’t exist
409ConflictDuplicate resource (username exists)
422Unprocessable EntityFastAPI validation errors
500Internal Server ErrorServer-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 posts
  • POST /users/{user_id}/posts – Create post for user
  • GET /posts with 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

Your email address will not be published. Required fields are marked *

Search