From 859407cb766fe69c11fbbb12a76233e1fe128d8d Mon Sep 17 00:00:00 2001 From: Bahjat Date: Sun, 17 May 2026 16:22:31 +0200 Subject: [PATCH 1/7] complete module 01 --- modules/module-01/REFLECTION.md | 10 +++---- modules/module-01/exercise.md | 47 ++++++++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/modules/module-01/REFLECTION.md b/modules/module-01/REFLECTION.md index ea9859e7..01407094 100644 --- a/modules/module-01/REFLECTION.md +++ b/modules/module-01/REFLECTION.md @@ -4,8 +4,8 @@ # Module 1 — Reflection -**Team name**: **\*\***\_\_\_**\*\*** -**Branch**: `module-01/` +**Team name**: Bahjat +**Branch**: `module-01/` **Submitted**: before Module 2 lesson --- @@ -22,7 +22,7 @@ You started from a painful monolith. Now you're splitting it into separate servi Think about it from three angles: the developer who has to change code, the team that has to deploy it, and the user who has to live with its failures. You don't need to cover all three, pick the one that felt most real to you today. -> _Your answer:_ +> Splitting the app solves the problem of deployment crashes and tight coupling. From a user's perspective, if the Game Library goes offline because of a bug or an update, the whole platform doesn't crash. They can still log in, view their profile, and chat with friends because the Identity and Activity services are running independently. --- @@ -34,7 +34,7 @@ Look at your service map. Every arrow between two services is a decision someone What would break, slow down, or become harder to manage if you merged those two services back together? -> _Your answer:_ +>I separated the Activity service from the Notification service. In the monolith, these were tightly coupled, meaning when a user logged a game, the system paused to write notifications for all their friends before finishing the request. By separating them and using an async event, logging an activity is instant, and notifications are processed safely in the background. --- @@ -46,7 +46,7 @@ Microservices solve the monolith's problems. But they create new ones. No need to solve it: just name it honestly. This is exactly the tension the rest of the course is about. -> _Your answer:_ +>Data querying is much harder now. In the monolith, if I wanted to show an activity feed, I could just write a single SQL JOIN to get the user's name, the game title, and the activity. Now, that data is locked in three different databases, requiring multiple network calls between services just to render one page. --- diff --git a/modules/module-01/exercise.md b/modules/module-01/exercise.md index d5bf307c..84e781d0 100644 --- a/modules/module-01/exercise.md +++ b/modules/module-01/exercise.md @@ -1,7 +1,7 @@ # Module 1 — Service Decomposition **Duration**: 2h in class -**Branch to submit**: `module-01/` +**Branch to submit**: `module-01/` --- @@ -26,11 +26,13 @@ A bounded context is a part of the system that has a clear responsibility and ow 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 | _(fill in)_ | _(fill in)_ | _(fill in)_ | -| _(add more)_ | | | | +| Bounded Context | Responsibilities | Owned Entities | Team | +| Identity | Manages who users are, handles registration and profiles | User, Session | Platform | +| Game Library | Manages the catalog of games, titles, and genres | Game, Category | Content | +| Activity | Manages social feeds, friendships, and logging what people play | Activity, Friendship | Social | +| Notification | Manages delivering alerts and messages to users | Notification, DeviceToken | Communications | +| Logging | Records system events, checks GDPR consent | Log, ConsentRecord | Infra | + There is no single correct answer: what matters is that you can justify each row. @@ -56,6 +58,24 @@ 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. +Contract 1: +identity-service → activity-service +Trigger: a user logs in successfully +Protocol: RabbitMQ message (async) +Payload: { user_id, session_id, timestamp } + +Contract 2: +activity-service → notification-service +Trigger: a user earns an achievement or milestone +Protocol: RabbitMQ message (async) +Payload: { user_id, achievement_id, message, timestamp } + +Contract 3: +gateway → identity-service +Trigger: a client sends a login or registration request +Protocol: REST (sync) +Payload: { username, email, password_hash } + --- ## Task 3 — Draw the service map _(~20 min)_ @@ -69,6 +89,15 @@ Draw the full GameHub service map: This can be a sketch on paper, a whiteboard photo, or ASCII art committed to your branch. + [Client] + | + [Gateway] + _____|__|__|__|_____ + | | | | | + [identity][game][activity][notification][logging] + |_async__| |___async___|___async___| + + --- ## Discussion _(~15 min)_ @@ -76,8 +105,14 @@ This can be a sketch on paper, a whiteboard photo, or ASCII art committed to you 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? +Because node handles lots of simultaneous I/O better. Each service can use whatever language fits its job. + 2. What is the risk of `activity-service` calling `logging-service` synchronously — why might you prefer an async event instead? +if logging-service crashes, activity-service crashes too. Async decouples them. + 3. Why does `logging-service` need a GDPR consent check before recording any activity? +you can't store personal data without consent. Logging is the last gate before data is written. + You do not need to write these answers down — they are warm-up for your REFLECTION.md. From 482fe0dc68c031cec67ff5daab59fe8ba42ef1c2 Mon Sep 17 00:00:00 2001 From: Bahjat Date: Sun, 17 May 2026 16:55:26 +0200 Subject: [PATCH 2/7] Completed Module 2: Game Service API, Test, and Alembic --- services/game-service/.env.example | 0 services/game-service/app/__init__.py | 0 services/game-service/app/database.py | 13 +++++++++ services/game-service/app/main.py | 13 +++++++++ services/game-service/app/models.py | 11 ++++++++ services/game-service/app/repository.py | 23 ++++++++++++++++ services/game-service/app/routes.py | 26 ++++++++++++++++++ services/game-service/app/schemas.py | 16 +++++++++++ services/game-service/app/service.py | 15 ++++++++++ services/game-service/games.db | Bin 0 -> 16384 bytes services/game-service/requirements.txt | 10 +++++++ .../versions/001_create_games_table.py | 24 ++++++++++++++++ services/game-service/tests/test_games.py | 8 ++++++ 13 files changed, 159 insertions(+) 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/games.db create mode 100644 services/game-service/requirements.txt create mode 100644 services/game-service/tests/alembic/versions/001_create_games_table.py create mode 100644 services/game-service/tests/test_games.py diff --git a/services/game-service/.env.example b/services/game-service/.env.example new file mode 100644 index 00000000..e69de29b 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..37985e3c --- /dev/null +++ b/services/game-service/app/database.py @@ -0,0 +1,13 @@ +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from sqlalchemy.orm import declarative_base + +SQLALCHEMY_DATABASE_URL = "sqlite+aiosqlite:///./games.db" + +engine = create_async_engine(SQLALCHEMY_DATABASE_URL, echo=False) +SessionLocal = async_sessionmaker(autocommit=False, autoflush=False, bind=engine, class_=AsyncSession) + +Base = declarative_base() + +async def get_db(): + async with SessionLocal() as session: + yield session \ 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..e5055b46 --- /dev/null +++ b/services/game-service/app/main.py @@ -0,0 +1,13 @@ +from fastapi import FastAPI +from contextlib import asynccontextmanager +from app.routes import router +from app.database import engine, Base + +@asynccontextmanager +async def lifespan(app: FastAPI): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + +app = FastAPI(title="Game Service", lifespan=lifespan) +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..d5643de3 --- /dev/null +++ b/services/game-service/app/models.py @@ -0,0 +1,11 @@ +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, index=True) + genre = Column(String) + platform = Column(String) + cover_url = Column(String) \ 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..5a95bf30 --- /dev/null +++ b/services/game-service/app/repository.py @@ -0,0 +1,23 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from app.models import Game +from app.schemas import GameCreate + +async def create_game(db: AsyncSession, game: GameCreate): + db_game = Game(**game.model_dump()) + db.add(db_game) + await db.commit() + await db.refresh(db_game) + return db_game + +async def get_games(db: AsyncSession): + result = await db.execute(select(Game)) + return result.scalars().all() + +async def get_game_by_id(db: AsyncSession, game_id: int): + result = await db.execute(select(Game).where(Game.id == game_id)) + return result.scalar_one_or_none() + +async def search_games(db: AsyncSession, term: str): + result = await db.execute(select(Game).where(Game.title.ilike(f"%{term}%"))) + return result.scalars().all() \ 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..de9ea1c0 --- /dev/null +++ b/services/game-service/app/routes.py @@ -0,0 +1,26 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from app.database import get_db +import app.service as service +import app.schemas as schemas + +router = APIRouter(prefix="/v1/games", tags=["games"]) + +@router.post("", response_model=schemas.GameResponse) +async def create_game(game: schemas.GameCreate, db: AsyncSession = Depends(get_db)): + return await service.add_game(db, game) + +@router.get("", response_model=list[schemas.GameResponse]) +async def list_games(db: AsyncSession = Depends(get_db)): + return await service.list_games(db) + +@router.get("/search", response_model=list[schemas.GameResponse]) +async def search_games(q: str, db: AsyncSession = Depends(get_db)): + return await service.search(db, q) + +@router.get("/{id}", response_model=schemas.GameResponse) +async def get_game(id: int, db: AsyncSession = Depends(get_db)): + game = await service.get_game(db, id) + if not game: + raise HTTPException(status_code=404, detail="Game not found") + return game \ 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..ff42d944 --- /dev/null +++ b/services/game-service/app/schemas.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel + +class GameBase(BaseModel): + title: str + genre: str + platform: str + cover_url: str + +class GameCreate(GameBase): + pass + +class GameResponse(GameBase): + 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 new file mode 100644 index 00000000..8fcb7ecb --- /dev/null +++ b/services/game-service/app/service.py @@ -0,0 +1,15 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from app.schemas import GameCreate +import app.repository as repository + +async def add_game(db: AsyncSession, game: GameCreate): + return await repository.create_game(db, game) + +async def list_games(db: AsyncSession): + return await repository.get_games(db) + +async def get_game(db: AsyncSession, game_id: int): + return await repository.get_game_by_id(db, game_id) + +async def search(db: AsyncSession, term: str): + return await repository.search_games(db, term) \ 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..c5c0d273b9e8190a9c673766a97102b73ba14273 GIT binary patch literal 16384 zcmeI#O-sWt7{Kv#9g`Jw9)x<#@Unt<@eA0^qGPqwTE&4ds*R(t7pYZt)uUg{FJ)U+ z=tR7D8~;E^n?6sP{MyTNedWffl240d7N+u8>MHoot7OmDlE3(N9AF0R#|0009ILKmY** z5I_Kd4Hhu+vr+$V@E7K22q1s}0tg_000IagfB*sr2tobNUw{At2q1s}0tg_000Iag JfWYPpd;_vPW(EKN literal 0 HcmV?d00001 diff --git a/services/game-service/requirements.txt b/services/game-service/requirements.txt new file mode 100644 index 00000000..e560c3cb --- /dev/null +++ b/services/game-service/requirements.txt @@ -0,0 +1,10 @@ +fastapi +uvicorn +sqlalchemy +pydantic +alembic +pytest +aiosqlite +ruff +mypy +httpx \ No newline at end of file diff --git a/services/game-service/tests/alembic/versions/001_create_games_table.py b/services/game-service/tests/alembic/versions/001_create_games_table.py new file mode 100644 index 00000000..28c8657f --- /dev/null +++ b/services/game-service/tests/alembic/versions/001_create_games_table.py @@ -0,0 +1,24 @@ +"""create games table + +Revision ID: 001 +Revises: +Create Date: 2026-05-17 +""" +from alembic import op +import sqlalchemy as sa + +revision = '001' +down_revision = None + +def upgrade(): + op.create_table( + 'games', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('title', sa.String(), index=True), + sa.Column('genre', sa.String()), + sa.Column('platform', sa.String()), + sa.Column('cover_url', sa.String()) + ) + +def downgrade(): + op.drop_table('games') \ 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..7a9c75ac --- /dev/null +++ b/services/game-service/tests/test_games.py @@ -0,0 +1,8 @@ +from fastapi.testclient import TestClient +from app.main import app + +def test_list_games_returns_200(): + with TestClient(app) as client: + response = client.get("/v1/games") + assert response.status_code == 200 + assert isinstance(response.json(), list) \ No newline at end of file From 3c3f8ed9ba8635eb59ff4ca694cc1d5553e6c14b Mon Sep 17 00:00:00 2001 From: Bahjat Date: Sun, 17 May 2026 17:04:34 +0200 Subject: [PATCH 3/7] Final sync for Module 2 --- .../{tests => }/alembic/versions/001_create_games_table.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename services/game-service/{tests => }/alembic/versions/001_create_games_table.py (100%) diff --git a/services/game-service/tests/alembic/versions/001_create_games_table.py b/services/game-service/alembic/versions/001_create_games_table.py similarity index 100% rename from services/game-service/tests/alembic/versions/001_create_games_table.py rename to services/game-service/alembic/versions/001_create_games_table.py From 4af2fc1e1a55988a485a24dc4ca8e9db59541ec3 Mon Sep 17 00:00:00 2001 From: Bahjat Date: Sun, 17 May 2026 17:07:51 +0200 Subject: [PATCH 4/7] Added Module 2 reflection answers --- modules/module-02/REFLECTION.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/modules/module-02/REFLECTION.md b/modules/module-02/REFLECTION.md index 52850343..18d61409 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**: Bahjat +**Branch**: `module-02/ *Your answer:* +> Splitting the code into layers protects the application from massive rewrites. If we decide to swap from SQLite to PostgreSQL later, we only have to update the repository.py and database.py files. The routing (routes.py) and business logic (service.py) don't care what database is used, so they remain completely untouched. It also makes testing easier because you can test business logic without needing a live database. --- @@ -30,7 +30,7 @@ Each service owns its data exclusively — no other service is allowed to touch Give a concrete scenario, not a general principle. -> *Your answer:* +> If the Activity Service was allowed to write directly to the games table in the Game Service database, it might bypass important validation rules (like adding a game without a cover_url). Even worse, if the Game Service team updates their database schema (like changing the column name title to game_name), the Activity Service's hardcoded SQL query would instantly break and crash without the Game Service team even knowing why. --- @@ -42,7 +42,7 @@ 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 cost is massive boilerplate. To do a simple SELECT * FROM games, you have to write code across five different files, which feels incredibly slow for a basic CRUD app. This complexity only pays off when the app scales — when you start adding caching, role-based permissions, or external API calls, having dedicated layers keeps the codebase from turning into spaghetti. --- From 292a5d3c3964d253a50f78d83b4e3b833eb5a1f1 Mon Sep 17 00:00:00 2001 From: Bahjat Date: Sun, 17 May 2026 17:19:59 +0200 Subject: [PATCH 5/7] fix branch name typo --- exit() | 32 ++++++++++++++++++++++++++++++++ modules/module-02/REFLECTION.md | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 exit() diff --git a/exit() b/exit() new file mode 100644 index 00000000..82183f44 --- /dev/null +++ b/exit() @@ -0,0 +1,32 @@ +4af2fc1 (HEAD -> module-02/bahjat9, origin/module-02/bahjat9) Added Module 2 reflection answers +3c3f8ed Final sync for Module 2 +482fe0d (module-01/bahjat9) Completed Module 2: Game Service API, Test, and Alembic +859407c (origin/module-01/bahjat9) complete module 01 +c35ed89 (origin/main, origin/HEAD, module-01/Avengers, main) module 2 +553e315 new strucures and files +b3a9e21 new models +7ae4d60 new models +3e5af4b new models +329d4c7 new models +f52cb8d new models +fbaa734 tech revision +b9d4b10 new file edits, md files +f07507e dependencies and installs +a33a249 dependencies and installs +eb73c21 dependencies and installs +c614bac dependencies and installs +5a7db1e dependencies and installs +697b867 dependencies and installs +3d16fb6 dependencies and installs +d804de5 feat: new installs +d26f3fe feat: new installs +5d0f3a5 feat: new installs +70e0a6c feat: new installs +35eae10 new installs and pers +aadbbeb new installs +171bbf7 new apps +6bcf2fc Add course adaptation guide for 30-hour EPITA condensed version +1b19be7 new domain and features +63d79a8 stiches and punts +dcd8c47 new pland and structure +dcf4163 new start diff --git a/modules/module-02/REFLECTION.md b/modules/module-02/REFLECTION.md index 18d61409..673edd0b 100644 --- a/modules/module-02/REFLECTION.md +++ b/modules/module-02/REFLECTION.md @@ -1,7 +1,7 @@ # Module 2 — Reflection **Team name**: Bahjat -**Branch**: `module-02/` **Submitted**: before Module 3 lesson --- From c7c06a8b3cffd5609c04dc93527cb5bd790d4828 Mon Sep 17 00:00:00 2001 From: Bahjat Date: Tue, 19 May 2026 00:23:49 +0200 Subject: [PATCH 6/7] completed module 02 - user-service and game-service --- modules/module-02/exercise.md | 2 +- services/game-service/alembic.ini | 149 ++++++++++++++++++ services/game-service/alembic/README | 1 + services/game-service/alembic/env.py | 84 ++++++++++ services/game-service/alembic/script.py.mako | 28 ++++ .../versions/001_create_games_table.py | 24 --- .../9dd63f409d0d_create_games_table.py | 41 +++++ services/game-service/app/database.py | 23 +-- services/game-service/app/main.py | 13 -- services/game-service/app/models.py | 15 +- services/game-service/app/repository.py | 35 ++-- services/game-service/app/routes.py | 34 ++-- services/game-service/app/schemas.py | 25 +-- services/game-service/app/service.py | 31 ++-- services/game-service/games.db | Bin 16384 -> 24576 bytes services/game-service/main.py | 10 ++ services/game-service/seed.py | 36 +++++ services/user-service/alembic.ini | 149 ++++++++++++++++++ services/user-service/alembic/README | 1 + services/user-service/alembic/env.py | 84 ++++++++++ services/user-service/alembic/script.py.mako | 28 ++++ .../24c4471193dd_create_users_table.py | 42 +++++ .../versions/66fde9768304_add_bio_to_users.py | 32 ++++ services/user-service/app/__init__.py | 0 services/user-service/app/database.py | 18 +++ services/user-service/app/models.py | 15 ++ services/user-service/app/repository.py | 22 +++ services/user-service/app/routes.py | 22 +++ services/user-service/app/schemas.py | 22 +++ services/user-service/app/service.py | 27 ++++ services/user-service/main.py | 10 ++ services/user-service/requirements.txt | 8 + services/user-service/seed.py | 40 +++++ services/user-service/users.db | Bin 0 -> 28672 bytes 34 files changed, 960 insertions(+), 111 deletions(-) create mode 100644 services/game-service/alembic.ini create mode 100644 services/game-service/alembic/README create mode 100644 services/game-service/alembic/env.py create mode 100644 services/game-service/alembic/script.py.mako delete mode 100644 services/game-service/alembic/versions/001_create_games_table.py create mode 100644 services/game-service/alembic/versions/9dd63f409d0d_create_games_table.py delete mode 100644 services/game-service/app/main.py create mode 100644 services/game-service/main.py create mode 100644 services/game-service/seed.py create mode 100644 services/user-service/alembic.ini create mode 100644 services/user-service/alembic/README create mode 100644 services/user-service/alembic/env.py create mode 100644 services/user-service/alembic/script.py.mako create mode 100644 services/user-service/alembic/versions/24c4471193dd_create_users_table.py create mode 100644 services/user-service/alembic/versions/66fde9768304_add_bio_to_users.py create mode 100644 services/user-service/app/__init__.py create mode 100644 services/user-service/app/database.py create mode 100644 services/user-service/app/models.py create mode 100644 services/user-service/app/repository.py create mode 100644 services/user-service/app/routes.py create mode 100644 services/user-service/app/schemas.py create mode 100644 services/user-service/app/service.py create mode 100644 services/user-service/main.py create mode 100644 services/user-service/requirements.txt create mode 100644 services/user-service/seed.py create mode 100644 services/user-service/users.db diff --git a/modules/module-02/exercise.md b/modules/module-02/exercise.md index d3d7c136..a5349c9b 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/` --- diff --git a/services/game-service/alembic.ini b/services/game-service/alembic.ini new file mode 100644 index 00000000..eab005a0 --- /dev/null +++ b/services/game-service/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = sqlite:///./games.db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/services/game-service/alembic/README b/services/game-service/alembic/README new file mode 100644 index 00000000..98e4f9c4 --- /dev/null +++ b/services/game-service/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/services/game-service/alembic/env.py b/services/game-service/alembic/env.py new file mode 100644 index 00000000..05a14497 --- /dev/null +++ b/services/game-service/alembic/env.py @@ -0,0 +1,84 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +from app.database import Base +from app.models import Game +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/services/game-service/alembic/script.py.mako b/services/game-service/alembic/script.py.mako new file mode 100644 index 00000000..11016301 --- /dev/null +++ b/services/game-service/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/services/game-service/alembic/versions/001_create_games_table.py b/services/game-service/alembic/versions/001_create_games_table.py deleted file mode 100644 index 28c8657f..00000000 --- a/services/game-service/alembic/versions/001_create_games_table.py +++ /dev/null @@ -1,24 +0,0 @@ -"""create games table - -Revision ID: 001 -Revises: -Create Date: 2026-05-17 -""" -from alembic import op -import sqlalchemy as sa - -revision = '001' -down_revision = None - -def upgrade(): - op.create_table( - 'games', - sa.Column('id', sa.Integer(), primary_key=True), - sa.Column('title', sa.String(), index=True), - sa.Column('genre', sa.String()), - sa.Column('platform', sa.String()), - sa.Column('cover_url', sa.String()) - ) - -def downgrade(): - op.drop_table('games') \ No newline at end of file diff --git a/services/game-service/alembic/versions/9dd63f409d0d_create_games_table.py b/services/game-service/alembic/versions/9dd63f409d0d_create_games_table.py new file mode 100644 index 00000000..6a2e65df --- /dev/null +++ b/services/game-service/alembic/versions/9dd63f409d0d_create_games_table.py @@ -0,0 +1,41 @@ +"""create games table + +Revision ID: 9dd63f409d0d +Revises: +Create Date: 2026-05-19 00:16:17.854123 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9dd63f409d0d' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('games', + sa.Column('id', sa.String(), nullable=False), + sa.Column('title', sa.String(), nullable=False), + sa.Column('genre', sa.String(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('title') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('games') + # ### end Alembic commands ### diff --git a/services/game-service/app/database.py b/services/game-service/app/database.py index 37985e3c..808b08b2 100644 --- a/services/game-service/app/database.py +++ b/services/game-service/app/database.py @@ -1,13 +1,18 @@ -from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession -from sqlalchemy.orm import declarative_base +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase +import os -SQLALCHEMY_DATABASE_URL = "sqlite+aiosqlite:///./games.db" +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./games.db") -engine = create_async_engine(SQLALCHEMY_DATABASE_URL, echo=False) -SessionLocal = async_sessionmaker(autocommit=False, autoflush=False, bind=engine, class_=AsyncSession) +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) -Base = declarative_base() +class Base(DeclarativeBase): + pass -async def get_db(): - async with SessionLocal() as session: - yield session \ No newline at end of file +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 deleted file mode 100644 index e5055b46..00000000 --- a/services/game-service/app/main.py +++ /dev/null @@ -1,13 +0,0 @@ -from fastapi import FastAPI -from contextlib import asynccontextmanager -from app.routes import router -from app.database import engine, Base - -@asynccontextmanager -async def lifespan(app: FastAPI): - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - yield - -app = FastAPI(title="Game Service", lifespan=lifespan) -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 d5643de3..e5ed06e2 100644 --- a/services/game-service/app/models.py +++ b/services/game-service/app/models.py @@ -1,11 +1,14 @@ -from sqlalchemy import Column, Integer, String +from sqlalchemy import Column, String, Boolean, DateTime, Text +from datetime import datetime, timezone +import uuid from app.database import Base class Game(Base): __tablename__ = "games" - id = Column(Integer, primary_key=True, index=True) - title = Column(String, index=True) - genre = Column(String) - platform = Column(String) - cover_url = Column(String) \ No newline at end of file + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + title = Column(String, unique=True, nullable=False) + genre = Column(String, nullable=False) + description = Column(Text, nullable=True) + is_active = Column(Boolean, default=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 index 5a95bf30..d415d313 100644 --- a/services/game-service/app/repository.py +++ b/services/game-service/app/repository.py @@ -1,23 +1,22 @@ -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select +from sqlalchemy.orm import Session from app.models import Game from app.schemas import GameCreate -async def create_game(db: AsyncSession, game: GameCreate): - db_game = Game(**game.model_dump()) - db.add(db_game) - await db.commit() - await db.refresh(db_game) - return db_game +def create_game(db: Session, data: GameCreate) -> Game: + game = Game( + title=data.title, + genre=data.genre, + description=data.description, + ) + db.add(game) + db.commit() + db.refresh(game) + return game -async def get_games(db: AsyncSession): - result = await db.execute(select(Game)) - return result.scalars().all() +def get_game(db: Session, game_id: str) -> Game | None: + return db.query(Game).filter(Game.id == game_id).first() -async def get_game_by_id(db: AsyncSession, game_id: int): - result = await db.execute(select(Game).where(Game.id == game_id)) - return result.scalar_one_or_none() - -async def search_games(db: AsyncSession, term: str): - result = await db.execute(select(Game).where(Game.title.ilike(f"%{term}%"))) - return result.scalars().all() \ No newline at end of file +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 \ No newline at end of file diff --git a/services/game-service/app/routes.py b/services/game-service/app/routes.py index de9ea1c0..e838340b 100644 --- a/services/game-service/app/routes.py +++ b/services/game-service/app/routes.py @@ -1,26 +1,22 @@ from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import Session from app.database import get_db -import app.service as service -import app.schemas as schemas +from app import service +from app.schemas import GameCreate, GameOut, GameList router = APIRouter(prefix="/v1/games", tags=["games"]) -@router.post("", response_model=schemas.GameResponse) -async def create_game(game: schemas.GameCreate, db: AsyncSession = Depends(get_db)): - return await service.add_game(db, game) +@router.post("/", status_code=201, response_model=GameOut) +def create_game(data: GameCreate, db: Session = Depends(get_db)): + return service.add_game(db, data) -@router.get("", response_model=list[schemas.GameResponse]) -async def list_games(db: AsyncSession = Depends(get_db)): - return await service.list_games(db) +@router.get("/", response_model=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("/search", response_model=list[schemas.GameResponse]) -async def search_games(q: str, db: AsyncSession = Depends(get_db)): - return await service.search(db, q) - -@router.get("/{id}", response_model=schemas.GameResponse) -async def get_game(id: int, db: AsyncSession = Depends(get_db)): - game = await service.get_game(db, id) - if not game: - raise HTTPException(status_code=404, detail="Game not found") - return game \ No newline at end of file +@router.get("/{game_id}", response_model=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 ff42d944..548e0bff 100644 --- a/services/game-service/app/schemas.py +++ b/services/game-service/app/schemas.py @@ -1,16 +1,23 @@ from pydantic import BaseModel +from datetime import datetime -class GameBase(BaseModel): +class GameCreate(BaseModel): title: str genre: str - platform: str - cover_url: str + description: str -class GameCreate(GameBase): - pass +class GameOut(BaseModel): + id: str + title: str + genre: str + description: str | None + is_active: bool + created_at: datetime -class GameResponse(GameBase): - id: int + model_config = {"from_attributes": True} - class Config: - from_attributes = True \ No newline at end of file +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 8fcb7ecb..5f913252 100644 --- a/services/game-service/app/service.py +++ b/services/game-service/app/service.py @@ -1,15 +1,22 @@ -from sqlalchemy.ext.asyncio import AsyncSession -from app.schemas import GameCreate -import app.repository as repository +from sqlalchemy.orm import Session +from app import repository +from app.schemas import GameCreate, GameOut, GameList -async def add_game(db: AsyncSession, game: GameCreate): - return await repository.create_game(db, game) +def add_game(db: Session, data: GameCreate) -> GameOut: + game = repository.create_game(db, data) + return GameOut.model_validate(game) -async def list_games(db: AsyncSession): - return await repository.get_games(db) +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) -async def get_game(db: AsyncSession, game_id: int): - return await repository.get_game_by_id(db, game_id) - -async def search(db: AsyncSession, term: str): - return await repository.search_games(db, term) \ No newline at end of file +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 index c5c0d273b9e8190a9c673766a97102b73ba14273..c95cea5257c15d045e1ee3215ab287886ee559df 100644 GIT binary patch literal 24576 zcmeI)&1&OD8~||1vg0bnQRpr;rIgMkBqp7pPfJ=@s5UX#u!)oSqwGS98EHn2h+?^t zoWzA5wuKf-pJM5~$I>_GBjnIiU!Xnq*pah=#B6rc)57u}ENMJ5n$i5M%Zx_u9_~gt zLt&DhkQ^DpvLK4W=LiXcQ0ChL->yQ5M~bwoAYMgYb1VxRKi#h>{|c4bg`m`w-)k33 ze=Ys?nlm5^1V8`;KmY_l00ck)1V8`;K;VA~JpWRv*6Vfg`+83Nm<`DZ%VxXsW~b%# zTBzqe+HIj(5?Wi7BZ|K8I-8HZ4r=fBP`kgo`x#o4^C*wmD|3cyl)gHLvMflWaULZj z)N4KM74<~ffCPDT%Fv_z{oR(=E)s*3k(|*1A9!x@OX}_HwTk3}&d#3Kd4`^}o}smf zHj1=RcY7Ug zr`R6wphsc+W0i<9wkZ6 z(#-@XW0Z|az)*IXw4tkd6RQ^19Hi?HwB`dt-7sC%(e=Ds?XP^aEqhvESejusv2HYd zY-&8_>yC~c7MM&A9M!R{-X!%C)ZsO!8Q({v;p1p{gtIYY6or#~l15n`1t^{6-P)?= zd@EJ^>!0q)o?%%upmu;w)#3dNZ41f!Y7VkQ9tbn<;ZTQX2sn1fhPp4VNrk#UrZ-kOD8{xAg zjVTI>-0@^|j0intBhG(o!?vpx&;KimDJWklKPbN{7d#391V8`;KmY_l00ck)1V8`; zKmY_lfD5cieeo_+F_og=U1m_qMZvpFdPqgVyG&1%R;9jtm$`(?@&5}!`CECR{Gt4* zz<=|>Jq`jO00JNY0w4eaAOHd&00JNY0&hX!zJ$g3MIDu8X+^xg?xK88S{LV*L`WY< R4e{nWh0?OLD&JT>@E_ee#f1O> literal 16384 zcmeI#O-sWt7{Kv#9g`Jw9)x<#@Unt<@eA0^qGPqwTE&4ds*R(t7pYZt)uUg{FJ)U+ z=tR7D8~;E^n?6sP{MyTNedWffl240d7N+u8>MHoot7OmDlE3(N9AF0R#|0009ILKmY** z5I_Kd4Hhu+vr+$V@E7K22q1s}0tg_000IagfB*sr2tobNUw{At2q1s}0tg_000Iag JfWYPpd;_vPW(EKN diff --git a/services/game-service/main.py b/services/game-service/main.py new file mode 100644 index 00000000..e9513b34 --- /dev/null +++ b/services/game-service/main.py @@ -0,0 +1,10 @@ +from fastapi import FastAPI +from app.routes import router + +app = FastAPI(title="game-service") + +@app.get("/health") +async def health(): + return {"status": "ok", "service": "game-service"} + +app.include_router(router) \ No newline at end of file diff --git a/services/game-service/seed.py b/services/game-service/seed.py new file mode 100644 index 00000000..d308d1f5 --- /dev/null +++ b/services/game-service/seed.py @@ -0,0 +1,36 @@ +import sys +import os +sys.path.insert(0, os.path.dirname(__file__)) + +from app.database import SessionLocal, engine +from app.models import Base, Game + +GAMES = [ + {"title": "Cyber Odyssey", "genre": "RPG", "description": "An open-world cyberpunk adventure."}, + {"title": "Shadow Tactics", "genre": "Strategy","description": "Stealth-based tactical warfare."}, + {"title": "Pixel Dungeon X", "genre": "Roguelike","description": "Procedurally generated dungeons."}, + {"title": "Turbo Racers", "genre": "Racing", "description": "High-speed futuristic racing."}, + {"title": "Galaxy Defenders", "genre": "Shooter", "description": "Co-op space shooter."}, +] + +def run(): + Base.metadata.create_all(bind=engine) + db = SessionLocal() + imported = 0 + for data in GAMES: + existing = db.query(Game).filter(Game.title == data["title"]).first() + if existing: + continue + game = Game( + title=data["title"], + genre=data["genre"], + description=data["description"], + ) + db.add(game) + imported += 1 + db.commit() + db.close() + print(f"Imported {imported} games.") + +if __name__ == "__main__": + run() \ No newline at end of file diff --git a/services/user-service/alembic.ini b/services/user-service/alembic.ini new file mode 100644 index 00000000..421007a0 --- /dev/null +++ b/services/user-service/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = sqlite:///./users.db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/services/user-service/alembic/README b/services/user-service/alembic/README new file mode 100644 index 00000000..98e4f9c4 --- /dev/null +++ b/services/user-service/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/services/user-service/alembic/env.py b/services/user-service/alembic/env.py new file mode 100644 index 00000000..a2f77339 --- /dev/null +++ b/services/user-service/alembic/env.py @@ -0,0 +1,84 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +from app.database import Base +from app.models import User +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/services/user-service/alembic/script.py.mako b/services/user-service/alembic/script.py.mako new file mode 100644 index 00000000..11016301 --- /dev/null +++ b/services/user-service/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/services/user-service/alembic/versions/24c4471193dd_create_users_table.py b/services/user-service/alembic/versions/24c4471193dd_create_users_table.py new file mode 100644 index 00000000..2d599fa3 --- /dev/null +++ b/services/user-service/alembic/versions/24c4471193dd_create_users_table.py @@ -0,0 +1,42 @@ +"""create users table + +Revision ID: 24c4471193dd +Revises: +Create Date: 2026-05-18 23:55:53.051566 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '24c4471193dd' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('id', sa.String(), nullable=False), + sa.Column('username', sa.String(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('hashed_password', sa.String(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email'), + sa.UniqueConstraint('username') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('users') + # ### end Alembic commands ### diff --git a/services/user-service/alembic/versions/66fde9768304_add_bio_to_users.py b/services/user-service/alembic/versions/66fde9768304_add_bio_to_users.py new file mode 100644 index 00000000..e18282e8 --- /dev/null +++ b/services/user-service/alembic/versions/66fde9768304_add_bio_to_users.py @@ -0,0 +1,32 @@ +"""add bio to users + +Revision ID: 66fde9768304 +Revises: 24c4471193dd +Create Date: 2026-05-19 00:00:49.717627 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '66fde9768304' +down_revision: Union[str, Sequence[str], None] = '24c4471193dd' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('bio', sa.Text(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'bio') + # ### end Alembic commands ### diff --git a/services/user-service/app/__init__.py b/services/user-service/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/user-service/app/database.py b/services/user-service/app/database.py new file mode 100644 index 00000000..7cc2d717 --- /dev/null +++ b/services/user-service/app/database.py @@ -0,0 +1,18 @@ +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/models.py b/services/user-service/app/models.py new file mode 100644 index 00000000..bf1c95f2 --- /dev/null +++ b/services/user-service/app/models.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, String, Boolean, DateTime, Text +from datetime import datetime, timezone +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, unique=True, nullable=False) + email = Column(String, unique=True, nullable=False) + hashed_password = Column(String, nullable=False) + bio = Column(Text, nullable=True) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) \ No newline at end of file diff --git a/services/user-service/app/repository.py b/services/user-service/app/repository.py new file mode 100644 index 00000000..6c3a43fa --- /dev/null +++ b/services/user-service/app/repository.py @@ -0,0 +1,22 @@ +from sqlalchemy.orm import Session +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 \ No newline at end of file diff --git a/services/user-service/app/routes.py b/services/user-service/app/routes.py new file mode 100644 index 00000000..d1f58670 --- /dev/null +++ b/services/user-service/app/routes.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.database import get_db +from app import service +from app.schemas import UserCreate, UserOut, UserList + +router = APIRouter(prefix="/v1/users", tags=["users"]) + +@router.post("/", status_code=201, response_model=UserOut) +def create_user(data: UserCreate, db: Session = Depends(get_db)): + return service.add_user(db, data) + +@router.get("/", response_model=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=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 new file mode 100644 index 00000000..d4f252f2 --- /dev/null +++ b/services/user-service/app/schemas.py @@ -0,0 +1,22 @@ +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 new file mode 100644 index 00000000..bfe1e013 --- /dev/null +++ b/services/user-service/app/service.py @@ -0,0 +1,27 @@ +from sqlalchemy.orm import Session +from app import repository +from app.schemas import UserCreate, UserOut, UserList + +def _hash_password(plain: str) -> str: + # 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, + ) \ No newline at end of file diff --git a/services/user-service/main.py b/services/user-service/main.py new file mode 100644 index 00000000..a407984f --- /dev/null +++ b/services/user-service/main.py @@ -0,0 +1,10 @@ +from fastapi import FastAPI +from app.routes import router + +app = FastAPI(title="user-service") + +@app.get("/health") +async def health(): + return {"status": "ok", "service": "user-service"} + +app.include_router(router) \ 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..f2e26c5f --- /dev/null +++ b/services/user-service/requirements.txt @@ -0,0 +1,8 @@ +fastapi +uvicorn +sqlalchemy +pydantic +alembic +pytest +aiosqlite +httpx \ 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..0bf5a34a --- /dev/null +++ b/services/user-service/seed.py @@ -0,0 +1,40 @@ +import sys +import os +sys.path.insert(0, os.path.dirname(__file__)) + +from app.database import SessionLocal, engine +from app.models import Base, User + +USERS = [ + {"username": "nova", "email": "nova@gamehub.io", "bio": "Explorer of virtual worlds."}, + {"username": "alex_g", "email": "alex@gamehub.io", "bio": "Speedrunner. Coffee addict."}, + {"username": "maya_r", "email": "maya@gamehub.io", "bio": "RPG lover, lore hunter."}, + {"username": "thunderbyte", "email": "thunder@gamehub.io", "bio": "FPS main, occasional cozy gamer."}, + {"username": "pixel_queen", "email": "pixel@gamehub.io", "bio": "Completionist. 100% or nothing."}, +] + +FAKE_HASH = "hashed_password" + +def run(): + Base.metadata.create_all(bind=engine) + db = SessionLocal() + imported = 0 + for data in USERS: + existing = db.query(User).filter(User.username == data["username"]).first() + if existing: + continue + user = User( + username=data["username"], + email=data["email"], + hashed_password=FAKE_HASH, + bio=data["bio"], + ) + db.add(user) + imported += 1 + + db.commit() + db.close() + print(f"Imported {imported} users.") + +if __name__ == "__main__": + run() \ 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..3cb8576caa93bc4a054c81d289f90bfe481aa457 GIT binary patch literal 28672 zcmeI(%WE4)7y$5Htt4Acr73Ar;oCqkv5-gX`{5FZqN=HhpR!b%Ls@1YvX{zQMOwLb zd~ymsl#+Y@iS}rEE%YDgsk!FRTcPxnV`n4oB8lwzpQJ zB*g8wzef|S2_FigC_Kbi5QLe@hiyiS#MbG_KHrF^_AlC<5h~x`$)h(yHh(0bJo+Vn zRCrxDDg1EJ1yBwGAOHd&00JNY0w4eaAOHe4P~b&Dnkg2G;_E_;kg_?-=%xuxhKOt8gf??T7f$#>Q&By3U{H_d}YnD_M8(Qx;`&Wv#x1y(q@b`j<^! zVY9KaR&6}RPwG$cVic5kS!;b|t5wH~qj=7gybZrJS1M(*#Ro-Eh`K>|FnHEs9aEPM zlX!IOjvQQVw3#VoF0ZPNs%A^+%d0A*s+r@GI8!W_#TU6zi_lKE=S9A|&l)C*yHkg$ zGp#grlDF67GPgV2d)oqwnp!&3c(sl7?PjC8vfji~cW`@O`}hpysrxak2dQjvt9%jG z^v#s10^XOO<=?41`-tdof&IY(0w4eaAOHd&00JNY0w4eaAOHd&a6<$NVrs!O+d=49 zrme~P9D6V5|KE_th1UQ95C8!X009sH0T2KI5C8!X0D*}Bk`{8O`~Up+|Lg~U3g~&^ zpF%hPD*u=rzybmw00JNY0w4eaAOHd&00Qr=fSa9d&ClP@RR!5LZP`#X;_GHebj_f| z@l}I(re~{)+NO%#9why!yK{Q&jl$mYaBjsH6 ziK8jDPd%CVzC!sEe8O&R6V2Cs)A4mh*3DjY5O&;W!!YcQb|3Dr1peKjSBc`Oy!@$0 z%FBks9;s-SRg3p}oiJgE>CqsmU`3V}aNNh;IN4>9rn5tH%R<~C+C4!TP?_I)}|n#X?p{1EdO z>{pIOi8Vx-1@(zx$d<2%7LgrKCAzM7#C8mWut-BqF=b6t*<^u3>avOHhx8i3sEy6X zxD&Ii`6agLhnT%#!ehv!CNb24K$msf4v5qCO~RNILQT&gj$zA|X0t{#dE)$myTjM- z9)oFZ_rfsf54&CVU|fsa?J&eN2qHhJ982PC>n^vb$w6DS_=DSKU=uwsJ>uB3&Hi~T zOHm!)pn=?t_bLCpdlZIUKj?9RK91XXKk6q#+QDqnM<*Cm`0xK&bW1?D&>!e4^c~XC z&+qNm90CUc5C8!X009sH0T2KI5C8!X0D;Q|7NwSWmEoZ*uXvSFo(!*emBE)ZuXvRK z5s6p4%BV%^zSPQHWmsX{|9>f>Y`S)43s;k0nL?Z+b@hBdIJ-Wl2c9Y$|IZbz3Uurg8|z{r?kz z_5EL?H|SS%f_}e#{BR`*fB*=900@8p2!H?xfB*=900@A<|0r;3pa4nt#sdZ8`6L;B ad}=mFnpYi9q>%XTcs@gFM!Gv13iuZ;6;Mb3 literal 0 HcmV?d00001 From 5caaf170b0a01b2a677eef1d7fe66c6ab0edca93 Mon Sep 17 00:00:00 2001 From: Bahjat Date: Wed, 20 May 2026 13:53:12 +0200 Subject: [PATCH 7/7] complete module 03 - activity service and gateway --- gateway/app/__init__.py | 0 gateway/app/config.py | 24 +++ gateway/app/main.py | 63 ++++++++ gateway/requirements.txt | 4 + services/activity-service/activities.db | Bin 0 -> 20480 bytes services/activity-service/alembic.ini | 149 ++++++++++++++++++ services/activity-service/alembic/README | 1 + services/activity-service/alembic/env.py | 84 ++++++++++ .../activity-service/alembic/script.py.mako | 28 ++++ .../d3b0909522cf_create_activities_table.py | 40 +++++ services/activity-service/app/__init__.py | 0 services/activity-service/app/config.py | 10 ++ services/activity-service/app/database.py | 18 +++ services/activity-service/app/models.py | 14 ++ services/activity-service/app/repository.py | 25 +++ services/activity-service/app/routes.py | 22 +++ services/activity-service/app/schemas.py | 31 ++++ services/activity-service/app/service.py | 64 ++++++++ services/activity-service/main.py | 10 ++ services/activity-service/requirements.txt | 6 + 20 files changed, 593 insertions(+) create mode 100644 gateway/app/__init__.py create mode 100644 gateway/app/config.py create mode 100644 gateway/app/main.py create mode 100644 gateway/requirements.txt create mode 100644 services/activity-service/activities.db create mode 100644 services/activity-service/alembic.ini create mode 100644 services/activity-service/alembic/README create mode 100644 services/activity-service/alembic/env.py create mode 100644 services/activity-service/alembic/script.py.mako create mode 100644 services/activity-service/alembic/versions/d3b0909522cf_create_activities_table.py create mode 100644 services/activity-service/app/__init__.py create mode 100644 services/activity-service/app/config.py create mode 100644 services/activity-service/app/database.py create mode 100644 services/activity-service/app/models.py create mode 100644 services/activity-service/app/repository.py create mode 100644 services/activity-service/app/routes.py create mode 100644 services/activity-service/app/schemas.py create mode 100644 services/activity-service/app/service.py create mode 100644 services/activity-service/main.py create mode 100644 services/activity-service/requirements.txt diff --git a/gateway/app/__init__.py b/gateway/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gateway/app/config.py b/gateway/app/config.py new file mode 100644 index 00000000..cff24dee --- /dev/null +++ b/gateway/app/config.py @@ -0,0 +1,24 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # Services available from Module 3 + user_service_url: str = "http://localhost:8001" + game_service_url: str = "http://localhost:8002" + activity_service_url: str = "http://localhost:8003" + + # Added in Module 4 + # notification_service_url: str = "http://localhost:8004" + + # Added in Module 5 + # logging_service_url: str = "http://localhost:8006" + + # Added in Module 6 + # auth_service_url: str = "http://localhost:8005" + # secret_key: str = "dev-secret-change-in-production" + + class Config: + env_file = ".env" + + +settings = Settings() diff --git a/gateway/app/main.py b/gateway/app/main.py new file mode 100644 index 00000000..0ed6d052 --- /dev/null +++ b/gateway/app/main.py @@ -0,0 +1,63 @@ +import httpx +from fastapi import FastAPI, Request, Response + +from app.config import settings + +app = FastAPI(title="gateway", version="1.0.0") + +# Routing table — maps the resource name in the URL path to the target service base URL. +# Path structure: /{version}/{resource}/... +# e.g. GET /v1/users/123 → resource = "users" → forward to user_service_url +# +# Add new entries here as each module introduces a new service. +# Module 4 will add: "notifications" +# Module 5 will add: "consent", "logs" +# Module 6 will add: "auth" +ROUTES: dict[str, str] = { + "users": settings.user_service_url, + "games": settings.game_service_url, + "activities": settings.activity_service_url, +} + + +@app.get("/health") +async def health(): + """ + Gateway liveness check. Handled here — never forwarded to a service. + In Module 10 this endpoint will be upgraded to fan out to all services + and return their individual status. + """ + return {"status": "ok", "service": "gateway"} + + +@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"]) +async def proxy(request: Request, path: str): + segments = path.split("/") + if len(segments) < 2: + return Response(status_code=404, content="Not found") + + resource = segments[1] + target_base = ROUTES.get(resource) + + if not target_base: + return Response(status_code=404, content=f"Unknown resource: {resource}") + + target_url = f"{target_base}/{path}" + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.request( + method=request.method, + url=target_url, + headers=request.headers.raw, + content=await request.body(), + params=request.query_params, + ) + return Response( + content=response.content, + status_code=response.status_code, + headers=dict(response.headers), + media_type=response.headers.get("content-type"), + ) + except httpx.RequestError: + return Response(status_code=503, content="Service unavailable") \ No newline at end of file diff --git a/gateway/requirements.txt b/gateway/requirements.txt new file mode 100644 index 00000000..c6d6f316 --- /dev/null +++ b/gateway/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +httpx==0.27.2 +pydantic-settings==2.4.0 diff --git a/services/activity-service/activities.db b/services/activity-service/activities.db new file mode 100644 index 0000000000000000000000000000000000000000..25f5644abf414f13cf0a5d180dc3853f43bac852 GIT binary patch literal 20480 zcmeI(O>fgk7zc1WF72jL>OCMud)ny%ZL2cX%=m4l5)z?AP*DnmWF@#*j%SQ4lD3-{ z1jm5HfiJ_EPqEki44n4JkugxEK}71IT+rXj$n)Y%p68djjGa7wbXZOdDNaViY(fI( zcgJ;|n}j%yvueu~Th5M}t*o3i?Abk6UshdpcHdoX_@A7W#z)6*_#YY{H~w38G5R3@ z0SG_<0uX=z1Rwwb2teR61ZK~@wdUrg`(|^J^#>-)C*_NBQkwC+wzA)8x4Lc8ZQVL- zlX*YcTCbOy{N3v8-)(is(St5I>Kz_lC+qd;*o@L8W6!doSvFQpescW7DfM)eRfFlU zJf2R>m>e8++jrWX>f`c}$tFgpcCUKdM%q2N->$kJb`I{hI#0;G_7k#I>h1OIU}d#= zXVZ1cV{KlJ{~6d#ZkkOeCtup>Jhhb1>sMym?pkwa$9-M@N;ELTewn8)%xG-m$rhWn zMXbfaO6=R)(&OpyEYGb#Y%lQbKR9~a?X+x$2yD2uYdYvN#Z)9j zUaZ@^u>b!>zFedM0SG_<0uX=z1Rwwb2tWV=5cnqGd)Mmc_y5)Ne{Uml{Bq-8d%^($ z2tWV=5P$##AOHafKmY;|m;IR6iI}p83Pw2Ji`X7lyD?*tQnP05EHatvJT!*#Kt?p=iJ~$zf`*YYI*2vn zJo(v?g=#O9yAh8=8O*M_Rb+oyvm(=mCjl+A$Z41{NhP;|szSs{Fq0I9T~=hLgY1>j zPZz;#SUlN{l#FClJ^%Oow~qhTfA8a;`SUf78U!E!0SG_<0uX=z1Rwwb2teS{1-87N Xdy!iy-j@q6aA%|Tm)EOb/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = sqlite:///./activities.db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/services/activity-service/alembic/README b/services/activity-service/alembic/README new file mode 100644 index 00000000..98e4f9c4 --- /dev/null +++ b/services/activity-service/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/services/activity-service/alembic/env.py b/services/activity-service/alembic/env.py new file mode 100644 index 00000000..7ee68f22 --- /dev/null +++ b/services/activity-service/alembic/env.py @@ -0,0 +1,84 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +from app.database import Base +from app.models import Activity +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/services/activity-service/alembic/script.py.mako b/services/activity-service/alembic/script.py.mako new file mode 100644 index 00000000..11016301 --- /dev/null +++ b/services/activity-service/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/services/activity-service/alembic/versions/d3b0909522cf_create_activities_table.py b/services/activity-service/alembic/versions/d3b0909522cf_create_activities_table.py new file mode 100644 index 00000000..83de7454 --- /dev/null +++ b/services/activity-service/alembic/versions/d3b0909522cf_create_activities_table.py @@ -0,0 +1,40 @@ +"""create activities table + +Revision ID: d3b0909522cf +Revises: +Create Date: 2026-05-20 13:38:09.238175 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd3b0909522cf' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('activities', + sa.Column('id', sa.String(), nullable=False), + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('game_id', sa.String(), nullable=False), + sa.Column('action', sa.String(), nullable=False), + sa.Column('duration_minutes', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('activities') + # ### end Alembic commands ### diff --git a/services/activity-service/app/__init__.py b/services/activity-service/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/activity-service/app/config.py b/services/activity-service/app/config.py new file mode 100644 index 00000000..2012b259 --- /dev/null +++ b/services/activity-service/app/config.py @@ -0,0 +1,10 @@ +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + user_service_url: str = "http://localhost:8001" + game_service_url: str = "http://localhost:8002" + + class Config: + env_file = ".env" + +settings = Settings() \ No newline at end of file diff --git a/services/activity-service/app/database.py b/services/activity-service/app/database.py new file mode 100644 index 00000000..228b95a9 --- /dev/null +++ b/services/activity-service/app/database.py @@ -0,0 +1,18 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase +import os + +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./activities.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/activity-service/app/models.py b/services/activity-service/app/models.py new file mode 100644 index 00000000..94e06e1a --- /dev/null +++ b/services/activity-service/app/models.py @@ -0,0 +1,14 @@ +from sqlalchemy import Column, String, Integer, DateTime +from datetime import datetime, timezone +import uuid +from app.database import Base + +class Activity(Base): + __tablename__ = "activities" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + user_id = Column(String, nullable=False) + game_id = Column(String, nullable=False) + action = Column(String, nullable=False) + duration_minutes = Column(Integer, nullable=True) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) \ No newline at end of file diff --git a/services/activity-service/app/repository.py b/services/activity-service/app/repository.py new file mode 100644 index 00000000..2c6aefc4 --- /dev/null +++ b/services/activity-service/app/repository.py @@ -0,0 +1,25 @@ +from sqlalchemy.orm import Session +from app.models import Activity +from app.schemas import ActivityCreate + +def create_activity(db: Session, data: ActivityCreate) -> Activity: + activity = Activity( + user_id=data.user_id, + game_id=data.game_id, + action=data.action, + duration_minutes=data.duration_minutes, + ) + db.add(activity) + db.commit() + db.refresh(activity) + return activity + +def list_activities(db: Session, limit: int = 20, offset: int = 0): + total = db.query(Activity).count() + items = db.query(Activity).offset(offset).limit(limit).all() + return items, total + +def list_activities_by_user(db: Session, user_id: str, limit: int = 20, offset: int = 0): + total = db.query(Activity).filter(Activity.user_id == user_id).count() + items = db.query(Activity).filter(Activity.user_id == user_id).offset(offset).limit(limit).all() + return items, total \ No newline at end of file diff --git a/services/activity-service/app/routes.py b/services/activity-service/app/routes.py new file mode 100644 index 00000000..46b1b922 --- /dev/null +++ b/services/activity-service/app/routes.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.database import get_db +from app import service +from app.schemas import ActivityCreate, ActivityOut, ActivityList + +router = APIRouter(prefix="/v1/activities", tags=["activities"]) + +@router.post("/", status_code=201, response_model=ActivityOut) +async def create_activity(data: ActivityCreate, db: Session = Depends(get_db)): + try: + return await service.add_activity(db, data) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + +@router.get("/", response_model=ActivityList) +async def list_activities(limit: int = 20, offset: int = 0, db: Session = Depends(get_db)): + return await service.fetch_all_activities(db, limit, offset) + +@router.get("/user/{user_id}", response_model=ActivityList) +async def list_user_activities(user_id: str, limit: int = 20, offset: int = 0, db: Session = Depends(get_db)): + return await service.fetch_user_activities(db, user_id, limit, offset) \ 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..5e91686c --- /dev/null +++ b/services/activity-service/app/schemas.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel +from datetime import datetime +from typing import Optional + +class GameOut(BaseModel): + id: str + title: str + genre: str + description: Optional[str] = None + +class ActivityCreate(BaseModel): + user_id: str + game_id: str + action: str + duration_minutes: Optional[int] = None + +class ActivityOut(BaseModel): + id: str + user_id: str + action: str + duration_minutes: Optional[int] + created_at: datetime + game: Optional[GameOut] = None + + model_config = {"from_attributes": True} + +class ActivityList(BaseModel): + items: list[ActivityOut] + total: int + limit: int + offset: int \ No newline at end of file diff --git a/services/activity-service/app/service.py b/services/activity-service/app/service.py new file mode 100644 index 00000000..15889d62 --- /dev/null +++ b/services/activity-service/app/service.py @@ -0,0 +1,64 @@ +import httpx +from sqlalchemy.orm import Session +from app import repository +from app.schemas import ActivityCreate, ActivityOut, ActivityList, GameOut +from app.config import settings + +async def validate_user(user_id: str) -> bool: + for attempt in range(3): + try: + async with httpx.AsyncClient(timeout=5.0) as client: + resp = await client.get(f"{settings.user_service_url}/v1/users/{user_id}") + return resp.status_code == 200 + except httpx.RequestError: + if attempt == 2: + raise + return False + +async def fetch_game(game_id: str) -> GameOut | None: + try: + async with httpx.AsyncClient(timeout=5.0) as client: + resp = await client.get(f"{settings.game_service_url}/v1/games/{game_id}") + if resp.status_code == 200: + data = resp.json() + return GameOut( + id=data["id"], + title=data["title"], + genre=data["genre"], + description=data.get("description"), + ) + except httpx.RequestError: + pass + return None + +async def add_activity(db: Session, data: ActivityCreate) -> ActivityOut: + user_exists = await validate_user(data.user_id) + if not user_exists: + raise ValueError(f"User {data.user_id} not found") + + activity = repository.create_activity(db, data) + game = await fetch_game(data.game_id) + + result = ActivityOut.model_validate(activity) + result.game = game + return result + +async def fetch_all_activities(db: Session, limit: int = 20, offset: int = 0) -> ActivityList: + items, total = repository.list_activities(db, limit, offset) + enriched = [] + for a in items: + game = await fetch_game(a.game_id) + out = ActivityOut.model_validate(a) + out.game = game + enriched.append(out) + return ActivityList(items=enriched, total=total, limit=limit, offset=offset) + +async def fetch_user_activities(db: Session, user_id: str, limit: int = 20, offset: int = 0) -> ActivityList: + items, total = repository.list_activities_by_user(db, user_id, limit, offset) + enriched = [] + for a in items: + game = await fetch_game(a.game_id) + out = ActivityOut.model_validate(a) + out.game = game + enriched.append(out) + return ActivityList(items=enriched, total=total, limit=limit, offset=offset) \ No newline at end of file diff --git a/services/activity-service/main.py b/services/activity-service/main.py new file mode 100644 index 00000000..b2bde61e --- /dev/null +++ b/services/activity-service/main.py @@ -0,0 +1,10 @@ +from fastapi import FastAPI +from app.routes import router + +app = FastAPI(title="activity-service") + +@app.get("/health") +async def health(): + return {"status": "ok", "service": "activity-service"} + +app.include_router(router) \ 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..422f24dc --- /dev/null +++ b/services/activity-service/requirements.txt @@ -0,0 +1,6 @@ +fastapi +uvicorn +sqlalchemy +httpx +pydantic-settings +alembic \ No newline at end of file