diff --git a/docs/assets/diagrams/clean_architecture.png b/docs/assets/diagrams/clean_architecture.png index b546153..12f20f0 100644 Binary files a/docs/assets/diagrams/clean_architecture.png and b/docs/assets/diagrams/clean_architecture.png differ diff --git a/docs/assets/diagrams/otp_login_flow.png b/docs/assets/diagrams/otp_login_flow.png index 855c1f2..ad01912 100644 Binary files a/docs/assets/diagrams/otp_login_flow.png and b/docs/assets/diagrams/otp_login_flow.png differ diff --git a/docs/assets/diagrams/production_deployment.png b/docs/assets/diagrams/production_deployment.png index 7bc2cd3..7783c61 100644 Binary files a/docs/assets/diagrams/production_deployment.png and b/docs/assets/diagrams/production_deployment.png differ diff --git a/docs/assets/diagrams/refresh_rotation_flow.png b/docs/assets/diagrams/refresh_rotation_flow.png index 0bfac08..48ca26f 100644 Binary files a/docs/assets/diagrams/refresh_rotation_flow.png and b/docs/assets/diagrams/refresh_rotation_flow.png differ diff --git a/docs/assets/diagrams/service_architecture.png b/docs/assets/diagrams/service_architecture.png index f4915b2..a9c8cc3 100644 Binary files a/docs/assets/diagrams/service_architecture.png and b/docs/assets/diagrams/service_architecture.png differ diff --git a/docs/assets/diagrams/sms_provider_strategy.png b/docs/assets/diagrams/sms_provider_strategy.png index 5c99dcf..045699a 100644 Binary files a/docs/assets/diagrams/sms_provider_strategy.png and b/docs/assets/diagrams/sms_provider_strategy.png differ diff --git a/docs/diagrams/generate.py b/docs/diagrams/generate.py index 0112f89..38e8ad0 100644 --- a/docs/diagrams/generate.py +++ b/docs/diagrams/generate.py @@ -1,206 +1,408 @@ +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 Internet +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": "transparent", - "pad": "0.35", - "ranksep": "0.8", - "nodesep": "0.55", - "fontname": "Inter", + "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": "Inter", - "fontsize": "13", - "style": "rounded,filled", - "fillcolor": "#111827", - "fontcolor": "#E5E7EB", - "color": "#334155", + "fontname": "Arial", + "fontsize": "14", + "fontcolor": "#172033", + "labelloc": "b", + "margin": "0.12", } def edge_attr() -> dict[str, str]: - return {"fontname": "Inter", "fontsize": "11", "color": "#64748B", "fontcolor": "#475569"} + """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", - 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") + with diagram("Service Architecture", "service_architecture"): + with Cluster("Client boundary", graph_attr=cluster_attr()): + browser = Client("Browser") + demo = Docker("demo-app\nFastAPI BFF") - 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 + 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", - 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") + with diagram("Clean Architecture Layers", "clean_architecture", direction="TB"): + with Cluster("Transport", graph_attr=cluster_attr()): + demo = Client("Demo BFF") + grpc = Server("gRPC servicer") - grpc >> auth - demo >> grpc - auth >> entities - auth >> ports - auth >> security - ports >> mongo - ports >> redis - ports >> rabbit - ports >> sms + 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", - 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") + with diagram("OTP Login Flow", "otp_login_flow"): + user = User("User") - 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 + 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", - 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") + with diagram("Refresh Token Rotation", "refresh_rotation_flow"): + client = Client("Client") + auth = Server("auth-service\nRefreshToken") + mongo = Mongodb("MongoDB\nrefresh_sessions") - 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 + 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", - 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") + 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(...)") - worker >> factory >> port - port >> kav - port >> smsir - port >> debug + 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", - 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") + with diagram("Production Deployment", "production_deployment"): + internet = Internet("Internet\ngapido.amiirkhl.ir") + caddy = Caddy("Caddy\n80 / 443 only") - internet >> Edge(label="gapido.amiirkhl.ir") >> caddy >> demo >> auth - auth >> private - private >> mongo - private >> redis - private >> rabbit >> worker + 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() @@ -212,4 +414,3 @@ def main() -> None: if __name__ == "__main__": main() -