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/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/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. diff --git a/modules/module-02/REFLECTION.md b/modules/module-02/REFLECTION.md index 52850343..673edd0b 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/` **Submitted**: before Module 3 lesson --- @@ -18,7 +18,7 @@ You built a service with distinct layers: models, schemas, repository, service, Think about what happens six months later when someone new joins the team, or when you need to swap SQLite for PostgreSQL. What does the layered structure protect you from? -> *Your answer:* +> 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. --- 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/activity-service/activities.db b/services/activity-service/activities.db new file mode 100644 index 00000000..25f5644a Binary files /dev/null and b/services/activity-service/activities.db differ diff --git a/services/activity-service/alembic.ini b/services/activity-service/alembic.ini new file mode 100644 index 00000000..8930335a --- /dev/null +++ b/services/activity-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:///./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 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/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/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/__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..808b08b2 --- /dev/null +++ b/services/game-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:///./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/models.py b/services/game-service/app/models.py new file mode 100644 index 00000000..e5ed06e2 --- /dev/null +++ b/services/game-service/app/models.py @@ -0,0 +1,14 @@ +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(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 new file mode 100644 index 00000000..d415d313 --- /dev/null +++ b/services/game-service/app/repository.py @@ -0,0 +1,22 @@ +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, + description=data.description, + ) + db.add(game) + db.commit() + db.refresh(game) + return game + +def get_game(db: Session, game_id: str) -> Game | None: + return db.query(Game).filter(Game.id == game_id).first() + +def list_games(db: Session, limit: int = 20, offset: int = 0) -> tuple[list[Game], int]: + total = db.query(Game).count() + games = db.query(Game).offset(offset).limit(limit).all() + return games, total \ 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..e838340b --- /dev/null +++ b/services/game-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 GameCreate, GameOut, GameList + +router = APIRouter(prefix="/v1/games", tags=["games"]) + +@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=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=GameOut) +def get_game(game_id: str, db: Session = Depends(get_db)): + try: + return service.fetch_game(db, game_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) \ No newline at end of file diff --git a/services/game-service/app/schemas.py b/services/game-service/app/schemas.py new file mode 100644 index 00000000..548e0bff --- /dev/null +++ b/services/game-service/app/schemas.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel +from datetime import datetime + +class GameCreate(BaseModel): + title: str + genre: str + description: str + +class GameOut(BaseModel): + id: str + title: str + genre: str + description: str | None + is_active: bool + created_at: datetime + + model_config = {"from_attributes": True} + +class GameList(BaseModel): + items: list[GameOut] + total: int + limit: int + offset: int \ No newline at end of file diff --git a/services/game-service/app/service.py b/services/game-service/app/service.py new file mode 100644 index 00000000..5f913252 --- /dev/null +++ b/services/game-service/app/service.py @@ -0,0 +1,22 @@ +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 00000000..c95cea52 Binary files /dev/null and b/services/game-service/games.db differ 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/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/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/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 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 00000000..3cb8576c Binary files /dev/null and b/services/user-service/users.db differ