diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 0000000..84c5a52 --- /dev/null +++ b/backend/api/__init__.py @@ -0,0 +1 @@ +"""Expose API routes via FastAPI routers from this package.""" diff --git a/backend/api/authentication.py b/backend/api/authentication.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/health.py b/backend/api/health.py new file mode 100644 index 0000000..425e1ba --- /dev/null +++ b/backend/api/health.py @@ -0,0 +1,19 @@ +"""Confirm system health via monitorable API end points. + +Production systems monitor these end points upon deployment, and at regular intervals, to ensure the service is running. +""" + +from fastapi import APIRouter, Depends +from ..services.health import HealthService + +openapi_tags = { + "name": "System Health", + "description": "Production systems monitor these end points upon deployment, and at regular intervals, to ensure the service is running.", +} + +api = APIRouter(prefix="/api/health") + + +@api.get("", tags=["System Health"]) +def health_check(health_svc: HealthService = Depends()) -> str: + return health_svc.check() diff --git a/backend/api/user.py b/backend/api/user.py new file mode 100644 index 0000000..87d3a23 --- /dev/null +++ b/backend/api/user.py @@ -0,0 +1,25 @@ +from fastapi import APIRouter, Depends +from ..services import UserService +from ..models.user_model import User, UserTypeEnum + +from typing import List + +api = APIRouter(prefix="/api/user") + +openapi_tags = { + "name": "Users", + "description": "User profile search and related operations.", +} + + +# TODO: Add security using HTTP Bearer Tokens +# TODO: Enable authorization by passing user uuid to API +# TODO: Create custom exceptions +@api.get("", response_model=List[User], tags=["Users"]) +def get_all(user_id: str, user_svc: UserService = Depends()): + subject = user_svc.get_user_by_uuid(user_id) + + if subject.role != UserTypeEnum.ADMIN: + raise Exception(f"Insufficient permissions for user {subject.uuid}") + + return user_svc.all() diff --git a/backend/database.py b/backend/database.py index a75f451..a256970 100644 --- a/backend/database.py +++ b/backend/database.py @@ -21,7 +21,6 @@ engine = sqlalchemy.create_engine(_engine_str(), echo=True) def db_session(): """Generator function offering dependency injection of SQLAlchemy Sessions.""" - print("ran") session = Session(engine) try: yield session diff --git a/backend/entities/user_entity.py b/backend/entities/user_entity.py index ebc7a87..9534746 100644 --- a/backend/entities/user_entity.py +++ b/backend/entities/user_entity.py @@ -41,6 +41,7 @@ class UserEntity(EntityBase): ) experience: Mapped[int] = mapped_column(Integer, nullable=False) group: Mapped[str] = mapped_column(String(50)) + uuid: Mapped[str] = mapped_column(String, nullable=True) @classmethod def from_model(cls, model: User) -> Self: @@ -62,6 +63,7 @@ class UserEntity(EntityBase): program=model.program, experience=model.experience, group=model.group, + uuid=model.uuid, ) def to_model(self) -> User: @@ -83,4 +85,5 @@ class UserEntity(EntityBase): program=self.program, role=self.role, created_at=self.created_at, + uuid=self.uuid, ) diff --git a/backend/main.py b/backend/main.py index e69de29..8bd689f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -0,0 +1,29 @@ +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from fastapi.middleware.gzip import GZipMiddleware + +from .api import user, health + +description = """ +Welcome to the **COMPASS** RESTful Application Programming Interface. +""" + +app = FastAPI( + title="Compass API", + version="0.0.1", + description=description, + openapi_tags=[user.openapi_tags, health.openapi_tags], +) + +app.add_middleware(GZipMiddleware) + +feature_apis = [user, health] + +for feature_api in feature_apis: + app.include_router(feature_api.api) + + +# Add application-wide exception handling middleware for commonly encountered API Exceptions +@app.exception_handler(Exception) +def permission_exception_handler(request: Request, e: Exception): + return JSONResponse(status_code=403, content={"message": str(e)}) diff --git a/backend/models/user_model.py b/backend/models/user_model.py index 1ba4e18..d7c1521 100644 --- a/backend/models/user_model.py +++ b/backend/models/user_model.py @@ -15,3 +15,4 @@ class User(BaseModel): program: List[ProgramTypeEnum] role: UserTypeEnum created_at: Optional[datetime] + uuid: str | None = None diff --git a/backend/script/reset_demo.py b/backend/script/reset_demo.py index d4c1aa0..a014d20 100644 --- a/backend/script/reset_demo.py +++ b/backend/script/reset_demo.py @@ -1,10 +1,13 @@ from sqlalchemy import create_engine +from sqlalchemy.orm import Session import subprocess from ..database import engine, _engine_str from ..env import getenv from .. import entities +from ..test.services import user_test_data + database = getenv("POSTGRES_DATABASE") engine = create_engine(_engine_str(), echo=True) @@ -16,3 +19,6 @@ subprocess.run(["python3", "-m", "backend.script.create_database"]) entities.EntityBase.metadata.drop_all(engine) entities.EntityBase.metadata.create_all(engine) + +with Session(engine) as session: + user_test_data.insert_fake_data(session) diff --git a/backend/services/health.py b/backend/services/health.py new file mode 100644 index 0000000..45d61aa --- /dev/null +++ b/backend/services/health.py @@ -0,0 +1,27 @@ +""" +Verify connectivity to the database from the service layer for health check purposes. + +The production system will regularly check the health of running containers via accessing an API endpoint. +The API endpoint is backed by this service which executes a simple statement against our backing database. +In more complex deployments, where multiple backing services may be depended upon, the health check process +would necessarily also become more complex to reflect the health of all subsystems. + +In this context health does not refer to correctness as much as running, connected, and responsive. +""" + +from fastapi import Depends +from sqlalchemy import text +from ..database import Session, db_session + + +class HealthService: + _session: Session + + def __init__(self, session: Session = Depends(db_session)): + self._session = session + + def check(self): + stmt = text("SELECT 'OK', NOW()") + result = self._session.execute(stmt) + row = result.all()[0] + return str(f"{row[0]} @ {row[1]}") diff --git a/backend/services/user.py b/backend/services/user.py index c354215..be01e00 100644 --- a/backend/services/user.py +++ b/backend/services/user.py @@ -26,6 +26,21 @@ class UserService: return user_entity.to_model() + def get_user_by_uuid(self, uuid: str) -> User: + """ + Gets a user by uuid from the database + + Returns: A User Pydantic model + + """ + query = select(UserEntity).where(UserEntity.uuid == uuid) + user_entity: UserEntity | None = self._session.scalar(query) + + if user_entity is None: + raise Exception(f"No user found with matching uuid: {uuid}") + + return user_entity.to_model() + def all(self) -> list[User]: """ Returns a list of all Users diff --git a/backend/test/services/user_test_data.py b/backend/test/services/user_test_data.py index ca72816..b8a6d98 100644 --- a/backend/test/services/user_test_data.py +++ b/backend/test/services/user_test_data.py @@ -13,6 +13,7 @@ roles = UserTypeEnum volunteer = User( id=1, + uuid="test1", username="volunteer", email="volunteer@compass.com", experience=1, @@ -24,6 +25,7 @@ volunteer = User( employee = User( id=2, + uuid="test2", username="employee", email="employee@compass.com", experience=5, @@ -35,6 +37,7 @@ employee = User( admin = User( id=3, + uuid="test3", username="admin", email="admin@compass.com", experience=10, @@ -51,6 +54,7 @@ admin = User( newUser = User( id=4, username="new", + uuid="test4", email="new@compass.com", experience=1, group="volunteer",