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:
deftest_show_task():name="A second task"id=prepare_task(name)response=client.get(f"/api/todo/{id}")assertresponse.status_code==200details=response.json()assertdetails['name']==name
deftest_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)assertprepare_response.status_code==200id=prepare_response.json()['id']response=client.get(f"/api/todo/{id}")assertresponse.status_code==200details=response.json()assertdetails['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..models.todoimportTaskInput,TaskOutputfromdatetimeimportdateclassDataStore:def__init__(self):self._data=[]self._id_next=1defadd(self,entry:TaskInput):extended_entry=TaskOutput(id=self._id_next,created_at=date.today(),**dict(entry))self._data.append(extended_entry)self._id_next+=1returnextended_entrydefall(self):returnself._datadefget(self,id)->TaskOutput:result=[itemforiteminself._dataifitem.id==id]iflen(result)>0:returnresult[0]else:returnNonedefdelete(self,id):entry=self.get(id)ifentry:self._data.remove(entry)defupdate(self,id:int,update:TaskInput):entry=self.get(id)ifentry:entry.name=update.nameentry.priority=update.priorityentry.due_date=update.due_dateentry.done=update.donereturnentryelse:raiseValueError(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:
fromfastapiimportFastAPI,HTTPExceptionfrom.models.todoimport*from.data.datastoreimportDataStoreapp=FastAPI()db=DataStore()@app.post("/api/todo")asyncdefcreate_task(task:TaskInput):result=db.add(task)returnresult@app.get("/api/todo/{id}")asyncdefshow_task(id:int):result=db.get(id)ifresult:returnresultelse:raiseHTTPException(status_code=404,detail="Task not found")@app.put("/api/todo/{id}")asyncdefupdate_task(id:int,task:TaskInput):try:result=db.update(id,task)returnresultexceptValueError:raiseHTTPException(status_code=404,detail="Task not found")@app.delete("/api/todo/{id}")asyncdefdelete_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.