Skip to content

Commit

Permalink
Integrate MongoDB Database (#72)
Browse files Browse the repository at this point in the history
* feat: Docker setup

- Using a Docker Compose file, launch three containers. The first
container is the MongoDB instance, the second container is a web
application that allows easier interfacing with the instance, and the
third is the backend.
- The containers are not running on the host network, which is why
the host of the Uvicorn server was changed to 0.0.0.0.

* feat: MongoDB Demo

- /demo shows how to add a user to a MongoDB database and query it

* fix: add docker compose down

* fix: Docker setup optimizations

* fix: component usage cleanup

* fix: backend cleanup

* fix: type error in page.tsx

* feat: unit tests for MongoDB

* update: Docker optimizations

* fix: change get_user route to GET

* feat: set up Docker volume

* update: use uvicorn to launch backend, not dev.py

* fix: remove unnecessarily exposed port

* update: remove motor asyncio workaround

* fix: remove unused import

* fix: add back event loop workaround

* fix: type errors

* feat: WORKDIR in Dockerfile

* fix: remove type parameters to AgnosticClient

* fix: BaseRecord Config deprecation
  • Loading branch information
samderanova authored Dec 13, 2023
1 parent cd80b69 commit 3a586cf
Show file tree
Hide file tree
Showing 11 changed files with 390 additions and 4 deletions.
15 changes: 15 additions & 0 deletions apps/api/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
FROM python:3.9

RUN apt-get update && apt-get install -y libxml2-dev libxmlsec1-dev libxmlsec1-openssl

WORKDIR /app

COPY requirements.txt .

RUN pip install --no-cache-dir -r requirements.txt "uvicorn[standard]"

COPY src/ .

ENV PYTHONPATH=src/

CMD ["uvicorn", "app:app", "--host", "0.0.0.0"]
32 changes: 32 additions & 0 deletions apps/api/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Use root/example as user/password credentials
version: "3.1"

services:
mongo:
image: mongo
restart: always
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: example
volumes:
- mongodb_data_volume:/data/db

mongo-express:
image: mongo-express
restart: always
ports:
- "8081:8081"
environment:
ME_CONFIG_MONGODB_ADMINUSERNAME: root
ME_CONFIG_MONGODB_ADMINPASSWORD: example
ME_CONFIG_MONGODB_URL: mongodb://root:example@mongo:27017/

fastapi-dev:
build: .
ports:
- "8000:8000"
environment:
MONGODB_URI: mongodb://root:example@mongo:27017

volumes:
mongodb_data_volume:
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "api",
"private": true,
"scripts": {
"dev": "python src/dev.py",
"dev": "./run-docker.sh",
"test": "pytest",
"lint": "flake8 src tests && mypy src tests",
"format:write": "black src tests",
Expand Down
2 changes: 2 additions & 0 deletions apps/api/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ fastapi==0.104.1
httpx==0.25.2
python-multipart==0.0.5
python3-saml==1.16.0
motor==3.3.2
pydantic[email]==2.5.2
2 changes: 2 additions & 0 deletions apps/api/run-docker.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
docker compose up --build
docker compose down
3 changes: 2 additions & 1 deletion apps/api/src/app.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from fastapi import FastAPI

from routers import saml
from routers import saml, demo

app = FastAPI()

app.include_router(saml.router, prefix="/saml", tags=["saml"])
app.include_router(demo.router, prefix="/demo", tags=["demo"])


@app.get("/")
Expand Down
19 changes: 19 additions & 0 deletions apps/api/src/routers/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from fastapi import APIRouter, Cookie, Form

from services.mongodb_handler import insert, retrieve, Collection

router = APIRouter()


Expand All @@ -15,3 +17,20 @@ async def me(username: Annotated[Union[str, None], Cookie()] = None) -> str:
async def square(value: Annotated[int, Form()]) -> int:
"""Calculate the square of the value."""
return value * value


@router.get("/user")
async def get_user(search_name: str) -> list[dict[str, object]]:
results = await retrieve(
Collection.USERS, {"name": search_name}, ["name", "ucinetid"]
)
for result in results:
result["_id"] = str(result["_id"])
return results


@router.post("/add-user")
async def add_user(
name: Annotated[str, Form()], ucinetid: Annotated[str, Form()]
) -> Union[str, bool]:
return str(await insert(Collection.USERS, {"name": name, "ucinetid": ucinetid}))
122 changes: 122 additions & 0 deletions apps/api/src/services/mongodb_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import asyncio
import os
from enum import Enum
from logging import getLogger
from typing import Any, Mapping, Optional, Union

from bson import CodecOptions
from motor.core import AgnosticClient
from motor.motor_asyncio import AsyncIOMotorClient
from pydantic import BaseModel, Field, ConfigDict

log = getLogger(__name__)

STAGING_ENV = os.getenv("DEPLOYMENT") == "STAGING"

MONGODB_URI = os.getenv("MONGODB_URI")

# Mypy thinks AgnosticClient is a generic type, but providing type parameters to it
# raises a TypeError.
MONGODB_CLIENT: AgnosticClient = AsyncIOMotorClient(MONGODB_URI) # type: ignore

# Resolve Vercel runtime issue
MONGODB_CLIENT.get_io_loop = asyncio.get_event_loop # type: ignore

DATABASE_NAME = "irvinehacks" if STAGING_ENV else "irvinehacks-prod"
DB = MONGODB_CLIENT[DATABASE_NAME].with_options(
codec_options=CodecOptions(tz_aware=True)
)


class BaseRecord(BaseModel):
model_config = ConfigDict(populate_by_name=True)

uid: str = Field(alias="_id")

def dict(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
if "by_alias" in kwargs:
return BaseModel.model_dump(self, *args, **kwargs)
return BaseModel.model_dump(self, by_alias=True, *args, **kwargs)


class Collection(str, Enum):
USERS = "users"
TESTING = "testing"
SETTINGS = "settings"


async def insert(
collection: Collection, data: Mapping[str, object]
) -> Union[str, bool]:
"""Insert a document into the specified collection of the database"""
COLLECTION = DB[collection.value]
result = await COLLECTION.insert_one(data)
if not result.acknowledged:
log.error("MongoDB document insertion was not acknowledged")
raise RuntimeError("Could not insert document into MongoDB collection")
new_document_id: str = result.inserted_id
return new_document_id


async def retrieve_one(
collection: Collection, query: Mapping[str, object], fields: list[str] = []
) -> Optional[dict[str, Any]]:
"""Search for and retrieve the specified fields of all documents (if any exist)
that satisfy the provided query."""
COLLECTION = DB[collection.value]

result: Optional[dict[str, object]] = await COLLECTION.find_one(query, fields)
return result


async def retrieve(
collection: Collection, query: Mapping[str, object], fields: list[str] = []
) -> list[dict[str, object]]:
"""Search for and retrieve the specified fields of a document (if any exist)
that satisfy the provided query."""
COLLECTION = DB[collection.value]

result = COLLECTION.find(query, fields)
output: list[dict[str, object]] = await result.to_list(length=None)
return output


async def update_one(
collection: Collection,
query: Mapping[str, object],
new_data: Mapping[str, object],
*,
upsert: bool = False,
) -> bool:
"""Search for and set a document's fields using the provided query and data."""
return await raw_update_one(collection, query, {"$set": new_data}, upsert=upsert)


async def raw_update_one(
collection: Collection,
query: Mapping[str, object],
update: Mapping[str, object],
*,
upsert: bool = False,
) -> bool:
"""Search for and update a document using the provided query and raw update."""
COLLECTION = DB[collection.value]
result = await COLLECTION.update_one(query, update, upsert=upsert)
if not result.acknowledged:
log.error("MongoDB document update was not acknowledged")
raise RuntimeError("Could not update documents in MongoDB collection")

return result.modified_count > 0


async def update(
collection: Collection, query: Mapping[str, object], new_data: Mapping[str, object]
) -> bool:
"""Search for and update documents (if they exist) using the provided query data."""
COLLECTION = DB[collection.value]
result = await COLLECTION.update_many(query, {"$set": new_data})
if not result.acknowledged:
log.error("MongoDB document update was not acknowledged")
raise RuntimeError("Could not update documents in MongoDB collection")

return result.modified_count > 0
Loading

0 comments on commit 3a586cf

Please sign in to comment.