├── .gitignore ├── Justfile ├── README.md ├── app.py ├── dev-requirements.in ├── dev-requirements.txt ├── requirements.in ├── requirements.txt └── test_api.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .vscode 3 | .envrc 4 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | requirements: 2 | pip-compile --strip-extras requirements.in 3 | pip-compile --strip-extras dev-requirements.in 4 | 5 | run: 6 | uvicorn app:app --reload 7 | 8 | test: 9 | pytest -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MongoDB with FastAPI 2 | 3 | This is a small sample project demonstrating how to build an API with [MongoDB](https://developer.mongodb.com/) and [FastAPI](https://fastapi.tiangolo.com/). 4 | It was written to accompany a [blog post](https://developer.mongodb.com/quickstart/python-quickstart-fastapi/) - you should go read it! 5 | 6 | If you want to fastrack your project even further, check out the [MongoDB FastAPI app generator](https://github.com/mongodb-labs/full-stack-fastapi-mongodb) and eliminate much of the boilerplate of getting started. 7 | 8 | ## TL;DR 9 | 10 | If you really don't want to read the [blog post](https://developer.mongodb.com/quickstart/python-quickstart-fastapi/) and want to get up and running, 11 | activate your Python virtualenv, and then run the following from your terminal (edit the `MONGODB_URL` first!): 12 | 13 | ```bash 14 | # Install the requirements: 15 | pip install -r requirements.txt 16 | 17 | # Configure the location of your MongoDB database: 18 | export MONGODB_URL="mongodb+srv://:@/?retryWrites=true&w=majority" 19 | 20 | # Start the service: 21 | uvicorn app:app --reload 22 | ``` 23 | 24 | (Check out [MongoDB Atlas](https://www.mongodb.com/cloud/atlas) if you need a MongoDB database.) 25 | 26 | Now you can load http://localhost:8000/docs in your browser ... but there won't be much to see until you've inserted some data. 27 | 28 | If you have any questions or suggestions, check out the [MongoDB Community Forums](https://developer.mongodb.com/community/forums/)! 29 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional, List 3 | 4 | from fastapi import FastAPI, Body, HTTPException, status 5 | from fastapi.responses import Response 6 | from pydantic import ConfigDict, BaseModel, Field, EmailStr 7 | from pydantic.functional_validators import BeforeValidator 8 | 9 | from typing_extensions import Annotated 10 | 11 | from bson import ObjectId 12 | import motor.motor_asyncio 13 | from pymongo import ReturnDocument 14 | 15 | 16 | app = FastAPI( 17 | title="Student Course API", 18 | summary="A sample application showing how to use FastAPI to add a ReST API to a MongoDB collection.", 19 | ) 20 | client = motor.motor_asyncio.AsyncIOMotorClient(os.environ["MONGODB_URL"]) 21 | db = client.college 22 | student_collection = db.get_collection("students") 23 | 24 | # Represents an ObjectId field in the database. 25 | # It will be represented as a `str` on the model so that it can be serialized to JSON. 26 | PyObjectId = Annotated[str, BeforeValidator(str)] 27 | 28 | 29 | class StudentModel(BaseModel): 30 | """ 31 | Container for a single student record. 32 | """ 33 | 34 | # The primary key for the StudentModel, stored as a `str` on the instance. 35 | # This will be aliased to `_id` when sent to MongoDB, 36 | # but provided as `id` in the API requests and responses. 37 | id: Optional[PyObjectId] = Field(alias="_id", default=None) 38 | name: str = Field(...) 39 | email: EmailStr = Field(...) 40 | course: str = Field(...) 41 | gpa: float = Field(..., le=4.0) 42 | model_config = ConfigDict( 43 | populate_by_name=True, 44 | arbitrary_types_allowed=True, 45 | json_schema_extra={ 46 | "example": { 47 | "name": "Jane Doe", 48 | "email": "jdoe@example.com", 49 | "course": "Experiments, Science, and Fashion in Nanophotonics", 50 | "gpa": 3.0, 51 | } 52 | }, 53 | ) 54 | 55 | 56 | class UpdateStudentModel(BaseModel): 57 | """ 58 | A set of optional updates to be made to a document in the database. 59 | """ 60 | 61 | name: Optional[str] = None 62 | email: Optional[EmailStr] = None 63 | course: Optional[str] = None 64 | gpa: Optional[float] = None 65 | model_config = ConfigDict( 66 | arbitrary_types_allowed=True, 67 | json_encoders={ObjectId: str}, 68 | json_schema_extra={ 69 | "example": { 70 | "name": "Jane Doe", 71 | "email": "jdoe@example.com", 72 | "course": "Experiments, Science, and Fashion in Nanophotonics", 73 | "gpa": 3.0, 74 | } 75 | }, 76 | ) 77 | 78 | 79 | class StudentCollection(BaseModel): 80 | """ 81 | A container holding a list of `StudentModel` instances. 82 | 83 | 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/) 84 | """ 85 | 86 | students: List[StudentModel] 87 | 88 | 89 | @app.post( 90 | "/students/", 91 | response_description="Add new student", 92 | response_model=StudentModel, 93 | status_code=status.HTTP_201_CREATED, 94 | response_model_by_alias=False, 95 | ) 96 | async def create_student(student: StudentModel = Body(...)): 97 | """ 98 | Insert a new student record. 99 | 100 | A unique `id` will be created and provided in the response. 101 | """ 102 | new_student = await student_collection.insert_one( 103 | student.model_dump(by_alias=True, exclude=["id"]) 104 | ) 105 | created_student = await student_collection.find_one( 106 | {"_id": new_student.inserted_id} 107 | ) 108 | return created_student 109 | 110 | 111 | @app.get( 112 | "/students/", 113 | response_description="List all students", 114 | response_model=StudentCollection, 115 | response_model_by_alias=False, 116 | ) 117 | async def list_students(): 118 | """ 119 | List all of the student data in the database. 120 | 121 | The response is unpaginated and limited to 1000 results. 122 | """ 123 | return StudentCollection(students=await student_collection.find().to_list(1000)) 124 | 125 | 126 | @app.get( 127 | "/students/{id}", 128 | response_description="Get a single student", 129 | response_model=StudentModel, 130 | response_model_by_alias=False, 131 | ) 132 | async def show_student(id: str): 133 | """ 134 | Get the record for a specific student, looked up by `id`. 135 | """ 136 | if ( 137 | student := await student_collection.find_one({"_id": ObjectId(id)}) 138 | ) is not None: 139 | return student 140 | 141 | raise HTTPException(status_code=404, detail=f"Student {id} not found") 142 | 143 | 144 | @app.put( 145 | "/students/{id}", 146 | response_description="Update a student", 147 | response_model=StudentModel, 148 | response_model_by_alias=False, 149 | ) 150 | async def update_student(id: str, student: UpdateStudentModel = Body(...)): 151 | """ 152 | Update individual fields of an existing student record. 153 | 154 | Only the provided fields will be updated. 155 | Any missing or `null` fields will be ignored. 156 | """ 157 | student = { 158 | k: v for k, v in student.model_dump(by_alias=True).items() if v is not None 159 | } 160 | 161 | if len(student) >= 1: 162 | update_result = await student_collection.find_one_and_update( 163 | {"_id": ObjectId(id)}, 164 | {"$set": student}, 165 | return_document=ReturnDocument.AFTER, 166 | ) 167 | if update_result is not None: 168 | return update_result 169 | else: 170 | raise HTTPException(status_code=404, detail=f"Student {id} not found") 171 | 172 | # The update is empty, but we should still return the matching document: 173 | if (existing_student := await student_collection.find_one({"_id": id})) is not None: 174 | return existing_student 175 | 176 | raise HTTPException(status_code=404, detail=f"Student {id} not found") 177 | 178 | 179 | @app.delete("/students/{id}", response_description="Delete a student") 180 | async def delete_student(id: str): 181 | """ 182 | Remove a single student record from the database. 183 | """ 184 | delete_result = await student_collection.delete_one({"_id": ObjectId(id)}) 185 | 186 | if delete_result.deleted_count == 1: 187 | return Response(status_code=status.HTTP_204_NO_CONTENT) 188 | 189 | raise HTTPException(status_code=404, detail=f"Student {id} not found") 190 | -------------------------------------------------------------------------------- /dev-requirements.in: -------------------------------------------------------------------------------- 1 | requests 2 | pytest 3 | pip-tools -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile --strip-extras dev-requirements.in 6 | # 7 | build==1.1.1 8 | # via pip-tools 9 | certifi==2023.7.22 10 | # via requests 11 | charset-normalizer==3.3.1 12 | # via requests 13 | click==8.1.7 14 | # via pip-tools 15 | idna==3.4 16 | # via requests 17 | iniconfig==2.0.0 18 | # via pytest 19 | packaging==23.2 20 | # via 21 | # build 22 | # pytest 23 | pip-tools==7.4.1 24 | # via -r dev-requirements.in 25 | pluggy==1.3.0 26 | # via pytest 27 | pyproject-hooks==1.0.0 28 | # via 29 | # build 30 | # pip-tools 31 | pytest==7.4.3 32 | # via -r dev-requirements.in 33 | requests==2.31.0 34 | # via -r dev-requirements.in 35 | urllib3==2.0.7 36 | # via requests 37 | wheel==0.42.0 38 | # via pip-tools 39 | 40 | # The following packages are considered to be unsafe in a requirements file: 41 | # pip 42 | # setuptools 43 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | fastapi ~=0.110 2 | motor ~=3.3 3 | uvicorn ~=0.28 4 | pydantic[email] -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile --strip-extras requirements.in 6 | # 7 | annotated-types==0.6.0 8 | # via pydantic 9 | anyio==3.7.1 10 | # via starlette 11 | click==8.1.7 12 | # via uvicorn 13 | dnspython==2.4.2 14 | # via 15 | # email-validator 16 | # pymongo 17 | email-validator==2.1.0.post1 18 | # via pydantic 19 | fastapi==0.110.0 20 | # via -r requirements.in 21 | h11==0.14.0 22 | # via uvicorn 23 | idna==3.4 24 | # via 25 | # anyio 26 | # email-validator 27 | motor==3.3.1 28 | # via -r requirements.in 29 | pydantic==2.6.3 30 | # via 31 | # -r requirements.in 32 | # fastapi 33 | pydantic-core==2.16.3 34 | # via pydantic 35 | pymongo==4.5.0 36 | # via motor 37 | sniffio==1.3.0 38 | # via anyio 39 | starlette==0.36.3 40 | # via fastapi 41 | typing-extensions==4.8.0 42 | # via 43 | # fastapi 44 | # pydantic 45 | # pydantic-core 46 | uvicorn==0.28.0 47 | # via -r requirements.in 48 | -------------------------------------------------------------------------------- /test_api.py: -------------------------------------------------------------------------------- 1 | from requests import get, post, put, delete, HTTPError 2 | 3 | 4 | def test_api(): 5 | """ 6 | An automated version of the manual testing I've been doing, 7 | testing the lifecycle of an inserted document. 8 | """ 9 | student_root = "http://localhost:8000/students/" 10 | 11 | initial_doc = { 12 | "course": "Test Course", 13 | "email": "jdoe_test@example.com", 14 | "gpa": "3.0", 15 | "name": "Jane Doe", 16 | } 17 | 18 | try: 19 | # Insert a student 20 | response = post(student_root, json=initial_doc) 21 | response.raise_for_status() 22 | doc = response.json() 23 | inserted_id = doc["id"] 24 | print(f"Inserted document with id: {inserted_id}") 25 | print( 26 | "If the test fails in the middle you may want to manually remove the document." 27 | ) 28 | assert doc["course"] == "Test Course" 29 | assert doc["email"] == "jdoe_test@example.com" 30 | assert doc["gpa"] == 3.0 31 | assert doc["name"] == "Jane Doe" 32 | 33 | # List students and ensure it's present 34 | response = get(student_root) 35 | response.raise_for_status() 36 | student_ids = {s["id"] for s in response.json()["students"]} 37 | assert inserted_id in student_ids 38 | 39 | # Get individual student doc 40 | response = get(student_root + inserted_id) 41 | response.raise_for_status() 42 | doc = response.json() 43 | assert doc["id"] == inserted_id 44 | assert doc["course"] == "Test Course" 45 | assert doc["email"] == "jdoe_test@example.com" 46 | assert doc["gpa"] == 3.0 47 | assert doc["name"] == "Jane Doe" 48 | 49 | # Update the student doc 50 | response = put( 51 | student_root + inserted_id, 52 | json={ 53 | "email": "updated_email@example.com", 54 | }, 55 | ) 56 | response.raise_for_status() 57 | doc = response.json() 58 | assert doc["id"] == inserted_id 59 | assert doc["course"] == "Test Course" 60 | assert doc["email"] == "updated_email@example.com" 61 | assert doc["gpa"] == 3.0 62 | assert doc["name"] == "Jane Doe" 63 | 64 | # Get the student doc and check for change 65 | response = get(student_root + inserted_id) 66 | response.raise_for_status() 67 | doc = response.json() 68 | assert doc["id"] == inserted_id 69 | assert doc["course"] == "Test Course" 70 | assert doc["email"] == "updated_email@example.com" 71 | assert doc["gpa"] == 3.0 72 | assert doc["name"] == "Jane Doe" 73 | 74 | # Delete the doc 75 | response = delete(student_root + inserted_id) 76 | response.raise_for_status() 77 | 78 | # Get the doc and ensure it's been deleted 79 | response = get(student_root + inserted_id) 80 | assert response.status_code == 404 81 | except HTTPError as he: 82 | print(he.response.json()) 83 | raise 84 | --------------------------------------------------------------------------------