Skip to content

#221: Refactor the FastAPI To-Do Application

Our minimalistic to-do application has collected some code smells. In this post we go through them and refactor our code to keep the application in a maintainable state for the upcoming changes.

Remove the duplication in our tests

When it comes to creating tasks, we only care in the test_create_task() function about all the necessary details that this operation involves. For all the other tests, we create tasks to have something with which we can work. Instead of repeating the details in every test, we can extract the code and put it in its own prepare_task() function:

def prepare_task(name, priority=4, due_date=None, done=False):
    if due_date == None:
        due_date = date.today() + timedelta(days=1)

    data = {
        "name": name,
        "priority": priority,
        "due_date": str(due_date),
        "done": done
    }

    prepare_response = client.post("/api/todo/", json=data)
    assert prepare_response.status_code == 200
    return prepare_response.json()['id']

This allows us to remove the duplication in our tests and replace it with a call to our new method:

1
2
3
4
5
6
7
8
def test_show_task():
    name = "A second task"
    id = prepare_task(name)

    response = client.get(f"/api/todo/{id}")
    assert response.status_code == 200
    details = response.json()
    assert details['name'] == name

This is much smaller than what we had before:

def test_show_task():
    data = {
        "name": "A second task",
        "priority": 4,
        "due_date": str(date.today() + timedelta(days=1))
    }

    prepare_response = client.post("/api/todo/", json=data)
    assert prepare_response.status_code == 200

    id = prepare_response.json()['id']
    response = client.get(f"/api/todo/{id}")
    assert response.status_code == 200
    details = response.json()
    assert details['name'] == data['name']

Refactor the API

The data store is only a mock that we replace in the future with a real database. But even with that in mind it is a bit unlucky that we have the logic for the data access copied throughout our endpoints.

If we encapsulate all the behaviour that interacts with our mock storage into its own class, we not only have smaller endpoints, but we can make sure that we do not reassign already used Ids for free. But before we can profit from the new possibilities, we must do the work.

To shorten this post, here are all the tests we need for our data store. You would write one test, write the implementation, and then repeat with the next test.

from ..data.datastore import DataStore
from ..models.todo import TaskOutput, TaskInput
from datetime import date, timedelta
import pytest

def test_created_store_is_empty():
    store = DataStore()

    data = store.all()

    assert data == []

def test_can_add_entry():
    entry = TaskInput(name="a simple task", 
                      priority=1, 
                      due_date=date.today(), 
                      done=False)
    store = DataStore()

    data = store.add(entry)

    assert data.name == "a simple task"
    assert data.priority == 1
    assert data.due_date == date.today()
    assert data.done == False
    assert data.created_at == date.today()
    assert data.id == 1


def test_can_add_multiple_entries():
    entry_a = TaskInput(name="a simple task", 
                        priority=1, 
                        due_date=date.today(), 
                        done=False)
    entry_b = TaskInput(name="b simple task", 
                        priority=2, 
                        due_date=date.today(), 
                        done=False)
    store = DataStore()

    data_a = store.add(entry_a)
    data_b = store.add(entry_b)

    assert data_a.id < data_b.id


def test_can_get_specific_entry_back():
    entry_a = TaskInput(name="a simple task", 
                        priority=1, 
                        due_date=date.today(), 
                        done=False)
    entry_b = TaskInput(name="b simple task", 
                        priority=2, 
                        due_date=date.today(), 
                        done=False)
    store = DataStore()
    store.add(entry_a)
    store.add(entry_b)

    entry = store.get(2)

    assert entry.name == "b simple task"


def test_missing_entry_gets_None_back():
    store = DataStore()

    entry = store.get(2)

    assert entry == None


def test_can_get_all_entrries_back():
    entry_a = TaskInput(name="a simple task", 
                        priority=1, 
                        due_date=date.today(), 
                        done=False)
    entry_b = TaskInput(name="b simple task", 
                        priority=2, 
                        due_date=date.today(), 
                        done=False)
    entry_c = TaskInput(name="b simple task", 
                        priority=2, 
                        due_date=date.today(), 
                        done=False)
    store = DataStore()
    store.add(entry_a)
    store.add(entry_b)
    store.add(entry_c)

    entries = store.all()

    assert len(entries) == 3


def test_can_delete_entry():
    entry_a = TaskInput(name="a simple task", 
                        priority=1, 
                        due_date=date.today(), 
                        done=False)
    entry_b = TaskInput(name="b simple task", 
                        priority=2, 
                        due_date=date.today(), 
                        done=False)
    store = DataStore()
    store.add(entry_a)
    store.add(entry_b)

    store.delete(2)

    entries = store.all()
    assert len(entries) == 1
    assert entries[0].id == 1


def test_can_update_entry():
    old = TaskInput(name="a simple task", 
                    priority=1, 
                    due_date=date.today(), 
                    done=False)
    store = DataStore()
    store.add(old)

    new = TaskInput(name="b simple task", 
                    priority=2, 
                    due_date=date.today() + timedelta(days=2), 
                    done=True)
    store.update(1, new)

    entry = store.get(1)
    assert entry.name == "b simple task"
    assert entry.priority == 2
    assert entry.due_date == date.today() + timedelta(days=2)
    assert entry.done == True


def test_non_existing_entry_cannot_be_updated():
    store = DataStore()

    new = TaskInput(name="b simple task", 
                    priority=2, 
                    due_date=date.today() + timedelta(days=2), 
                    done=True)
    with pytest.raises(ValueError) as e_info:
        store.update(123, new)
    assert str(e_info.value) == "no taks known with id '123'"

With our tests we can come up with a class like this DataStore inside the data folder:

from ..models.todo import TaskInput, TaskOutput
from datetime import date

class DataStore:
    def __init__(self):
        self._data = []
        self._id_next = 1


    def add(self, entry: TaskInput): 
        extended_entry = TaskOutput(id=self._id_next, 
                                    created_at=date.today(), 
                                    **dict(entry))
        self._data.append(extended_entry)
        self._id_next += 1
        return extended_entry


    def all(self):
        return self._data


    def get(self, id) -> TaskOutput:
        result = [item for item in self._data if item.id == id]
        if len(result) > 0:
            return result[0]
        else:
            return None


    def delete(self, id):
        entry = self.get(id)
        if entry:
            self._data.remove(entry)


    def update(self, id: int, update: TaskInput):
        entry = self.get(id)
        if entry: 
            entry.name = update.name
            entry.priority = update.priority
            entry.due_date = update.due_date
            entry.done = update.done
            return entry
        else: 
            raise ValueError(f"no taks known with id '{id}'")

Again, do not forget to create an empty __init__.py inside the data folder next to the datastore.py.

We can now rewrite our FastAPI application and then run the tests to check that everything still works as expected:

from fastapi import FastAPI, HTTPException
from .models.todo import *
from .data.datastore import DataStore

app = FastAPI()

db = DataStore()

@app.post("/api/todo")
async def create_task(task: TaskInput):
    result = db.add(task)
    return result


@app.get("/api/todo/{id}")
async def show_task(id: int):
    result = db.get(id)

    if result:
        return result
    else:
        raise HTTPException(status_code=404, detail="Task not found")



@app.put("/api/todo/{id}")
async def update_task(id: int, task: TaskInput):
    try:
        result = db.update(id, task)
        return result
    except ValueError:
        raise HTTPException(status_code=404, detail="Task not found")


@app.delete("/api/todo/{id}")
async def delete_task(id: int):
    db.delete(id)

After extracting all the logic to store and retrieve data, our API is reduced to a thin layer over our data store. The more logic we can take out of our API, the better it is. Then it is much easier to reuse Python classes than API endpoints.

Next

The refactorings help us to keep our code in a better state. We will notice the improvement as soon as we need to add new features, and that usually happens sooner than later.

While checking if the Swagger documentation still works, I noticed two missing features that I like to add next week so that we can put our new structure to a test.