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 TodoItems. 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 ids and the values are TodoItems. 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 TodoItems 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 TodoItems 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 TodoItems 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.

By Douglas Starnes

Entrepreneur, 5x Microsoft MVP, AI/BI nerd, crypto investor, content creator, trained composer, challenging the status quo, proud American

Leave a Reply

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