|
| 1 | +.. _pymongo-fastapi: |
| 2 | + |
| 3 | +======================================== |
| 4 | +Getting Started With MongoDB and FastAPI |
| 5 | +======================================== |
| 6 | + |
| 7 | +.. contents:: On this page |
| 8 | + :local: |
| 9 | + :backlinks: none |
| 10 | + :depth: 2 |
| 11 | + :class: singlecol |
| 12 | + |
| 13 | +.. facet:: |
| 14 | + :name: genre |
| 15 | + :values: tutorial |
| 16 | + |
| 17 | +.. meta:: |
| 18 | + :description: Learn how to create an app to connect to MongoDB deployment by using the PyMongo driver. |
| 19 | + :keywords: quick start, tutorial, basics |
| 20 | + |
| 21 | +`FastAPI <https://fastapi.tiangolo.com/>`__ is a modern, high-performance, easy-to-learn, fast-to-code, production-ready, Python 3.6+ framework for building APIs based on standard Python type hints. While it might not be as established as some other Python frameworks such as Django, it is already in production at companies such as Uber, Netflix, and Microsoft. |
| 22 | + |
| 23 | +FastAPI is async, and as its name implies, it is super fast; so, MongoDB is the perfect accompaniment. In this quick start, we will create a CRUD (Create, Read, Update, Delete) app showing how you can integrate MongoDB with your FastAPI projects. |
| 24 | + |
| 25 | +Prerequisites |
| 26 | +------------- |
| 27 | + |
| 28 | +- Python 3.9.0 |
| 29 | +- A MongoDB Atlas cluster. Follow the "`Get Started with Atlas <https://docs.atlas.mongodb.com/getting-started/>`__" guide to create your account and MongoDB cluster. Keep a note of your username, password, and `connection string <https://docs.atlas.mongodb.com/tutorial/connect-to-your-cluster/#connect-to-your-atlas-cluster>`__` as you will need those later. |
| 30 | + |
| 31 | +Running the Example |
| 32 | +___________________ |
| 33 | + |
| 34 | +To begin, you should `clone the example code from GitHub <https://github.com/mongodb-developer/mongodb-with-fastapi>`__. |
| 35 | + |
| 36 | +.. code-block:: shell |
| 37 | + |
| 38 | + git clone [email protected]:mongodb-developer/mongodb-with-fastapi.git |
| 39 | + |
| 40 | +You will need to install a few dependencies: FastAPI, `Motor <https://motor.readthedocs.io/>`__, etc. I always recommend that you install all Python dependencies in a `virtualenv <https://docs.python.org/3/tutorial/venv.html>`__ for the project. Before running pip, ensure your ``virtualenv`` is active. |
| 41 | + |
| 42 | +.. code-block:: shell |
| 43 | + |
| 44 | + cd mongodb-with-fastapi |
| 45 | + pip install -r requirements.txt |
| 46 | + |
| 47 | +It may take a few moments to download and install your dependencies. This is normal, especially if you have not installed a particular package before. |
| 48 | + |
| 49 | +Once you have installed the dependencies, you need to create an environment variable for your MongoDB connection string. |
| 50 | + |
| 51 | +.. code-block:: shell |
| 52 | + |
| 53 | + export MONGODB_URL="mongodb+srv://<username>:<password>@<url>/<db>?retryWrites=true&w=majority" |
| 54 | + |
| 55 | +Remember, anytime you start a new terminal session, you will need to set this environment variable again. I use `direnv <https://direnv.net/>`__ to make this process easier. |
| 56 | + |
| 57 | +The final step is to start your FastAPI server. |
| 58 | + |
| 59 | +.. code-block:: shell |
| 60 | + |
| 61 | + uvicorn app:app --reload |
| 62 | + |
| 63 | +.. image:: /includes/integrations/fastapi-terminal.png |
| 64 | + :alt: Screenshot of terminal running FastAPI |
| 65 | + |
| 66 | +Once the application has started, you can view it in your browser at <http://127.0.0.1:8000/docs>. |
| 67 | + |
| 68 | +.. image:: /includes/integrations/fastapi-browser.png |
| 69 | + :alt: Screenshot of browser and swagger UI |
| 70 | + |
| 71 | +Once you have had a chance to try the example, come back and we will walk through the code. |
| 72 | + |
| 73 | +Creating the Application |
| 74 | +------------------------ |
| 75 | + |
| 76 | +All the code for the example application is within `app.py`. I'll break it down into sections and walk through what each is doing. |
| 77 | + |
| 78 | +Connecting to MongoDB |
| 79 | +~~~~~~~~~~~~~~~~~~~~~ |
| 80 | + |
| 81 | +One of the very first things we do is connect to our MongoDB database. |
| 82 | + |
| 83 | +.. code-block:: python |
| 84 | + |
| 85 | + client = motor.motor_asyncio.AsyncIOMotorClient(os.environ[MONGODB_URL]) |
| 86 | + db = client.get_database("college") |
| 87 | + student_collection = db.get_collection("students") |
| 88 | + |
| 89 | +We're using the async `motor driver <https://motor.readthedocs.io/en/stable/>`__ to create our MongoDB client, and then we specify our database name `college`. |
| 90 | + |
| 91 | +The _id Attribute and ObjectIds |
| 92 | +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| 93 | + |
| 94 | +.. code-block:: python |
| 95 | + |
| 96 | + # Represents an ObjectId field in the database. |
| 97 | + # It will be represented as a `str` on the model so that it can be serialized to JSON. |
| 98 | + PyObjectId = Annotated[str, BeforeValidator(str)] |
| 99 | + |
| 100 | +MongoDB stores data as `BSON <https://www.mongodb.com/json-and-bson>`__. FastAPI encodes and decodes data as JSON strings. BSON has support for additional non-JSON-native data types, including `ObjectId` which can't be directly encoded as JSON. Because of this, we convert `ObjectId`s to strings before storing them as the `id` field. |
| 101 | + |
| 102 | +Database Models |
| 103 | +~~~~~~~~~~~~~~~ |
| 104 | + |
| 105 | +Many people think of MongoDB as being schema-less, which is wrong. MongoDB has a flexible schema. That is to say that collections do not enforce document structure by default, so you have the flexibility to make whatever data-modelling choices best match your application and its performance requirements. So, it's not unusual to create models when working with a MongoDB database. Our application has three models, the `StudentModel`, the `UpdateStudentModel`, and the `StudentCollection`. |
| 106 | + |
| 107 | +.. code-block:: python |
| 108 | + |
| 109 | + class StudentModel(BaseModel): |
| 110 | + """ |
| 111 | + Container for a single student record. |
| 112 | + """ |
| 113 | + |
| 114 | + # The primary key for the StudentModel, stored as a `str` on the instance. |
| 115 | + # This will be aliased to `_id` when sent to MongoDB, |
| 116 | + # but provided as `id` in the API requests and responses. |
| 117 | + id: Optional[PyObjectId] = Field(alias="_id", default=None) |
| 118 | + name: str = Field(...) |
| 119 | + email: EmailStr = Field(...) |
| 120 | + course: str = Field(...) |
| 121 | + gpa: float = Field(..., le=4.0) |
| 122 | + model_config = ConfigDict( |
| 123 | + populate_by_name=True, |
| 124 | + arbitrary_types_allowed=True, |
| 125 | + json_schema_extra={ |
| 126 | + "example": { |
| 127 | + "name": "Jane Doe", |
| 128 | + |
| 129 | + "course": "Experiments, Science, and Fashion in Nanophotonics", |
| 130 | + "gpa": 3.0, |
| 131 | + } |
| 132 | + }, |
| 133 | + ) |
| 134 | + |
| 135 | +This is the primary model we use as the `response model <https://fastapi.tiangolo.com/tutorial/response-model/>`__ for the majority of our endpoints. |
| 136 | + |
| 137 | +I want to draw attention to the `id` field on this model. MongoDB uses `_id`, but in Python, underscores at the start of attributes have special meaning. If you have an attribute on your model that starts with an underscore, `pydantic <https://pydantic-docs.helpmanual.io/>`__—the data validation framework used by FastAPI—will assume that it is a private variable, meaning you will not be able to assign it a value! To get around this, we name the field `id` but give it an alias of `_id`. You also need to set `populate_by_name` to `True` in the model's `model_config` |
| 138 | + |
| 139 | +We set this `id` value automatically to `None`, so you do not need to supply it when creating a new student. |
| 140 | + |
| 141 | +.. code-block:: python |
| 142 | + |
| 143 | + class UpdateStudentModel(BaseModel): |
| 144 | + """ |
| 145 | + A set of optional updates to be made to a document in the database. |
| 146 | + """ |
| 147 | + |
| 148 | + name: Optional[str] = None |
| 149 | + email: Optional[EmailStr] = None |
| 150 | + course: Optional[str] = None |
| 151 | + gpa: Optional[float] = None |
| 152 | + model_config = ConfigDict( |
| 153 | + arbitrary_types_allowed=True, |
| 154 | + json_encoders={ObjectId: str}, |
| 155 | + json_schema_extra={ |
| 156 | + "example": { |
| 157 | + "name": "Jane Doe", |
| 158 | + |
| 159 | + "course": "Experiments, Science, and Fashion in Nanophotonics", |
| 160 | + "gpa": 3.0, |
| 161 | + } |
| 162 | + }, |
| 163 | + ) |
| 164 | + |
| 165 | +The `UpdateStudentModel` has two key differences from the `StudentModel`: |
| 166 | + |
| 167 | +- It does not have an `id` attribute as this cannot be modified. |
| 168 | +- All fields are optional, so you only need to supply the fields you wish to update. |
| 169 | + |
| 170 | +Finally, `StudentCollection` is defined to encapsulate a list of `StudentModel` instances. In theory, the endpoint could return a top-level list of StudentModels, but there are some vulnerabilities associated with returning JSON responses with top-level lists. |
| 171 | + |
| 172 | +.. code-block:: python |
| 173 | + |
| 174 | + class StudentCollection(BaseModel): |
| 175 | + """ |
| 176 | + A container holding a list of `StudentModel` instances. |
| 177 | + |
| 178 | + This exists because providing a top-level array in a JSON response can be a `vulnerability <https://haacked.com/archive/2009/06/25/json-hijacking.aspx/>`__ |
| 179 | + """ |
| 180 | + |
| 181 | + students: List[StudentModel] |
| 182 | + |
| 183 | +Application Routes |
| 184 | +~~~~~~~~~~~~~~~~~~ |
| 185 | + |
| 186 | +Our application has five routes: |
| 187 | + |
| 188 | +- POST /students/ - creates a new student. |
| 189 | +- GET /students/ - view a list of all students. |
| 190 | +- GET /students/{id} - view a single student. |
| 191 | +- PUT /students/{id} - update a student. |
| 192 | +- DELETE /students/{id} - delete a student. |
| 193 | + |
| 194 | +Create Student Route |
| 195 | +```````````````````` |
| 196 | + |
| 197 | +.. code-block:: python |
| 198 | + |
| 199 | + @app.post( |
| 200 | + "/students/", |
| 201 | + response_description="Add new student", |
| 202 | + response_model=StudentModel, |
| 203 | + status_code=status.HTTP_201_CREATED, |
| 204 | + response_model_by_alias=False, |
| 205 | + ) |
| 206 | + async def create_student(student: StudentModel = Body(...)): |
| 207 | + """ |
| 208 | + Insert a new student record. |
| 209 | + |
| 210 | + A unique `id` will be created and provided in the response. |
| 211 | + """ |
| 212 | + new_student = await student_collection.insert_one( |
| 213 | + student.model_dump(by_alias=True, exclude=["id"]) |
| 214 | + ) |
| 215 | + created_student = await student_collection.find_one( |
| 216 | + {"_id": new_student.inserted_id} |
| 217 | + ) |
| 218 | + return created_student |
| 219 | + |
| 220 | +The `create_student` route receives the new student data as a JSON string in a `POST` request. We have to decode this JSON request body into a Python dictionary before passing it to our MongoDB client. |
| 221 | + |
| 222 | +The `insert_one` method response includes the `_id` of the newly created student (provided as `id` because this endpoint specifies `response_model_by_alias=False` in the `post` decorator call. After we insert the student into our collection, we use the `inserted_id` to find the correct document and return this in our `JSONResponse`. |
| 223 | + |
| 224 | +FastAPI returns an HTTP `200` status code by default; but in this instance, a `201` created is more appropriate. |
| 225 | + |
| 226 | +Read Routes |
| 227 | ++++++++++++ |
| 228 | + |
| 229 | +The application has two read routes: one for viewing all students and the other for viewing an individual student. |
| 230 | + |
| 231 | +.. code-block:: python |
| 232 | + |
| 233 | + @app.get( |
| 234 | + "/students/", |
| 235 | + response_description="List all students", |
| 236 | + response_model=StudentCollection, |
| 237 | + response_model_by_alias=False, |
| 238 | + ) |
| 239 | + async def list_students(): |
| 240 | + """ |
| 241 | + List all of the student data in the database. |
| 242 | + |
| 243 | + The response is unpaginated and limited to 1000 results. |
| 244 | + """ |
| 245 | + return StudentCollection(students=await student_collection.find().to_list(1000)) |
| 246 | + |
| 247 | +Motor's `to_list` method requires a max document count argument. For this example, I have hardcoded it to `1000`; but in a real application, you would use the `skip and limit parameters <https://pymongo.readthedocs.io/en/3.11.0/api/pymongo/collection.html#pymongo.collection.Collection.find) in `find` to paginate your results. |
| 248 | + |
| 249 | +.. code-block:: python |
| 250 | + |
| 251 | + @app.get( |
| 252 | + "/students/{id}", |
| 253 | + response_description="Get a single student", |
| 254 | + response_model=StudentModel, |
| 255 | + response_model_by_alias=False, |
| 256 | + ) |
| 257 | + async def show_student(id: str): |
| 258 | + """ |
| 259 | + Get the record for a specific student, looked up by `id`. |
| 260 | + """ |
| 261 | + if ( |
| 262 | + student := await student_collection.find_one({"_id": ObjectId(id)}) |
| 263 | + ) is not None: |
| 264 | + return student |
| 265 | + |
| 266 | + raise HTTPException(status_code=404, detail="Student {id} not found") |
| 267 | + |
| 268 | +The student detail route has a path parameter of `id`, which FastAPI passes as an argument to the `show_student` function. We use the `id` to attempt to find the corresponding student in the database. The conditional in this section is using an `assignment expression <https://www.python.org/dev/peps/pep-0572/>`__, an addition to Python 3.8 and often referred to by the cute sobriquet "walrus operator." |
| 269 | + |
| 270 | +If a document with the specified `_id` does not exist, we raise an `HTTPException` with a status of `404`. |
| 271 | + |
| 272 | +Update Route |
| 273 | +++++++++++++ |
| 274 | + |
| 275 | +.. code-block:: python |
| 276 | + |
| 277 | + @app.put( |
| 278 | + "/students/{id}", |
| 279 | + response_description="Update a student", |
| 280 | + response_model=StudentModel, |
| 281 | + response_model_by_alias=False, |
| 282 | + ) |
| 283 | + async def update_student(id: str, student: UpdateStudentModel = Body(...)): |
| 284 | + """ |
| 285 | + Update individual fields of an existing student record. |
| 286 | + |
| 287 | + Only the provided fields will be updated. |
| 288 | + Any missing or `null` fields will be ignored. |
| 289 | + """ |
| 290 | + student = { |
| 291 | + k: v for k, v in student.model_dump(by_alias=True).items() if v is not None |
| 292 | + } |
| 293 | + |
| 294 | + if len(student) >= 1: |
| 295 | + update_result = await student_collection.find_one_and_update( |
| 296 | + {"_id": ObjectId(id)}, |
| 297 | + {"$set": student}, |
| 298 | + return_document=ReturnDocument.AFTER, |
| 299 | + ) |
| 300 | + if update_result is not None: |
| 301 | + return update_result |
| 302 | + else: |
| 303 | + raise HTTPException(status_code=404, detail=f"Student {id} not found") |
| 304 | + |
| 305 | + # The update is empty, but we should still return the matching document: |
| 306 | + if (existing_student := await student_collection.find_one({"_id": id})) is not None: |
| 307 | + return existing_student |
| 308 | + |
| 309 | + raise HTTPException(status_code=404, detail=f"Student {id} not found") |
| 310 | + |
| 311 | +The `update_student` route is like a combination of the `create_student` and the `show_student` routes. It receives the `id` of the document to update as well as the new data in the JSON body. We don't want to update any fields with empty values; so, first of all, we iterate over all the items in the received dictionary and only add the items that have a value to our new document. |
| 312 | + |
| 313 | +If, after we remove the empty values, there are no fields left to update, we instead look for an existing record that matches the `id` and return that unaltered. However, if there are values to update, we use `find_one_and_update <https://motor.readthedocs.io/en/stable/api-asyncio/asyncio_motor_collection.html#motor.motor_asyncio.AsyncIOMotorCollection.find_one_and_update) to `$set <https://docs.mongodb.com/manual/reference/operator/update/set/>`__ the new values, and then return the updated document. |
| 314 | + |
| 315 | +If we get to the end of the function and we have not been able to find a matching document to update or return, then we raise a `404` error again. |
| 316 | + |
| 317 | +Delete Route |
| 318 | +++++++++++++ |
| 319 | + |
| 320 | +.. code-block:: python |
| 321 | + |
| 322 | + @app.delete("/students/{id}", response_description="Delete a student") |
| 323 | + async def delete_student(id: str): |
| 324 | + """ |
| 325 | + Remove a single student record from the database. |
| 326 | + """ |
| 327 | + delete_result = await student_collection.delete_one({"_id": ObjectId(id)}) |
| 328 | + |
| 329 | + if delete_result.deleted_count == 1: |
| 330 | + return Response(status_code=status.HTTP_204_NO_CONTENT) |
| 331 | + |
| 332 | + raise HTTPException(status_code=404, detail=f"Student {id} not found") |
| 333 | + |
| 334 | +Our final route is `delete_student`. Again, because this is acting upon a single document, we have to supply an `id` in the URL. If we find a matching document and successfully delete it, then we return an HTTP status of `204` or "No Content." In this case, we do not return a document as we've already deleted it! However, if we cannot find a student with the specified `id`, then instead we return a `404`. |
| 335 | + |
| 336 | +Our New FastAPI App Generator |
| 337 | +----------------------------- |
| 338 | + |
| 339 | +If you're excited to build something more production-ready with FastAPI, React & MongoDB, head over to the `Github repository <https://github.com/mongodb-labs/full-stack-fastapi-mongodb) for our `new FastAPI app generator <https://www.mongodb.com/blog/post/introducing-full-stack-fast-api-app-generator-for-python-developers) and start transforming your web development experience. |
| 340 | + |
| 341 | +Wrapping Up |
| 342 | +----------- |
| 343 | + |
| 344 | +I hope you have found this introduction to FastAPI with MongoDB useful. If you would like to learn more, check out my post `introducing the FARM stack (FastAPI, React and MongoDB) <https://developer.mongodb.com/how-to/FARM-Stack-FastAPI-React-MongoDB>`__ as well as the `FastAPI documentation <https://motor.readthedocs.io>`__ and this `awesome list <https://github.com/mjhea0/awesome-fastapi>`__. |
| 345 | + |
| 346 | +If you have questions, please head to our `developer community website <https://community.mongodb.com/>`__ where MongoDB engineers and the MongoDB community will help you build your next big idea with MongoDB. |
| 347 | +} |
0 commit comments