From ac29433db42336addcb446c31f70884f81aae692 Mon Sep 17 00:00:00 2001 From: adithyareddym105-gif Date: Wed, 20 May 2026 09:14:48 +0200 Subject: [PATCH 1/5] Complete module 1 --- modules/module-01/REFLECTION.md | 6 ++-- modules/module-01/exercise.md | 60 +++++++++++++++++++++++++++++---- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/modules/module-01/REFLECTION.md b/modules/module-01/REFLECTION.md index ea9859e7..31ac9f8f 100644 --- a/modules/module-01/REFLECTION.md +++ b/modules/module-01/REFLECTION.md @@ -24,7 +24,7 @@ Think about it from three angles: the developer who has to change code, the team > _Your answer:_ ---- +---Splitting the monolith into separate services makes the system easier to manage and maintain. For developers, changing one service becomes safer because it does not risk breaking unrelated parts of the application. For deployment teams, each service can be updated independently without redeploying the entire system. For users, failures are more isolated, meaning one broken service does not necessarily take down the whole platform ## 2. Your choice @@ -36,7 +36,7 @@ What would break, slow down, or become harder to manage if you merged those two > _Your answer:_ ---- +---We decided to separate the activity-service from the logging-service. The activity-service is responsible for gameplay actions, while the logging-service only stores logs and audit records. If both services were merged together, gameplay performance could be affected whenever logging becomes slow or unavailable. Keeping them separate also makes the system easier to scale and maintain because logging can grow independently from gameplay activity. ## 3. The tradeoff @@ -48,6 +48,6 @@ No need to solve it: just name it honestly. This is exactly the tension the rest > _Your answer:_ ---- +---Communication between components was simpler in the monolith because everything was inside the same application and database. In a distributed system, services must communicate over the network using REST APIs or async events, which adds complexity, latency, and more points of failure. _Keep this file. You will refer back to it during the oral presentation._ diff --git a/modules/module-01/exercise.md b/modules/module-01/exercise.md index d5bf307c..c3d466b8 100644 --- a/modules/module-01/exercise.md +++ b/modules/module-01/exercise.md @@ -25,12 +25,14 @@ Read these two documents before doing anything else: A bounded context is a part of the system that has a clear responsibility and owns its data exclusively. No other service should reach into its database. For each bounded context you identify, fill in the table: +| Bounded Context | Responsibilities | Owned Entities | Team | +| --------------- | -------------------------------------------------------- | -------------------------- | -------------- | +| Identity | Manages who users are, handles registration and profiles | User, Session | Platform | +| Game Library | Manages the game catalog and game details | Game, Genre | Content | +| Activity | Tracks player activity and gameplay events | Activity, MatchHistory | Analytics | +| Notification | Sends emails, alerts, and real-time notifications | Notification, QueueMessage | Communication | +| Logging | Stores system logs and audit events | LogEntry, AuditRecord | Infrastructure | -| Bounded Context | Responsibilities | Owned Entities | Team | -| --------------- | -------------------------------------------------------- | -------------- | ----------- | -| Identity | Manages who users are, handles registration and profiles | User, Session | Platform | -| Game Library | _(fill in)_ | _(fill in)_ | _(fill in)_ | -| _(add more)_ | | | | There is no single correct answer: what matters is that you can justify each row. @@ -56,7 +58,21 @@ Payload: { activity_id, user_id, action, game_id, timestamp } Focus on the flows that feel non-obvious. You do not need to document every possible pair. ---- +---Service Contracts +gateway → identity-service +Trigger: user logs in +Protocol: REST +Payload: { email, password } + +activity-service → logging-service +Trigger: gameplay activity recorded +Protocol: RabbitMQ event (async) +Payload: { activity_id, user_id, action, timestamp } + +game-library-service → notification-service +Trigger: new game added +Protocol: RabbitMQ event (async) +Payload: { game_id, title, release_date } ## Task 3 — Draw the service map _(~20 min)_ @@ -70,13 +86,45 @@ Draw the full GameHub service map: This can be a sketch on paper, a whiteboard photo, or ASCII art committed to your branch. --- + +-------------+ + | Gateway | + +-------------+ + / | \ + / | \ + v v v + + +-------------+ +-------------+ + | Identity | | Game Library| + | Service | | Service | + +-------------+ +-------------+ + | | + | | + v v + + +-------------+ - - - - - - - - - + | Activity | - - > Notification | + | Service | (RabbitMQ) | + +-------------+ - - - - - - - - - + + | + | + v + + +-------------+ + | Logging | + | Service | + +-------------+ ## Discussion _(~15 min)_ Three questions to discuss as a team before you leave: 1. Why does `notification-service` use Node.js instead of Python like the rest? What does that tell you about microservices and technology choices? +--Node.js is well suited for real-time communication and event-driven systems. +This shows that microservices can use different technologies depending on the needs of each service. + 2. What is the risk of `activity-service` calling `logging-service` synchronously — why might you prefer an async event instead? + 3. Why does `logging-service` need a GDPR consent check before recording any activity? You do not need to write these answers down — they are warm-up for your REFLECTION.md. From 8c957aff9b3c6e27f77f3e73d3d9468c42309c78 Mon Sep 17 00:00:00 2001 From: adithyareddym105-gif Date: Wed, 20 May 2026 10:43:04 +0200 Subject: [PATCH 2/5] Complete module 2 --- services/game-service/README.md | 167 ++++++++++++---------- services/game-service/app/database.py | 34 ++++- services/game-service/app/main.py | 17 ++- services/game-service/app/models.py | 35 +++-- services/game-service/app/repository.py | 44 ++++-- services/game-service/app/routes.py | 59 ++++++-- services/game-service/app/schemas.py | 34 ++++- services/game-service/app/service.py | 44 ++++-- services/game-service/games.db | Bin 0 -> 12288 bytes services/game-service/tests/test_games.py | 2 + services/game-service/tests/test_users.py | 17 +++ 11 files changed, 321 insertions(+), 132 deletions(-) create mode 100644 services/game-service/games.db create mode 100644 services/game-service/tests/test_users.py diff --git a/services/game-service/README.md b/services/game-service/README.md index da9ab229..dea960b8 100644 --- a/services/game-service/README.md +++ b/services/game-service/README.md @@ -1,26 +1,26 @@ -# game-service — your implementation +# user-service — reference implementation -This is the service you build in Module 2. Use `services/user-service/README.md` as your working reference — the structure is identical, only the entity changes. +Read this before building `game-service`. Every file below is annotated to explain its role. The structure you see here is the one you must replicate. --- ## Folder structure ``` -game-service/ +user-service/ ├── app/ │ ├── __init__.py # empty, makes app a package │ ├── main.py # FastAPI app init, mounts the router │ ├── database.py # engine + session factory -│ ├── models.py # SQLAlchemy ORM model (Game) -│ ├── schemas.py # Pydantic DTOs (GameCreate, GameOut) +│ ├── models.py # SQLAlchemy ORM model (User) +│ ├── schemas.py # Pydantic DTOs (UserCreate, UserOut) │ ├── repository.py # raw DB queries — no business logic here │ ├── service.py # business logic — calls repository │ └── routes.py # FastAPI router + endpoint handlers ├── alembic/ │ └── versions/ # auto-generated migration files go here ├── tests/ -│ └── test_games.py +│ └── test_users.py ├── alembic.ini ├── requirements.txt └── .env.example @@ -32,23 +32,22 @@ game-service/ ### `app/models.py` — ORM model -Defines the `games` table. This is the only file that knows about columns. +Defines the `users` table. This is the only file that knows about columns. ```python -from sqlalchemy import Column, String, Integer, DateTime +from sqlalchemy import Column, String, Boolean, DateTime from datetime import datetime, timezone import uuid from app.database import Base -class Game(Base): - __tablename__ = "games" +class User(Base): + __tablename__ = "users" 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) + username = Column(String, unique=True, nullable=False) + email = Column(String, unique=True, nullable=False) + hashed_password = Column(String, nullable=False) + is_active = Column(Boolean, default=True) created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) ``` @@ -56,33 +55,29 @@ class Game(Base): ### `app/schemas.py` — Pydantic DTOs -`GameCreate` = what comes **in**. `GameOut` = what goes **out**. Keep them separate. +Two schemas: one for input, one for output. Keep them separate — the response shape is not always the same as the request shape. ```python 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 UserCreate(BaseModel): + username: str + email: str + password: str # plain-text on the way in — hash it in the service layer -class GameOut(BaseModel): +class UserOut(BaseModel): id: str - title: str - genre: str - platform: str - release_year: int | None - cover_url: str | None + username: str + email: str + is_active: bool created_at: datetime model_config = {"from_attributes": True} -class GameList(BaseModel): +class UserList(BaseModel): """Paginated envelope — all list endpoints return this shape.""" - items: list[GameOut] + items: list[UserOut] total: int limit: int offset: int @@ -99,7 +94,7 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, DeclarativeBase import os -DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./games.db") +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./users.db") engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) @@ -123,21 +118,27 @@ Functions here take a `Session` and return ORM objects. No HTTP, no business rul ```python from sqlalchemy.orm import Session -from app.models import Game -from app.schemas import GameCreate - -def create_game(db: Session, data: GameCreate) -> Game: - ... - -def get_game(db: Session, game_id: str) -> Game | None: - ... - -def list_games(db: Session, limit: int = 20, offset: int = 0) -> tuple[list[Game], int]: - ... - -def search_games(db: Session, q: str, limit: int = 20, offset: int = 0) -> tuple[list[Game], int]: - # hint: use .filter(Game.title.ilike(f"%{q}%")) - ... +from app.models import User +from app.schemas import UserCreate + +def create_user(db: Session, data: UserCreate, hashed_password: str) -> User: + user = User( + username=data.username, + email=data.email, + hashed_password=hashed_password, + ) + db.add(user) + db.commit() + db.refresh(user) + return user + +def get_user(db: Session, user_id: str) -> User | None: + return db.query(User).filter(User.id == user_id).first() + +def list_users(db: Session, limit: int = 20, offset: int = 0) -> tuple[list[User], int]: + total = db.query(User).count() + users = db.query(User).offset(offset).limit(limit).all() + return users, total ``` --- @@ -149,53 +150,63 @@ Calls the repository and returns Pydantic schemas (not raw ORM objects) to the r ```python from sqlalchemy.orm import Session from app import repository -from app.schemas import GameCreate, GameOut, GameList - -def add_game(db: Session, data: GameCreate) -> GameOut: - ... - -def fetch_game(db: Session, game_id: str) -> GameOut: - # raise ValueError if not found — routes.py turns it into a 404 - ... - -def fetch_all_games(db: Session, limit: int = 20, offset: int = 0) -> GameList: - ... - -def find_games(db: Session, q: str, limit: int = 20, offset: int = 0) -> GameList: - ... +from app.schemas import UserCreate, UserOut, UserList + +def _hash_password(plain: str) -> str: + # for now a placeholder — swap for passlib in Module 6 + return plain + "_hashed" + +def add_user(db: Session, data: UserCreate) -> UserOut: + hashed = _hash_password(data.password) + user = repository.create_user(db, data, hashed) + return UserOut.model_validate(user) + +def fetch_user(db: Session, user_id: str) -> UserOut: + user = repository.get_user(db, user_id) + if user is None: + raise ValueError(f"User {user_id} not found") + return UserOut.model_validate(user) + +def fetch_all_users(db: Session, limit: int = 20, offset: int = 0) -> UserList: + users, total = repository.list_users(db, limit=limit, offset=offset) + return UserList( + items=[UserOut.model_validate(u) for u in users], + total=total, + limit=limit, + offset=offset, + ) ``` +The `ValueError` raised here gets caught in `routes.py` and turned into an HTTP 404. + --- ### `app/routes.py` — HTTP layer One function per endpoint. Routes only call service functions — never the repository directly. -> **Order matters**: `/search` must be declared **before** `/{game_id}`, otherwise FastAPI will try to match the word "search" as an integer ID and return a 422. - ```python 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)): - ... +router = APIRouter(prefix="/v1/users", tags=["users"]) -@router.get("/", response_model=schemas.GameList) -def list_games(limit: int = 20, offset: int = 0, db: Session = Depends(get_db)): - ... +@router.post("/", response_model=schemas.UserOut, status_code=201) +def create_user(data: schemas.UserCreate, db: Session = Depends(get_db)): + return service.add_user(db, data) -@router.get("/search", response_model=schemas.GameList) -def search_games(q: str, limit: int = 20, offset: int = 0, db: Session = Depends(get_db)): - ... +@router.get("/", response_model=schemas.UserList) +def list_users(limit: int = 20, offset: int = 0, db: Session = Depends(get_db)): + return service.fetch_all_users(db, limit=limit, offset=offset) -@router.get("/{game_id}", response_model=schemas.GameOut) -def get_game(game_id: str, db: Session = Depends(get_db)): - ... +@router.get("/{user_id}", response_model=schemas.UserOut) +def get_user(user_id: str, db: Session = Depends(get_db)): + try: + return service.fetch_user(db, user_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) ``` --- @@ -206,7 +217,7 @@ def get_game(game_id: str, db: Session = Depends(get_db)): from fastapi import FastAPI from app.routes import router -app = FastAPI(title="game-service") +app = FastAPI(title="user-service") app.include_router(router) ``` @@ -229,7 +240,7 @@ aiosqlite ### `.env.example` ``` -DATABASE_URL=sqlite:///./games.db +DATABASE_URL=sqlite:///./users.db ``` Copy to `.env` before running. Never commit `.env`. diff --git a/services/game-service/app/database.py b/services/game-service/app/database.py index 56dfd44d..176598c8 100644 --- a/services/game-service/app/database.py +++ b/services/game-service/app/database.py @@ -1,10 +1,30 @@ # Infrastructure layer — database connection. # -# Replicate the same structure as user-service/app/database.py. -# The only difference: the default DATABASE_URL points to games.db. +# This file is responsible for: +# - Creating the SQLAlchemy engine from DATABASE_URL (read from .env) +# - Defining the declarative Base that all ORM models inherit from +# - Providing get_db(), a FastAPI dependency that opens a session per request +# and closes it when the request is done (using yield) # -# This file should provide: -# - engine — SQLAlchemy engine built from DATABASE_URL -# - SessionLocal — session factory bound to the engine -# - Base — DeclarativeBase that all ORM models inherit from -# - get_db() — FastAPI dependency: yields a session, closes it after the request +# Nothing in this file knows about Users, Games, or any business concept. +# It is pure infrastructure. +# +# See the README for the full implementation. +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +DATABASE_URL = "sqlite:///./games.db" + +engine = create_engine( + DATABASE_URL, + connect_args={"check_same_thread": False} +) + +SessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=engine +) + +Base = declarative_base() \ No newline at end of file diff --git a/services/game-service/app/main.py b/services/game-service/app/main.py index e629900e..06226a5e 100644 --- a/services/game-service/app/main.py +++ b/services/game-service/app/main.py @@ -1,9 +1,20 @@ # Entry point — FastAPI application. # -# Create the FastAPI app instance and register the router from app.routes. +# This file creates the FastAPI app instance and registers the router. # Keep it minimal: no business logic, no endpoints defined here. # # To run the service locally: -# uvicorn app.main:app --reload --port 8002 +# uvicorn app.main:app --reload --port 8001 # -# Then open: http://localhost:8002/docs +# Then open: http://localhost:8001/docs +# +# See the README for the full implementation. +from fastapi import FastAPI +from app.routes import router +from app.database import Base, engine + +Base.metadata.create_all(bind=engine) + +app = FastAPI() + +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 index 52eb2756..8baf0df1 100644 --- a/services/game-service/app/models.py +++ b/services/game-service/app/models.py @@ -1,14 +1,25 @@ # Infrastructure layer — ORM model. # -# Define the `games` table. This is the only file that knows about columns. -# -# The Game model should have these columns: -# - id String, primary key, UUID generated by default -# - title String, not nullable -# - genre String, not nullable -# - platform String, not nullable -# - release_year Integer, nullable -# - cover_url String, nullable -# - created_at DateTime, defaults to now (UTC) -# -# Import Base from app.database — do not redefine it here. +# This is the only file that defines the shape of the `users` table. +# It maps Python attributes to database columns using SQLAlchemy. +# +# This file should: +# - Import Base from app.database +# - Define a User class with columns: id, username, email, +# hashed_password, is_active, created_at +# +# Rule: no business logic here. This file only describes data structure. +# +# See the README for the full implementation. +from sqlalchemy import Column, Integer, String +from app.database import Base + + +class Game(Base): + __tablename__ = "games" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String, nullable=False) + genre = Column(String, nullable=False) + platform = Column(String, nullable=False) + cover_url = Column(String, nullable=False) \ No newline at end of file diff --git a/services/game-service/app/repository.py b/services/game-service/app/repository.py index 8120c970..9a313a14 100644 --- a/services/game-service/app/repository.py +++ b/services/game-service/app/repository.py @@ -1,10 +1,38 @@ # Infrastructure layer — raw database queries. # -# Implement these four functions. Each takes `db: Session` as its first argument. -# No business logic here — only ORM queries. -# -# - create_game(db, data) -> Game -# - get_game(db, game_id) -> Game | None -# - list_games(db, limit, offset) -> tuple[list[Game], int] -# - search_games(db, q, limit, offset) -> tuple[list[Game], int] -# Hint: filter by title using .ilike(f"%{q}%") for case-insensitive search +# Functions here take a SQLAlchemy Session and return ORM objects. +# This is the only layer allowed to write SQL / ORM queries. +# +# Rules: +# - No HTTP knowledge here (no Request, no HTTPException) +# - No business rules here (no password hashing, no validation logic) +# - Every function receives `db: Session` as its first argument +# +# This file should implement: +# - create_user(db, data, hashed_password) -> User +# - get_user(db, user_id) -> User | None +# - list_users(db, limit, offset) -> tuple[list[User], int] +# +# See the README for the full implementation. +from sqlalchemy.orm import Session +from app.models import Game + + +def create_game(db: Session, game_data): + game = Game(**game_data.dict()) + db.add(game) + db.commit() + db.refresh(game) + return game + + +def get_games(db: Session): + return db.query(Game).all() + + +def get_game_by_id(db: Session, game_id: int): + return db.query(Game).filter(Game.id == game_id).first() + + +def search_games(db: Session, q: str): + return db.query(Game).filter(Game.title.ilike(f"%{q}%")).all() \ No newline at end of file diff --git a/services/game-service/app/routes.py b/services/game-service/app/routes.py index db173fd2..41d66d81 100644 --- a/services/game-service/app/routes.py +++ b/services/game-service/app/routes.py @@ -1,11 +1,52 @@ # Interface layer — HTTP endpoints. # -# Define a router with prefix="/v1/games" and implement these endpoints: -# - POST /v1/games/ -> create a game (201) -# - GET /v1/games/ -> list games (limit/offset pagination) -# - GET /v1/games/search -> search games by title (?q=...) -# - GET /v1/games/{game_id} -> get one game by ID (404 if not found) -# -# IMPORTANT: declare /search BEFORE /{game_id} in your router. -# If /{game_id} comes first, FastAPI will try to match "search" as an ID -# and return a 422 Unprocessable Entity error. +# This file defines the FastAPI router and maps HTTP verbs + paths to +# service function calls. It is the only layer that knows about HTTP. +# +# Rules: +# - Never call repository functions directly — always go through service +# - Catch ValueError from the service layer and raise HTTPException instead +# - Use Depends(get_db) to inject the database session +# +# This file should expose: +# - POST /v1/users/ -> create a user +# - GET /v1/users/ -> list users (with limit/offset pagination) +# - GET /v1/users/{user_id} -> get one user by ID (404 if not found) +# +# See the README for the full implementation. +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.database import SessionLocal +from app.schemas import GameCreate, GameResponse +from app import service + +router = APIRouter(prefix="/v1/games", tags=["games"]) + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +@router.post("", response_model=GameResponse) +def create_game(game: GameCreate, db: Session = Depends(get_db)): + return service.create_game(db, game) + + +@router.get("", response_model=list[GameResponse]) +def get_games(db: Session = Depends(get_db)): + return service.get_games(db) + + +@router.get("/{game_id}", response_model=GameResponse) +def get_game(game_id: int, db: Session = Depends(get_db)): + return service.get_game_by_id(db, game_id) + + +@router.get("/search/", response_model=list[GameResponse]) +def search_games(q: str, db: Session = Depends(get_db)): + return service.search_games(db, q) \ No newline at end of file diff --git a/services/game-service/app/schemas.py b/services/game-service/app/schemas.py index ca6aca4e..ad4dfddd 100644 --- a/services/game-service/app/schemas.py +++ b/services/game-service/app/schemas.py @@ -1,10 +1,30 @@ -# Application layer — Pydantic DTOs. +# Application layer — Pydantic DTOs (Data Transfer Objects). # -# Define the shapes of data coming IN and going OUT of the API. +# This file defines the shapes of data coming IN and going OUT of the API. +# It is separate from models.py on purpose: the API shape is not always +# the same as the database shape (e.g. password comes in, never goes out). # # This file should define: -# - GameCreate — fields accepted when creating a game -# (title, genre, platform required; release_year and cover_url optional) -# - GameOut — fields returned to the caller (includes id and created_at) -# add model_config = {"from_attributes": True} -# - GameList — paginated envelope: { items, total, limit, offset } +# - UserCreate — fields accepted when creating a user (includes plain password) +# - UserOut — fields returned to the caller (no password, ever) +# - UserList — paginated envelope: { items, total, limit, offset } +# +# Use model_config = {"from_attributes": True} on UserOut so Pydantic can +# read directly from SQLAlchemy ORM objects. +# +# See the README for the full implementation. +from pydantic import BaseModel + + +class GameCreate(BaseModel): + title: str + genre: str + platform: str + cover_url: str + + +class GameResponse(GameCreate): + id: int + + class Config: + from_attributes = True \ No newline at end of file diff --git a/services/game-service/app/service.py b/services/game-service/app/service.py index 78c4451a..a1b21397 100644 --- a/services/game-service/app/service.py +++ b/services/game-service/app/service.py @@ -1,10 +1,38 @@ # Application layer — business logic. # -# Calls repository functions and returns Pydantic schemas (not raw ORM objects). -# Raises ValueError when a game is not found — routes.py turns it into a 404. -# -# Implement these four functions: -# - add_game(db, data) -> GameOut -# - fetch_game(db, game_id) -> GameOut (raises ValueError if not found) -# - fetch_all_games(db, limit, offset) -> GameList -# - find_games(db, q, limit, offset) -> GameList (delegates to search_games in repository) +# This is where decisions live: hashing passwords, raising errors when a user +# is not found, converting ORM objects into Pydantic schemas before returning. +# +# Rules: +# - Only calls repository functions — never queries the DB directly +# - Returns Pydantic schemas (UserOut, UserList), not raw ORM objects +# - Raises ValueError for business errors (routes.py turns them into HTTP errors) +# +# This file should implement: +# - add_user(db, data) -> UserOut +# - fetch_user(db, user_id) -> UserOut (raises ValueError if not found) +# - fetch_all_users(db, limit, offset) -> UserList +# +# Note: _hash_password is a placeholder for now — it will be replaced +# with passlib in Module 6. +# +# See the README for the full implementation. +from sqlalchemy.orm import Session +from app import repository +from app.schemas import GameCreate + + +def create_game(db: Session, game: GameCreate): + return repository.create_game(db, game) + + +def get_games(db: Session): + return repository.get_games(db) + + +def get_game_by_id(db: Session, game_id: int): + return repository.get_game_by_id(db, game_id) + + +def search_games(db: Session, q: str): + return repository.search_games(db, q) \ 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..f4618bf695e26becfe80b708b968c5add9d46b44 GIT binary patch literal 12288 zcmeI#ze~eF6bJCTL@Ehr6@&~99=KSLc5qQA(;BpxrZtUXr;;{jJ;^W9Q?lyl|K)$= z=wKRJs8ClC`93c19lU#i`)t?0wuR*EKFK0qvLmuZC?#i%5kl1YSClf8BCpN&vqG2l zKW$Y~f7{t8y2|DUsU8;z0Rad=00Izz00bZa0SG_<0ucBmfu>S1_6<4^almtt4<~-a zr$Z5xRc*PJ=~=ApoLhHnsmrfM%6IvmAyW8E4i1|44 z?`7YQgHe+AS`SjD)6+(S=YEuiygp8%MzlCpe@rKOaW7i^B*j8N00Izz00bZa0SG_< R0uX=z1R(Ga1yohjz5qbsZdCvP literal 0 HcmV?d00001 diff --git a/services/game-service/tests/test_games.py b/services/game-service/tests/test_games.py index b2533ab1..d1c5cf1c 100644 --- a/services/game-service/tests/test_games.py +++ b/services/game-service/tests/test_games.py @@ -14,3 +14,5 @@ # # Run tests with: # pytest tests/ +def test_placeholder(): + assert True \ No newline at end of file diff --git a/services/game-service/tests/test_users.py b/services/game-service/tests/test_users.py new file mode 100644 index 00000000..d46d19f7 --- /dev/null +++ b/services/game-service/tests/test_users.py @@ -0,0 +1,17 @@ +# Module 2 exercise — write your pytest tests here. +# +# Use FastAPI's TestClient to send HTTP requests to your app without +# running a real server. The client is synchronous and works out of the box. +# +# Suggested test cases to get started: +# - POST /v1/users/ with valid data returns 201 and a UserOut body +# - POST /v1/users/ with a duplicate username returns 4xx +# - GET /v1/users/{id} with a valid ID returns 200 and the correct user +# - GET /v1/users/{id} with an unknown ID returns 404 +# - GET /v1/users/ returns a UserList with the correct total +# +# Tip: use an in-memory SQLite database in your test fixture so tests +# never touch the real users.db file. +# +# Run tests with: +# pytest tests/ From 038c28fdca9c16940c63d037e41220927857d40a Mon Sep 17 00:00:00 2001 From: adithyareddym105-gif Date: Tue, 26 May 2026 16:29:07 +0200 Subject: [PATCH 3/5] module-03: reflection answers --- modules/module-03/REFLECTION.md | 37 +++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 modules/module-03/REFLECTION.md diff --git a/modules/module-03/REFLECTION.md b/modules/module-03/REFLECTION.md new file mode 100644 index 00000000..6aae2b92 --- /dev/null +++ b/modules/module-03/REFLECTION.md @@ -0,0 +1,37 @@ +# Module 3 — Reflection +**Team name**: microservicesWithPython +**Branch**: `module-03/microservicesWithPython` +**Submitted**: before Module 4 lesson + +--- + +## 1. The Gateway +Without a gateway, what would a frontend app need to hardcode? +What happens when a service moves to a different port or machine? + +> *Your answer:* +> Without a gateway, the frontend would need to hardcode the address and port of every service — user-service on 8001, game-service on 8002, and so on. If any service moves to a different port or machine, every frontend client breaks and needs to be updated. The gateway acts as a single stable entry point: services can move or be replaced behind it without the frontend ever knowing. It also means we only expose one public address instead of several. + +--- + +## 2. Two calls, two behaviors +User validation blocks the activity from being saved. +Game enrichment is optional — the activity saves regardless. + +What is the consequence for the data if you skip user validation? +What is the consequence if you block on a missing game? + +> *Your answer:* +> If we skip user validation, we end up saving activities that reference user IDs that don't exist — the database fills with orphaned records that can never be meaningfully displayed or queried. That is a data integrity problem that is hard to clean up later. On the other hand, if we block on a missing game, we reject a valid user action simply because game-service happens to be down or slow. The activity itself is real and should be recorded. Game data is enrichment — nice to have in the response, but not a reason to lose the event entirely. The two risks are different: one is about corrupting data, the other is about unnecessary availability loss. + +--- + +## 3. Chain latency +If three services each take 1 second, how long does the user wait? +What if one of them goes down entirely? + +> *Your answer:* +> If three services are called sequentially and each takes 1 second, the user waits at least 3 seconds — latency adds up in a chain. If one service goes down entirely and there is no timeout or fallback, the request hangs until it times out, which could mean the user waits indefinitely or gets a hard error. This is why the game-service call is designed to fail gracefully: it has a bounded retry window and defaults to null instead of blocking forever. For the user validation call, a 503 is returned quickly rather than hanging, so the system stays predictable even under partial failure. + +--- +*Keep this file. You will refer back to it during the oral presentation.* \ No newline at end of file From 264ad2c1cd9c2ecc41bbfa553b744237731fb07b Mon Sep 17 00:00:00 2001 From: adithyareddym105-gif Date: Tue, 26 May 2026 17:13:11 +0200 Subject: [PATCH 4/5] module-03: implement gateway proxy and user-service --- services/user-service/app/database.py | 19 +++++++++++++++++ services/user-service/app/main.py | 12 +++++++++++ services/user-service/app/models.py | 13 ++++++++++++ services/user-service/app/repository.py | 17 ++++++++++++++++ services/user-service/app/routes.py | 25 +++++++++++++++++++++++ services/user-service/app/schemas.py | 22 ++++++++++++++++++++ services/user-service/app/service.py | 26 ++++++++++++++++++++++++ services/user-service/seed.py | 23 +++++++++++++++++++++ services/user-service/users.db | Bin 0 -> 20480 bytes 9 files changed, 157 insertions(+) create mode 100644 services/user-service/seed.py create mode 100644 services/user-service/users.db diff --git a/services/user-service/app/database.py b/services/user-service/app/database.py index a5fe24f9..57db9e4a 100644 --- a/services/user-service/app/database.py +++ b/services/user-service/app/database.py @@ -10,3 +10,22 @@ # It is pure infrastructure. # # See the README for the full implementation. +from sqlalchemy import create_engine +from sqlalchemy.orm import declarative_base, sessionmaker +import os +from dotenv import load_dotenv + +load_dotenv() + +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./users.db") + +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/services/user-service/app/main.py b/services/user-service/app/main.py index a4463d42..5c5bb0ec 100644 --- a/services/user-service/app/main.py +++ b/services/user-service/app/main.py @@ -9,3 +9,15 @@ # Then open: http://localhost:8001/docs # # See the README for the full implementation. +from fastapi import FastAPI +from app.database import Base, engine +from app.routes import router + +Base.metadata.create_all(bind=engine) + +app = FastAPI() +app.include_router(router) + +@app.get("/") +def root(): + return {"message": "User service running"} \ No newline at end of file diff --git a/services/user-service/app/models.py b/services/user-service/app/models.py index 03e8cdba..bcbc4fd0 100644 --- a/services/user-service/app/models.py +++ b/services/user-service/app/models.py @@ -11,3 +11,16 @@ # Rule: no business logic here. This file only describes data structure. # # See the README for the full implementation. +from sqlalchemy import Column, Integer, String, Boolean, DateTime +from sqlalchemy.sql import func +from app.database import Base + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String, unique=True, index=True, nullable=False) + email = Column(String, unique=True, index=True, nullable=False) + hashed_password = Column(String, nullable=False) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) \ No newline at end of file diff --git a/services/user-service/app/repository.py b/services/user-service/app/repository.py index f519c460..2f809e74 100644 --- a/services/user-service/app/repository.py +++ b/services/user-service/app/repository.py @@ -14,3 +14,20 @@ # - list_users(db, limit, offset) -> tuple[list[User], int] # # See the README for the full implementation. +from sqlalchemy.orm import Session +from app.models import User + +def create_user(db: Session, data, hashed_password: str) -> User: + user = User(username=data.username, email=data.email, hashed_password=hashed_password) + db.add(user) + db.commit() + db.refresh(user) + return user + +def get_user(db: Session, user_id: int): + return db.query(User).filter(User.id == user_id).first() + +def list_users(db: Session, limit: int, offset: int): + total = db.query(User).count() + users = db.query(User).offset(offset).limit(limit).all() + return users, total diff --git a/services/user-service/app/routes.py b/services/user-service/app/routes.py index 176c60cd..6c581257 100644 --- a/services/user-service/app/routes.py +++ b/services/user-service/app/routes.py @@ -14,3 +14,28 @@ # - GET /v1/users/{user_id} -> get one user by ID (404 if not found) # # See the README for the full implementation. +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.database import get_db +from app.schemas import UserCreate, UserOut, UserList +from app import service + +router = APIRouter(prefix="/v1/users") + +@router.post("/", response_model=UserOut) +def create_user(data: UserCreate, db: Session = Depends(get_db)): + try: + return service.add_user(db, data) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + +@router.get("/", response_model=UserList) +def list_users(limit: int = 10, offset: int = 0, db: Session = Depends(get_db)): + return service.fetch_all_users(db, limit, offset) + +@router.get("/{user_id}", response_model=UserOut) +def get_user(user_id: int, db: Session = Depends(get_db)): + try: + return service.fetch_user(db, user_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) diff --git a/services/user-service/app/schemas.py b/services/user-service/app/schemas.py index f2627a95..0cc59464 100644 --- a/services/user-service/app/schemas.py +++ b/services/user-service/app/schemas.py @@ -13,3 +13,25 @@ # read directly from SQLAlchemy ORM objects. # # See the README for the full implementation. +from pydantic import BaseModel +from datetime import datetime +from typing import List + +class UserCreate(BaseModel): + username: str + email: str + password: str + +class UserOut(BaseModel): + id: int + username: str + email: str + is_active: bool + created_at: datetime + model_config = {"from_attributes": True} + +class UserList(BaseModel): + items: List[UserOut] + total: int + limit: int + offset: int \ No newline at end of file diff --git a/services/user-service/app/service.py b/services/user-service/app/service.py index 39143a27..5279a9bd 100644 --- a/services/user-service/app/service.py +++ b/services/user-service/app/service.py @@ -17,3 +17,29 @@ # with passlib in Module 6. # # See the README for the full implementation. +from sqlalchemy.orm import Session +from app import repository +from app.schemas import UserOut, UserList, UserCreate + +def _hash_password(password: str) -> str: + return f"hashed_{password}" + +def add_user(db: Session, data: UserCreate) -> UserOut: + hashed = _hash_password(data.password) + user = repository.create_user(db, data, hashed) + return UserOut.model_validate(user) + +def fetch_user(db: Session, user_id: int) -> UserOut: + user = repository.get_user(db, user_id) + if not user: + raise ValueError(f"User {user_id} not found") + return UserOut.model_validate(user) + +def fetch_all_users(db: Session, limit: int = 10, offset: int = 0) -> UserList: + users, total = repository.list_users(db, limit, offset) + return UserList( + items=[UserOut.model_validate(u) for u in users], + total=total, + limit=limit, + offset=offset + ) \ No newline at end of file diff --git a/services/user-service/seed.py b/services/user-service/seed.py new file mode 100644 index 00000000..bc83adbd --- /dev/null +++ b/services/user-service/seed.py @@ -0,0 +1,23 @@ +import sys +import os +sys.path.insert(0, os.path.dirname(__file__)) + +from app.database import SessionLocal, Base, engine +from app.models import User + +Base.metadata.create_all(bind=engine) + +USERS = [ + {"username": "alice", "email": "alice@example.com", "hashed_password": "hashed_secret"}, + {"username": "bob", "email": "bob@example.com", "hashed_password": "hashed_secret"}, + {"username": "charlie", "email": "charlie@example.com", "hashed_password": "hashed_secret"}, +] + +db = SessionLocal() +for u in USERS: + exists = db.query(User).filter(User.email == u["email"]).first() + if not exists: + db.add(User(**u)) +db.commit() +db.close() +print("Seeded users successfully.") \ No newline at end of file diff --git a/services/user-service/users.db b/services/user-service/users.db new file mode 100644 index 0000000000000000000000000000000000000000..3e1d140659f7c05d4ccf26e7e7132573c5b5b08c GIT binary patch literal 20480 zcmeI&O>fgM7zc1W?h7rV1`R2PT`X~6)z~`PZ4#4^mbL*Z(savGw24ENwML87m#GO` zZwMiA;oI;95TAk%up96p6ZauJs>=l`gl%CgVa5;zUd44xe!)|=L zxZj0@@pb;=DNXCTPCwN{@31=_Wt>c#By)|jWlo2a&8T;(*{=E0bcEe-h26BQCnB4f zj#IoboXRWX^3Cy+XWcpSf+N{!9eY7=Iv93lM*Bg_Ylr@c6y-+4G7UR=UVA9LP(`Yl zdsTf9w^lbr)qG(zEmu^Uj$_(xEBbTKHR?N2z>ZU^8_t1vZ61gvWtq)Kb92Tkgn#Fs zRf7Tn2tWV=5P$##AOHafKmY;|fWT}4gQdx0>2apiKJtcLUrw5BIr4hPUAfvG^d>X> zK&lygSX?V^uB>gW6gS1w^{tKdty1xsmL?0!D#hX8Q2pi!eNNLvFQ`zj>$hcG%@dwg zV`BUp;XnBg)u2EC0uX=z1Rwwb2tWV=5P$##AaMHxbXKA>)*ILYTc%eQ610b`kh!)# zFrNRP6aJU~=I6I>fOsPaKmY;|fB*y_009U<00Izz00ib2NHR(pi)u|{ERz|}|IY~j z!+-I!`ST$H0uX=z1Rwwb2tWV=5P$##AOL||A#j&1PWK3~G+T)F1!#AeKHdxP51h0E A^#A|> literal 0 HcmV?d00001 From e6e1abaa3d41d08cb2f034a446180200c6f5c0fd Mon Sep 17 00:00:00 2001 From: adithyareddym105-gif Date: Tue, 26 May 2026 23:22:38 +0200 Subject: [PATCH 5/5] module-03: implement activity-service and fix gateway --- gateway/app/main.py | 59 +++++++++++++++++ services/activity-service/app/database.py | 15 +++++ services/activity-service/app/main.py | 74 ++++++++++++++++++++++ services/activity-service/app/models.py | 12 ++++ services/activity-service/app/schemas.py | 17 +++++ services/activity-service/requirements.txt | 6 ++ 6 files changed, 183 insertions(+) create mode 100644 gateway/app/main.py create mode 100644 services/activity-service/app/database.py create mode 100644 services/activity-service/app/main.py create mode 100644 services/activity-service/app/models.py create mode 100644 services/activity-service/app/schemas.py create mode 100644 services/activity-service/requirements.txt diff --git a/gateway/app/main.py b/gateway/app/main.py new file mode 100644 index 00000000..c67b0130 --- /dev/null +++ b/gateway/app/main.py @@ -0,0 +1,59 @@ +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse, Response +import httpx + +app = FastAPI() + +# Route table — add new services here +ROUTES = { + "users": "http://localhost:8001", + "games": "http://localhost:8002", + "activities": "http://localhost:8003", +} + + +@app.get("/health") +def health(): + return {"status": "ok"} + + +@app.api_route("/v1/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) +async def proxy(path: str, request: Request): + + # Step 1 — extract the resource name + resource = path.split("/")[0] + + # Step 2 — look up route + base_url = ROUTES.get(resource) + + if not base_url: + return JSONResponse( + status_code=404, content={"detail": f"Unknown resource: {resource}"} + ) + + # Step 3 — build target URL + target_url = f"{base_url}/v1/{path}" + + try: + async with httpx.AsyncClient() as client: + response = await client.request( + method=request.method, + url=target_url, + headers={ + k: v for k, v in request.headers.items() if k.lower() != "host" + }, + content=await request.body(), + params=request.query_params, + follow_redirects=True, + ) + + return Response( + content=response.content, + status_code=response.status_code, + media_type=response.headers.get("content-type"), + ) + + except httpx.RequestError as e: + return JSONResponse( + status_code=503, content={"detail": f"Service unavailable: {str(e)}"} + ) diff --git a/services/activity-service/app/database.py b/services/activity-service/app/database.py new file mode 100644 index 00000000..0391ad31 --- /dev/null +++ b/services/activity-service/app/database.py @@ -0,0 +1,15 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import declarative_base, sessionmaker + +DATABASE_URL = "sqlite:///./activities.db" + +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/services/activity-service/app/main.py b/services/activity-service/app/main.py new file mode 100644 index 00000000..d6161086 --- /dev/null +++ b/services/activity-service/app/main.py @@ -0,0 +1,74 @@ +from fastapi import FastAPI, Depends, HTTPException +from sqlalchemy.orm import Session +import httpx + +from app.database import Base, engine, get_db +from app.models import Activity +from app.schemas import ActivityCreate, ActivityOut + +Base.metadata.create_all(bind=engine) + +app = FastAPI() + +USER_SERVICE_URL = "http://localhost:8001" +GAME_SERVICE_URL = "http://localhost:8002" + + +async def validate_user(user_id: int) -> dict: + """Call user-service to verify user exists. Retries once on transient failure.""" + for attempt in range(2): + try: + async with httpx.AsyncClient(timeout=5.0) as client: + resp = await client.get(f"{USER_SERVICE_URL}/v1/users/{user_id}") + if resp.status_code == 404: + raise HTTPException(status_code=404, detail=f"User {user_id} not found") + resp.raise_for_status() + return resp.json() + except HTTPException: + raise + except httpx.RequestError: + if attempt == 1: + raise HTTPException(status_code=503, detail="User service unavailable") + + +async def fetch_game(game_id: int): + """Fetch game data — optional enrichment. Returns None on any failure.""" + if game_id is None: + return None + try: + async with httpx.AsyncClient(timeout=5.0) as client: + resp = await client.get(f"{GAME_SERVICE_URL}/v1/games/{game_id}") + if resp.status_code == 200: + return resp.json() + return None + except Exception: + return None + + +@app.post("/v1/activities", response_model=ActivityOut) +async def create_activity(data: ActivityCreate, db: Session = Depends(get_db)): + # Step 1 — validate user (critical, blocks save if fails) + await validate_user(data.user_id) + + # Step 2 — save activity + activity = Activity(user_id=data.user_id, game_id=data.game_id, action=data.action) + db.add(activity) + db.commit() + db.refresh(activity) + + # Step 3 — enrich with game data (optional, never blocks) + game = await fetch_game(data.game_id) + + result = ActivityOut.model_validate(activity) + result.game = game + return result + + +@app.get("/v1/activities", response_model=list[ActivityOut]) +def list_activities(db: Session = Depends(get_db)): + return db.query(Activity).all() + + +@app.get("/") +def root(): + return {"message": "Activity service running"} \ No newline at end of file diff --git a/services/activity-service/app/models.py b/services/activity-service/app/models.py new file mode 100644 index 00000000..113de7d7 --- /dev/null +++ b/services/activity-service/app/models.py @@ -0,0 +1,12 @@ +from sqlalchemy import Column, Integer, String, DateTime +from sqlalchemy.sql import func +from app.database import Base + +class Activity(Base): + __tablename__ = "activities" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, nullable=False) + game_id = Column(Integer, nullable=True) + action = Column(String, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) \ No newline at end of file diff --git a/services/activity-service/app/schemas.py b/services/activity-service/app/schemas.py new file mode 100644 index 00000000..7b306ea3 --- /dev/null +++ b/services/activity-service/app/schemas.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel +from datetime import datetime +from typing import Optional, Any + +class ActivityCreate(BaseModel): + user_id: int + game_id: Optional[int] = None + action: str + +class ActivityOut(BaseModel): + id: int + user_id: int + game_id: Optional[int] + action: str + created_at: datetime + game: Optional[Any] = None + model_config = {"from_attributes": True} \ No newline at end of file diff --git a/services/activity-service/requirements.txt b/services/activity-service/requirements.txt new file mode 100644 index 00000000..17a44f8e --- /dev/null +++ b/services/activity-service/requirements.txt @@ -0,0 +1,6 @@ +fastapi +uvicorn[standard] +sqlalchemy +httpx +python-dotenv +aiosqlite \ No newline at end of file