From 605657aa5b0c91d166a4a024752f96e2e2fec169 Mon Sep 17 00:00:00 2001 From: lostmart Date: Thu, 28 May 2026 15:23:01 +0200 Subject: [PATCH 1/3] new data and installs --- .gitignore | 2 +- modules/module-04/REFLECTION.md | 49 +++++++++ modules/module-04/docker-compose.override.yml | 9 ++ modules/module-04/exercise.md | 101 ++++++++++++++++++ services/notification-service/readme.md | 6 +- services/notification-service/src/routes.ts | 4 +- 6 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 modules/module-04/REFLECTION.md create mode 100644 modules/module-04/docker-compose.override.yml create mode 100644 modules/module-04/exercise.md diff --git a/.gitignore b/.gitignore index a4f01478..a5eb4277 100644 --- a/.gitignore +++ b/.gitignore @@ -46,7 +46,7 @@ frontend/*.local -modules/module-04/* + modules/module-05/* modules/module-06/* modules/module-07/* diff --git a/modules/module-04/REFLECTION.md b/modules/module-04/REFLECTION.md new file mode 100644 index 00000000..728d1295 --- /dev/null +++ b/modules/module-04/REFLECTION.md @@ -0,0 +1,49 @@ +# Module 4 — Reflection + +**Team name**: _______________ +**Branch**: `module-04/` +**Submitted**: before Module 5 lesson + +--- + +Answer the three questions below. There are no right or wrong answers — we are looking for your reasoning, not a textbook definition. A few honest sentences are worth more than a long generic paragraph. + +--- + +## 1. The "why" + +In Module 3, services called each other directly over HTTP. Now activity-service drops a message into a broker and moves on — it never waits for a reply. + +**What does the activity-service gain by not waiting? And what does the notification-service gain by consuming at its own pace?** + +Think about what happens under load, or when notification-service is temporarily down. + +> *Your answer:* + +--- + +## 2. Your choice + +In Module 3 you already knew how to call another service directly over HTTP — you did it for user validation and game enrichment. + +**Why not use the same approach for notifications? What does introducing a broker give you that a direct HTTP call doesn't?** + +Think about what happens if notification-service is slow, or crashes mid-message. + +> *Your answer:* + +--- + +## 3. The tradeoff + +With synchronous REST, you get an immediate answer: success or failure. With async messaging, the activity is saved and the message is sent — but you have no idea if the notification was ever delivered. + +**How would a user know if their notification was never sent? How would you know as a developer?** + +What visibility do you lose when you go async? + +> *Your answer:* + +--- + +*Keep this file. You will refer back to it during the oral presentation.* diff --git a/modules/module-04/docker-compose.override.yml b/modules/module-04/docker-compose.override.yml new file mode 100644 index 00000000..d9757589 --- /dev/null +++ b/modules/module-04/docker-compose.override.yml @@ -0,0 +1,9 @@ +# Module 4: Messaging infrastructure only (RabbitMQ) +# Services still run locally with uvicorn + SQLite. +# RabbitMQ is provided by docker-compose.infra.yml. +# +# Start with: +# docker compose -f docker-compose.infra.yml up -d rabbitmq +# +# No service containers in this module. +version: "3.9" diff --git a/modules/module-04/exercise.md b/modules/module-04/exercise.md new file mode 100644 index 00000000..ed35e066 --- /dev/null +++ b/modules/module-04/exercise.md @@ -0,0 +1,101 @@ +# Module 4 — Asynchronous Messaging + +**Duration**: 2h in class +**Branch to submit**: `module-04/` + +--- + +## Objective + +Until now, services communicated synchronously — one service called another and waited for a reply. This module introduces asynchronous messaging: a service drops a message into a broker and moves on, without waiting for any response. + +You will wire one messaging flow into the system: when an activity is logged, `activity-service` publishes a message to RabbitMQ and `notification-service` consumes it. + +--- + +## Before you start + +Your module-03 work must be in place: gateway running on port 8000, `activity-service` working, seeded users and games available. + +Start the RabbitMQ broker: + +```bash +docker compose -f docker-compose.infra.yml up -d rabbitmq +``` + +Confirm it's up: +- RabbitMQ management UI: http://localhost:15672 (guest / guest) + +Start `notification-service` in a separate terminal: + +```bash +cd services/notification-service +npm install +npm run dev +``` + +Confirm it's up: +- http://localhost:8004/v1/notifications should return an empty list `[]` + +--- + +## What's provided + +- RabbitMQ is running via Docker. You do not need to configure it. +- `rabbitmq_publisher.py` is scaffolded in `services/activity-service/app/infrastructure/` — the connection and publish logic is done, you fill in the call site. +- `notification-service` is already built (Node.js) and running on port 8004. + +Install the new dependency in `activity-service`: + +```bash +cd services/activity-service +pip install -r requirements.txt +``` + +--- + +## Part A — Wire the RabbitMQ publisher *(~50 min)* + +When a user logs an activity, a notification should be sent to `notification-service` via RabbitMQ. + +Open `services/activity-service/app/infrastructure/rabbitmq_publisher.py` — the publisher is already implemented. Your job is to **call it** from the right place in `create_activity`. + +After wiring it up, verify the full flow: + +1. Log an activity through the gateway: +```bash +curl -X POST http://localhost:8000/v1/activities \ + -H "Content-Type: application/json" \ + -d '{"user_id": "YOUR_USER_ID", "game_id": "YOUR_GAME_ID", "action": "started"}' +``` +2. Open the RabbitMQ UI at http://localhost:15672 — go to the **Queues** tab and confirm messages appeared in `gamehub.notifications` and `gamehub.logs` +3. Check the `notification-service` logs — a notification should appear + +--- + +## Part B — Register notification-service in the gateway *(~20 min)* + +`notification-service` is now part of the system. Add it to the gateway's routing table. + +Open `gateway/app/config.py` and `gateway/app/main.py` — the lines for `notification-service` are already there, commented out with `# Added in Module 4`. Uncomment them. + +Verify: +```bash +curl http://localhost:8000/v1/notifications +``` + +--- + +## Discussion *(~15 min)* + +- What happens to the activity request if `notification-service` is down when the message is published? Should the activity creation fail? +- In Module 3, you called `game-service` directly over HTTP to enrich the response. Why not do the same for notifications — why introduce a broker at all? +- The activity is saved and the message is sent — but you have no confirmation the notification was delivered. What visibility do you lose compared to a synchronous call? + +--- + +## Minimum to submit this branch + +- [ ] Activity creation publishes a RabbitMQ message — visible in the management UI +- [ ] `notification-service` registered in the gateway and reachable via port 8000 +- [ ] `REFLECTION.md` completed and committed diff --git a/services/notification-service/readme.md b/services/notification-service/readme.md index 2ca3bc5f..f3b86f5c 100644 --- a/services/notification-service/readme.md +++ b/services/notification-service/readme.md @@ -8,7 +8,7 @@ notification-service/ │ ├── index.ts ← starts HTTP server + consumer │ ├── consumer.ts ← RabbitMQ consumer → SQLite │ ├── db.ts ← SQLite init - │ └── routes.ts ← GET /notifications, GET /notifications/:user_id + │ └── routes.ts ← GET /v1/notifications, GET /v1/notifications/:user_id ├── package.json ├── tsconfig.json └── .env.example @@ -55,8 +55,8 @@ The service starts on http://localhost:8004. | Method | Path | Description | |--------|------|-------------| -| GET | `/notifications` | All notifications (newest first) | -| GET | `/notifications/:user_id` | Notifications for a specific user | +| GET | `/v1/notifications` | All notifications (newest first) | +| GET | `/v1/notifications/:user_id` | Notifications for a specific user | ## How it works diff --git a/services/notification-service/src/routes.ts b/services/notification-service/src/routes.ts index 418b8b10..c44b802c 100644 --- a/services/notification-service/src/routes.ts +++ b/services/notification-service/src/routes.ts @@ -3,14 +3,14 @@ import db from "./db"; const router = Router(); -router.get("/notifications", (_req: Request, res: Response) => { +router.get("/v1/notifications", (_req: Request, res: Response) => { const notifications = db.prepare( "SELECT * FROM notifications ORDER BY received_at DESC" ).all(); res.json(notifications); }); -router.get("/notifications/:user_id", (req: Request, res: Response) => { +router.get("/v1/notifications/:user_id", (req: Request, res: Response) => { const notifications = db.prepare( "SELECT * FROM notifications WHERE user_id = ? ORDER BY received_at DESC" ).all(req.params.user_id); From efb57002111c771081f2e4db506ec198ec7ec8ae Mon Sep 17 00:00:00 2001 From: lostmart Date: Thu, 28 May 2026 15:32:03 +0200 Subject: [PATCH 2/3] new now into the data --- modules/module-04/exercise.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/module-04/exercise.md b/modules/module-04/exercise.md index ed35e066..d49fc483 100644 --- a/modules/module-04/exercise.md +++ b/modules/module-04/exercise.md @@ -43,7 +43,7 @@ Confirm it's up: - RabbitMQ is running via Docker. You do not need to configure it. - `rabbitmq_publisher.py` is scaffolded in `services/activity-service/app/infrastructure/` — the connection and publish logic is done, you fill in the call site. -- `notification-service` is already built (Node.js) and running on port 8004. +- `notification-service` is already built (Node.js) — you started it above. Install the new dependency in `activity-service`: From 74cd32937deca924c02afe4e13e84f74068f0a01 Mon Sep 17 00:00:00 2001 From: BenjaminGerard Date: Thu, 4 Jun 2026 11:07:31 +0200 Subject: [PATCH 3/3] Module 4 RabbitMQ messaging --- modules/module-04/REFLECTION.md | 12 +++- services/activity-service/activities.db | Bin 0 -> 12288 bytes services/activity-service/app/main.py | 71 +++++++++++++++--------- services/game-service/app/database.py | 36 ++++++++---- services/game-service/app/main.py | 18 +++--- services/game-service/app/models.py | 28 +++++----- services/game-service/app/repository.py | 40 +++++++++---- services/game-service/app/routes.py | 57 ++++++++++++++----- services/game-service/app/schemas.py | 35 ++++++++---- services/game-service/app/service.py | 54 ++++++++++++------ services/game-service/games.db | Bin 0 -> 12288 bytes services/gateway/app/config.py | 2 +- services/gateway/app/main.py | 3 +- services/user-service/app/database.py | 38 +++++++++---- services/user-service/app/main.py | 20 +++---- services/user-service/app/models.py | 28 +++++----- services/user-service/app/repository.py | 44 +++++++++------ services/user-service/app/routes.py | 37 ++++++------ services/user-service/app/schemas.py | 40 ++++++++----- services/user-service/app/service.py | 56 ++++++++++++------- services/user-service/users.db | Bin 0 -> 16384 bytes 21 files changed, 400 insertions(+), 219 deletions(-) create mode 100644 services/activity-service/activities.db create mode 100644 services/game-service/games.db create mode 100644 services/user-service/users.db diff --git a/modules/module-04/REFLECTION.md b/modules/module-04/REFLECTION.md index 728d1295..021ed77b 100644 --- a/modules/module-04/REFLECTION.md +++ b/modules/module-04/REFLECTION.md @@ -18,7 +18,9 @@ In Module 3, services called each other directly over HTTP. Now activity-service Think about what happens under load, or when notification-service is temporarily down. -> *Your answer:* +> *Your answer:* By not waiting for notification-service, activity-service can save the activity and respond to the user immediately. This makes the system faster and prevents activity creation from failing just because notifications are slow or temporarily unavailable. + +Notification-service also benefits because it can process messages at its own pace. If there is a spike in traffic, messages can stay in RabbitMQ until the service is ready to consume them. If notification-service goes down for a short time, the messages remain in the queue and can be processed when the service comes back online. --- @@ -30,7 +32,9 @@ In Module 3 you already knew how to call another service directly over HTTP — Think about what happens if notification-service is slow, or crashes mid-message. -> *Your answer:* +> *Your answer:* Using a broker is more reliable than making a direct HTTP call for notifications. With HTTP, activity-service would have to wait for notification-service to respond, and activity creation could fail if notification-service was slow or offline. + +RabbitMQ acts as a buffer between the services. Activity-service only needs to publish a message and continue. If notification-service crashes or becomes overloaded, the messages remain in the queue instead of being lost. This reduces coupling between the services and improves resilience. --- @@ -42,7 +46,9 @@ With synchronous REST, you get an immediate answer: success or failure. With asy What visibility do you lose when you go async? -> *Your answer:* +> *Your answer:* With asynchronous messaging, users do not get immediate confirmation that a notification was actually delivered. They only know that the activity was created successfully. If the notification is never processed, the user may not notice until they realize they never received it. + +As a developer, I also lose immediate visibility because there is no direct success or failure response from notification-service. Instead, I have to rely on monitoring tools, logs, queue metrics, and dead-letter queues to detect problems. The tradeoff is better scalability and reliability, but less immediate feedback about whether downstream processing succeeded. --- diff --git a/services/activity-service/activities.db b/services/activity-service/activities.db new file mode 100644 index 0000000000000000000000000000000000000000..37dbbbbcad6dea2f0b808da6f657bfab2c27b0e7 GIT binary patch literal 12288 zcmeI$O>fgM7zc1WLyT71$^oinvbP z_*#61NqiKJI7m}3Oq;|B>Ho-9Y(I$||8DlXx0AZntXi(-vSl8*PbekN7$byOrf!*f z71T8DTy@N|>)#X>IsE=$XYh+yyO(75a`5A(50Hld1Rwwb2tWV=5P$##AOHaf+*;t` z6W!h$4(YdHD`)3gmTmo|Zfo6aTGnxz#96|!`1vGZn{l?^@70REkJICqamuEzGd9g9 zlc%iTTQ_=DT$B4C=lZ%_H~Dh$*D2L{CA(xXuNUi9H*7r3l9MFumMd3UwptbDSpLHN zX*NDhy5TqJ_%u$>*sJ7>?bqs{f8ben_fCeC)C;A*HlNSUdA^YAcKN4YY_?YLO}jhS z-|IYgOr?X5#1sSsAOHafKmY;|fB*y_009U<00RFgaPg3C=Xw5!#;%e)6r!|6SaMsq zm9}LR2wQlSMRuap`eqW=j=}x_K&l zR;tjv&3zGSvy7;kR<6t)!eiHQ1KTl0Fef_l{Ud*L7&t-XM}4y|I`~OUK|lZk5P$## bAOHafKmY;|fB*y_@Sh1h*~)vjxoP+fJ`R@R literal 0 HcmV?d00001 diff --git a/services/activity-service/app/main.py b/services/activity-service/app/main.py index 714887a1..3ac3bdd6 100644 --- a/services/activity-service/app/main.py +++ b/services/activity-service/app/main.py @@ -9,6 +9,7 @@ import httpx from fastapi import Depends, FastAPI, HTTPException from sqlalchemy.orm import Session +from app.infrastructure.rabbitmq_publisher import publish_activity_event from app.config import settings from app.database import Base, engine, get_db @@ -24,40 +25,48 @@ # --------------------------------------------------------------------------- async def validate_user(user_id: str) -> None: - """ - Verify that the user exists in user-service before logging an activity. + retries = 2 - Call: GET {settings.user_service_url}/v1/users/{user_id} + for attempt in range(retries): + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get( + f"{settings.user_service_url}/v1/users/{user_id}" + ) - Behaviour: - - 200 → user exists, return normally (None) - - 404 → raise HTTPException(status_code=404, detail="User not found") - - Network error (httpx.RequestError) → retry the call once, then raise - HTTPException(status_code=503, detail="user-service unavailable") - - Any other non-2xx status → raise HTTPException(status_code=503, ...) + if response.status_code == 200: + return - Use `async with httpx.AsyncClient(timeout=5.0) as client:` for HTTP calls. - This call is CRITICAL — the request must not proceed if validation fails. - """ - raise NotImplementedError + if response.status_code == 404: + raise HTTPException(status_code=404, detail="User not found") + raise HTTPException( + status_code=503, + detail="user-service unavailable" + ) + + except httpx.RequestError: + if attempt == retries - 1: + raise HTTPException( + status_code=503, + detail="user-service unavailable" + ) -async def fetch_game(game_id: str) -> dict | None: - """ - Fetch game data from game-service to enrich the activity response. - Call: GET {settings.game_service_url}/v1/games/{game_id} +async def fetch_game(game_id: str) -> dict | None: + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get( + f"{settings.game_service_url}/v1/games/{game_id}" + ) - Behaviour: - - 200 → return the response JSON as a dict - - Any non-2xx status OR network error → return None (do NOT raise) + if response.status_code == 200: + return response.json() - This call is OPTIONAL — the activity is saved regardless of the result. - Graceful degradation is the goal: the response will include "game": null - when game-service is unreachable. - """ - raise NotImplementedError + except httpx.RequestError: + pass + return None # --------------------------------------------------------------------------- # Endpoints — pre-written, they call your two functions above @@ -71,8 +80,19 @@ def health(): @app.post("/v1/activities", response_model=schemas.ActivityOut, status_code=201) async def create_activity(data: schemas.ActivityCreate, db: Session = Depends(get_db)): await validate_user(data.user_id) + activity = repository.create_activity(db, data) game_data = await fetch_game(activity.game_id) + + game_title = game_data["title"] if game_data else None + + await publish_activity_event( + user_id=activity.user_id, + game_id=activity.game_id, + action=activity.action, + game_title=game_title, + ) + return { "id": activity.id, "user_id": activity.user_id, @@ -82,7 +102,6 @@ async def create_activity(data: schemas.ActivityCreate, db: Session = Depends(ge "game": game_data, } - @app.get("/v1/activities", response_model=schemas.ActivityList) async def list_activities(limit: int = 20, offset: int = 0, db: Session = Depends(get_db)): activities, total = repository.list_activities(db, limit=limit, offset=offset) diff --git a/services/game-service/app/database.py b/services/game-service/app/database.py index 56dfd44d..fdb2e37d 100644 --- a/services/game-service/app/database.py +++ b/services/game-service/app/database.py @@ -1,10 +1,26 @@ -# 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 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 +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 index e629900e..2a7602bb 100644 --- a/services/game-service/app/main.py +++ b/services/game-service/app/main.py @@ -1,9 +1,9 @@ -# Entry point — FastAPI application. -# -# Create the FastAPI app instance and register the router from app.routes. -# Keep it minimal: no business logic, no endpoints defined here. -# -# To run the service locally: -# uvicorn app.main:app --reload --port 8002 -# -# Then open: http://localhost:8002/docs +from fastapi import FastAPI +from app.routes import router +from app.database import Base, engine +from app.models import Game + +Base.metadata.create_all(bind=engine) + +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 index 52eb2756..f30131f0 100644 --- a/services/game-service/app/models.py +++ b/services/game-service/app/models.py @@ -1,14 +1,14 @@ -# 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. +from sqlalchemy import Column, String +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) + 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..e0e65f65 100644 --- a/services/game-service/app/repository.py +++ b/services/game-service/app/repository.py @@ -1,10 +1,30 @@ -# 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 +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, + 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): + total = db.query(Game).count() + games = db.query(Game).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 index 435b3ded..f4ad6c85 100644 --- a/services/game-service/app/routes.py +++ b/services/game-service/app/routes.py @@ -1,15 +1,42 @@ -# 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. -# -# Module 5 — CQRS: also add this endpoint (declare it before /{game_id}): -# - GET /v1/games/{game_id}/summary -> read from Redis cache (404 if not cached) -# from app.infrastructure.cache import get_game_summary +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, + ) + + +@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 index ca6aca4e..e8f4cb70 100644 --- a/services/game-service/app/schemas.py +++ b/services/game-service/app/schemas.py @@ -1,10 +1,25 @@ -# Application layer — Pydantic DTOs. -# -# Define the shapes of data coming IN and going OUT of the API. -# -# 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 } +from pydantic import BaseModel + + +class GameCreate(BaseModel): + title: str + genre: str + platform: str + cover_url: str + + +class GameOut(BaseModel): + id: str + title: str + genre: str + platform: str + cover_url: str + + 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 index 08f93578..e98e67eb 100644 --- a/services/game-service/app/service.py +++ b/services/game-service/app/service.py @@ -1,17 +1,37 @@ -# 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) -# -# Module 5 — CQRS: -# In add_game(), after saving to the DB, also write to the Redis cache: -# from app.infrastructure.cache import set_game_summary -# set_game_summary(game.id, {"id": game.id, "title": game.title, -# "genre": game.genre, "platform": game.platform, -# "cover_url": game.cover_url}) +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(g) for g in games], + total=total, + limit=limit, + offset=offset, + ) \ 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..5802c00d1ee6aa6829401e43930bb912a9d6c3cf GIT binary patch literal 12288 zcmeI$K}*9h6bJC6bLs|C1)&EI=B5L;Iai$uf^`%Wr_OE@c94;+>8!Nt?4s4ISx^u?FF z8;Cv+#VEOGRmZlPHfvfZHJc?{Y%`njU3OzR)l)LY*@fk_*}2_jo4%XN=5pG+ab!^94_q-GJp`(=@9;+% zCF@S|`khiTP5S!9iRY3^s=pC65D1Rwwb2tWV=5P$##AOHaf{FT5hoo}tLtz7Z_ z5-*pwyLs;J9pud&&&zjvZY6J)OS@)8?6}1Rwwb2tWV=5P$##AOHaf{4;@#R4en7Uj|<% Co_gK@ literal 0 HcmV?d00001 diff --git a/services/gateway/app/config.py b/services/gateway/app/config.py index cff24dee..549aa632 100644 --- a/services/gateway/app/config.py +++ b/services/gateway/app/config.py @@ -8,7 +8,7 @@ class Settings(BaseSettings): activity_service_url: str = "http://localhost:8003" # Added in Module 4 - # notification_service_url: str = "http://localhost:8004" + notification_service_url: str = "http://localhost:8004" # Added in Module 5 # logging_service_url: str = "http://localhost:8006" diff --git a/services/gateway/app/main.py b/services/gateway/app/main.py index 984301b8..8013fbda 100644 --- a/services/gateway/app/main.py +++ b/services/gateway/app/main.py @@ -9,8 +9,7 @@ "users": settings.user_service_url, "games": settings.game_service_url, "activities": settings.activity_service_url, - # Added in Module 4 - # "notifications": settings.notification_service_url, + "notifications": settings.notification_service_url, } diff --git a/services/user-service/app/database.py b/services/user-service/app/database.py index a5fe24f9..0447f3c9 100644 --- a/services/user-service/app/database.py +++ b/services/user-service/app/database.py @@ -1,12 +1,26 @@ -# Infrastructure layer — database connection. -# -# 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) -# -# 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.orm import sessionmaker, DeclarativeBase +import os + +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 +) + +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/user-service/app/main.py b/services/user-service/app/main.py index a4463d42..eb2e1211 100644 --- a/services/user-service/app/main.py +++ b/services/user-service/app/main.py @@ -1,11 +1,9 @@ -# Entry point — FastAPI application. -# -# 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 8001 -# -# 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 +from app.models import User + +Base.metadata.create_all(bind=engine) + +app = FastAPI(title="user-service") +app.include_router(router) \ No newline at end of file diff --git a/services/user-service/app/models.py b/services/user-service/app/models.py index 03e8cdba..22a5bf6f 100644 --- a/services/user-service/app/models.py +++ b/services/user-service/app/models.py @@ -1,13 +1,15 @@ -# Infrastructure layer — ORM model. -# -# 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, String, Boolean, DateTime +from datetime import datetime +import uuid + +from app.database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + username = Column(String, nullable=False) + email = Column(String, nullable=False, unique=True) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) \ No newline at end of file diff --git a/services/user-service/app/repository.py b/services/user-service/app/repository.py index f519c460..bcff2ad0 100644 --- a/services/user-service/app/repository.py +++ b/services/user-service/app/repository.py @@ -1,16 +1,28 @@ -# Infrastructure layer — raw database queries. -# -# 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 User +from app.schemas import UserCreate + + +def create_user(db: Session, data: UserCreate) -> User: + user = User( + username=data.username, + email=data.email, + ) + + 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): + total = db.query(User).count() + users = db.query(User).offset(offset).limit(limit).all() + + return users, total \ No newline at end of file diff --git a/services/user-service/app/routes.py b/services/user-service/app/routes.py index 176c60cd..1c214ab4 100644 --- a/services/user-service/app/routes.py +++ b/services/user-service/app/routes.py @@ -1,16 +1,21 @@ -# Interface layer — HTTP endpoints. -# -# 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, HTTPException +from sqlalchemy.orm import Session +from app.database import get_db +from app import service, schemas + +router = APIRouter(prefix="/v1/users", tags=["users"]) + +@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("/", 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("/{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)) \ No newline at end of file diff --git a/services/user-service/app/schemas.py b/services/user-service/app/schemas.py index f2627a95..0f5a131d 100644 --- a/services/user-service/app/schemas.py +++ b/services/user-service/app/schemas.py @@ -1,15 +1,25 @@ -# Application layer — Pydantic DTOs (Data Transfer Objects). -# -# 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: -# - 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 +from datetime import datetime + + +class UserCreate(BaseModel): + username: str + email: str + password: str + + +class UserOut(BaseModel): + id: str + 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..a4f632f2 100644 --- a/services/user-service/app/service.py +++ b/services/user-service/app/service.py @@ -1,19 +1,37 @@ -# Application layer — business logic. -# -# 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 UserCreate, UserOut, UserList + + +def add_user(db: Session, data: UserCreate) -> UserOut: + user = repository.create_user(db, data) + 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, + ) \ 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..d56209a6999eaf7e232490ad15c3ae16736def6c GIT binary patch literal 16384 zcmeI&!A{#S7zc2>L5x&_pbaTk-_{6HrD>9NNRtRG(kem=p+VvlnZ}hOHSN}>6W8sy z$JmLN;l^Pv!JQ*cCN8Lm?Szp2*5^3>Y$f*Z<|wBpVO9t>n$B`wur}Ewl#)Hh2q86D zS7d!CRNg%9Ka4%q)=2Z$*1C2_l#Ls*aid*pzn_|lJOm&B0SG_<0uX=z1Rwwb2teTf z1b!USH@dFVpNoQDjKyLuX7lP&JBR{*5U_#29|o*Su|{3ZQg-G?2VeY%_4)(Wi^Fh- z)zxx*!gH~7P~ayEX}5*h8M@qTd1>c^J>tT1TNh#?V^3W2{OedWhtu4)Y|pf0ILzwoy3VfKYPK!M zvAnw67uEg{87L5d00bZa0SG_<0uX=z1Rwwb2)r_Z_e!k(%eR8R=l@%x-M+F7V!052 f00bZa0SG_<0uX=z1Rwwb2rL(Pt8A-}{tUPW8BvbL literal 0 HcmV?d00001