417 lines
14 KiB
Python
417 lines
14 KiB
Python
import struct
|
|
import zlib
|
|
from pathlib import Path
|
|
|
|
from diagrams import Cluster, Diagram, Edge
|
|
from diagrams.generic.blank import Blank
|
|
from diagrams.onprem.client import Client, User
|
|
from diagrams.onprem.compute import Server
|
|
from diagrams.onprem.container import Docker
|
|
from diagrams.onprem.database import Mongodb
|
|
from diagrams.onprem.inmemory import Redis
|
|
from diagrams.onprem.network import Caddy, Internet
|
|
from diagrams.onprem.queue import Rabbitmq
|
|
from diagrams.onprem.security import Vault
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
OUT = ROOT / "docs" / "assets" / "diagrams"
|
|
TARGET_RATIO = 16 / 9
|
|
|
|
|
|
def graph_attr() -> dict[str, str]:
|
|
"""Return shared Graphviz settings for clean 16:9 diagram output."""
|
|
|
|
return {
|
|
"bgcolor": "white",
|
|
"pad": "0.42",
|
|
"ranksep": "1.05",
|
|
"nodesep": "0.72",
|
|
"fontname": "Arial",
|
|
"fontsize": "18",
|
|
"dpi": "220",
|
|
"size": "16,9!",
|
|
"ratio": "fill",
|
|
"splines": "spline",
|
|
"outputorder": "edgesfirst",
|
|
}
|
|
|
|
|
|
def node_attr() -> dict[str, str]:
|
|
"""Return readable node text styling while preserving provider icons."""
|
|
|
|
return {
|
|
"fontname": "Arial",
|
|
"fontsize": "14",
|
|
"fontcolor": "#172033",
|
|
"labelloc": "b",
|
|
"margin": "0.12",
|
|
}
|
|
|
|
|
|
def edge_attr() -> dict[str, str]:
|
|
"""Return restrained edge styling for presentation diagrams."""
|
|
|
|
return {
|
|
"fontname": "Arial",
|
|
"fontsize": "11",
|
|
"color": "#64748B",
|
|
"fontcolor": "#334155",
|
|
"arrowsize": "0.75",
|
|
"penwidth": "1.6",
|
|
}
|
|
|
|
|
|
def cluster_attr() -> dict[str, str]:
|
|
"""Return subtle cluster styling that keeps related services visually grouped."""
|
|
|
|
return {
|
|
"bgcolor": "#F8FAFC",
|
|
"fontname": "Arial",
|
|
"fontsize": "16",
|
|
"fontcolor": "#334155",
|
|
"pencolor": "#CBD5E1",
|
|
}
|
|
|
|
|
|
def diagram(name: str, filename: str, *, direction: str = "LR") -> Diagram:
|
|
"""Create a 16:9 icon-based diagram using the diagrams package."""
|
|
|
|
return Diagram(
|
|
name,
|
|
filename=str(OUT / filename),
|
|
show=False,
|
|
direction=direction,
|
|
outformat="png",
|
|
curvestyle="curved",
|
|
graph_attr=graph_attr(),
|
|
node_attr=node_attr(),
|
|
edge_attr=edge_attr(),
|
|
)
|
|
|
|
|
|
def normalize_png(filename: str) -> None:
|
|
"""Center a rendered PNG on an exact 16:9 white canvas."""
|
|
|
|
path = OUT / f"{filename}.png"
|
|
png = _read_png(path)
|
|
target_width, target_height = _target_size(png.width, png.height)
|
|
if (png.width, png.height) == (target_width, target_height):
|
|
return
|
|
|
|
channels = png.channels
|
|
white_pixel = b"\xff" * channels
|
|
rows = [bytearray(white_pixel * target_width) for _ in range(target_height)]
|
|
offset_x = (target_width - png.width) // 2
|
|
offset_y = (target_height - png.height) // 2
|
|
for source_y, source_row in enumerate(png.rows):
|
|
target_row = rows[offset_y + source_y]
|
|
start = offset_x * channels
|
|
target_row[start : start + len(source_row)] = source_row
|
|
|
|
_write_png(path, PngImage(target_width, target_height, png.color_type, rows))
|
|
|
|
|
|
class PngImage:
|
|
"""Small in-memory PNG representation for deterministic 16:9 padding."""
|
|
|
|
def __init__(
|
|
self,
|
|
width: int,
|
|
height: int,
|
|
color_type: int,
|
|
rows: list[bytearray],
|
|
) -> None:
|
|
self.width = width
|
|
self.height = height
|
|
self.color_type = color_type
|
|
self.rows = rows
|
|
|
|
@property
|
|
def channels(self) -> int:
|
|
"""Return channel count for supported truecolor PNG types."""
|
|
|
|
return 4 if self.color_type == 6 else 3
|
|
|
|
|
|
def _target_size(width: int, height: int) -> tuple[int, int]:
|
|
"""Calculate the smallest exact 16:9 canvas that contains the diagram."""
|
|
|
|
if width / height < TARGET_RATIO:
|
|
return round(height * TARGET_RATIO), height
|
|
return width, round(width / TARGET_RATIO)
|
|
|
|
|
|
def _read_png(path: Path) -> PngImage:
|
|
"""Read a non-interlaced 8-bit RGB/RGBA PNG produced by Graphviz."""
|
|
|
|
data = path.read_bytes()
|
|
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
raise ValueError(f"{path} is not a PNG file")
|
|
|
|
position = 8
|
|
width = height = color_type = 0
|
|
compressed = bytearray()
|
|
while position < len(data):
|
|
length = struct.unpack(">I", data[position : position + 4])[0]
|
|
chunk_type = data[position + 4 : position + 8]
|
|
chunk_data = data[position + 8 : position + 8 + length]
|
|
position += 12 + length
|
|
if chunk_type == b"IHDR":
|
|
width, height, bit_depth, color_type, _, _, interlace = struct.unpack(
|
|
">IIBBBBB", chunk_data
|
|
)
|
|
if bit_depth != 8 or color_type not in {2, 6} or interlace != 0:
|
|
raise ValueError(f"Unsupported PNG encoding in {path}")
|
|
elif chunk_type == b"IDAT":
|
|
compressed.extend(chunk_data)
|
|
elif chunk_type == b"IEND":
|
|
break
|
|
|
|
channels = 4 if color_type == 6 else 3
|
|
stride = width * channels
|
|
raw = zlib.decompress(bytes(compressed))
|
|
rows: list[bytearray] = []
|
|
previous = bytearray(stride)
|
|
cursor = 0
|
|
for _ in range(height):
|
|
filter_type = raw[cursor]
|
|
cursor += 1
|
|
row = bytearray(raw[cursor : cursor + stride])
|
|
cursor += stride
|
|
_unfilter(row, previous, filter_type, channels)
|
|
rows.append(row)
|
|
previous = row
|
|
|
|
return PngImage(width, height, color_type, rows)
|
|
|
|
|
|
def _unfilter(row: bytearray, previous: bytearray, filter_type: int, channels: int) -> None:
|
|
"""Reverse PNG scanline filters used by Graphviz output."""
|
|
|
|
for index in range(len(row)):
|
|
left = row[index - channels] if index >= channels else 0
|
|
up = previous[index]
|
|
upper_left = previous[index - channels] if index >= channels else 0
|
|
match filter_type:
|
|
case 0:
|
|
value = row[index]
|
|
case 1:
|
|
value = row[index] + left
|
|
case 2:
|
|
value = row[index] + up
|
|
case 3:
|
|
value = row[index] + ((left + up) // 2)
|
|
case 4:
|
|
value = row[index] + _paeth(left, up, upper_left)
|
|
case _:
|
|
raise ValueError(f"Unsupported PNG filter type: {filter_type}")
|
|
row[index] = value & 0xFF
|
|
|
|
|
|
def _paeth(left: int, up: int, upper_left: int) -> int:
|
|
"""Return the PNG Paeth predictor for a single channel."""
|
|
|
|
prediction = left + up - upper_left
|
|
left_distance = abs(prediction - left)
|
|
up_distance = abs(prediction - up)
|
|
upper_left_distance = abs(prediction - upper_left)
|
|
if left_distance <= up_distance and left_distance <= upper_left_distance:
|
|
return left
|
|
if up_distance <= upper_left_distance:
|
|
return up
|
|
return upper_left
|
|
|
|
|
|
def _write_png(path: Path, png: PngImage) -> None:
|
|
"""Write a simple unfiltered PNG with the same color type as the source."""
|
|
|
|
raw = bytearray()
|
|
for row in png.rows:
|
|
raw.append(0)
|
|
raw.extend(row)
|
|
|
|
ihdr = struct.pack(">IIBBBBB", png.width, png.height, 8, png.color_type, 0, 0, 0)
|
|
chunks = [
|
|
_chunk(b"IHDR", ihdr),
|
|
_chunk(b"IDAT", zlib.compress(bytes(raw), level=6)),
|
|
_chunk(b"IEND", b""),
|
|
]
|
|
path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"".join(chunks))
|
|
|
|
|
|
def _chunk(chunk_type: bytes, data: bytes) -> bytes:
|
|
"""Create one PNG chunk with CRC."""
|
|
|
|
return (
|
|
struct.pack(">I", len(data))
|
|
+ chunk_type
|
|
+ data
|
|
+ struct.pack(">I", zlib.crc32(chunk_type + data) & 0xFFFFFFFF)
|
|
)
|
|
|
|
|
|
def link(label: str) -> Edge:
|
|
"""Create a consistent labeled connector."""
|
|
|
|
return Edge(label=label)
|
|
|
|
|
|
def render_service_architecture() -> None:
|
|
with diagram("Service Architecture", "service_architecture"):
|
|
with Cluster("Client boundary", graph_attr=cluster_attr()):
|
|
browser = Client("Browser")
|
|
demo = Docker("demo-app\nFastAPI BFF")
|
|
|
|
with Cluster("Application services", graph_attr=cluster_attr()):
|
|
auth = Server("auth-service\ngRPC API")
|
|
worker = Docker("sms-worker")
|
|
|
|
with Cluster("State and messaging", graph_attr=cluster_attr()):
|
|
mongo = Mongodb("MongoDB\nusers + sessions")
|
|
redis = Redis("Redis\nOTP TTL + limits")
|
|
rabbit = Rabbitmq("RabbitMQ\nSMS jobs")
|
|
|
|
providers = Internet("Kavenegar / SMS.ir")
|
|
|
|
browser >> link("HTTPS / local HTTP") >> demo >> link("gRPC") >> auth
|
|
auth >> link("documents") >> mongo
|
|
auth >> link("ephemeral state") >> redis
|
|
auth >> link("durable job") >> rabbit >> link("consume") >> worker
|
|
worker >> link("provider API") >> providers
|
|
normalize_png("service_architecture")
|
|
|
|
|
|
def render_clean_architecture() -> None:
|
|
with diagram("Clean Architecture Layers", "clean_architecture", direction="TB"):
|
|
with Cluster("Transport", graph_attr=cluster_attr()):
|
|
demo = Client("Demo BFF")
|
|
grpc = Server("gRPC servicer")
|
|
|
|
with Cluster("Application", graph_attr=cluster_attr()):
|
|
auth = Server("AuthService\nuse cases")
|
|
security = Vault("JWT + OTP\nsecurity helpers")
|
|
|
|
with Cluster("Domain", graph_attr=cluster_attr()):
|
|
entities = Blank("Entities\nUser, Session, Role")
|
|
ports = Blank("Ports\nrepositories + SMS")
|
|
|
|
with Cluster("Infrastructure", graph_attr=cluster_attr()):
|
|
mongo = Mongodb("Mongo repositories")
|
|
redis = Redis("Redis OTP store")
|
|
rabbit = Rabbitmq("RabbitMQ publisher")
|
|
sms = Internet("SMS strategies")
|
|
|
|
demo >> link("calls") >> grpc >> link("executes") >> auth
|
|
auth >> link("uses") >> entities
|
|
auth >> link("depends on") >> ports
|
|
auth >> link("delegates crypto") >> security
|
|
ports << link("implements") << [mongo, redis, rabbit, sms]
|
|
normalize_png("clean_architecture")
|
|
|
|
|
|
def render_otp_flow() -> None:
|
|
with diagram("OTP Login Flow", "otp_login_flow"):
|
|
user = User("User")
|
|
|
|
with Cluster("Request OTP", graph_attr=cluster_attr()):
|
|
demo_request = Client("demo-app")
|
|
auth_request = Server("RequestOtp")
|
|
redis_store = Redis("Redis\nstore HMAC + TTL")
|
|
rabbit = Rabbitmq("RabbitMQ\npublish SMS job")
|
|
|
|
with Cluster("Delivery", graph_attr=cluster_attr()):
|
|
worker = Docker("sms-worker")
|
|
sms = Internet("SMS provider")
|
|
|
|
with Cluster("Verify OTP", graph_attr=cluster_attr()):
|
|
demo_verify = Client("demo-app")
|
|
auth_verify = Server("VerifyOtp")
|
|
redis_verify = Redis("Redis\ncompare hash")
|
|
mongo = Mongodb("MongoDB\nuser + session")
|
|
|
|
user >> link("1. mobile") >> demo_request >> link("2. gRPC") >> auth_request
|
|
auth_request >> link("3. hash only") >> redis_store
|
|
auth_request >> link("4. queued") >> rabbit >> link("5. consume") >> worker
|
|
worker >> link("6. send OTP") >> sms
|
|
user >> link("7. code") >> demo_verify >> link("8. gRPC") >> auth_verify
|
|
auth_verify >> link("9. verify") >> redis_verify
|
|
auth_verify >> link("10. issue session") >> mongo
|
|
auth_verify >> link("11. token pair") >> demo_verify
|
|
normalize_png("otp_login_flow")
|
|
|
|
|
|
def render_refresh_flow() -> None:
|
|
with diagram("Refresh Token Rotation", "refresh_rotation_flow"):
|
|
client = Client("Client")
|
|
auth = Server("auth-service\nRefreshToken")
|
|
mongo = Mongodb("MongoDB\nrefresh_sessions")
|
|
|
|
with Cluster("Rotation result", graph_attr=cluster_attr()):
|
|
old = Vault("Old hash\nrevoked")
|
|
new = Vault("New hash\nactive")
|
|
|
|
client >> link("1. opaque refresh token") >> auth
|
|
auth >> link("2. hash lookup") >> mongo >> link("3. active session") >> auth
|
|
auth >> link("4. revoke") >> old
|
|
auth >> link("5. create") >> new >> link("6. persist") >> mongo
|
|
auth >> link("7. new token pair") >> client
|
|
normalize_png("refresh_rotation_flow")
|
|
|
|
|
|
def render_sms_strategy() -> None:
|
|
with diagram("SMS Provider Strategy", "sms_provider_strategy", direction="TB"):
|
|
worker = Docker("sms-worker")
|
|
factory = Server("create_sms_client()\nprovider factory")
|
|
port = Blank("SmsClient port\nsend_otp(...)")
|
|
|
|
with Cluster("Concrete strategies", graph_attr=cluster_attr()):
|
|
kavenegar = Internet("Kavenegar")
|
|
sms_ir = Internet("SMS.ir")
|
|
debug = Redis("Debug provider\nlocal Redis")
|
|
|
|
worker >> link("startup wiring") >> factory >> link("returns interface") >> port
|
|
port >> link("SMS_PROVIDER=kavenegar") >> kavenegar
|
|
port >> link("SMS_PROVIDER=sms_ir") >> sms_ir
|
|
port >> link("SMS_PROVIDER=debug") >> debug
|
|
normalize_png("sms_provider_strategy")
|
|
|
|
|
|
def render_production_deployment() -> None:
|
|
with diagram("Production Deployment", "production_deployment"):
|
|
internet = Internet("Internet\ngapido.amiirkhl.ir")
|
|
caddy = Caddy("Caddy\n80 / 443 only")
|
|
|
|
with Cluster("Private Docker network", graph_attr=cluster_attr()):
|
|
demo = Docker("demo-app\nFastAPI UI")
|
|
auth = Server("auth-service\nprivate gRPC")
|
|
mongo = Mongodb("MongoDB\nprivate")
|
|
redis = Redis("Redis\nprivate")
|
|
rabbit = Rabbitmq("RabbitMQ\nprivate")
|
|
worker = Docker("sms-worker")
|
|
|
|
sms = Internet("SMS provider")
|
|
|
|
internet >> link("HTTPS") >> caddy >> link("reverse proxy") >> demo
|
|
demo >> link("gRPC") >> auth
|
|
auth >> link("sessions") >> mongo
|
|
auth >> link("OTP state") >> redis
|
|
auth >> link("SMS job") >> rabbit >> link("consume") >> worker
|
|
worker >> link("provider API") >> sms
|
|
normalize_png("production_deployment")
|
|
|
|
|
|
def main() -> None:
|
|
"""Regenerate all 16:9 icon-based diagrams for docs and slides."""
|
|
|
|
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()
|