Resource Management with FastAPI

UPDATED ON:

undraw svg category

Welcome to Part 5 of Up and Running with FastAPI. If you missed part 4, you can find it here .

This series is focused on building a full-stack application with the FastAPI framework. The app allows users to post requests to have their residence cleaned, and other users can select a cleaning project for a given hourly rate.

Up And Running With FastAPI

In our last post we configured our testing framework - pytest - and modified our db config to spin up a fresh PostgreSQL database for each testing session. We tested a POST route and used TDD to develop a GET route for our cleaning resource. In this post, we’ll follow use Test-Driven Development to implement endpoints for other RESTful CRUD actions.

Let’s begin.

RESTful Endpoints

When developing RESTful endpoints, we’ll follow the standard protocols. This ensures that our API behaves predictably across our entire application. Here’s the general structure for our cleaning resource.

EndpointMethodDescription
/cleaning/POSTCreate a new cleaning
/cleaning/{id}/GETGet a cleaning by id
/cleaning/GETGet all available cleanings
/cleaning/{id}/PUTUpdate a cleaning by id
/cleaning/{id}/DELETEDelete a cleaning by id

We’ve implemented the first two endpoints already - get a cleaning by id and create a cleaning. Moving down the list, our next task is to build a GET endpoint for reading all available cleanings.

As before, we’ll start by writing our tests and making sure they fail. Then we’ll write whatever code is needed to get our tests passing. Let’s make sure our docker container is running by using docker-compose up, and open up our test_cleanings.py file. Update the TestGetCleaning class at the bottom of the file with a new test:

test_cleanings.py

Our new test hits an endpoint with the name cleanings:get-all-cleanings and checks that we get a 200 response. We then ensure that our response is a list, assert that the list is not empty, and coerce all returned cleanings into the shape of our CleaningInDB model. Finally, we verify that our test_cleaning fixture is present in the response.

Remember that we run our tests by executing the command pytest -v inside the container hosting our FastAPI server. Get the container id by running docker ps, and execute bash commands interactively with docker exec -it [CONTAINER_ID] bash.

Run the tests and we should see a familiar starlette.routing.NoMatchFound error.

One thing to note about our current testing setup: because the database persists for the duration of our testing session, each time we use the test_cleaning fixture, we’re adding an additional record to the database. If we had any columns with unique constraints, our fixture would throw an error. Solving that problem is simple enough. Don’t worry about it now, as we’ll get to that issue in a later post. For the moment, just be aware that the number of elements returned in a response may vary depending on what tests have been run before it.

Now we implement our GET route starting with our api/routes/cleanings.py file and working our way inward to our CleaningsRepository. Opening up our cleanings route, we see a mock endpoint that we created in our first post. Let’s modify that a bit to get our tests passing

api/routes/cleanings.py

All we’ve done is give our route a name that Starlette will recognize and let FastAPI know that we’re intending to return a list of CleaningPublic models. For the sake of development, we simply return None. Running our tests again, pytest throws a new error. Is this what we want?

Once again, pytest is gracious enough to tell us exactly what’s happening. Our tests are expecting the response to be a list, and it’s actually None.

Fix that before running the tests again.

api/routes/cleanings.py

Hunker down, because we’ll be doing this a lot.

Run those tests one more time!

We’re slowly working our way down the test, writing just enough code to fix the previous problem. We know what’s happening here too - our list is supposed to contain at least one item and it’s empty.

Fixing that should be easy enough:

api/routes/cleanings.py

Now, this is deliberately silly. We know this code isn’t doing anything useful, but it will take us to the last assertion we’ll need to handle for this test.

Running our tests shows the following error:

Since we’re returning a fake response, the cleaning provided by our test_cleaning fixture isn’t present in the response. We’ll need to actually hit our database and update our db/repositories/cleanings.py file to get this one passing.

api/routes/cleanings.py

We won’t bore ourselves with another error message, since we’re getting the hang of this now, but we’ll run them anyway.

When we do, we see that pytest is politely telling us that our CleaningsRepository has no get_all_cleanings attribute. That makes sense, since we haven’t written the method yet.

Let’s do that now.

db/repositories/cleanings.py

Not too much going on here. We’ve added a SQL query that grabs all cleaning records in our database, and we’ve written our get_all_cleanings method that executes our new query before returning a list of CleaningInDB models for each record found.

And look at that! Running our tests again gives us 13 greens - all passing.

Don’t be discouraged if that process felt tedious. We took it slow and stepped through each action deliberately. For the next two routes, we’ll breeze through the basics and spend most of our time in the code.

PUT Route

The next thing we’ll do is provide a route for updating a cleaning.

Head back to the test_cleanings.py file and add the following code:

test_cleanings.py

Our first new test is parametrized once for each attribute on our cleaning resource, and a few combinations of them. We’ll attempt to update each one of them and make sure that our route returns an updated version of the original entry.

The second test ensures that a number of different invalid payloads and id combinations return the >= 400 status code we expect.

We run our tests again and see that we now have 12 tests failing. They’re all failing for the same reason - Starlette can’t find the PUT route. And we know how to handle that.

We won’t bother with stepping through each line. Instead we’ll get straight into the implementation.

Update the api/routes/cleanings.py file with two new imports and a PUT route.

api/routes/cleanings.py

A few new things going here. Our route accepts an id path parameter that takes advantage of Path - which we just imported from FastAPI - for additional validation. With ge=1, we’re telling FastAPI that the cleaning id must be an integer greater than or equal to 1. If it’s not, FastAPI will return an HTTP_422_UNPROCESSABLE_ENTITY exception for us. We can do similar things for strings, along with a number of other fancy validations that we’ll get to in a later post.

We pass the id of the specified cleaning and any updates we’re applying to that cleaning to our CleaningsRepository and call the update_cleaning method on it. If the method does not return a valid cleaning, we raise a 404 exception, indicating that the id did not correspond to any cleaning resource in our database.

Let’s go ahead and write the update_cleaning method now.

db/repositories/cleanings.py

Our update_cleaning method has a few interesting things going on here. We start by calling the get_cleaning_by_id method that we defined in our previous post. If that method doesn’t find a cleaning with the id we passed it, we return None and let our route raise a 404 exception.Because it returns a CleaningInDB pydantic model, we can convert and export our model in a few useful ways.

As specified in pydantic docs , we can call the .copy() method on the model and pass any changes we’d like to make to the update parameter. Pydantic indicates that update should be “a dictionary of values to change when creating the copied model”, and we obtain that by calling the .dict() method on the CleaningUpdate model we received in our PUT route. By specifying exclude_unset=True, Pydantic will leave out any attributes that were not explicitly set when the model was created.

An example makes this clearer.

The updated copy will therefore be modified using only the attributes specified in the PUT request. We’ll take advantage of this convenient syntax all over our application. We then pass our new params to the UPDATE_CLEANING_BY_ID_QUERY and ensure that if anything goes wrong we return a 400 error.

If all is well, we return the updated cleaning and celebrate our victory. When we run our tests, we see that everything is passing! However, looking in our terminal running our FastAPI server should show us something interesting.

All the tests pass, but a null value in column 'cleaning_type' violates not-null constraint error is thrown and printed to the terminal. Good thing we made sure to catch all exceptions when updating our cleaning record.

So why is this happening?

Note that because we listed “cleaning_type” with an Optional type specification in our CleaningUpdate model, None is an allowed value. In our test_update_cleaning_with_invalid_input_throws_error test, one of our parametrized payloads includes "cleaning_type": None. It’s a good idea to test any invalid input permutation we can think of to catch errors like this.

There are other more elegant ways to handle this problem other than a try-catch, but we’ll leave that as an exercise for the reader. Let’s simply handle this edge case by raising an error if the cleaning_type is None.

db/repositories/cleanings.py

Run those tests again and make sure everything passes.

Now for the last endpoint.

Delete Endpoint

We’ll wrap this one up real quick, starting with the tests.

test_cleanings.py

More of the same here. We start by attempting to delete a cleaning from our database, and then we check to make sure it no longer exists. We also test that passing an invalid id return the response we want.

Run those tests and watch them fail.

And, like before, we implement the feature.

api/routes/cleanings.py

We run our tests again and see a new error.

This process is starting to feel familiar at this point, so we move on to our repository and write the delete_cleaning_by_id method.

db/repositories/cleanings.py

We run our tests again and watch them all pass. Then we pat ourselves on the back and maybe go outside for a little bit.

Wrapping Up and Resources

And just like that, we’ve configured our FastAPI backend to support managing our Cleanings resource using RESTful API conventions. One thing that should be mentioned is that it’s always a good idea to manually test the endpoints using the interactive api docs FastAPI provides at localhost:8000/docs. Head there and make sure that everything works as expected. The OpenAPI docs are a powerful and convenient interface to our application and database, so don’t sleep on them!

With the grunt work out of the way, we’re ready to move on to getting users signed up for our application. Feel free to check out any of these resources that were used to put this post together and maybe go outside for a little bit.

Github Repo

All code up to this point can be found here:

Special thanks to Ermand Durro for correcting errors in the original code.

Tags:

Previous Post undraw svg category

Testing FastAPI Endpoints with Docker and Pytest

Next Post undraw svg category

Designing a Robust User Model in a FastAPI App