diff --git a/gateway/.env.example b/gateway/.env.example new file mode 100644 index 00000000..abe1e628 --- /dev/null +++ b/gateway/.env.example @@ -0,0 +1,3 @@ +USER_SERVICE_URL=http://localhost:8001 +GAME_SERVICE_URL=http://localhost:8002 +ACTIVITY_SERVICE_URL=http://localhost:8003 diff --git a/gateway/Dockerfile b/gateway/Dockerfile new file mode 100644 index 00000000..d9404afb --- /dev/null +++ b/gateway/Dockerfile @@ -0,0 +1,17 @@ +# --- Stage 1: builder --- +FROM python:3.12-slim AS builder + +WORKDIR /app +COPY requirements.txt . +RUN pip install --prefix=/install --no-cache-dir -r requirements.txt + + +# --- Stage 2: runtime --- +FROM python:3.12-slim AS runtime + +COPY --from=builder /install /usr/local +WORKDIR /app +COPY . . + +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/gateway/README.md b/gateway/README.md new file mode 100644 index 00000000..b69ebe60 --- /dev/null +++ b/gateway/README.md @@ -0,0 +1,30 @@ +# gateway + +Single entry point for all client requests from Module 3 onward. Routes by path prefix — no business logic. + +## Setup + +```bash +# From the repo root, copy this starter into place first: +cp -r modules/module-03/gateway-starter gateway + +cd gateway +cp .env.example .env +pip install -r requirements.txt +uvicorn app.main:app --reload --port 8000 +``` + +## Your task + +Open `app/main.py` and implement the `proxy` function. The step-by-step instructions are in the docstring. + +## Routing table + +| Path prefix | Forwards to | +|---|---| +| `/v1/users/...` | user-service (port 8001) | +| `/v1/games/...` | game-service (port 8002) | +| `/v1/activities/...` | activity-service (port 8003) | +| `/health` | handled by the gateway itself | + +New entries are added to `ROUTES` in `main.py` as each module introduces a new service. 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..97e6aef6 --- /dev/null +++ b/gateway/app/config.py @@ -0,0 +1,23 @@ +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" + + model_config = {"env_file": ".env"} + + +settings = Settings() diff --git a/gateway/app/main.py b/gateway/app/main.py new file mode 100644 index 00000000..b4adfa1d --- /dev/null +++ b/gateway/app/main.py @@ -0,0 +1,140 @@ +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, + "notifications": settings.notification_service_url, # Added in Module 4 + # "auth": settings.auth_service_url, # Added in Module 6 + # "consent": settings.logging_service_url, # Added in Module 5 + # "logs": settings.logging_service_url, # Added in Module 5 +} + + +@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): + """ + Catch-all reverse proxy — forwards every request to the correct downstream service. + + Your job: implement the forwarding logic described below. + + --- + Step 1 — Parse the path to find the resource name. + + The path arrives without the leading slash, e.g. "v1/users/123". + Split it on "/" to get a list of segments: + segments = path.split("/") + # ["v1", "users", "123"] + + Index 0 is the version ("v1"). + Index 1 is the resource ("users", "games", "activities", ...). + + If the path has fewer than 2 segments, return a 404. + + --- + Step 2 — Look up the resource in ROUTES. + + resource = segments[1] + target_base = ROUTES.get(resource) + + If `resource` is not in ROUTES, return: + Response(status_code=404, content=f"Unknown resource: {resource}") + + --- + Step 3 — Build the target URL and forward the request. + + The full path must be forwarded as-is — no stripping, no rewriting. + Reconstruct it with the leading slash: + target_url = f"{target_base}/{path}" + + Forward using httpx, preserving method, headers, and body: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.request( + method=request.method, + url=target_url, + headers=request.headers.raw, # forward all original headers + content=await request.body(), # forward the body as-is + params=request.query_params, # forward query string + ) + + Return a FastAPI Response with the downstream status, headers, and body: + return Response( + content=response.content, + status_code=response.status_code, + headers=dict(response.headers), + media_type=response.headers.get("content-type"), + ) + + --- + Step 4 — Handle an unreachable downstream service. + + Wrap the httpx call in a try/except for httpx.RequestError. + If the service cannot be reached, return: + Response(status_code=503, content="Service unavailable") + + --- + Verify your implementation: + curl http://localhost:8000/health + curl http://localhost:8000/v1/users + curl http://localhost:8000/v1/games + curl http://localhost:8000/v1/activities + curl http://localhost:8000/v1/unknown # should return 404 + """ + # Step 1 — Parse the path to find the resource name + segments = path.split("/") + if len(segments) < 2: + return Response(status_code=404, content="Not found") + + resource = segments[1] + + # Step 2 — Look up the resource in ROUTES + target_base = ROUTES.get(resource) + if target_base is None: + return Response(status_code=404, content=f"Unknown resource: {resource}") + + # Step 3 — Build the target URL and forward the request + 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"), + ) + + # Step 4 — Handle an unreachable downstream service + except httpx.RequestError: + return Response(status_code=503, content="Service unavailable") diff --git a/gateway/requirements.txt b/gateway/requirements.txt new file mode 100644 index 00000000..6124d340 --- /dev/null +++ b/gateway/requirements.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn[standard] +httpx +pydantic-settings +python-jose[cryptography] diff --git a/modules/module-03/REFLECTION.md b/modules/module-03/REFLECTION.md index 105be9f9..82c57252 100644 --- a/modules/module-03/REFLECTION.md +++ b/modules/module-03/REFLECTION.md @@ -1,8 +1,8 @@ # Module 3 — Reflection -**Team name**: _______________ -**Branch**: `module-03/` -**Submitted**: before Module 4 lesson +**Team name**: Chimaera-Emerald +**Branch**: `module-03/chimaera-emerald` +**Submitted**: Module 3 implementation complete --- @@ -19,6 +19,7 @@ All client requests now go through the gateway. No client ever calls a service d Think about what the client would need to know and manage if it talked to each service on its own port. > *Your answer:* +> without the gateway the client gotta know where every service is. like remember which port is 8001, 8002, 8003? pain. if we move stuff around or scale, client code breaks. gateway solves that — client just talks to one address and the gateway figures out where to send it. also means we can add auth or rate limiting in one place instead of everywhere. --- @@ -31,6 +32,7 @@ The activity-service makes two outbound calls: one to validate the user (with re What is the consequence for the user in each case if the downstream service is unavailable? > *Your answer:* +> user validation is a must-have. u can't save an activity without knowing the user exists or u get junk data. so we retry and fail hard if it doesn't work. game data is just extra — the activity is still good even if we don't have the game name. so if game-service dies we're like "ok whatever here's null" instead of blowing up everything. --- @@ -43,6 +45,7 @@ Every time a client creates an activity, three services are involved synchronous What happens to the user experience if the slowest service in the chain takes 3 seconds to respond? > *Your answer:* +> ur system is only as fast as the slowest service. if one takes 3 seconds everything waits. plus if anything dies the whole chain breaks. that's why big systems use queues and stuff — so one slow/dead service doesn't tank everything else. --- diff --git a/modules/module-04/REFLECTION.md b/modules/module-04/REFLECTION.md index 728d1295..1b449ec5 100644 --- a/modules/module-04/REFLECTION.md +++ b/modules/module-04/REFLECTION.md @@ -1,7 +1,7 @@ # Module 4 — Reflection -**Team name**: _______________ -**Branch**: `module-04/` +**Team name**: Chimaera-Emerald +**Branch**: `module-04/chimaera-emerald` **Submitted**: before Module 5 lesson --- @@ -19,6 +19,7 @@ In Module 3, services called each other directly over HTTP. Now activity-service Think about what happens under load, or when notification-service is temporarily down. > *Your answer:* +> activity-service can save the activity fast and move on, so the user isn't stuck waiting for notifications. if notification-service is down or slow, the message sits in rabbitmq and gets handled later. that means the main flow stays working even if notifications are temporarily offline. --- @@ -31,6 +32,7 @@ In Module 3 you already knew how to call another service directly over HTTP — Think about what happens if notification-service is slow, or crashes mid-message. > *Your answer:* +> if we did HTTP, activity creation would hang or fail whenever notification-service was slow or down. with rabbitmq the activity-service just drops a message and keeps going, so the main request stays fast and reliable. the broker decouples them — notifications can be processed later without holding up the user. --- @@ -43,6 +45,7 @@ With synchronous REST, you get an immediate answer: success or failure. With asy What visibility do you lose when you go async? > *Your answer:* +> the user probably wouldn't know right away — they just see the activity succeed and may never see the notification. as a dev you'd need logs, queue metrics, or dead-letter handling to know if the message got stuck. going async means you lose immediate delivery confirmation: the request is done before the notification is actually processed. --- diff --git a/services/activity-service/activities.db b/services/activity-service/activities.db new file mode 100644 index 00000000..2a05c476 Binary files /dev/null and b/services/activity-service/activities.db differ diff --git a/services/activity-service/app/main.py b/services/activity-service/app/main.py index 714887a1..f25c304d 100644 --- a/services/activity-service/app/main.py +++ b/services/activity-service/app/main.py @@ -12,6 +12,7 @@ from app.config import settings from app.database import Base, engine, get_db +from app.infrastructure.rabbitmq_publisher import publish_activity_event from app import repository, schemas Base.metadata.create_all(bind=engine) @@ -39,7 +40,24 @@ async def validate_user(user_id: str) -> None: Use `async with httpx.AsyncClient(timeout=5.0) as client:` for HTTP calls. This call is CRITICAL — the request must not proceed if validation fails. """ - raise NotImplementedError + url = f"{settings.user_service_url}/v1/users/{user_id}" + + retries = 2 + for attempt in range(retries): + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get(url) + + if response.status_code == 200: + return # User exists + elif response.status_code == 404: + raise HTTPException(status_code=404, detail="User not found") + else: + raise HTTPException(status_code=503, detail="user-service unavailable") + + except httpx.RequestError: + if attempt == retries - 1: # Last retry failed + raise HTTPException(status_code=503, detail="user-service unavailable") async def fetch_game(game_id: str) -> dict | None: @@ -56,7 +74,19 @@ async def fetch_game(game_id: str) -> dict | None: Graceful degradation is the goal: the response will include "game": null when game-service is unreachable. """ - raise NotImplementedError + url = f"{settings.game_service_url}/v1/games/{game_id}" + + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get(url) + + if response.status_code == 200: + return response.json() + else: + return None + + except httpx.RequestError: + return None # --------------------------------------------------------------------------- @@ -73,6 +103,15 @@ async def create_activity(data: schemas.ActivityCreate, db: Session = Depends(ge await validate_user(data.user_id) activity = repository.create_activity(db, data) game_data = await fetch_game(activity.game_id) + + game_title = game_data["title"] if game_data else None + await publish_activity_event( + user_id=activity.user_id, + game_id=activity.game_id, + action=activity.action, + game_title=game_title, + ) + return { "id": activity.id, "user_id": activity.user_id, 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..4420004c --- /dev/null +++ b/services/game-service/alembic/env.py @@ -0,0 +1,77 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# 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 app.database import Base +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/4875e609fcfa_create_games_table.py b/services/game-service/alembic/versions/4875e609fcfa_create_games_table.py new file mode 100644 index 00000000..3fe75264 --- /dev/null +++ b/services/game-service/alembic/versions/4875e609fcfa_create_games_table.py @@ -0,0 +1,32 @@ +"""create games table + +Revision ID: 4875e609fcfa +Revises: +Create Date: 2026-06-08 11:38:53.305709 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '4875e609fcfa' +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! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/services/game-service/app/database.py b/services/game-service/app/database.py index 56dfd44d..e2bedf00 100644 --- a/services/game-service/app/database.py +++ b/services/game-service/app/database.py @@ -1,10 +1,18 @@ -# Infrastructure layer — database connection. -# -# Replicate the same structure as user-service/app/database.py. -# The only difference: the default DATABASE_URL points to games.db. -# -# This file should provide: -# - engine — SQLAlchemy engine built from DATABASE_URL -# - SessionLocal — session factory bound to the engine -# - Base — DeclarativeBase that all ORM models inherit from -# - get_db() — FastAPI dependency: yields a session, closes it after the request +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase +import os + +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./games.db") + +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +class Base(DeclarativeBase): + pass + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/services/game-service/app/main.py b/services/game-service/app/main.py index e629900e..5a0fdc8e 100644 --- a/services/game-service/app/main.py +++ b/services/game-service/app/main.py @@ -1,9 +1,9 @@ -# Entry point — FastAPI application. -# -# Create the FastAPI app instance and register the router from app.routes. -# Keep it minimal: no business logic, no endpoints defined here. -# -# To run the service locally: -# uvicorn app.main:app --reload --port 8002 -# -# Then open: http://localhost:8002/docs +from fastapi import FastAPI +from app.routes import router + +app = FastAPI(title="game-service") +app.include_router(router) + +@app.get("/health") +async def health(): + return {"status": "ok", "service": "game-service"} diff --git a/services/game-service/app/models.py b/services/game-service/app/models.py index 52eb2756..d39b51b1 100644 --- a/services/game-service/app/models.py +++ b/services/game-service/app/models.py @@ -1,14 +1,15 @@ -# Infrastructure layer — ORM model. -# -# Define the `games` table. This is the only file that knows about columns. -# -# The Game model should have these columns: -# - id String, primary key, UUID generated by default -# - title String, not nullable -# - genre String, not nullable -# - platform String, not nullable -# - release_year Integer, nullable -# - cover_url String, nullable -# - created_at DateTime, defaults to now (UTC) -# -# Import Base from app.database — do not redefine it here. +from sqlalchemy import Column, String, Integer, DateTime +from datetime import datetime, timezone +import uuid +from app.database import Base + +class Game(Base): + __tablename__ = "games" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + title = Column(String, nullable=False) + genre = Column(String, nullable=False) + platform = Column(String, nullable=False) + release_year = Column(Integer, nullable=True) + cover_url = Column(String, nullable=True) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) diff --git a/services/game-service/app/repository.py b/services/game-service/app/repository.py index 8120c970..73fab319 100644 --- a/services/game-service/app/repository.py +++ b/services/game-service/app/repository.py @@ -1,10 +1,29 @@ -# Infrastructure layer — raw database queries. -# -# Implement these four functions. Each takes `db: Session` as its first argument. -# No business logic here — only ORM queries. -# -# - create_game(db, data) -> Game -# - get_game(db, game_id) -> Game | None -# - list_games(db, limit, offset) -> tuple[list[Game], int] -# - search_games(db, q, limit, offset) -> tuple[list[Game], int] -# Hint: filter by title using .ilike(f"%{q}%") for case-insensitive search +from sqlalchemy.orm import Session +from app.models import Game +from app.schemas import GameCreate + +def create_game(db: Session, data: GameCreate) -> Game: + game = Game( + title=data.title, + genre=data.genre, + platform=data.platform, + release_year=data.release_year, + cover_url=data.cover_url, + ) + db.add(game) + db.commit() + db.refresh(game) + return game + +def get_game(db: Session, game_id: str) -> Game | None: + return db.query(Game).filter(Game.id == game_id).first() + +def list_games(db: Session, limit: int = 20, offset: int = 0) -> tuple[list[Game], int]: + total = db.query(Game).count() + games = db.query(Game).offset(offset).limit(limit).all() + return games, total + +def search_games(db: Session, q: str, limit: int = 20, offset: int = 0) -> tuple[list[Game], int]: + total = db.query(Game).filter(Game.title.ilike(f"%{q}%")).count() + games = db.query(Game).filter(Game.title.ilike(f"%{q}%")).offset(offset).limit(limit).all() + return games, total diff --git a/services/game-service/app/routes.py b/services/game-service/app/routes.py index 435b3ded..75493e68 100644 --- a/services/game-service/app/routes.py +++ b/services/game-service/app/routes.py @@ -1,15 +1,25 @@ -# Interface layer — HTTP endpoints. -# -# Define a router with prefix="/v1/games" and implement these endpoints: -# - POST /v1/games/ -> create a game (201) -# - GET /v1/games/ -> list games (limit/offset pagination) -# - GET /v1/games/search -> search games by title (?q=...) -# - GET /v1/games/{game_id} -> get one game by ID (404 if not found) -# -# IMPORTANT: declare /search BEFORE /{game_id} in your router. -# If /{game_id} comes first, FastAPI will try to match "search" as an ID -# and return a 422 Unprocessable Entity error. -# -# Module 5 — CQRS: also add this endpoint (declare it before /{game_id}): -# - GET /v1/games/{game_id}/summary -> read from Redis cache (404 if not cached) -# from app.infrastructure.cache import get_game_summary +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.database import get_db +from app import service, schemas + +router = APIRouter(prefix="/v1/games", tags=["games"]) + +@router.post("/", response_model=schemas.GameOut, status_code=201) +def create_game(data: schemas.GameCreate, db: Session = Depends(get_db)): + return service.add_game(db, data) + +@router.get("/", response_model=schemas.GameList) +def list_games(limit: int = 20, offset: int = 0, db: Session = Depends(get_db)): + return service.fetch_all_games(db, limit=limit, offset=offset) + +@router.get("/search", response_model=schemas.GameList) +def search_games(q: str, limit: int = 20, offset: int = 0, db: Session = Depends(get_db)): + return service.find_games(db, q, limit=limit, offset=offset) + +@router.get("/{game_id}", response_model=schemas.GameOut) +def get_game(game_id: str, db: Session = Depends(get_db)): + try: + return service.fetch_game(db, game_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) diff --git a/services/game-service/app/schemas.py b/services/game-service/app/schemas.py index ca6aca4e..90cb5e3d 100644 --- a/services/game-service/app/schemas.py +++ b/services/game-service/app/schemas.py @@ -1,10 +1,26 @@ -# Application layer — Pydantic DTOs. -# -# Define the shapes of data coming IN and going OUT of the API. -# -# This file should define: -# - GameCreate — fields accepted when creating a game -# (title, genre, platform required; release_year and cover_url optional) -# - GameOut — fields returned to the caller (includes id and created_at) -# add model_config = {"from_attributes": True} -# - GameList — paginated envelope: { items, total, limit, offset } +from pydantic import BaseModel +from datetime import datetime + +class GameCreate(BaseModel): + title: str + genre: str + platform: str + release_year: int | None = None + cover_url: str | None = None + +class GameOut(BaseModel): + id: str + title: str + genre: str + platform: str + release_year: int | None + cover_url: str | None + created_at: datetime + + model_config = {"from_attributes": True} + +class GameList(BaseModel): + items: list[GameOut] + total: int + limit: int + offset: int diff --git a/services/game-service/app/service.py b/services/game-service/app/service.py index 08f93578..8dc0d4fd 100644 --- a/services/game-service/app/service.py +++ b/services/game-service/app/service.py @@ -1,17 +1,31 @@ -# Application layer — business logic. -# -# Calls repository functions and returns Pydantic schemas (not raw ORM objects). -# Raises ValueError when a game is not found — routes.py turns it into a 404. -# -# Implement these four functions: -# - add_game(db, data) -> GameOut -# - fetch_game(db, game_id) -> GameOut (raises ValueError if not found) -# - fetch_all_games(db, limit, offset) -> GameList -# - find_games(db, q, limit, offset) -> GameList (delegates to search_games in repository) -# -# Module 5 — CQRS: -# In add_game(), after saving to the DB, also write to the Redis cache: -# from app.infrastructure.cache import set_game_summary -# set_game_summary(game.id, {"id": game.id, "title": game.title, -# "genre": game.genre, "platform": game.platform, -# "cover_url": game.cover_url}) +from sqlalchemy.orm import Session +from app import repository +from app.schemas import GameCreate, GameOut, GameList + +def add_game(db: Session, data: GameCreate) -> GameOut: + game = repository.create_game(db, data) + return GameOut.model_validate(game) + +def fetch_game(db: Session, game_id: str) -> GameOut: + game = repository.get_game(db, game_id) + if game is None: + raise ValueError(f"Game {game_id} not found") + return GameOut.model_validate(game) + +def fetch_all_games(db: Session, limit: int = 20, offset: int = 0) -> GameList: + games, total = repository.list_games(db, limit=limit, offset=offset) + return GameList( + items=[GameOut.model_validate(g) for g in games], + total=total, + limit=limit, + offset=offset, + ) + +def find_games(db: Session, q: str, limit: int = 20, offset: int = 0) -> GameList: + games, total = repository.search_games(db, q, limit=limit, offset=offset) + return GameList( + items=[GameOut.model_validate(g) for g in games], + total=total, + limit=limit, + offset=offset, + ) diff --git a/services/game-service/games.db b/services/game-service/games.db new file mode 100644 index 00000000..e9644a72 Binary files /dev/null and b/services/game-service/games.db differ diff --git a/services/game-service/seed.py b/services/game-service/seed.py new file mode 100644 index 00000000..7c201a16 --- /dev/null +++ b/services/game-service/seed.py @@ -0,0 +1,39 @@ +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": "Hollow Knight", "genre": "metroidvania", "platform": "PC", "release_year": 2017, "cover_url": "https://example.com/hollow-knight.jpg"}, + {"title": "Elden Ring", "genre": "action-rpg", "platform": "PS5", "release_year": 2022, "cover_url": "https://example.com/elden-ring.jpg"}, + {"title": "Stardew Valley", "genre": "farming-sim", "platform": "PC", "release_year": 2016, "cover_url": "https://example.com/stardew.jpg"}, + {"title": "Celeste", "genre": "platformer", "platform": "Nintendo Switch", "release_year": 2018, "cover_url": "https://example.com/celeste.jpg"}, + {"title": "Hades", "genre": "roguelike", "platform": "PC", "release_year": 2020, "cover_url": "https://example.com/hades.jpg"}, +] + +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"], + platform=data["platform"], + release_year=data["release_year"], + cover_url=data["cover_url"], + ) + db.add(game) + imported += 1 + + db.commit() + db.close() + print(f"Imported {imported} games.") + +if __name__ == "__main__": + run() 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..4420004c --- /dev/null +++ b/services/user-service/alembic/env.py @@ -0,0 +1,77 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# 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 app.database import Base +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/206fd6f55e90_create_users_table.py b/services/user-service/alembic/versions/206fd6f55e90_create_users_table.py new file mode 100644 index 00000000..f03f6b56 --- /dev/null +++ b/services/user-service/alembic/versions/206fd6f55e90_create_users_table.py @@ -0,0 +1,43 @@ +"""create users table + +Revision ID: 206fd6f55e90 +Revises: +Create Date: 2026-06-08 11:30:54.526083 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '206fd6f55e90' +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.drop_table('users') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('id', sa.VARCHAR(), nullable=False), + sa.Column('username', sa.VARCHAR(), nullable=False), + sa.Column('email', sa.VARCHAR(), nullable=False), + sa.Column('hashed_password', sa.VARCHAR(), nullable=False), + sa.Column('bio', sa.VARCHAR(), nullable=True), + 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 ### diff --git a/services/user-service/app/database.py b/services/user-service/app/database.py index a5fe24f9..7cc2d717 100644 --- a/services/user-service/app/database.py +++ b/services/user-service/app/database.py @@ -1,12 +1,18 @@ -# Infrastructure layer — database connection. -# -# This file is responsible for: -# - Creating the SQLAlchemy engine from DATABASE_URL (read from .env) -# - Defining the declarative Base that all ORM models inherit from -# - Providing get_db(), a FastAPI dependency that opens a session per request -# and closes it when the request is done (using yield) -# -# Nothing in this file knows about Users, Games, or any business concept. -# It is pure infrastructure. -# -# See the README for the full implementation. +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase +import os + +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./users.db") + +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +class Base(DeclarativeBase): + pass + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/services/user-service/app/main.py b/services/user-service/app/main.py index a4463d42..7212dc91 100644 --- a/services/user-service/app/main.py +++ b/services/user-service/app/main.py @@ -9,3 +9,12 @@ # Then open: http://localhost:8001/docs # # See the README for the full implementation. + +from fastapi import FastAPI +from app.routes import router + +app = FastAPI(title="user-service") +app.include_router(router) +@app.get("/health") +async def health(): + return {"status": "ok", "service": "user-service"} \ No newline at end of file diff --git a/services/user-service/app/models.py b/services/user-service/app/models.py index 03e8cdba..a2699f41 100644 --- a/services/user-service/app/models.py +++ b/services/user-service/app/models.py @@ -1,13 +1,15 @@ -# Infrastructure layer — ORM model. -# -# This is the only file that defines the shape of the `users` table. -# It maps Python attributes to database columns using SQLAlchemy. -# -# This file should: -# - Import Base from app.database -# - Define a User class with columns: id, username, email, -# hashed_password, is_active, created_at -# -# Rule: no business logic here. This file only describes data structure. -# -# See the README for the full implementation. +from sqlalchemy import Column, String, Boolean, DateTime +from datetime import datetime, 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(String, 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 index f519c460..6c3a43fa 100644 --- a/services/user-service/app/repository.py +++ b/services/user-service/app/repository.py @@ -1,16 +1,22 @@ -# Infrastructure layer — raw database queries. -# -# Functions here take a SQLAlchemy Session and return ORM objects. -# This is the only layer allowed to write SQL / ORM queries. -# -# Rules: -# - No HTTP knowledge here (no Request, no HTTPException) -# - No business rules here (no password hashing, no validation logic) -# - Every function receives `db: Session` as its first argument -# -# This file should implement: -# - create_user(db, data, hashed_password) -> User -# - get_user(db, user_id) -> User | None -# - list_users(db, limit, offset) -> tuple[list[User], int] -# -# See the README for the full implementation. +from sqlalchemy.orm import Session +from app.models import User +from app.schemas import UserCreate + +def create_user(db: Session, data: UserCreate, 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 index 176c60cd..1f3efa4c 100644 --- a/services/user-service/app/routes.py +++ b/services/user-service/app/routes.py @@ -14,3 +14,24 @@ # - GET /v1/users/{user_id} -> get one user by ID (404 if not found) # # See the README for the full implementation. +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.database import get_db +from app import service, schemas + +router = APIRouter(prefix="/v1/users", tags=["users"]) + +@router.post("/", response_model=schemas.UserOut, status_code=201) +def create_user(data: schemas.UserCreate, db: Session = Depends(get_db)): + return service.add_user(db, data) + +@router.get("/", response_model=schemas.UserList) +def list_users(limit: int = 20, offset: int = 0, db: Session = Depends(get_db)): + return service.fetch_all_users(db, limit=limit, offset=offset) + +@router.get("/{user_id}", response_model=schemas.UserOut) +def get_user(user_id: str, db: Session = Depends(get_db)): + try: + return service.fetch_user(db, user_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) \ No newline at end of file diff --git a/services/user-service/app/schemas.py b/services/user-service/app/schemas.py index f2627a95..d4f252f2 100644 --- a/services/user-service/app/schemas.py +++ b/services/user-service/app/schemas.py @@ -1,15 +1,22 @@ -# Application layer — Pydantic DTOs (Data Transfer Objects). -# -# This file defines the shapes of data coming IN and going OUT of the API. -# It is separate from models.py on purpose: the API shape is not always -# the same as the database shape (e.g. password comes in, never goes out). -# -# This file should define: -# - UserCreate — fields accepted when creating a user (includes plain password) -# - UserOut — fields returned to the caller (no password, ever) -# - UserList — paginated envelope: { items, total, limit, offset } -# -# Use model_config = {"from_attributes": True} on UserOut so Pydantic can -# read directly from SQLAlchemy ORM objects. -# -# See the README for the full implementation. +from pydantic import BaseModel +from datetime import datetime + +class UserCreate(BaseModel): + username: str + email: str + password: str + +class UserOut(BaseModel): + id: str + username: str + email: str + is_active: bool + created_at: datetime + + model_config = {"from_attributes": True} + +class UserList(BaseModel): + items: list[UserOut] + total: int + limit: int + offset: int \ No newline at end of file diff --git a/services/user-service/app/service.py b/services/user-service/app/service.py index 39143a27..bfe1e013 100644 --- a/services/user-service/app/service.py +++ b/services/user-service/app/service.py @@ -1,19 +1,27 @@ -# Application layer — business logic. -# -# This is where decisions live: hashing passwords, raising errors when a user -# is not found, converting ORM objects into Pydantic schemas before returning. -# -# Rules: -# - Only calls repository functions — never queries the DB directly -# - Returns Pydantic schemas (UserOut, UserList), not raw ORM objects -# - Raises ValueError for business errors (routes.py turns them into HTTP errors) -# -# This file should implement: -# - add_user(db, data) -> UserOut -# - fetch_user(db, user_id) -> UserOut (raises ValueError if not found) -# - fetch_all_users(db, limit, offset) -> UserList -# -# Note: _hash_password is a placeholder for now — it will be replaced -# with passlib in Module 6. -# -# See the README for the full implementation. +from sqlalchemy.orm import Session +from app import repository +from app.schemas import UserCreate, UserOut, UserList + +def _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/seed.py b/services/user-service/seed.py new file mode 100644 index 00000000..e150f362 --- /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() diff --git a/services/user-service/users.db b/services/user-service/users.db new file mode 100644 index 00000000..e886117d Binary files /dev/null and b/services/user-service/users.db differ