FastAPI on AWS with MongoDB Atlas and Okta — Part 2
Project Setup and Local Development
Virtual Environment
In July 2023 the latest AWS lambda runtime version is Python 3.11
. You can use a program like asdf
to manage multiple versions of developer tools. In your project workspace use the following commands to create a virtual environment and install the required libraries. As you add dependencies with pipenv
they will be tracked with a Pipfile
.
File Tree
Within the project workspace we will create the below file structure. The __init__.py
files are all empty and just mark the containing folder as a python package.
|-- api
| |-- __init__.py
| |-- config.py
| |-- depends.py
| |-- factory.py
| |-- model
| | |-- __init__.py
| | |-- document.py
| | |-- message.py
| | `-- object_id.py
| |-- router.py
| |-- routes
| | |-- __init__.py
| | `-- message.py
| |-- schema
| | |-- __init__.py
| | |-- crud.py
| | |-- message.py
| | `-- user.py
| `-- util
| |-- __init__.py
| `-- okta_flow.py
|-- lambda_function.py
`-- server.py
This structure wraps almost of all the fastapi logic into the api
package. The server
module should be used for local development or containerized deployments, and the lambda_function
module will be used in our AWS lambda deployment. The idea behind model
routes
and schema
being packages is to keep the project well organized as it expands with new features. Each top level data resource would be defined under model
, the routes to manage that data model would be under routes
, and the request and response schema for those routes would be under schema
.
Hello World
To start local development we will focus on the following four modules: config
router
factory
server
.
At this point when you start up the application with python server.py
you would see errors around missing environment variables. I suggest using direnv
with .envrc
and local.env
files to manage that config for local development. Direnv lets us modify the shell configuration based on the current working directory. And the IntelliJ IDE has plugins that work well with dot env files. By using both tools we can define the config once and make it available for both the shell and IDE.
Now when you start the local fastapi server and head over to http://localhost:8000/docs
you should see an OpenAPI page with one hello world route, and you should be able to try it out and get a successful response.
There should also be an Authorize
button on the page. Since we've already included the swagger_ui
settings in the factory function, you don't need to enter any config when you authorize. There's no client secret because we created the client in okta as Single Page App (SPA), which uses Proof Key for Code Exchange (PKCE). So the auth flow from the docs should take your okta hosted login, and after you authenticate there, it should redirect you back to the docs to complete the flow.
Okta Integration
Next we need to protect the backend endpoints by requiring user authentication. We could use the OAuth2AuthorizationCodeBearer
directly under fastapi.security
, but I like to extend that class with logic tailored to the authentication service provider. The custom OktaAuthCodeBearer
defined below takes in the okta host and optionally the issuer id, builds the OIDC metadata endpoint, and loads the authorization and token urls into the parent constructor.
Let’s instantiate the class within our depends
module so that it can be reused across multiple routers with the api. In the auth flow when users authenticate on the browser they receive an access token from Okta. In order to read user profile information or setup role based access control, we need to use the access token to call the user info endpoint. The resulting identity token should contain the claims configured for your okta authorization server, including the user's profile name.
Now we can update our hello world endpoint. The GetUser
annotation allows us to include the get_user
dependency to the route handler function in a concise way. And that's useful since each function that needs access to user information would need the user
parameter. Without the annotation that parameter would be user: User = Depends(get_user)
.
MongoDB Integration
For our database integrations we’re going to create an instance of the pymongo MongoClient
within our factory function. Then we'll create another annotated dependency function so that we can easily grab pymongo Collection
objects as needed within our route handlers.
Now we can start implementing the endpoints for users to create and read their messages in the collection. We’ll update the root router module and remove the hello world endpoint. We’ll define the schema for parsing the json payload when creating new messages.
At this point you should be able to test both endpoints from the openapi docs. If you are testing with more than one user, you’ll notice that each user can only read messages that they’ve posted. In the query within the list messages endpoint, we are filtering for documents based on the user’s okta id. However, the query needs to be optimized by adding an index on the collection. Check out the MongoDB docs to learn how to create collection indexes [link]. For the example project I created two indexes; {"user_id": "hashed"}
and {"created": -1}
Database Models and Response Schemas
With the integrations now working we can improve the generated openapi docs by defining response schemas. For example, the endpoint to read messages should return a list of Message
documents. We'll create some helper modules to define generic crud responses and the base class for data models. You may also want to look at two other libraries for defining data models: mongoengine and beanie
Now we can define the data model for messages, and update the router.