|
1 | 1 | import os
|
| 2 | +from typing import Optional, List |
| 3 | + |
2 | 4 | 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 | + |
6 | 11 | from bson import ObjectId
|
7 |
| -from typing import Optional, List |
8 | 12 | import motor.motor_asyncio
|
| 13 | +from pymongo import ReturnDocument |
| 14 | + |
9 | 15 |
|
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 | +) |
11 | 20 | client = motor.motor_asyncio.AsyncIOMotorClient(os.environ["MONGODB_URL"])
|
12 | 21 | db = client.college
|
| 22 | +student_collection = db.get_collection("students") |
13 | 23 |
|
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)] |
29 | 27 |
|
30 | 28 |
|
31 | 29 | 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) |
33 | 38 | name: str = Field(...)
|
34 | 39 | email: EmailStr = Field(...)
|
35 | 40 | course: str = Field(...)
|
36 | 41 | 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={ |
43 | 46 | "example": {
|
44 | 47 | "name": "Jane Doe",
|
45 | 48 |
|
46 | 49 | "course": "Experiments, Science, and Fashion in Nanophotonics",
|
47 |
| - "gpa": "3.0", |
| 50 | + "gpa": 3.0, |
48 | 51 | }
|
49 |
| - } |
| 52 | + }, |
| 53 | + ) |
50 | 54 |
|
51 | 55 |
|
52 | 56 | 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={ |
62 | 69 | "example": {
|
63 | 70 | "name": "Jane Doe",
|
64 | 71 |
|
65 | 72 | "course": "Experiments, Science, and Fashion in Nanophotonics",
|
66 |
| - "gpa": "3.0", |
| 73 | + "gpa": 3.0, |
67 | 74 | }
|
68 |
| - } |
| 75 | + }, |
| 76 | + ) |
69 | 77 |
|
70 | 78 |
|
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 | +) |
72 | 96 | 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 |
77 | 109 |
|
78 | 110 |
|
79 | 111 | @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, |
81 | 116 | )
|
82 | 117 | 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)) |
85 | 124 |
|
86 | 125 |
|
87 | 126 | @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, |
89 | 131 | )
|
90 | 132 | 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: |
92 | 139 | return student
|
93 | 140 |
|
94 | 141 | raise HTTPException(status_code=404, detail=f"Student {id} not found")
|
95 | 142 |
|
96 | 143 |
|
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 | +) |
98 | 150 | 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. |
100 | 153 |
|
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 | + } |
103 | 160 |
|
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: |
111 | 174 | return existing_student
|
112 | 175 |
|
113 | 176 | raise HTTPException(status_code=404, detail=f"Student {id} not found")
|
114 | 177 |
|
115 | 178 |
|
116 |
| -@app.delete("/{id}", response_description="Delete a student") |
| 179 | +@app.delete("/students/{id}", response_description="Delete a student") |
117 | 180 | 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)}) |
119 | 185 |
|
120 | 186 | if delete_result.deleted_count == 1:
|
121 | 187 | return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
0 commit comments