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()
143 changes: 143 additions & 0 deletions gateway/app/main.py
Original file line number Diff line number Diff line change
@@ -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"
)
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]
19 changes: 19 additions & 0 deletions modules/module-03/REFLECTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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.*
Binary file added services/activity-service/activities.db
Binary file not shown.
50 changes: 26 additions & 24 deletions services/activity-service/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


# ---------------------------------------------------------------------------
Expand Down
Loading