Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions gateway/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
USER_SERVICE_URL=http://localhost:8001
GAME_SERVICE_URL=http://localhost:8002
ACTIVITY_SERVICE_URL=http://localhost:8003
17 changes: 17 additions & 0 deletions gateway/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
30 changes: 30 additions & 0 deletions gateway/README.md
Original file line number Diff line number Diff line change
@@ -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.
Empty file added gateway/app/__init__.py
Empty file.
23 changes: 23 additions & 0 deletions gateway/app/config.py
Original file line number Diff line number Diff line change
@@ -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()
140 changes: 140 additions & 0 deletions gateway/app/main.py
Original file line number Diff line number Diff line change
@@ -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")
5 changes: 5 additions & 0 deletions gateway/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
fastapi
uvicorn[standard]
httpx
pydantic-settings
python-jose[cryptography]
9 changes: 6 additions & 3 deletions modules/module-03/REFLECTION.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Module 3 — Reflection

**Team name**: _______________
**Branch**: `module-03/<team-name>`
**Submitted**: before Module 4 lesson
**Team name**: Chimaera-Emerald
**Branch**: `module-03/chimaera-emerald`
**Submitted**: Module 3 implementation complete

---

Expand All @@ -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.

---

Expand All @@ -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.

---

Expand All @@ -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.

---

Expand Down
7 changes: 5 additions & 2 deletions modules/module-04/REFLECTION.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Module 4 — Reflection

**Team name**: _______________
**Branch**: `module-04/<team-name>`
**Team name**: Chimaera-Emerald
**Branch**: `module-04/chimaera-emerald`
**Submitted**: before Module 5 lesson

---
Expand All @@ -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.

---

Expand All @@ -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.

---

Expand All @@ -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.

---

Expand Down
Binary file added services/activity-service/activities.db
Binary file not shown.
43 changes: 41 additions & 2 deletions services/activity-service/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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


# ---------------------------------------------------------------------------
Expand All @@ -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,
Expand Down
Loading