Skip to content

Commit 5f1ede0

Browse files
authored
Dependency update and a bunch of fixes. (#16)
* Documentation, fixes, dependency updates, and a test script.
1 parent ef8e87d commit 5f1ede0

File tree

7 files changed

+301
-86
lines changed

7 files changed

+301
-86
lines changed

Justfile

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
requirements:
2+
pip-compile --strip-extras requirements.in
3+
pip-compile --strip-extras test-requirements.in
4+
5+
run:
6+
uvicorn app:app --reload
7+
8+
test:
9+
pytest

app.py

Lines changed: 129 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,121 +1,187 @@
11
import os
2+
from typing import Optional, List
3+
24
from fastapi import FastAPI, Body, HTTPException, status
3-
from fastapi.responses import Response, JSONResponse
4-
from fastapi.encoders import jsonable_encoder
5-
from pydantic import BaseModel, Field, EmailStr
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+
611
from bson import ObjectId
7-
from typing import Optional, List
812
import motor.motor_asyncio
13+
from pymongo import ReturnDocument
14+
915

10-
app = FastAPI()
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+
)
1120
client = motor.motor_asyncio.AsyncIOMotorClient(os.environ["MONGODB_URL"])
1221
db = client.college
22+
student_collection = db.get_collection("students")
1323

14-
15-
class PyObjectId(ObjectId):
16-
@classmethod
17-
def __get_validators__(cls):
18-
yield cls.validate
19-
20-
@classmethod
21-
def validate(cls, v):
22-
if not ObjectId.is_valid(v):
23-
raise ValueError("Invalid objectid")
24-
return ObjectId(v)
25-
26-
@classmethod
27-
def __modify_schema__(cls, field_schema):
28-
field_schema.update(type="string")
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)]
2927

3028

3129
class StudentModel(BaseModel):
32-
id: PyObjectId = Field(default_factory=PyObjectId, alias="_id")
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)
3338
name: str = Field(...)
3439
email: EmailStr = Field(...)
3540
course: str = Field(...)
3641
gpa: float = Field(..., le=4.0)
37-
38-
class Config:
39-
allow_population_by_field_name = True
40-
arbitrary_types_allowed = True
41-
json_encoders = {ObjectId: str}
42-
schema_extra = {
42+
model_config = ConfigDict(
43+
populate_by_name=True,
44+
arbitrary_types_allowed=True,
45+
json_schema_extra={
4346
"example": {
4447
"name": "Jane Doe",
4548
"email": "[email protected]",
4649
"course": "Experiments, Science, and Fashion in Nanophotonics",
47-
"gpa": "3.0",
50+
"gpa": 3.0,
4851
}
49-
}
52+
},
53+
)
5054

5155

5256
class UpdateStudentModel(BaseModel):
53-
name: Optional[str]
54-
email: Optional[EmailStr]
55-
course: Optional[str]
56-
gpa: Optional[float]
57-
58-
class Config:
59-
arbitrary_types_allowed = True
60-
json_encoders = {ObjectId: str}
61-
schema_extra = {
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={
6269
"example": {
6370
"name": "Jane Doe",
6471
"email": "[email protected]",
6572
"course": "Experiments, Science, and Fashion in Nanophotonics",
66-
"gpa": "3.0",
73+
"gpa": 3.0,
6774
}
68-
}
75+
},
76+
)
6977

7078

71-
@app.post("/", response_description="Add new student", response_model=StudentModel)
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+
)
7296
async def create_student(student: StudentModel = Body(...)):
73-
student = jsonable_encoder(student)
74-
new_student = await db["students"].insert_one(student)
75-
created_student = await db["students"].find_one({"_id": new_student.inserted_id})
76-
return JSONResponse(status_code=status.HTTP_201_CREATED, content=created_student)
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
77109

78110

79111
@app.get(
80-
"/", response_description="List all students", response_model=List[StudentModel]
112+
"/students/",
113+
response_description="List all students",
114+
response_model=StudentCollection,
115+
response_model_by_alias=False,
81116
)
82117
async def list_students():
83-
students = await db["students"].find().to_list(1000)
84-
return 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))
85124

86125

87126
@app.get(
88-
"/{id}", response_description="Get a single student", response_model=StudentModel
127+
"/students/{id}",
128+
response_description="Get a single student",
129+
response_model=StudentModel,
130+
response_model_by_alias=False,
89131
)
90132
async def show_student(id: str):
91-
if (student := await db["students"].find_one({"_id": id})) is not None:
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:
92139
return student
93140

94141
raise HTTPException(status_code=404, detail=f"Student {id} not found")
95142

96143

97-
@app.put("/{id}", response_description="Update a student", response_model=StudentModel)
144+
@app.put(
145+
"/students/{id}",
146+
response_description="Update a student",
147+
response_model=StudentModel,
148+
response_model_by_alias=False,
149+
)
98150
async def update_student(id: str, student: UpdateStudentModel = Body(...)):
99-
student = {k: v for k, v in student.dict().items() if v is not None}
151+
"""
152+
Update individual fields of an existing student record.
100153
101-
if len(student) >= 1:
102-
update_result = await db["students"].update_one({"_id": id}, {"$set": student})
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+
}
103160

104-
if update_result.modified_count == 1:
105-
if (
106-
updated_student := await db["students"].find_one({"_id": id})
107-
) is not None:
108-
return updated_student
109-
110-
if (existing_student := await db["students"].find_one({"_id": id})) is not None:
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:
111174
return existing_student
112175

113176
raise HTTPException(status_code=404, detail=f"Student {id} not found")
114177

115178

116-
@app.delete("/{id}", response_description="Delete a student")
179+
@app.delete("/students/{id}", response_description="Delete a student")
117180
async def delete_student(id: str):
118-
delete_result = await db["students"].delete_one({"_id": id})
181+
"""
182+
Remove a single student record from the database.
183+
"""
184+
delete_result = await student_collection.delete_one({"_id": ObjectId(id)})
119185

120186
if delete_result.deleted_count == 1:
121187
return Response(status_code=status.HTTP_204_NO_CONTENT)

requirements.in

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
fastapi==0.104.1
2+
motor==3.3.1
3+
uvicorn==0.23.2
4+
pydantic[email]==2.4.2

requirements.txt

Lines changed: 50 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,50 @@
1-
appdirs==1.4.4
2-
black==20.8b1
3-
click==7.1.2
4-
dnspython==2.0.0
5-
email-validator==1.1.2
6-
fastapi==0.62.0
7-
flake8==3.8.4
8-
h11==0.11.0
9-
idna==2.10
10-
mccabe==0.6.1
11-
motor==2.3.0
12-
mypy-extensions==0.4.3
13-
pathspec==0.8.1
14-
pycodestyle==2.6.0
15-
pydantic==1.7.3
16-
pyflakes==2.2.0
17-
pymongo==3.11.1
18-
regex==2020.11.13
19-
starlette==0.13.6
20-
toml==0.10.2
21-
typed-ast==1.4.1
22-
typing-extensions==3.7.4.3
23-
uvicorn==0.12.3
1+
#
2+
# This file is autogenerated by pip-compile with Python 3.11
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
11+
# fastapi
12+
# starlette
13+
click==8.1.7
14+
# via uvicorn
15+
dnspython==2.4.2
16+
# via
17+
# email-validator
18+
# pymongo
19+
email-validator==2.1.0.post1
20+
# via pydantic
21+
fastapi==0.104.1
22+
# via -r requirements.in
23+
h11==0.14.0
24+
# via uvicorn
25+
idna==3.4
26+
# via
27+
# anyio
28+
# email-validator
29+
motor==3.3.1
30+
# via -r requirements.in
31+
pydantic==2.4.2
32+
# via
33+
# -r requirements.in
34+
# fastapi
35+
# pydantic
36+
pydantic-core==2.10.1
37+
# via pydantic
38+
pymongo==4.5.0
39+
# via motor
40+
sniffio==1.3.0
41+
# via anyio
42+
starlette==0.27.0
43+
# via fastapi
44+
typing-extensions==4.8.0
45+
# via
46+
# fastapi
47+
# pydantic
48+
# pydantic-core
49+
uvicorn==0.23.2
50+
# via -r requirements.in

test-requirements.in

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
requests
2+
pytest

test-requirements.txt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#
2+
# This file is autogenerated by pip-compile with Python 3.11
3+
# by the following command:
4+
#
5+
# pip-compile --strip-extras test-requirements.in
6+
#
7+
certifi==2023.7.22
8+
# via requests
9+
charset-normalizer==3.3.1
10+
# via requests
11+
idna==3.4
12+
# via requests
13+
iniconfig==2.0.0
14+
# via pytest
15+
packaging==23.2
16+
# via pytest
17+
pluggy==1.3.0
18+
# via pytest
19+
pytest==7.4.3
20+
# via -r test-requirements.in
21+
requests==2.31.0
22+
# via -r test-requirements.in
23+
urllib3==2.0.7
24+
# via requests

0 commit comments

Comments
 (0)