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..6766a86d --- /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..c310085f --- /dev/null +++ b/gateway/app/main.py @@ -0,0 +1,143 @@ +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 + """ + segments = path.split("/") + + if len(segments) < 2: + return Response(status_code=404, content="Invalid path") + + 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..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..ec62eecd 100644 --- a/modules/module-03/REFLECTION.md +++ b/modules/module-03/REFLECTION.md @@ -20,6 +20,12 @@ Think about what the client would need to know and manage if it talked to each s > *Your answer:* +The gateway exists to simplify communication between the client and the microservices. Instead of the client needing to know where every service is running, the client only communicates with a single entry point. + +Without a gateway, the frontend would need to manage multiple service URLs and ports such as user-service on port 8001, game-service on port 8002, and activity-service on port 8003. If a service changed location, port, or infrastructure, every client application would also need to be updated. + +The gateway centralizes routing and hides the internal architecture from the client. It also makes it easier to add authentication, logging, rate limiting, or monitoring later without changing every service individually. + --- ## 2. Your choice @@ -32,8 +38,15 @@ What is the consequence for the user in each case if the downstream service is u > *Your answer:* +The user validation call is critical because an activity should never be created for a user that does not exist. If this validation fails, invalid data could be permanently stored in the database. That is why the service retries the request and blocks activity creation if the user cannot be validated. + +The game lookup is different because it is only used for optional enrichment of the response. Even if the game-service is unavailable, the activity itself is still valid and can be saved correctly. In that case, the system degrades gracefully by returning `"game": null` instead of failing completely. + +For the user, this means activity creation still works even when the game-service is temporarily down, which improves resilience and availability. + --- + ## 3. The tradeoff Every time a client creates an activity, three services are involved synchronously. They all have to be running, healthy, and fast. @@ -44,6 +57,12 @@ What happens to the user experience if the slowest service in the chain takes 3 > *Your answer:* +The main risk of synchronous service chains is that the overall system becomes slower and more fragile. Every additional service call increases latency and creates another possible point of failure. + +If one service in the chain becomes slow, the entire request slows down because each service waits for the previous one to finish. For example, if the slowest service takes 3 seconds to respond, the user may experience long delays before receiving a final response. + +If one service becomes unavailable entirely, it can also affect multiple other services and create cascading failures throughout the system. This is one of the main tradeoffs of microservice architectures compared to monolithic applications. + --- *Keep this file. You will refer back to it during the oral presentation.* 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..fdf28f99 100644 --- a/services/activity-service/app/main.py +++ b/services/activity-service/app/main.py @@ -24,39 +24,41 @@ # --------------------------------------------------------------------------- async def validate_user(user_id: str) -> None: - """ - Verify that the user exists in user-service before logging an activity. + retries = 3 - Call: GET {settings.user_service_url}/v1/users/{user_id} + for _ in range(retries): + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get( + f"http://localhost:8001/v1/users/{user_id}" + ) - Behaviour: - - 200 → user exists, return normally (None) - - 404 → raise HTTPException(status_code=404, detail="User not found") - - Network error (httpx.RequestError) → retry the call once, then raise - HTTPException(status_code=503, detail="user-service unavailable") - - Any other non-2xx status → raise HTTPException(status_code=503, ...) + if response.status_code == 404: + return False - Use `async with httpx.AsyncClient(timeout=5.0) as client:` for HTTP calls. - This call is CRITICAL — the request must not proceed if validation fails. - """ - raise NotImplementedError + if response.status_code == 200: + return True + + except httpx.RequestError: + continue + + return False async def fetch_game(game_id: str) -> dict | None: - """ - Fetch game data from game-service to enrich the activity response. + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get( + f"http://localhost:8002/v1/games/{game_id}" + ) - Call: GET {settings.game_service_url}/v1/games/{game_id} + if response.status_code == 200: + return response.json() - Behaviour: - - 200 → return the response JSON as a dict - - Any non-2xx status OR network error → return None (do NOT raise) + except httpx.RequestError: + pass - This call is OPTIONAL — the activity is saved regardless of the result. - Graceful degradation is the goal: the response will include "game": null - when game-service is unreachable. - """ - raise NotImplementedError + return None # --------------------------------------------------------------------------- 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..2e23d520 --- /dev/null +++ b/services/game-service/alembic/env.py @@ -0,0 +1,81 @@ +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 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/30ff5650f8fa_create_games_table.py b/services/game-service/alembic/versions/30ff5650f8fa_create_games_table.py new file mode 100644 index 00000000..6a6a87be --- /dev/null +++ b/services/game-service/alembic/versions/30ff5650f8fa_create_games_table.py @@ -0,0 +1,32 @@ +"""create games table + +Revision ID: 30ff5650f8fa +Revises: +Create Date: 2026-05-19 20:25:18.675037 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '30ff5650f8fa' +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..c53b878f 100644 --- a/services/game-service/app/database.py +++ b/services/game-service/app/database.py @@ -1,10 +1,17 @@ -# 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 declarative_base, sessionmaker + +DATABASE_URL = "sqlite:///./games.db" + +engine = create_engine( + DATABASE_URL, + connect_args={"check_same_thread": False} +) + +SessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=engine +) + +Base = declarative_base() \ No newline at end of file diff --git a/services/game-service/app/main.py b/services/game-service/app/main.py index e629900e..0afd8dc9 100644 --- a/services/game-service/app/main.py +++ b/services/game-service/app/main.py @@ -1,9 +1,11 @@ -# Entry point — FastAPI application. -# -# Create the FastAPI app instance and register the router from app.routes. -# Keep it minimal: no business logic, no endpoints defined here. -# -# To run the service locally: -# uvicorn app.main:app --reload --port 8002 -# -# Then open: http://localhost:8002/docs +from fastapi import FastAPI + +from app.routes import router +from app.database import Base, engine +from app.models import Game + +Base.metadata.create_all(bind=engine) + +app = FastAPI(title="Game Service") + +app.include_router(router) \ No newline at end of file diff --git a/services/game-service/app/models.py b/services/game-service/app/models.py index 52eb2756..c3325dde 100644 --- a/services/game-service/app/models.py +++ b/services/game-service/app/models.py @@ -1,14 +1,11 @@ -# 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, Integer, String +from app.database import Base + +class Game(Base): + __tablename__ = "games" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String, nullable=False) + genre = Column(String, nullable=False) + platform = Column(String, nullable=False) + cover_url = Column(String, nullable=False) \ No newline at end of file diff --git a/services/game-service/app/repository.py b/services/game-service/app/repository.py index 8120c970..bcc06313 100644 --- a/services/game-service/app/repository.py +++ b/services/game-service/app/repository.py @@ -1,10 +1,21 @@ -# 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 sqlalchemy import select +from app.models import Game + +def create_game(db: Session, game_data): + game = Game(**game_data.dict()) + db.add(game) + db.commit() + db.refresh(game) + return game + +def get_games(db: Session): + return db.query(Game).all() + +def get_game_by_id(db: Session, game_id: int): + return db.query(Game).filter(Game.id == game_id).first() + +def search_games(db: Session, query: str): + return db.query(Game).filter( + Game.title.ilike(f"%{query}%") + ).all() \ No newline at end of file diff --git a/services/game-service/app/routes.py b/services/game-service/app/routes.py index 435b3ded..60b24c20 100644 --- a/services/game-service/app/routes.py +++ b/services/game-service/app/routes.py @@ -1,15 +1,31 @@ -# 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 +from sqlalchemy.orm import Session + +from app.database import SessionLocal +from app.schemas import GameCreate, GameResponse +from app import service + +router = APIRouter(prefix="/v1/games", tags=["games"]) + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +@router.post("", response_model=GameResponse) +def create_game(game: GameCreate, db: Session = Depends(get_db)): + return service.create_game(db, game) + +@router.get("", response_model=list[GameResponse]) +def get_games(db: Session = Depends(get_db)): + return service.get_games(db) + +@router.get("/{game_id}", response_model=GameResponse) +def get_game(game_id: int, db: Session = Depends(get_db)): + return service.get_game_by_id(db, game_id) + +@router.get("/search/") +def search_games(q: str, db: Session = Depends(get_db)): + return service.search_games(db, q) \ No newline at end of file diff --git a/services/game-service/app/schemas.py b/services/game-service/app/schemas.py index ca6aca4e..5ce07410 100644 --- a/services/game-service/app/schemas.py +++ b/services/game-service/app/schemas.py @@ -1,10 +1,13 @@ -# Application layer — Pydantic DTOs. -# -# Define the shapes of data coming IN and going OUT of the API. -# -# This file should define: -# - GameCreate — fields accepted when creating a game -# (title, genre, platform required; release_year and cover_url optional) -# - GameOut — fields returned to the caller (includes id and created_at) -# add model_config = {"from_attributes": True} -# - GameList — paginated envelope: { items, total, limit, offset } +from pydantic import BaseModel + +class GameCreate(BaseModel): + title: str + genre: str + platform: str + cover_url: str + +class GameResponse(GameCreate): + id: int + + class Config: + from_attributes = True \ No newline at end of file diff --git a/services/game-service/app/service.py b/services/game-service/app/service.py index 08f93578..0a8c99e5 100644 --- a/services/game-service/app/service.py +++ b/services/game-service/app/service.py @@ -1,17 +1,14 @@ -# 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 + +def create_game(db: Session, game_data): + return repository.create_game(db, game_data) + +def get_games(db: Session): + return repository.get_games(db) + +def get_game_by_id(db: Session, game_id: int): + return repository.get_game_by_id(db, game_id) + +def search_games(db: Session, query: str): + return repository.search_games(db, query) \ 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..8296224b Binary files /dev/null and b/services/game-service/games.db differ diff --git a/services/game-service/requirements.txt b/services/game-service/requirements.txt index 765660e4..b92cbab6 100644 --- a/services/game-service/requirements.txt +++ b/services/game-service/requirements.txt @@ -1,11 +1,9 @@ fastapi -uvicorn[standard] +uvicorn sqlalchemy -alembic pydantic -pydantic-settings -python-dotenv +alembic +pytest aiosqlite -asyncpg -redis -python-jose[cryptography] +ruff +mypy \ No newline at end of file diff --git a/services/game-service/user-service/.gitkeep b/services/game-service/user-service/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/services/game-service/user-service/README.md b/services/game-service/user-service/README.md new file mode 100644 index 00000000..dea960b8 --- /dev/null +++ b/services/game-service/user-service/README.md @@ -0,0 +1,246 @@ +# user-service — reference implementation + +Read this before building `game-service`. Every file below is annotated to explain its role. The structure you see here is the one you must replicate. + +--- + +## Folder structure + +``` +user-service/ +├── app/ +│ ├── __init__.py # empty, makes app a package +│ ├── main.py # FastAPI app init, mounts the router +│ ├── database.py # engine + session factory +│ ├── models.py # SQLAlchemy ORM model (User) +│ ├── schemas.py # Pydantic DTOs (UserCreate, UserOut) +│ ├── repository.py # raw DB queries — no business logic here +│ ├── service.py # business logic — calls repository +│ └── routes.py # FastAPI router + endpoint handlers +├── alembic/ +│ └── versions/ # auto-generated migration files go here +├── tests/ +│ └── test_users.py +├── alembic.ini +├── requirements.txt +└── .env.example +``` + +--- + +## File-by-file breakdown + +### `app/models.py` — ORM model + +Defines the `users` table. This is the only file that knows about columns. + +```python +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) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) +``` + +--- + +### `app/schemas.py` — Pydantic DTOs + +Two schemas: one for input, one for output. Keep them separate — the response shape is not always the same as the request shape. + +```python +from pydantic import BaseModel +from datetime import datetime + +class UserCreate(BaseModel): + username: str + email: str + password: str # plain-text on the way in — hash it in the service layer + +class UserOut(BaseModel): + id: str + username: str + email: str + is_active: bool + created_at: datetime + + model_config = {"from_attributes": True} + +class UserList(BaseModel): + """Paginated envelope — all list endpoints return this shape.""" + items: list[UserOut] + total: int + limit: int + offset: int +``` + +--- + +### `app/database.py` — engine + session + +`get_db` is a FastAPI dependency. It opens a session before the request and closes it after. + +```python +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() +``` + +--- + +### `app/repository.py` — DB queries only + +Functions here take a `Session` and return ORM objects. No HTTP, no business rules. + +```python +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 +``` + +--- + +### `app/service.py` — business logic + +Calls the repository and returns Pydantic schemas (not raw ORM objects) to the routes. + +```python +from sqlalchemy.orm import Session +from app import repository +from app.schemas import UserCreate, UserOut, UserList + +def _hash_password(plain: str) -> str: + # for now a placeholder — swap for passlib in Module 6 + return plain + "_hashed" + +def add_user(db: Session, data: UserCreate) -> UserOut: + hashed = _hash_password(data.password) + user = repository.create_user(db, data, hashed) + return UserOut.model_validate(user) + +def fetch_user(db: Session, user_id: str) -> UserOut: + user = repository.get_user(db, user_id) + if user is None: + raise ValueError(f"User {user_id} not found") + return UserOut.model_validate(user) + +def fetch_all_users(db: Session, limit: int = 20, offset: int = 0) -> UserList: + users, total = repository.list_users(db, limit=limit, offset=offset) + return UserList( + items=[UserOut.model_validate(u) for u in users], + total=total, + limit=limit, + offset=offset, + ) +``` + +The `ValueError` raised here gets caught in `routes.py` and turned into an HTTP 404. + +--- + +### `app/routes.py` — HTTP layer + +One function per endpoint. Routes only call service functions — never the repository directly. + +```python +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.database import get_db +from app import service, schemas + +router = APIRouter(prefix="/v1/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)) +``` + +--- + +### `app/main.py` — entry point + +```python +from fastapi import FastAPI +from app.routes import router + +app = FastAPI(title="user-service") +app.include_router(router) +``` + +--- + +### `requirements.txt` + +``` +fastapi +uvicorn[standard] +sqlalchemy +alembic +pydantic +python-dotenv +aiosqlite +``` + +--- + +### `.env.example` + +``` +DATABASE_URL=sqlite:///./users.db +``` + +Copy to `.env` before running. Never commit `.env`. diff --git a/services/user-service/app/database.py b/services/user-service/app/database.py index a5fe24f9..2444adfe 100644 --- a/services/user-service/app/database.py +++ b/services/user-service/app/database.py @@ -1,12 +1,17 @@ -# 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 declarative_base, sessionmaker + +DATABASE_URL = "sqlite:///./users.db" + +engine = create_engine( + DATABASE_URL, + connect_args={"check_same_thread": False} +) + +SessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=engine +) + +Base = declarative_base() diff --git a/services/user-service/app/main.py b/services/user-service/app/main.py index a4463d42..b69c6e53 100644 --- a/services/user-service/app/main.py +++ b/services/user-service/app/main.py @@ -1,11 +1,9 @@ -# Entry point — FastAPI application. -# -# This file creates the FastAPI app instance and registers the router. -# Keep it minimal: no business logic, no endpoints defined here. -# -# To run the service locally: -# uvicorn app.main:app --reload --port 8001 -# -# Then open: http://localhost:8001/docs -# -# See the README for the full implementation. +from fastapi import FastAPI +from app.routes import router +from app.database import Base, engine + +Base.metadata.create_all(bind=engine) + +app = FastAPI(title="User Service") + +app.include_router(router) \ No newline at end of file diff --git a/services/user-service/app/models.py b/services/user-service/app/models.py index 03e8cdba..06e7e3c9 100644 --- a/services/user-service/app/models.py +++ b/services/user-service/app/models.py @@ -1,13 +1,9 @@ -# 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, Integer, String +from app.database import Base + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String, nullable=False) + email = Column(String, nullable=False) \ No newline at end of file diff --git a/services/user-service/app/repository.py b/services/user-service/app/repository.py index f519c460..7adc4194 100644 --- a/services/user-service/app/repository.py +++ b/services/user-service/app/repository.py @@ -1,16 +1,5 @@ -# 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 + +def get_users(db: Session): + return db.query(User).all() \ No newline at end of file diff --git a/services/user-service/app/routes.py b/services/user-service/app/routes.py index 176c60cd..2f5aebb2 100644 --- a/services/user-service/app/routes.py +++ b/services/user-service/app/routes.py @@ -1,16 +1,18 @@ -# Interface layer — HTTP endpoints. -# -# This file defines the FastAPI router and maps HTTP verbs + paths to -# service function calls. It is the only layer that knows about HTTP. -# -# Rules: -# - Never call repository functions directly — always go through service -# - Catch ValueError from the service layer and raise HTTPException instead -# - Use Depends(get_db) to inject the database session -# -# This file should expose: -# - POST /v1/users/ -> create a user -# - GET /v1/users/ -> list users (with limit/offset pagination) -# - GET /v1/users/{user_id} -> get one user by ID (404 if not found) -# -# See the README for the full implementation. +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.database import SessionLocal +from app.service import fetch_users + +router = APIRouter(prefix="/v1/users", tags=["users"]) + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +@router.get("") +def get_users(db: Session = Depends(get_db)): + return fetch_users(db) diff --git a/services/user-service/app/schemas.py b/services/user-service/app/schemas.py index f2627a95..3d8ede00 100644 --- a/services/user-service/app/schemas.py +++ b/services/user-service/app/schemas.py @@ -1,15 +1,11 @@ -# 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 + +class UserCreate(BaseModel): + username: str + email: str + +class UserResponse(UserCreate): + id: int + + class Config: + from_attributes = True \ No newline at end of file diff --git a/services/user-service/app/service.py b/services/user-service/app/service.py index 39143a27..5bbc2777 100644 --- a/services/user-service/app/service.py +++ b/services/user-service/app/service.py @@ -1,19 +1,5 @@ -# 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.repository import get_users + +def fetch_users(db: Session): + return get_users(db) diff --git a/services/user-service/users.db b/services/user-service/users.db new file mode 100644 index 00000000..c4534bc6 Binary files /dev/null and b/services/user-service/users.db differ