The course: "FastAPI for Busy Engineers" is available if you prefer videos.

The Ultimate FastAPI Tutorial Part 4 - Pydantic Schemas

In part 4 of the FastAPI tutorial, we'll look at an API endpoint with Pydantic validation
Created: 16 July 2021
Last updated: 16 July 2021

Introduction

Welcome to the Ultimate FastAPI tutorial series. This post is part 4. The series is a project-based tutorial where we will build a cooking recipe API. Each post gradually adds more complex functionality, showcasing the capabilities of FastAPI, ending with a realistic, production-ready API. The series is designed to be followed in order, but if you already know FastAPI you can jump to the relevant part.

Code

Project github repo directory for this part of the tutorial

Tutorial Series Contents

Optional Preamble: FastAPI vs. Flask

Beginner Level Difficulty

Part 1: Hello World
Part 2: URL Path Parameters & Type Hints
Part 3: Query Parameters
Part 4: Pydantic Schemas & Data Validation
Part 5: Basic Error Handling
Part 6: Jinja Templates
Part 6b: Basic FastAPI App Deployment on Linode

Intermediate Level Difficulty

Part 7: Setting up a Database with SQLAlchemy and its ORM
Part 8: Production app structure and API versioning
Part 9: Creating High Performance Asynchronous Logic via async def and await
Part 10: Authentication via JWT
Part 11: Dependency Injection and FastAPI Depends
Part 12: Setting Up A React Frontend
Part 13: Using Docker, Uvicorn and Gunicorn to Deploy Our App to Heroku
Part 14: Using Docker and Uvicorn to Deploy Our App to IaaS (Coming soon)
Part 15: Exploring the Open Source Starlette Toolbox - GraphQL (Coming soon)
Part 16: Alternative Backend/Python Framework Comparisons (i.e. Django) (Coming soon)

Post Contents

Introduction to Pydantic
Practical Section - Using Pydantic with FastAPI

FastAPI logo


Introduction to Pydantic

Pydantic describes itself as:

Data validation and settings management using python type annotations.

It’s a tool which allows you to be much more precise with your data structures. For example, up until now we have been relying on a dictionary to define a typical recipe in our project. With Pydantic we can define a recipe like this:

from pydantic import BaseModel

class Recipe(BaseModel):
    id: int
    label: str
    source: str

raw_recipe = {'id': 1, 'label': 'Lasagna', 'source': 'Grandma Wisdom'}
structured_recipe = Recipe(**raw_recipe)
print(structured_recipe.id)
#> 1

In this simple example, the Recipe class inherits from the Pydantic BaseModel, and we can define each of its expected fields and their types using standard Python type hints.

As well as using the typing module’s standard types, you can use Pydantic models recursively like so:

from pydantic import BaseModel

class Car(BaseModel):
    brand: str
    color: str
    gears: int


class ParkingLot(BaseModel):
    cars: List[Car]  # recursively use `Car`
    spaces: int

When you combine these capabilities, you can define very complex objects. This is just scratching the surface of Pydantic’s capabilities, here is a quick summary of its benefits:

  • No new micro-language to learn (which means it plays well with IDEs/linters)
  • Great for both “validate this request/response data” and also loading config
  • Validate complex data structures - Pydantic offers extremely granular validators
  • Extensible - you can create custom data types
  • Works with Python data classes
  • It’s very fast

Practical Section - Using Pydantic with FastAPI

If you haven’t already, go ahead and clone the example project repo See the README file for local setup.

In the app/main.py file, you will find the following new code:

from app.schemas import RecipeSearchResults, Recipe, RecipeCreate
# skipping...

# 1 Updated to use a response_model
@api_router.get("/recipe/{recipe_id}", status_code=200, response_model=Recipe)
def fetch_recipe(*, recipe_id: int) -> dict:
    """
    Fetch a single recipe by ID
    """

    result = [recipe for recipe in RECIPES if recipe["id"] == recipe_id]
    if result:
        return result[0]

# skipping...

The Recipe response model is imported from a new schemas.py file. Let’s look the relevant part of the app/schemas.py file:

from pydantic import BaseModel, HttpUrl

# 2
class Recipe(BaseModel):
    id: int
    label: str
    source: str
    url: HttpUrl  # 3

# skipping...

Let’s break this down:

  1. Our path parameter endpoint /recipe/{recipe_id}, which we introduced in part 2 has been updated to include a response_model field. Here we define the structure of the JSON response, and we do this via Pydantic.
  2. The new Recipe class inherits from the pydantic BaseModel, and each field is defined with standard type hints…
  3. …except the url field, which uses the Pydantic HttpUrl helper. This will enforce expected URL components, such as the presence of a scheme (http or https).

Next, we’ve updated the search endpoint:

# app/main.py
from fastapi import FastAPI, APIRouter, Query

from typing import Optional

from app.schemas import RecipeSearchResults
# skipping...

# 1
@api_router.get("/search/", status_code=200, response_model=RecipeSearchResults)
def search_recipes(
    *,
    keyword: Optional[str] = Query(None, min_length=3, example="chicken"),  # 2
    max_results: Optional[int] = 10
) -> dict:
    """
    Search for recipes based on label keyword
    """
    if not keyword:
        # we use Python list slicing to limit results
        # based on the max_results query parameter
        return {"results": RECIPES[:max_results]}

    results = filter(lambda recipe: keyword.lower() in recipe["label"].lower(), RECIPES)
    return {"results": list(results)[:max_results]}

# skipping...

Now let’s look at app/schemas.py

# skipping...

# 3
class RecipeSearchResults(BaseModel):
    results: Sequence[Recipe]  # 4

1 We’ve added a response_model RecipeSearchResults to our /search endpoint search endpoint response

Notice the response format matches the schema (if it did not, we’d get a Pydantic validation error).

2 We bring in the FastAPI Query class, which allows us add additional validation and requirements to our query params, such as a minimum length. Notice that because we’ve set the example field, this shows up on the docs page when you “Try It”

3 The RecipeSearchResults class uses Pydantic’s recursive capability to define a field that refers to another Pydantic class we’ve previously defined, the Recipe class. We specify that the results field will be a Sequence (which is an iterable with support for len and __getitem__) of Recipes.

Having done all that (and followed the README setup instructions), you can run the code in the example repo with this command: poetry run ./run.sh

Navigate to localhost:8001/docs

Give the endpoint a try:

  • Expand the GET endpoint by clicking on it
  • Click on the “Try It Out” button
  • Enter the value “chicken” for the keyword
  • Press the large “Execute” button
  • Press the smaller “Execute” button that appears

Creating a POST endpoint

Another addition we’ve made to our API is ability to create new recipes. This is done via a POST request. Here is the updated code in app/main.py:

# skipping...
# New addition, using Pydantic model `RecipeCreate` to define
# the POST request body
# 1
@api_router.post("/recipe/", status_code=201, response_model=Recipe)
def create_recipe(*, recipe_in: RecipeCreate) -> dict:  # 2
    """
    Create a new recipe (in memory only)
    """
    new_entry_id = len(RECIPES) + 1
    recipe_entry = Recipe(
        id=new_entry_id,
        label=recipe_in.label,
        source=recipe_in.source,
        url=recipe_in.url,
    )
    RECIPES.append(recipe_entry.dict())  # 3

    return recipe_entry

And here is the updated app/schemas.py code:

# 4
class RecipeCreate(BaseModel):
    label: str
    source: str
    url: HttpUrl
    submitter_id: int

A few key points to note:

  1. To set the function to handle POST requests, we just tweak our api_router decorator. Notice that we’re also setting the HTTP status_code to 201, since we are creating resources.
  2. The recipe_in field is the POST request body. By specifying a Pydantic schema, we are able to automatically validate incoming requests, ensuring that their bodies adhere to our schema.
  3. To persist the created recipe, we’re doing a primitive list append. Naturally, this is just for a toy example and won’t persist the data when the server is restarted. Later in the series, we will cover databases.
  4. The RecipeCreate schema contains a new field, submitter_id, so we distinguish it from the Recipe schema.

Be sure to try creating some new recipes as you run the app locally via the interactive documentation.


Continue Learning FastAPI

In the next part of the tutorial, we’ll cover more basic error handling for our example project endpoints. Go to part 5

Category