In the previous post we saw how to create a simple FastAPI application, run it with uvicorn and use the interactive documentation. But that application only implemented an endpoint that handled GET requests. In this post, we will see how to implement the other HTTP methods (POST, PUT, DELETE) to complete the API.
Representing Data in FastAPI
One of the advantages of FastAPI is that it takes advantage of modern Python features including type hints. We will see that FastAPI can validate the structure of a JSON payload if we define a type to represent it. FastAPI is built upon the pydantic package that enforces Python type hints at runtime. (by default, the Python interpreter ignores type hints) pydantic provides a base class we can use to define the expected structure of a JSON payload.
from pydantic import BaseModel
class TodoItem(BaseModel):
id: int
task: str
complete: bool
As you might expect, each field in the TodoItem class is annotated with a type hint. Now we can create a list of TodoItem to masquerade as a database until we integrate a real database in a later post.
todo_db = [
TodoItem(id=1, task="Task One", complete=True),
TodoItem(id=2, task="Task Two", complete=False)
]
Refactor the Existing Application
Before adding new endpoints for the POST, PUT and DELETE methods, we can refactor the existing GET endpoint to use type hints. First, rename it to get_all_todos
as this handler function will return the entire “database” of TodoItem
s. The get
decorator has the response_model
keyword argument that tells which type the handler function will return. Here it will return a List
of TodoItem
. The List
type from the typing
module needs to be imported.
from typing import List
@api.get("/", response_model=List[TodoItem])
def get_all_todos():
return todos
If you run the application and invoke the root endpoint, it won’t behave any differently. Notice though that the interactive documentation now includes information about the schema of the response. And this schema is the TodoItem
just added.
Also, notice that JSON returned looks the same as it did before. As mentioned in the previous post, FastAPI will convert between Python types and JSON if possible. FastAPI knows how to convert BaseModel
subclasses into JSON so it did that here.
Retrieving Single TodoItems
Here is the route and handler function to get a single TodoItem
by its unique id.
from fastapi import HTTPException
@api.get("/todo/{todo_id}", response_model=TodoItem)
def get_todo_by_id(todo_id: int):
for todo in todos:
if todo.id == todo_id:
return todo
raise HTTPException(status_code=404, detail="TodoItem not found")
There are several new concepts in this snippet so let’s look at them.
First, in the get
decorator on line 3, the path includes a parameter named todo_id
. In other words, the value included in the second path segment, after /todo
, will be passed to the handler function. In line 4, the handler function accepts an argument named todo_id
. And it is annotated with the type hint int
. Thus trying to access the path /todo/abc
would raise an error as “abc” cannot be converted to an integer.
As this application is not (yet) using a real database, the process of looking for a TodoItem
with an id is done by brute force. Simply loop through the todos
list and if a TodoItem
with the selected id
is found, return it. If the end of the loop is reached on line 8, that means the TodoItem
was not found. In that case, raise an exception, an HTTPException
in this case. The HTTPException
is imported on line 1 from the fastapi
module. The HTTPException
expects a status_code
and a detail
message. The status_code
here is 404 as there was no TodoItem
with the todo_id
. The detail
message will be returned in the JSON payload from the handler function.
Run the application and go to the /docs path in the browser. There is a new item for the /todo/{todo_id} path.
Expand it, and click the Try it out button. Notice that the docs have an input for the todo_id
path parameter. Also, under the parameter name, is a reminder that this should be an integer. This was derived from the type hint in the code.
Enter 1 in the input box and click the blue Execute button. Scroll down to see that the JSON representation of the TodoItem
with an id of 1 has been returned.
Now try to get an id of 100.
There is no TodoItem
with an id
of 100. Therefore, the HTTPException
is raised resulting in the 404 status code. Also the detail message from the HTTPException
is included in the response body.
What happens if you try to get an id that can’t be converted into an integer? Put “abc” in the input box and click the Execute button.
The interactive docs won’t let you do this. And it’s kind of disappointing because you don’t get to see the actual response if you tried to do this in an application. The actual response is a 422 status code which means Unprocessable Entity. This makes sense. As the string “abc” cannot be converted into an integer, the value cannot be processed.
Adding New TodoItems
Adding a new TodoItem
is done with the POST method. To route a POST request to a function handler, use the post
decorator. Even though there is no parameter in the path, a todo
parameter is expected by the new_todo
function. This is because the TodoItem
will be constructed from JSON data included in the body of the POST request. In addition to converting BaseModel
subclasses to JSON like we saw in the GET endpoints, FastAPI can also convert JSON to BaseModel
subclasses. The todo
is then appended to the todos
list. Don’t worry about conflicting ids right now, we’ll take care of that soon.
@api.post("/todo", response_model=TodoItem)
def new_todo(todo: TodoItem):
todos.append(todo)
return todo
Run the application, expand the item for the /todo
POST request, and click the Try it out button. A textarea contains a scaffolded JSON object with keys for the fields in the TodoItem
class.
Add values for a new TodoItem
.
{
"id": 100,
"task": "Task One Hundred",
"complete": False
}
Press the Execute button. Scroll down and see the results. The endpoint returned the new TodoItem
.
Go back to the item for the /
path. Expand the item, click the Try it now button, and the Execute button. The returned JSON now includes item 100.
Creating a DTO
In the current implementation, you could add a TodoItem
to the “database” with the same id
as an existing item and the application would allow it. In addition, you have to explicitly set the complete
field to True
despite it making little sense to add a task to a todo list that it already completed. In other words, when creating a new TodoItem
you only need to provide the task
. The id
should be automatically generated and the complete
field should be set to False
.
To do this, we can create another subclass of BaseModel
that contains only the task
. This is commonly referred to as a Data Transfer Object or DTO. Create a new BaseModel
subclass and call it CreateTodoItem
.
class CreateTodoItem(BaseModel):
task: str
In the new_todo
function, change the type of the todo
parameter to CreateTodoItem
.
def new_todo(todo: CreateTodoItem):
To make this work, you need to refactor how the todo list is implemented. Right now it’s a List
of TodoItem
. But it’s going to work better as a dictionary where the keys are the numeric id
s and the values are TodoItem
s. First change the todos
variable to a dictionary.
todos = {
1: TodoItem(id=1, task="Task One", complete=True),
2: TodoItem(id=2, task="Task Two", complete=False),
}
In the get_all_todos
function you need to return just the values of the dictionary.
def get_all_todos():
return todos.values()
The implementation of get_todo_by_id
is simplified. Attempt to get the todo_id
key from the dictionary and return it on line 3. If the key doesn’t exist, except
the KeyError
and raise the HTTPException
on line 5.
def get_todo_by_id(todo_id: int):
try:
return todos[todo_id]
except KeyError:
raise HTTPException(status_code=404, detail="TodoItem not found")
And in the new_todo
function, get the maximum value from the keys on line 2. Add one to it for the id of the new TodoItem
. Then use the id
and the task
from the CreateItemTodo
DTO to create a new TodoItem
on line 3. Set the complete keyword argument to False. Add the new TodoItem to the dictionary using the id as the key on line 4.
def new_todo(todo: CreateTodoItem):
next_id = max(todos.keys()) + 1
new_todo = TodoItem(id=next_id, task=todo.task, complete=False)
todos[next_id] = new_todo
return new_todo
You’ll see how much time this saves us when implementing updates with the PUT method. But first, let’s create another new TodoItem
. Run the application, and expand the item in the interactive docs for the POST request. Notice that the scaffolded JSON only has a key for the task. Fill it in and click the Execute button.
Take a look at the response body. As there are already two TodoItem
s in the dictionary with ids 1 and 2, the new TodoItem
was given an id of 3. Also, the complete
key was set to false
by default.
Updating TodoItems
To update a TodoItem
is done using a PUT request. As you might have guessed there is a put
decorator to route the URL to the handler function. But the PUT request is going to be a little different. First, we have to get an existing TodoItem
to update. Refactoring the todo list to be a dictionary has made that simple. Next we only want to update the fields that have new values. We do this by creating a new UpdateTodoItem
DTO and make all of the fields optional.
from typing import Optional
class UpdateTodoItem(BaseModel):
task: Optional[str] = None
complete: Optional[bool] = None
In this class, both task
and complete
are Optional
with a default value of None
. Thus we need to check for the presence of None
in each field. If the field is None
, do nothing. Otherwise use the value in the DTO to update the existing TodoItem
. Here’s the handler function implementation.
@api.put("/todo/{todo_id}", response_model=TodoItem)
def update_todo(todo_id: int, todo: UpdateTodoItem):
try:
updated_todo = todos[todo_id]
if todo.task is not None:
updated_todo.task = todo.task
if todo.complete is not None:
updated_todo.complete = todo.complete
return updated_todo
except KeyError:
raise HTTPException(status_code=404, detail="TodoItem not found")
Also, notice the put
decorator. It has a path parameter for the todo_id
which is reflected by the todo_id
parameter in the update_todo
function. But the PUT request also has a JSON body with the update data. So it has to passed to the update_todo
function as well.
Start the application and browse to the interactive documentation. Expand the PUT endpoint for /todos/{todo_id}
and click the Try it out button. Notice the is an input box for the todo_id
path parameter as well as a textarea with a scaffolded JSON object for the UpdateTodoItem
DTO. Even though in the UpdateTodoItem, the code marks both fields as optional, the interactive documentation still provides default values for both. Let’s try it out.
You still have to provide a valid todo_id
so put 2 in the input box. Then delete both fields from the JSON leaving an empty object.
Click the Execute button and scroll down to the response body. The response body contains the JSON for the updated TodoItem
. But the values for task
and complete
have not changed. That’s because they were omitted from the request body JSON.
Go back to the request body and add a key for complete
and set it to true
.
Click the Execute button and notice in the response body now contains an updated value for complete
. But the value of task
, which was omitted from the request JSON, has not changed.
Go to the endpoint for /
and execute it to get all of the TodoItem
s and notice that the updated value of complete
for item 2 has been persisted in the application.
Deleting TodoItems
To delete a TodoItem
, apply the delete
decorator to a handler function. The path will require a parameter todo_id
. The handler function will attempt to delete that key from the dictionary. If the key does not exist, the it will raise a KeyError
. The application will then raise an HTTPException
with a 404 status code.
@api.delete("/todo/{todo_id}", status_code=204)
def delete_todo(todo_id: int):
try:
del todos[todo_id]
return
except KeyError:
raise HTTPException(status_code=404, detail="TodoItem not found")
Notice the use of the status_code
keyword argument in the delete
decorator. A successful DELETE request should not return a response body but it should have a 204 status code. And the handler function does not return a value.
In the interactive documentation, deleting item number one returns no response body and a 204 status code.
And the list of TodoItem
s contains only the second one.
Summary
In this post, you saw how to use FastAPI to complete a REST API. You implemented the POST, PUT and DELETE methods. You saw how to use the BaseModel class to structure the body of requests and responses. And you used the interactive documentation to try things out. Of course you could still use Postman to test the API or use requests to call it in code.