diff --git a/modules/module-02/REFLECTION.md b/modules/module-02/REFLECTION.md index 52850343..49081550 100644 --- a/modules/module-02/REFLECTION.md +++ b/modules/module-02/REFLECTION.md @@ -1,7 +1,7 @@ # Module 2 — Reflection -**Team name**: _______________ -**Branch**: `module-02/` +**Team name**: BUI_Nhat +**Branch**: `module-02/BUI_Nhat` **Submitted**: before Module 3 lesson --- @@ -19,6 +19,12 @@ You built a service with distinct layers: models, schemas, repository, service, Think about what happens six months later when someone new joins the team, or when you need to swap SQLite for PostgreSQL. What does the layered structure protect you from? > *Your answer:* +Keeping everything in one file may work for a very small project, but it becomes difficult to maintain as the system grows. The layered structure separates responsibilities so each part of the service has a clear role. +For example, the repository layer handles database queries, while the service layer contains business logic. This makes the code easier to understand for new developers because they immediately know where to look when changing a feature or fixing a bug. +The structure also makes future changes safer. If we later switch from SQLite to PostgreSQL, most changes would stay inside the database and repository layers without affecting the API routes or schemas. Without separation, changing one part of the system could accidentally break unrelated features. + +--- + --- @@ -31,6 +37,10 @@ Each service owns its data exclusively — no other service is allowed to touch Give a concrete scenario, not a general principle. > *Your answer:* +The "Game" entity should only be owned by the game-service. +For example, imagine the recommendation-service could directly write into the "games" table. A bug in the recommendation logic could accidentally modify or delete game information while generating recommendations. +This would create inconsistent data across the platform. Users might suddenly see incorrect game titles, genres, or missing entries. By forcing all changes to go through the game-service API, the service keeps control over validation and business rules. + --- @@ -43,6 +53,9 @@ You now have models, schemas, a repository, a service, and routes — five layer And at what point does the complexity start to pay off? Where is the tipping point? > *Your answer:* +The main cost of this structure is extra complexity and boilerplate. For a small CRUD service, creating models, schemas, repositories, services, and routes can feel repetitive and slower than writing everything in one file. +Debugging can also require jumping through multiple layers before finding the source of a problem. +However, the structure starts paying off once the project grows beyond a few endpoints or multiple developers start working on it. At that point, clean separation makes the codebase easier to scale, test, and maintain without turning into a monolith full of tightly coupled logic. --- diff --git a/modules/module-02/exercise.md b/modules/module-02/exercise.md index d3d7c136..f917b51c 100644 --- a/modules/module-02/exercise.md +++ b/modules/module-02/exercise.md @@ -1,7 +1,7 @@ # Module 2 — FastAPI Service Design **Duration**: 2h in class -**Branch to submit**: `module-02/` +**Branch to submit**: `module-02/BUI_Nhat` --- diff --git a/services/game-service/app/database.py b/services/game-service/app/database.py index 56dfd44d..dd243765 100644 --- a/services/game-service/app/database.py +++ b/services/game-service/app/database.py @@ -1,3 +1,29 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase +import os + +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./games.db") + +engine = create_engine( + DATABASE_URL, + connect_args={"check_same_thread": False} +) + +SessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=engine +) + +class Base(DeclarativeBase): + pass + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() # Infrastructure layer — database connection. # # Replicate the same structure as user-service/app/database.py. diff --git a/services/game-service/app/main.py b/services/game-service/app/main.py index e629900e..be15c57d 100644 --- a/services/game-service/app/main.py +++ b/services/game-service/app/main.py @@ -1,3 +1,10 @@ +from fastapi import FastAPI + +from app.routes import router + +app = FastAPI(title="game-service") + +app.include_router(router) # Entry point — FastAPI application. # # Create the FastAPI app instance and register the router from app.routes. diff --git a/services/game-service/app/models.py b/services/game-service/app/models.py index 52eb2756..46df16ed 100644 --- a/services/game-service/app/models.py +++ b/services/game-service/app/models.py @@ -1,3 +1,28 @@ +from sqlalchemy import Column, String, Integer, DateTime +from datetime import datetime, timezone +import uuid + +from app.database import Base + +class Game(Base): + __tablename__ = "games" + + id = Column( + String, + primary_key=True, + default=lambda: str(uuid.uuid4()) + ) + + title = Column(String, nullable=False) + genre = Column(String, nullable=False) + platform = Column(String, nullable=False) + release_year = Column(Integer, nullable=True) + cover_url = Column(String, nullable=True) + + created_at = Column( + DateTime, + default=lambda: datetime.now(timezone.utc) + ) # Infrastructure layer — ORM model. # # Define the `games` table. This is the only file that knows about columns. diff --git a/services/game-service/app/repository.py b/services/game-service/app/repository.py index 8120c970..251d47a8 100644 --- a/services/game-service/app/repository.py +++ b/services/game-service/app/repository.py @@ -1,3 +1,63 @@ +from sqlalchemy.orm import Session + +from app.models import Game +from app.schemas import GameCreate + +def create_game(db: Session, data: GameCreate) -> Game: + game = Game( + title=data.title, + genre=data.genre, + platform=data.platform, + release_year=data.release_year, + cover_url=data.cover_url, + ) + + db.add(game) + db.commit() + db.refresh(game) + + return game + +def get_game(db: Session, game_id: str) -> Game | None: + return db.query(Game).filter(Game.id == game_id).first() + +def list_games( + db: Session, + limit: int = 20, + offset: int = 0 +) -> tuple[list[Game], int]: + + total = db.query(Game).count() + + games = ( + db.query(Game) + .offset(offset) + .limit(limit) + .all() + ) + + return games, total + +def search_games( + db: Session, + q: str, + limit: int = 20, + offset: int = 0 +) -> tuple[list[Game], int]: + + query = db.query(Game).filter( + Game.title.ilike(f"%{q}%") + ) + + total = query.count() + + games = ( + query.offset(offset) + .limit(limit) + .all() + ) + + return games, total # Infrastructure layer — raw database queries. # # Implement these four functions. Each takes `db: Session` as its first argument. diff --git a/services/game-service/app/routes.py b/services/game-service/app/routes.py index 435b3ded..3f08ed11 100644 --- a/services/game-service/app/routes.py +++ b/services/game-service/app/routes.py @@ -1,3 +1,75 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.database import get_db +from app import service, schemas + +router = APIRouter( + prefix="/v1/games", + tags=["games"] +) + +@router.post( + "/", + response_model=schemas.GameOut, + status_code=201 +) +def create_game( + data: schemas.GameCreate, + db: Session = Depends(get_db) +): + return service.add_game(db, data) + +@router.get( + "/", + response_model=schemas.GameList +) +def list_games( + limit: int = 20, + offset: int = 0, + db: Session = Depends(get_db) +): + return service.fetch_all_games( + db, + limit=limit, + offset=offset, + ) + +# IMPORTANT: +# search route must come BEFORE /{game_id} +@router.get( + "/search", + response_model=schemas.GameList +) +def search_games( + q: str, + limit: int = 20, + offset: int = 0, + db: Session = Depends(get_db) +): + return service.find_games( + db, + q=q, + limit=limit, + offset=offset, + ) + +@router.get( + "/{game_id}", + response_model=schemas.GameOut +) +def get_game( + game_id: str, + db: Session = Depends(get_db) +): + try: + return service.fetch_game(db, game_id) + + except ValueError as e: + raise HTTPException( + status_code=404, + detail=str(e) + ) # Interface layer — HTTP endpoints. # # Define a router with prefix="/v1/games" and implement these endpoints: diff --git a/services/game-service/app/schemas.py b/services/game-service/app/schemas.py index ca6aca4e..f057187f 100644 --- a/services/game-service/app/schemas.py +++ b/services/game-service/app/schemas.py @@ -1,3 +1,29 @@ +from pydantic import BaseModel +from datetime import datetime + +class GameCreate(BaseModel): + title: str + genre: str + platform: str + release_year: int | None = None + cover_url: str | None = None + +class GameOut(BaseModel): + id: str + title: str + genre: str + platform: str + release_year: int | None + cover_url: str | None + created_at: datetime + + model_config = {"from_attributes": True} + +class GameList(BaseModel): + items: list[GameOut] + total: int + limit: int + offset: int # Application layer — Pydantic DTOs. # # Define the shapes of data coming IN and going OUT of the API. diff --git a/services/game-service/app/service.py b/services/game-service/app/service.py index 08f93578..f4b935b6 100644 --- a/services/game-service/app/service.py +++ b/services/game-service/app/service.py @@ -1,3 +1,69 @@ +from sqlalchemy.orm import Session + +from app import repository +from app.schemas import ( + GameCreate, + GameOut, + GameList, +) + +def add_game(db: Session, data: GameCreate) -> GameOut: + game = repository.create_game(db, data) + return GameOut.model_validate(game) + +def fetch_game(db: Session, game_id: str) -> GameOut: + game = repository.get_game(db, game_id) + + if game is None: + raise ValueError(f"Game {game_id} not found") + + return GameOut.model_validate(game) + +def fetch_all_games( + db: Session, + limit: int = 20, + offset: int = 0 +) -> GameList: + + games, total = repository.list_games( + db, + limit=limit, + offset=offset, + ) + + return GameList( + items=[ + GameOut.model_validate(game) + for game in games + ], + total=total, + limit=limit, + offset=offset, + ) + +def find_games( + db: Session, + q: str, + limit: int = 20, + offset: int = 0 +) -> GameList: + + games, total = repository.search_games( + db, + q=q, + limit=limit, + offset=offset, + ) + + return GameList( + items=[ + GameOut.model_validate(game) + for game in games + ], + total=total, + limit=limit, + offset=offset, + ) # Application layer — business logic. # # Calls repository functions and returns Pydantic schemas (not raw ORM objects). diff --git a/services/game-service/create_db.py b/services/game-service/create_db.py new file mode 100644 index 00000000..b25d0b15 --- /dev/null +++ b/services/game-service/create_db.py @@ -0,0 +1,6 @@ +from app.database import engine +from app.models import Base + +Base.metadata.create_all(bind=engine) + +print("Database tables created") \ No newline at end of file diff --git a/services/game-service/games.db b/services/game-service/games.db new file mode 100644 index 00000000..53bd5f29 Binary files /dev/null and b/services/game-service/games.db differ diff --git a/services/game-service/requirements.txt b/services/game-service/requirements.txt index 765660e4..39ab4efc 100644 --- a/services/game-service/requirements.txt +++ b/services/game-service/requirements.txt @@ -3,6 +3,10 @@ uvicorn[standard] sqlalchemy alembic pydantic +python-dotenv +aiosqlite +pytest +httpx pydantic-settings python-dotenv aiosqlite diff --git a/services/game-service/tests/test_games.py b/services/game-service/tests/test_games.py index b2533ab1..9b4047d0 100644 --- a/services/game-service/tests/test_games.py +++ b/services/game-service/tests/test_games.py @@ -1,3 +1,14 @@ +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + +def test_list_games(): + response = client.get("/v1/games") + + assert response.status_code == 200 + assert "items" in response.json() # Module 2 exercise — write your pytest tests here. # # Use FastAPI's TestClient to test your endpoints without a running server. diff --git a/services/user-service/requirements.txt b/services/user-service/requirements.txt index f43ebdcd..43fb5f81 100644 --- a/services/user-service/requirements.txt +++ b/services/user-service/requirements.txt @@ -5,4 +5,8 @@ alembic pydantic python-dotenv aiosqlite +python-jose +passlib[bcrypt] +pytest +httpx asyncpg