From dc8c442fee03df473a31062fcf3a6f99644d316e Mon Sep 17 00:00:00 2001 From: Sunnyvie-ctrl Date: Mon, 18 May 2026 17:16:47 +0200 Subject: [PATCH] finished module 2 --- modules/module-02/REFLECTION.md | 17 ++++- modules/module-02/exercise.md | 2 +- services/game-service/.env.example | 1 + services/game-service/app/__init__.py | 0 services/game-service/app/database.py | 26 ++++++++ services/game-service/app/main.py | 7 +++ services/game-service/app/models.py | 25 ++++++++ services/game-service/app/repository.py | 60 ++++++++++++++++++ services/game-service/app/routes.py | 72 ++++++++++++++++++++++ services/game-service/app/schemas.py | 26 ++++++++ services/game-service/app/service.py | 66 ++++++++++++++++++++ services/game-service/create_db.py | 6 ++ services/game-service/games.db | Bin 0 -> 12288 bytes services/game-service/requirements.txt | 9 +++ services/game-service/tests/test_games.py | 11 ++++ services/user-service/requirements.txt | 11 ++++ 16 files changed, 336 insertions(+), 3 deletions(-) create mode 100644 services/game-service/.env.example create mode 100644 services/game-service/app/__init__.py create mode 100644 services/game-service/app/database.py create mode 100644 services/game-service/app/main.py create mode 100644 services/game-service/app/models.py create mode 100644 services/game-service/app/repository.py create mode 100644 services/game-service/app/routes.py create mode 100644 services/game-service/app/schemas.py create mode 100644 services/game-service/app/service.py create mode 100644 services/game-service/create_db.py create mode 100644 services/game-service/games.db create mode 100644 services/game-service/requirements.txt create mode 100644 services/game-service/tests/test_games.py create mode 100644 services/user-service/requirements.txt 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/.env.example b/services/game-service/.env.example new file mode 100644 index 00000000..ae8a3a01 --- /dev/null +++ b/services/game-service/.env.example @@ -0,0 +1 @@ +DATABASE_URL=sqlite:///./games.db \ No newline at end of file diff --git a/services/game-service/app/__init__.py b/services/game-service/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/game-service/app/database.py b/services/game-service/app/database.py new file mode 100644 index 00000000..fdb2e37d --- /dev/null +++ b/services/game-service/app/database.py @@ -0,0 +1,26 @@ +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() \ No newline at end of file diff --git a/services/game-service/app/main.py b/services/game-service/app/main.py new file mode 100644 index 00000000..968befbc --- /dev/null +++ b/services/game-service/app/main.py @@ -0,0 +1,7 @@ +from fastapi import FastAPI + +from app.routes import router + +app = FastAPI(title="game-service") + +app.include_router(router) \ No newline at end of file diff --git a/services/game-service/app/models.py b/services/game-service/app/models.py new file mode 100644 index 00000000..47c94401 --- /dev/null +++ b/services/game-service/app/models.py @@ -0,0 +1,25 @@ +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) + ) \ No newline at end of file diff --git a/services/game-service/app/repository.py b/services/game-service/app/repository.py new file mode 100644 index 00000000..49cab889 --- /dev/null +++ b/services/game-service/app/repository.py @@ -0,0 +1,60 @@ +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 \ No newline at end of file diff --git a/services/game-service/app/routes.py b/services/game-service/app/routes.py new file mode 100644 index 00000000..f7a1b819 --- /dev/null +++ b/services/game-service/app/routes.py @@ -0,0 +1,72 @@ +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) + ) \ No newline at end of file diff --git a/services/game-service/app/schemas.py b/services/game-service/app/schemas.py new file mode 100644 index 00000000..6a0fc324 --- /dev/null +++ b/services/game-service/app/schemas.py @@ -0,0 +1,26 @@ +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 \ No newline at end of file diff --git a/services/game-service/app/service.py b/services/game-service/app/service.py new file mode 100644 index 00000000..c857ec87 --- /dev/null +++ b/services/game-service/app/service.py @@ -0,0 +1,66 @@ +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, + ) \ No newline at end of file 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 0000000000000000000000000000000000000000..53bd5f29ee602d62b4590881b58265ce79d1bb9c GIT binary patch literal 12288 zcmeI#&r8EF6bJBRin_vn9E4uqaRU)VJPOtsbl6p`RoJOS8%MF*G|NPf;?4ifzhVD| zN1H%zgMr8K4J6^c@JRFN>D}Ihxl!~q)njSskhK}-?1YFh#>=@;&T80rvkJ=H#;XQr z``^2b)-S6y7p%Ex{j4g$IRqd80SG_<0uX=z1Rwwb2teQ;0-w)(+xLC`b!6m2p+<77 zW^SqVVi6>QlHfEHUl=@LEO6tVv4R3itfU2kG#6cO`(>ajMPLgKY1ynv2FFz zGOY?FXDa7Je(*w8JzJAAgHtf>23kC!r009U<00Izz t00bZa0SG_<0_!T^*@vP3U)SG@cR>IG5P$##AOHafKmY;|fB*!Xz#rKHTzCKg literal 0 HcmV?d00001 diff --git a/services/game-service/requirements.txt b/services/game-service/requirements.txt new file mode 100644 index 00000000..56895233 --- /dev/null +++ b/services/game-service/requirements.txt @@ -0,0 +1,9 @@ +fastapi +uvicorn[standard] +sqlalchemy +alembic +pydantic +python-dotenv +aiosqlite +pytest +httpx \ No newline at end of file diff --git a/services/game-service/tests/test_games.py b/services/game-service/tests/test_games.py new file mode 100644 index 00000000..4ffb5c8e --- /dev/null +++ b/services/game-service/tests/test_games.py @@ -0,0 +1,11 @@ +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() \ No newline at end of file diff --git a/services/user-service/requirements.txt b/services/user-service/requirements.txt new file mode 100644 index 00000000..32c1c96d --- /dev/null +++ b/services/user-service/requirements.txt @@ -0,0 +1,11 @@ +fastapi +uvicorn[standard] +sqlalchemy +alembic +pydantic +python-dotenv +aiosqlite +python-jose +passlib[bcrypt] +pytest +httpx \ No newline at end of file