docs: add architecture documentation and slides

This commit is contained in:
2026-07-14 11:04:49 +03:30
parent e99060de41
commit c54f6edc1e
23 changed files with 765 additions and 0 deletions

View File

@@ -73,6 +73,15 @@ pytest
The production Docker image installs runtime dependencies only. Development tools are installed locally through `.[dev]`.
## Documentation
Project documentation is available in [`docs/`](docs/README.md):
- [Architecture](docs/architecture.md)
- [Implementation decisions](docs/implementation-decisions.md)
- [Architecture decision records](docs/adr/README.md)
- [Presentation slides](docs/slides/index.html)
## gRPC Methods
- `RequestOtp`: public; creates a short-lived OTP and publishes an SMS job.

21
docs/README.md Normal file
View File

@@ -0,0 +1,21 @@
# Documentation
This directory contains the design notes, diagrams, and presentation material for the Gapido backend code challenge.
## Contents
- [Architecture](architecture.md): service boundaries, flows, deployment modes, and runtime topology.
- [Implementation Decisions](implementation-decisions.md): key decisions and tradeoffs made during development.
- [ADR Index](adr/README.md): focused architectural decision records.
- [Slides](slides/index.html): static HTML presentation deck.
- [Diagrams](assets/diagrams/): generated PNG diagrams used by docs and slides.
## Regenerate Diagrams
Graphviz must be installed on the machine because the Python `diagrams` package renders through Graphviz.
```bash
pip install -e ".[dev]"
python docs/diagrams/generate.py
```

View File

@@ -0,0 +1,18 @@
# ADR 001: Use Python gRPC for Auth
## Status
Accepted.
## Context
The assessment requires Python with gRPC and focuses on microservice communication.
## Decision
Implement the authentication boundary as an async `grpc.aio` service with protobuf contracts.
## Consequences
Service-to-service calls are strongly typed and efficient. Browser interaction needs a separate demo client service because native browser gRPC is not practical without grpc-web or a proxy.

View File

@@ -0,0 +1,18 @@
# ADR 002: Store OTP State In Redis
## Status
Accepted.
## Context
OTP state is temporary, security-sensitive, and must expire automatically.
## Decision
Store only HMAC hashes of OTP codes in Redis, with TTLs, attempt counters, and request rate limits.
## Consequences
OTP verification is fast and self-expiring. Plaintext OTPs are not persisted. The local debug provider writes a separate development-only key for demos.

View File

@@ -0,0 +1,18 @@
# ADR 003: Dispatch SMS Through RabbitMQ
## Status
Accepted.
## Context
SMS providers are external services and can fail or respond slowly.
## Decision
Publish OTP SMS jobs to RabbitMQ and process them in a dedicated worker with manual acknowledgements, retries, and a dead-letter queue.
## Consequences
The auth service remains responsive. SMS delivery is isolated and easier to retry, monitor, and replace.

View File

@@ -0,0 +1,18 @@
# ADR 004: Select SMS Providers With Strategy
## Status
Accepted.
## Context
The project supports Kavenegar, SMS.ir, and local debug delivery.
## Decision
Expose a stable `SmsClient` port and choose the concrete provider through a small factory based on `SMS_PROVIDER`.
## Consequences
Adding a new SMS provider does not change the auth use case or worker flow. Tests can mock providers without network access.

View File

@@ -0,0 +1,18 @@
# ADR 005: Add FastAPI Demo Client Service
## Status
Accepted.
## Context
The gRPC service needs a simple browser demo for interviews and reviewers.
## Decision
Add `gapido_demo`, a small FastAPI backend-for-frontend that serves static UI and calls the auth service over gRPC.
## Consequences
The demo shows how a separate microservice consumes auth. It remains separate from the auth service package and can be exposed safely through Caddy.

View File

@@ -0,0 +1,18 @@
# ADR 006: Use Caddy For Production Ingress
## Status
Accepted.
## Context
Production should expose only the demo UI under `gapido.amiirkhl.ir` with HTTPS.
## Decision
Use Caddy as the reverse proxy and TLS terminator. Route public traffic to `demo-app:8080`.
## Consequences
TLS automation is simple. Internal services remain private on Docker networking.

View File

@@ -0,0 +1,18 @@
# ADR 007: Split Local And Production Compose Overlays
## Status
Accepted.
## Context
Local development needs direct access to service ports, while production must expose only Caddy.
## Decision
Keep `docker-compose.yml` private by default, add `docker-compose.local.yml` for local ports, and add `docker-compose.prod.yml` for Caddy and production environment rules.
## Consequences
The same services run locally and on a server, but exposure is controlled by the chosen Compose overlay.

10
docs/adr/README.md Normal file
View File

@@ -0,0 +1,10 @@
# Architecture Decision Records
- [ADR 001: Use Python gRPC for auth](001-use-python-grpc.md)
- [ADR 002: Store OTP state in Redis](002-redis-otp-state.md)
- [ADR 003: Dispatch SMS through RabbitMQ](003-rabbitmq-sms-worker.md)
- [ADR 004: Select SMS providers with Strategy](004-sms-provider-strategy.md)
- [ADR 005: Add FastAPI demo client service](005-fastapi-demo-client.md)
- [ADR 006: Use Caddy for production ingress](006-caddy-production-ingress.md)
- [ADR 007: Split local and production Compose overlays](007-compose-overlays.md)

40
docs/architecture.md Normal file
View File

@@ -0,0 +1,40 @@
# Architecture
The project demonstrates a production-shaped authentication microservice built around gRPC, OTP login, asynchronous SMS dispatch, and a small demo client service.
## System View
![Service architecture](assets/diagrams/service_architecture.png)
The browser talks only to `demo-app`. The demo service acts as a backend-for-frontend and calls the gRPC `auth-service`. Auth data is split by responsibility: MongoDB persists users and refresh sessions, Redis stores short-lived OTP state and rate-limit counters, and RabbitMQ decouples OTP generation from SMS delivery.
## Clean Architecture
![Clean architecture layers](assets/diagrams/clean_architecture.png)
The domain layer owns roles, users, refresh sessions, and ports. The application layer coordinates OTP, tokens, and access control. Infrastructure adapters implement MongoDB, Redis, RabbitMQ, and SMS providers. Transports translate gRPC and demo HTTP requests into application use cases.
## OTP Login Flow
![OTP login sequence](assets/diagrams/otp_login_flow.png)
OTP codes are generated with `secrets`, stored only as HMAC hashes in Redis, and sent through RabbitMQ. Verification compares hashes and deletes successful OTP state before issuing an access token and opaque refresh token.
## Refresh Rotation
![Refresh token rotation](assets/diagrams/refresh_rotation_flow.png)
Refresh tokens are opaque random values. Only SHA-256 hashes are stored in MongoDB. Every refresh creates a new session and revokes the old token hash.
## SMS Provider Strategy
![SMS provider strategy](assets/diagrams/sms_provider_strategy.png)
The worker depends on the `SmsClient` port and receives the concrete provider from a small factory. Kavenegar, SMS.ir, and debug-local delivery share the same interface.
## Production Deployment
![Production deployment](assets/diagrams/production_deployment.png)
Production exposes only Caddy on ports `80` and `443`. Caddy routes `gapido.amiirkhl.ir` to `demo-app`. gRPC, MongoDB, Redis, RabbitMQ, and the SMS worker remain private on the Docker network.

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

215
docs/diagrams/generate.py Normal file
View File

@@ -0,0 +1,215 @@
from pathlib import Path
from diagrams import Cluster, Diagram, Edge
from diagrams.generic.blank import Blank
from diagrams.onprem.database import Mongodb
from diagrams.onprem.inmemory import Redis
from diagrams.onprem.network import Internet
from diagrams.onprem.queue import Rabbitmq
ROOT = Path(__file__).resolve().parents[2]
OUT = ROOT / "docs" / "assets" / "diagrams"
def graph_attr() -> dict[str, str]:
return {
"bgcolor": "transparent",
"pad": "0.35",
"ranksep": "0.8",
"nodesep": "0.55",
"fontname": "Inter",
}
def node_attr() -> dict[str, str]:
return {
"fontname": "Inter",
"fontsize": "13",
"style": "rounded,filled",
"fillcolor": "#111827",
"fontcolor": "#E5E7EB",
"color": "#334155",
}
def edge_attr() -> dict[str, str]:
return {"fontname": "Inter", "fontsize": "11", "color": "#64748B", "fontcolor": "#475569"}
def render_service_architecture() -> None:
with Diagram(
"Service Architecture",
filename=str(OUT / "service_architecture"),
show=False,
direction="LR",
graph_attr=graph_attr(),
node_attr=node_attr(),
edge_attr=edge_attr(),
):
browser = Internet("Browser")
demo = Blank("demo-app\nFastAPI UI")
auth = Blank("auth-service\ngRPC")
worker = Blank("sms-worker")
mongo = Mongodb("MongoDB")
redis = Redis("Redis")
rabbit = Rabbitmq("RabbitMQ")
providers = Blank("Kavenegar / SMS.ir")
browser >> Edge(label="HTTPS / local HTTP") >> demo
demo >> Edge(label="gRPC") >> auth
auth >> Edge(label="users + refresh sessions") >> mongo
auth >> Edge(label="OTP hash + rate limits") >> redis
auth >> Edge(label="SMS job") >> rabbit >> worker >> providers
def render_clean_architecture() -> None:
with Diagram(
"Clean Architecture Layers",
filename=str(OUT / "clean_architecture"),
show=False,
direction="TB",
graph_attr=graph_attr(),
node_attr=node_attr(),
edge_attr=edge_attr(),
):
with Cluster("Transport"):
grpc = Blank("gRPC servicer")
demo = Blank("Demo BFF")
with Cluster("Application"):
auth = Blank("AuthService\nuse cases")
security = Blank("Token + OTP helpers")
with Cluster("Domain"):
entities = Blank("Entities")
ports = Blank("Ports")
with Cluster("Infrastructure"):
mongo = Blank("Mongo repositories")
redis = Blank("Redis OTP store")
rabbit = Blank("RabbitMQ publisher")
sms = Blank("SMS strategies")
grpc >> auth
demo >> grpc
auth >> entities
auth >> ports
auth >> security
ports >> mongo
ports >> redis
ports >> rabbit
ports >> sms
def render_otp_flow() -> None:
with Diagram(
"OTP Login Flow",
filename=str(OUT / "otp_login_flow"),
show=False,
direction="LR",
graph_attr=graph_attr(),
node_attr=node_attr(),
edge_attr=edge_attr(),
):
user = Blank("User")
demo = Blank("demo-app")
auth = Blank("auth-service")
redis = Redis("Redis")
rabbit = Rabbitmq("RabbitMQ")
worker = Blank("sms-worker")
sms = Blank("SMS provider")
mongo = Mongodb("MongoDB")
user >> Edge(label="mobile") >> demo >> Edge(label="RequestOtp") >> auth
auth >> Edge(label="store OTP hash") >> redis
auth >> Edge(label="publish job") >> rabbit >> worker >> sms
user >> Edge(label="code") >> demo >> Edge(label="VerifyOtp") >> auth
auth >> Edge(label="compare hash") >> redis
auth >> Edge(label="upsert user + session") >> mongo
auth >> Edge(label="token pair") >> demo >> user
def render_refresh_flow() -> None:
with Diagram(
"Refresh Token Rotation",
filename=str(OUT / "refresh_rotation_flow"),
show=False,
direction="LR",
graph_attr=graph_attr(),
node_attr=node_attr(),
edge_attr=edge_attr(),
):
client = Blank("Client")
auth = Blank("auth-service")
mongo = Mongodb("MongoDB")
old = Blank("Old session\nrevoked")
new = Blank("New session\nactive")
client >> Edge(label="refresh token") >> auth
auth >> Edge(label="hash lookup") >> mongo
mongo >> Edge(label="active session") >> auth
auth >> old
auth >> new
new >> Edge(label="new token pair") >> client
def render_sms_strategy() -> None:
with Diagram(
"SMS Provider Strategy",
filename=str(OUT / "sms_provider_strategy"),
show=False,
direction="LR",
graph_attr=graph_attr(),
node_attr=node_attr(),
edge_attr=edge_attr(),
):
worker = Blank("sms-worker")
factory = Blank("create_sms_client")
port = Blank("SmsClient port")
kav = Blank("Kavenegar")
smsir = Blank("SMS.ir")
debug = Blank("Debug local")
worker >> factory >> port
port >> kav
port >> smsir
port >> debug
def render_production_deployment() -> None:
with Diagram(
"Production Deployment",
filename=str(OUT / "production_deployment"),
show=False,
direction="LR",
graph_attr=graph_attr(),
node_attr=node_attr(),
edge_attr=edge_attr(),
):
internet = Internet("Internet")
caddy = Blank("Caddy\nHTTPS")
demo = Blank("demo-app")
auth = Blank("auth-service")
private = Blank("Private Docker network")
mongo = Mongodb("MongoDB")
redis = Redis("Redis")
rabbit = Rabbitmq("RabbitMQ")
worker = Blank("sms-worker")
internet >> Edge(label="gapido.amiirkhl.ir") >> caddy >> demo >> auth
auth >> private
private >> mongo
private >> redis
private >> rabbit >> worker
def main() -> None:
OUT.mkdir(parents=True, exist_ok=True)
render_service_architecture()
render_clean_architecture()
render_otp_flow()
render_refresh_flow()
render_sms_strategy()
render_production_deployment()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,34 @@
# Implementation Decisions
## Python gRPC Service
The challenge explicitly required Python with gRPC. The auth boundary is therefore a `grpc.aio` service instead of REST. A separate FastAPI demo service was added only as a browser-friendly client.
## OTP Storage
OTP codes are never stored in plaintext. Redis stores HMAC hashes with a short TTL, attempt counters, and request rate-limit counters. This keeps OTP state fast, temporary, and easy to expire.
## Token Model
Access tokens are signed JWTs with short TTLs. Refresh tokens are opaque random values, stored only as hashes in MongoDB, and rotated on every use.
## RabbitMQ SMS Dispatch
SMS delivery is asynchronous. The auth service publishes a durable message and returns quickly. The worker uses manual acknowledgement, bounded retries, and a dead-letter queue for failed deliveries.
## SMS Provider Strategy
Kavenegar, SMS.ir, and debug delivery implement the same `SmsClient` port. The provider is selected through configuration, which keeps the worker closed for modification when adding providers.
## Demo Service
Browsers do not speak native gRPC directly. A small FastAPI backend-for-frontend demonstrates how another microservice consumes the auth service through gRPC while serving a minimal UI.
## Local vs Production Compose
The base Compose file is production-safe and keeps service ports private. `docker-compose.local.yml` publishes developer ports. `docker-compose.prod.yml` adds Caddy as the only public entrypoint.
## Caddy Reverse Proxy
Caddy was chosen for automatic HTTPS and a compact configuration. Production serves only `https://gapido.amiirkhl.ir`; internal service ports are not published.

103
docs/slides/index.html Normal file
View File

@@ -0,0 +1,103 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Gapido Auth Challenge</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header class="topbar">
<span>Gapido Backend Challenge</span>
<div class="controls">
<button id="theme-toggle" type="button">Toggle theme</button>
<button id="prev" type="button">Prev</button>
<button id="next" type="button">Next</button>
</div>
</header>
<main class="deck">
<section class="slide active">
<p class="eyebrow">The problem</p>
<h1>Secure OTP auth for microservices</h1>
<p class="lede">Python • gRPC • MongoDB • Redis • RabbitMQ • Docker</p>
</section>
<section class="slide split">
<div>
<p class="eyebrow">Constraints</p>
<h2>What had to be proven</h2>
</div>
<ul class="tiles">
<li>gRPC auth boundary</li>
<li>secure OTP</li>
<li>refresh rotation</li>
<li>role-based access</li>
<li>async SMS delivery</li>
<li>production deployment path</li>
</ul>
</section>
<section class="slide diagram">
<p class="eyebrow">Solution shape</p>
<h2>Service architecture</h2>
<img src="../assets/diagrams/service_architecture.png" alt="Service architecture" />
</section>
<section class="slide diagram">
<p class="eyebrow">Code organization</p>
<h2>Clean architecture layers</h2>
<img src="../assets/diagrams/clean_architecture.png" alt="Clean architecture layers" />
</section>
<section class="slide diagram">
<p class="eyebrow">Auth flow</p>
<h2>OTP without storing plaintext codes</h2>
<img src="../assets/diagrams/otp_login_flow.png" alt="OTP login flow" />
</section>
<section class="slide diagram">
<p class="eyebrow">Token safety</p>
<h2>Refresh token rotation</h2>
<img src="../assets/diagrams/refresh_rotation_flow.png" alt="Refresh token rotation" />
</section>
<section class="slide diagram">
<p class="eyebrow">Extensibility</p>
<h2>Selectable SMS providers</h2>
<img src="../assets/diagrams/sms_provider_strategy.png" alt="SMS provider strategy" />
</section>
<section class="slide diagram">
<p class="eyebrow">Deployment</p>
<h2>Only Caddy is public</h2>
<img src="../assets/diagrams/production_deployment.png" alt="Production deployment" />
</section>
<section class="slide split">
<div>
<p class="eyebrow">Reliability</p>
<h2>What makes it reviewable</h2>
</div>
<ul class="tiles">
<li>Mocked SMS providers</li>
<li>gRPC tests</li>
<li>RBAC coverage</li>
<li>Compose validation</li>
<li>Health checks</li>
<li>Postman guide</li>
</ul>
</section>
<section class="slide">
<p class="eyebrow">Outcome</p>
<h2>Interview-ready microservice demo</h2>
<p class="lede">A focused auth service with clean boundaries, secure OTP, async messaging, provider strategy, and production deployment posture.</p>
</section>
</main>
<footer class="progress"><span id="counter">1 / 10</span></footer>
<script src="slides.js"></script>
</body>
</html>

28
docs/slides/slides.js Normal file
View File

@@ -0,0 +1,28 @@
const slides = [...document.querySelectorAll(".slide")];
const counter = document.querySelector("#counter");
const root = document.documentElement;
let index = 0;
function render() {
slides.forEach((slide, current) => slide.classList.toggle("active", current === index));
counter.textContent = `${index + 1} / ${slides.length}`;
}
function move(delta) {
index = (index + delta + slides.length) % slides.length;
render();
}
document.querySelector("#next").addEventListener("click", () => move(1));
document.querySelector("#prev").addEventListener("click", () => move(-1));
document.querySelector("#theme-toggle").addEventListener("click", () => {
root.classList.toggle("light");
});
document.addEventListener("keydown", (event) => {
if (event.key === "ArrowRight" || event.key === " ") move(1);
if (event.key === "ArrowLeft") move(-1);
});
render();

178
docs/slides/styles.css Normal file
View File

@@ -0,0 +1,178 @@
:root {
color-scheme: dark;
--bg: #070b14;
--panel: #101827;
--text: #e6edf7;
--muted: #93a4bc;
--accent: #34d399;
--border: #223149;
}
:root.light {
color-scheme: light;
--bg: #f7fafc;
--panel: #ffffff;
--text: #172033;
--muted: #5f7088;
--accent: #0f766e;
--border: #d8e1ee;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
}
.topbar,
.progress {
position: fixed;
left: 0;
right: 0;
z-index: 10;
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
color: var(--muted);
}
.topbar {
top: 0;
}
.progress {
bottom: 0;
}
.controls {
display: flex;
gap: 8px;
}
button {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
color: var(--text);
padding: 8px 12px;
font-weight: 700;
cursor: pointer;
}
.deck {
min-height: 100vh;
display: grid;
place-items: center;
padding: 72px 5vw;
}
.slide {
display: none;
width: min(1120px, 100%);
min-height: 620px;
padding: 48px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--panel);
box-shadow: 0 24px 80px rgb(0 0 0 / 24%);
}
.slide.active {
display: grid;
align-content: center;
gap: 24px;
}
.split.active {
grid-template-columns: 0.9fr 1.1fr;
align-items: center;
}
.diagram.active {
align-content: start;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-size: 14px;
font-weight: 800;
text-transform: uppercase;
}
h1,
h2 {
margin: 0;
letter-spacing: 0;
}
h1 {
max-width: 900px;
font-size: clamp(54px, 7vw, 92px);
line-height: 0.95;
}
h2 {
font-size: clamp(36px, 5vw, 62px);
line-height: 1;
}
.lede {
max-width: 820px;
margin: 0;
color: var(--muted);
font-size: 24px;
line-height: 1.45;
}
.tiles {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.tiles li {
min-height: 96px;
display: grid;
place-items: center;
padding: 18px;
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
background: color-mix(in srgb, var(--panel), var(--accent) 6%);
font-size: 20px;
font-weight: 800;
text-align: center;
}
img {
width: 100%;
max-height: 460px;
object-fit: contain;
border-radius: 8px;
background: #ffffff;
padding: 12px;
}
@media (max-width: 820px) {
.slide {
min-height: 620px;
padding: 28px;
}
.split.active,
.tiles {
grid-template-columns: 1fr;
}
}

View File

@@ -21,6 +21,7 @@ dependencies = [
[project.optional-dependencies]
dev = [
"diagrams==0.24.4",
"grpcio-tools==1.68.1",
"mypy==1.14.1",
"pytest==8.3.4",