Designing a Robust User Model in a FastAPI App

UPDATED ON:

undraw svg category

Welcome to Part 6 of Up and Running with FastAPI. If you missed part 5, 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 the previous post we tested and developed HTTP endpoints for create, read, update, and delete actions involving our Cleaning resource. With that structure in place, we’re now ready to allow users to sign up for our application and manage cleanings themselves. We’ll start in this post by designing a user model and letting users register for our application. In the next couple posts we’ll move on to implementing simple authorization and authentication and eventually social authentication as well.

Now for the code.

Database Migrations

A lot of important decisions happen in database design and there is no “right” way to define a user.

For some applications, storing all user-related information in a single table is the most efficient approach. We’ll take a slightly different approach and split the user into a table for authentication purposes and a table for supplementary information consistent with a user profile. This will make our system easier to test and ensure that we can extend our User model without having to worry about how our authentication table is structured.

Starting with our migrations file, we’ll add a table for our users. We’ll need to overhaul our database a bit here, so we’ll start by showing our entire migrations file and the changes we need to add to it. Then we’ll talk a bit about what’s happening and how to manage these changes.

Before we make start writing code, let’s go ahead and rollback our migrations.

And then we refactor our migration file. Remember that migration filenames will be different from project to project - as alembic is responsible for generating those filenames.

Open up the file that looks like: db/migrations/versions/12345678654_create_main_tables.py.

Quite a few new things happening here. The biggest change is that we’ve written a bit of code that makes it easy for us to add timestamps to every table in our database. The timestamps() function creates two columns - created_at and updated_at - that we unpack into both our cleanings and users table with *timestamps(). This will help us track when records are entered into our database and when they’re updated. Both of these default to the current moment in time using sa.func.now(). We also provide the option to index our timestamps if needed.

We’ve also written a PL/pgSQL trigger that we create for every table in our create_updated_at_trigger function. This trigger will run whenever a row in a given table is updated and set the updated_at column to that moment in time. Handing the management of updating timestamps over to postgres is relatively straightforward and extremely convenient in the long run.

For readers unfamiliar with Pl/pgSQL, there are some additional resources included at the end of this post that may help.

Our new users table stores all authentication-related information and sets unique constraints on the email and username columns, along with an index . We’ll discuss both of these in greater detail shortly, but for now let’s go ahead and run our migrations with alembic upgrade head and then move on to our Pydantic models.

Pydantic Models

Moving outward from the database layer, we’re going to define a new common model that will help us manage timestamps across our application. Update the core.py file with the following.

models/core.py

This new mixin - DateTimeModelMixin is taking advantage of another pydantic feature - the validator decorator - to set a default datetime for both the created_at and updated_at fields. Feel free to read more about all the things that can be done with validators here .

Let’s go ahead and use that mixin in a new models file: user.py.

As usual, create the file:

And then add the following:

models/user.py

Most attributes are simply echoed from our migrations file, with sensitive information excluded from as many models as we can. We’re requiring that new users provide an email address, username, and password, while existing users are able to udpate their email address and/or username at any point. We also want to provide users the ability to reset their password.

An important detail to list is that we’re leaving password and salt out of the UserBase and UserPublic models, ensuring that this information never leaves the backend.

Users have email_verified default to False until we can confirm that their email is valid, while is_active and is_superuser default to True and False, respectively.

There’s also some additional validation we’re doing with pydantic. We’ve imported the constr typed and used that for every password attribute across our models. The constr type is one of pydantic’s constrained types and it stands for constrained string . Constrained strings offer the ability to set minimum and maximum lengths on any string value, along with other validations. We’re ensuring that all passwords have a minimum length of 7 and a maximum length of 100. Not sure anyone would ever have a password with 100 characters, but it’s nice to show off that feature.

We’re also doing custom validation on usernames with our validate_username function. We check to make sure that every character in a username is either alphanumeric, or is an underscore or hyphen. We also check to make sure that usernames are at least 3 characters long. Though we’ve extracted that logic into it’s own function, pydantic actually gives us a way to do that with built in string validators. The same logic can be accomplished with regex and the same constr field type we used before. We could then refactor UserCreate and UserUpdate like so:

We can then remove the import string statement and the validator import from the top of the file, along with the validate_username function. Depending on the complexity of the validation needed, feel free to use whatever method fits.

Let’s head over to our tests.

Testing User Registration

Before we start implementing user registration, let’s make one thing very clear:

NEVER, EVER, EVER store user passwords in plain text.

This single mistake is one of the worst decisions that can be made for an application. It’s easy to avoid and absurdly costly if things go wrong. Having said that, we’re going to do just that here, before we remedy it in the next post.

Now go ahead and create the testing file:

Then add an additional testing class to test for the existence of the user routes.

tests/test_users.py

Run the tests and watch that one fail. We see the same starlette.routing.NoMatchFound that we’ve run into so often at this point. Let’s go ahead and make that route now.

And in the new file:

api/routes/users.py

Simple enough. We’re sending the new user over to our UsersRepository and returning the created user. Let’s register these routes in our api router.

api/routes/__init__.py

As soon as we save that file, we should see an ImportError pop up in our terminal. Observant readers might have noticed that we never created our UsersRepository file, so our error makes a lot of sense.

Let’s go ahead and do that now.

Then add just enough to that new file to get our tests to pass:

db/repositories/users.py

Run our tests again and watch them pass. We know that this code isn’t really valid, but it’ll do for now.

Now it’s time to flesh out the UsersRepository with actual registration logic.

We’ll start by writing more tests.

Head into the test_users.py file and add a new class:

tests/test_users.py

We’re testing two situations here.

The first is the case when a user submits valid credentials. We send a POST request with proper credentials and then we check that the user was created in our database, and that the endpoint returns a user with the shape we expect to see. We exclude the password and salt attributes from the user record queried from our database, as those should not be made available by any request that returns a user.

The second case is when a user submits credentials where either the email or password has already been taken, or one of the credentials will cause pydantic to throw a validation error. We expect that our endpoint returns a 400 status code when the username or email is already taken, and that a 422 status code is returned when a validation error is seen.

Run the tests and watch them fail. We get a nice error message telling us that the UsersRepository object has no attribute get_user_by_email.

Let’s fix that.

db/repositories/users.py

A couple things going on here. Let’s start with the get_user_by_email and get_user_by_username methods. Both take in a single parameter - either email or username - and query our database for a user where either the email or username matches. We’ll use these later on when we’re fetching users, but we also define them here so that we can use them in our register_new_user method.

Each time a new user is created, we first check to make sure that neither the email or password is already taken. If either returns a user, we raise FastAPI’s HTTPException with the appropriate error message. Otherwise, we create the user with the provided credentials and a fake salt. We’ll add a real one when we handle passwords correctly in the next post.

REMINDER: NEVER STORE PASSWORDS IN PLAIN TEXT LIKE WE ARE HERE. There’s a reason we’re hardcoding in a fake salt here. For actual authentication, we’ll want to generate a salt and hash the password with passlib and bcrypt.

Run the tests again and watch them all pass. Voila!

We’re now ready to start authenticating users.

Wrapping Up and Resources

Now that we’re starting to sign users up for our application, we have a lot of new decisions to make. Fortunately Pydantic and FastAPI make our work a whole lot easier. Up next, we’ll give users the ability to login by sending JWT tokens to users who have registered or provided valid login credentials.

Github Repo

All code up to this point can be found here:

Tags:

Previous Post undraw svg category

Resource Management with FastAPI

Next Post undraw svg category

User Auth in FastAPI with JWT Tokens