FastAPI Tutorial (Part 2) - Routes

FastAPI Tutorial (Part 2) - Routes

12 min read

--

Introduction

In the last post we created a base setup for our project. In case you didn’t read it, or want to check it, here is the link.

In this post we’re going to focus on defining our API endpoints (routes operations) and sending and receiving data from the client using url and query parameters. We’ll also see how to use Pydantic to add a global configuration to our application.

At the end, we’ll fix the CORS origin error to allow front-end applications to interact with our API.

Tutorial Series Contents

Project files and repo

All the files for this and the other sections are stored in this repo. Each part of the series has a corresponding branch and the main branch includes the finished project.

There is also a starter branch with a base starting project, you can clone it or download it in case you want to follow along.

FastAPI Global Config

FastAPI uses a few main libraries under the hood to make development easier. One of those libraries is Pydantic. In case you don’t know, Pydantic is a Python library made to help with data validation using python type annotations.

We’re going to use a lot of Pydantic through the series to validate data, but, another of Pydantic main features is to manage project global settings.

To create a global config in our application, for this, let’s update our project structure to look like this:

fastapi-tutorial/
├── .vscode/
├── app/
│ ├── core/ --> NEW
│ │ ├── **init**.py --> NEW
│ │ └── config.py --> NEW  
│ ├── endpoints/  
│ │ ├── **init**.py
│ │ ├── api.py
│ │ └── books.py
│ ├── **init**.py
│ └── main.py
├── .gitignore
├── main.py
└── pyproject.toml

We create a new core folder in which we can manage all global related settings. Inside this folder, we add a new config.py file to create our settings.

To create global settings all we have to do is create a new Settings class and extend Pydantic’s BaseSettings, and then instantiate this class in a new settings variable. We have the following:

from pydantic import BaseSettings


class Settings(BaseSettings):
	"""Defines project global settings"""

	API_STR: str = "/api"
	PROJECT_TITLE: str = "Online Book Store API"
	PROJECT_DESCRIPTION: str = "Backend API for an online Book Store"


settings = Settings()

Inside this new class we can add all the global configuration we need/want (we’ll add other properties to the class later). For the moment we’re creating 3 new configurations and adding string (str) types to help with editor intellisense and avoid errors. You can use any title and description you like (this will be displayed in the API docs).

The API_STR, refers to the API base endpoint. In the previous post we added this directly to the api_router config since we didn’t have this settings at the moment. By doing it like this we can add versioning to our API more easy (e.g. /api/v1), among other advantages. For simplicity, we’re not adding versioning to this project.

Use Global Settings

To use our new global settings, in the main.py file, add this:

# from fastapi import FastAPI

# from app.endpoints.api import api_router
from app.core.config import settings

app = FastAPI(title=settings.PROJECT_TITLE, description=settings.PROJECT_DESCRIPTION)

app.include_router(api_router, prefix=settings.API_STR)

# @app.get("/")
# def index():
#	return {"message": "Hello World"}

Now, go to your API docs.

api title description inside swagger docs

As you can see, our API title has changed and a new description was added.

Books CRUD Routes

In the last post we created a simple GET route to retrieve dummy data from a python list. Now let’s add the rest of routes for reading, updating, deleting and retrieving a single book.

Path Parameters

The first route we’ll add is to GET a single book from the API using the book ID. To do this, we need to get the book ID from the URL. FastAPI makes this process simple, all we have to do is add the variable between curly brackets in the route decorator and then accept this variable as a parameter in the route function (operation) as follow:

# code above omitted 👆

@router.get("/{book_id}")
def get_book_by_id(book_id):
	pass

By adding the book_id to the path, FastAPI will read anything that goes after http://localhost:8000/api/books/ as a book ID, and then we accept this ID in the path operation.

Of course at the moment, python doesn’t know what type is this variable, so it can’t help us with any validation. To fix this, we can tell FastAPI that this ID should be of type int (integer), that way FastAPI will try to convert any string passed to this endpoint to an integer and if it can’t then will raise a new exception.

# code above omitted 👆

@router.get("/{book_id}")
def get_book_by_id(book_id: int):
	pass

Get Single book

Since we’re not persisting data yet, at this point we can use any python way to get an item from a list. For this case I’m going to loop through the list since it’s just 3 items. Later when we add a database we’ll improve this code.

# code above omitted 👆

@router.get("/{book_id}")

def get_book_by_id(book_id: int):
	"""Get single book from database"""

	for book in books:
		if book["id"] == book_id:
			return book

# code above bellow 👇

Again, this may not be the most efficient way, but for the moment it will work. We’ll improve it later.

Finish the CRUD Routes

The same way as before, I’m going to create a route for POST, PUT, and DELETE. We end up with the following books.py file:

from fastapi import APIRouter
from fastapi.encoders import jsonable_encoder

router = APIRouter()

books = [
	{"id": 1, "title": "book 1", "price": 16.99},
	{"id": 2, "title": "book 2", "price": 12.99},
	{"id": 3, "title": "book 3", "price": 9.99},
]


@router.get("/")
def get_books():
	"""Get books from database"""

	return books


@router.post("/")
def add_book(new_book):
	"""Create new book and store in database"""

	books.append(new_book)
	return books


@router.get("/{book_id}")
def get_book_by_id(book_id: int):
	"""Get single book from database"""

	for book in books:
		if book["id"] == book_id:
			return book


@router.put("/{book_id}")
def update_book(book_id: int, book):
	"""Update book in database"""
	update_book_encoded = jsonable_encoder(book)
	books[book_id] = update_book_encoded

	return update_book_encoded


@router.delete("/{book_id}")
def delete_book(book_id: int):
	"""Delete book from database"""
	for book in books:
		if book["id"] == book_id:
			books.pop(book_id - 1)

	return books

Notice the update endpoint. We’re receiving two parameters, the book_id and the book object. The problem is, at the point FastAPI doesn’t know how does the book object should look. If you go to your API docs, will notice that we currently can pass any data here that can be converted to a string.

You can also notice that if we try the POST endpoint the client can send any data and the API will add it to the books list without any type of validation. Of course, we don’t want that.

We’ll fix all of this later by adding a Pydantic Schema to tell FastAPI how this objects should look.

To update books, we’re using a function provided by FastAPI jsonable_encoder. Basically this function will convert a Pydantic schema into JSON format.

At this point we have the complete CRUD operations for our books. In the next post, we’ll add validation schemas to allow FastAPI help us handle the data we receive from the client and send back.

Query parameters

When we create an API, it’s typical to allow the client to add extra parameters to the endpoint. A common example is a query for a specific range of values or adding pagination.

Since adding a variable between curly brackets to the path will make FastAPI see this as a path parameter, then the question is, how do we tell FastAPI about any additional query parameters.

Actually this is a very easy process. All we have to do is accept this query parameters as function parameters inside our path operation. FastAPI will see this params and check if they are part of the endpoint path, and if not, then FastAPI will interpret them as query parameters.

Let’s take pagination as an example. To add pagination to our project, the client will send a request to a route with something like this: http://localhost:8000/api/books?limit=1&skip=0. This will ask to get at most one result per query (limit=1), and skip zero results (skip=0).

To allow this in our endpoint, all we have to do is pass this two params (limit & skip) ass our function params:

# code above omitted 👆

@router.get("/")
def get_books(limit: int = 1, skip: int = 0):
	"""Get books from database"""
	response = books[skip : skip + limit]

	return response

# code above bellow 👇

If you refresh your browser, you can see two new fields in your docs (skip & limit).

endpoint with query params

Since we’re adding default values, you can test your API and notice that now you only get 1 result instead of all the books in the dummy list (3).

endpoint with query params response

Authors Endpoints

Now that we have our books endpoints “ready”. Let’s create endpoints to get authors info. This endpoints will be useful later to create relationships between books and authors.

Let’s create the endpoints and register the router the same way we did with the books. We have the following authors.py inside the endpoints/ folder.

from fastapi import APIRouter

router = APIRouter()


authors = [
	{"id": 1, "name": "author 1", "bio": "author bio"},
	{"id": 2, "name": "author 2", "bio": "author bio"},
	{"id": 3, "name": "author 3", "bio": "author bio"},
]


@router.get("/")
def get_authors(limit: int = 1, skip: int = 0):
	"""Get authors from database"""
	response = authors[skip : skip + limit]

	return response


@router.post("/")
def add_author(new_author):
	"""Create new author and store in database"""
	authors.append(new_author)

	return authors


@router.get("/{author_id}")
def get_author_by_id(author_id: int):
	"""Get single author from database"""
	for author in authors:
		if author["id"] == author_id:
			return author


@router.put("/{author_id}")
def update_author(author_id: int):
	"""Update author in database"""
	return {"data": "updated author"}


@router.delete("/{author_id}")
def delete_author(author_id: int):
	"""Delete author from database"""
	for author in authors:
		if author["id"] == author_id:
			authors.pop(author_id - 1)

	return authors

Later we’ll add other endpoints, but for the moment this will work.

Register this new router by updating the main api_router inside the api.py file.

# from fastapi import APIRouter

from . import books, authors

# api_router = APIRouter()

# api_router.include_router(books.router, prefix="/books", tags=["Books"])
api_router.include_router(authors.router, prefix="/authors", tags=["Authors"])

In your API docs you should see something like this:

endpoint with query params response

The new authors endpoints (just as any project endpoints) depend on what would you want to allow the client to do. Maybe you don’t want the client to delete authors or create a new author, in those cases you can omit this routes.

In this example we’re allowing all operations just for demostration.

CORS (Cross-Origin Resource Sharing)

At this point, you main want to test your endpoints from a frontend client application. If you’re building a full-stack application, for example using React, Vue or Svelte, you may encounter a situation in which your client application cannot communicate with your API.

This is because your client and your server are running in different ports (e.g. a React project running on port 3000 and your API running on port 8000). This is what’s called CORS or “Cross-Origin Resource Sharing”.

To fix this, we have to tell our API, which origins are allowed to communicate with our server. You can do this by “blacklisting” (listing all the origins that are not allowed), or “whitelisting” (listing only the allowed origins).

Of course you can allow all origins by using * as a wildcard (useful if your API is public).

Adding allowed origins

In our case we will only list which origins are allowed. To do this, in our global config file config.py, we can add a list of allowed origins.

from typing import List

from pydantic import AnyHttpUrl, BaseSettings

# class Settings(BaseSettings):
#	"""Defines project global settings"""

#	API_STR: str = "/api"
# 	PROJECT_TITLE: str = "Online Book Store API"
#	PROJECT_DESCRIPTION: str = "Backend API for an online Book Store"
	CORS_ORIGINS: List[AnyHttpUrl] = []


#settings = Settings()

Inside the list you can add something like "http://localhost:3000" for a client running on port 3000.

Add list of origins to the app

Inside our main.py file, we add our CORS config to the API

# from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

# from app.endpoints.api import api_router
# from app.core.config import settings

# app = FastAPI(title=settings.PROJECT_TITLE, description=settings.PROJECT_DESCRIPTION)

# app.include_router(api_router, prefix=settings.API_STR)

app.add_middleware(
	CORSMiddleware,
	allow_origins=settings.CORS_ORIGINS,
	allow_credentials=True,
	allow_methods=["*"],
	allow_headers=["*"],
)

# @app.get("/")
# def index():
#	return {"message": "Hello World"}

By doing this, now our API knows which origins (urls) are allowed to interact with our data.

Quick Recap

In this post, we defined the remaining endpoints for our books and added new endpoints for authors. We saw how to use Url and query parameters inside our path operation functions and how to take advantage of python types to get some data validation.

We finally added a global configuration for our API and added the allowed origins to allow different client applications to communicate with our API.

Next steps

In the next post, we’re going to start working with Pydantic validation schemas to improve our request and response models. We will also see utilities functions provide by FastAPI to manage exceptions and errors inside our API.