diff --git a/backend/app/api/v1/export.py b/backend/app/api/v1/export.py index 62ba71e..5103c6b 100644 --- a/backend/app/api/v1/export.py +++ b/backend/app/api/v1/export.py @@ -1,18 +1,108 @@ -from fastapi import APIRouter, Body, Depends, HTTPException, UploadFile -from fastapi.responses import JSONResponse +import uuid +from typing import Literal + +from fastapi import APIRouter, Body, Depends, HTTPException, Query, UploadFile +from fastapi.responses import JSONResponse, PlainTextResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.api.deps import get_optional_user from app.core.database import get_db from app.models.connection import Connection from app.models.object import ModelObject from app.schemas.connection import ConnectionResponse from app.schemas.object import ObjectResponse -from app.services import mermaid_service, structurizr_service +from app.services import ( + access_service, + diagram_service, + mermaid_service, + plantuml_service, + structurizr_service, + workspace_service, +) router = APIRouter(tags=["import-export"]) +ExportFormat = Literal["mermaid", "plantuml", "structurizr", "json"] + + +@router.get("/diagrams/{diagram_id}/export") +async def export_diagram( + diagram_id: uuid.UUID, + fmt: ExportFormat = Query("mermaid", alias="format"), + db: AsyncSession = Depends(get_db), + current_user=Depends(get_optional_user), +): + """Render a single diagram as Mermaid / PlantUML / Structurizr DSL / JSON. + + Auth: workspace-scoped diagrams require an authenticated member with + `can_read_diagram` access. Workspace-less diagrams stay open (legacy + behaviour shared with the rest of the API). + """ + diagram = await diagram_service.get_diagram(db, diagram_id) + if diagram is None: + raise HTTPException(status_code=404, detail="Diagram not found") + + if diagram.workspace_id is not None: + if current_user is None: + raise HTTPException(status_code=401, detail="Authentication required") + membership = await workspace_service.get_user_membership( + db, current_user.id, diagram.workspace_id + ) + if membership is None: + raise HTTPException(status_code=403, detail="No access to diagram") + if not await access_service.can_read_diagram( + db, current_user.id, diagram, membership.role + ): + raise HTTPException(status_code=403, detail="No access to diagram") + + if fmt == "json": + return JSONResponse(await _export_diagram_json(db, diagram)) + if fmt == "mermaid": + text = await mermaid_service.export_mermaid(db, diagram) + elif fmt == "plantuml": + text = await plantuml_service.export_plantuml(db, diagram) + elif fmt == "structurizr": + text = await structurizr_service.export_dsl(db, diagram) + else: # pragma: no cover — Literal narrows this away + raise HTTPException(status_code=400, detail=f"Unknown format: {fmt}") + return PlainTextResponse(text) + + +async def _export_diagram_json(db: AsyncSession, diagram) -> dict: + payload = await diagram_service.get_diagram_payload(db, diagram) + placements = payload["placements"] + connections = payload["connections"] + + return { + "version": "1.0", + "diagram": { + "id": str(diagram.id), + "name": diagram.name, + "type": diagram.type.value, + "description": diagram.description, + "scope_object_id": ( + str(diagram.scope_object_id) if diagram.scope_object_id else None + ), + }, + "objects": [ + { + **ObjectResponse.from_model(p.object).model_dump(mode="json"), + "position_x": p.position_x, + "position_y": p.position_y, + "width": p.width, + "height": p.height, + } + for p in placements + ], + "connections": [ + ConnectionResponse.model_validate(c).model_dump(mode="json") + for c in connections + ], + } + + @router.get("/export") async def export_model(db: AsyncSession = Depends(get_db)): objects_result = await db.execute(select(ModelObject).order_by(ModelObject.name)) diff --git a/backend/app/services/c4_common.py b/backend/app/services/c4_common.py new file mode 100644 index 0000000..04bd3ae --- /dev/null +++ b/backend/app/services/c4_common.py @@ -0,0 +1,125 @@ +"""Shared C4 helpers for the Mermaid + PlantUML exporters. + +Mermaid C4 and C4-PlantUML use the same macro vocabulary (Person, System, +Container, Component, *_Boundary, Rel/BiRel) and almost the same arg shapes. +This module owns the vocabulary so the two exporters stay in lock-step. +""" + +import uuid + +from app.models.diagram import DiagramType +from app.models.object import ObjectType + +# Macros whose third positional arg is technology, not description. +# (Container family + Component family in C4-PlantUML / Mermaid C4.) +_KW_WITH_TECH_ARG = frozenset( + { + "Container", + "ContainerDb", + "ContainerQueue", + "Component", + "ComponentDb", + "ComponentQueue", + } +) + + +def alias(obj_id: uuid.UUID) -> str: + """Stable, parser-safe alias derived from the object's UUID.""" + return f"n_{obj_id.hex[:8]}" + + +def c4_keyword(obj_type: ObjectType, diagram_type: DiagramType) -> str: + """Map an ArchFlow object type to the appropriate C4 macro for `diagram_type`. + + Why diagram-aware: ContainerDb / Component aren't defined in C4Context / + C4_Context.puml, so emitting them under a context view crashes some + renderers. We collapse APP/COMPONENT/STORE down to the L1 vocabulary + when rendering at landscape/context level, and only let the richer + macros through when the view actually defines them. + """ + if obj_type == ObjectType.GROUP: + return "System_Boundary" + if obj_type == ObjectType.ACTOR: + return "Person" + if obj_type == ObjectType.EXTERNAL_SYSTEM: + return "System_Ext" + if obj_type == ObjectType.SYSTEM: + return "System" + + is_landscape_or_context = diagram_type in ( + DiagramType.SYSTEM_LANDSCAPE, + DiagramType.SYSTEM_CONTEXT, + ) + if is_landscape_or_context: + return "SystemDb" if obj_type == ObjectType.STORE else "System" + if diagram_type == DiagramType.CONTAINER: + if obj_type == ObjectType.STORE: + return "ContainerDb" + return "Container" # APP + COMPONENT both render as Container at L2 + if diagram_type == DiagramType.COMPONENT: + if obj_type == ObjectType.STORE: + return "ContainerDb" + if obj_type == ObjectType.APP: + return "Container" + return "Component" + return "System" + + +def is_boundary_kw(kw: str) -> bool: + return kw.endswith("_Boundary") + + +def kw_takes_tech_arg(kw: str) -> bool: + return kw in _KW_WITH_TECH_ARG + + +def esc_c4_arg(s: str | None) -> str: + """Make `s` safe to drop into a C4 macro double-quoted arg. + + Mermaid C4 / C4-PlantUML strings have no escape sequences — quotes and + backslashes pass through to the renderer's regex. Newlines also tend to + break the macro parser. We swap the dangerous chars for visually similar + safe ones rather than dropping content silently. + """ + if not s: + return "" + return ( + s.replace("\\", "/") + .replace('"', "'") + .replace("\n", " ") + .replace("\r", " ") + .strip() + ) + + +def build_c4_args( + kw: str, + obj_alias: str, + name: str, + tech: str | None, + description: str | None, +) -> str: + """Build the comma-separated arg list for a C4 element macro call. + + Boundary macros use a different shape (`(alias, "name") { ... }`) and are + rendered separately; this helper covers the Person / System / Container / + Component families. + """ + parts = [obj_alias, f'"{name}"'] + if kw_takes_tech_arg(kw): + # Container/Component family: third arg is technology, fourth is description. + parts.append(f'"{tech or ""}"') + if description: + parts.append(f'"{description}"') + else: + # Person / System / SystemDb / System_Ext: no tech slot. Fold tech into + # the description so the AI consumer doesn't lose it. + merged = description + if tech and description: + merged = f"{description} ({tech})" + elif tech: + merged = tech + if merged: + parts.append(f'"{merged}"') + return ", ".join(parts) diff --git a/backend/app/services/diagram_service.py b/backend/app/services/diagram_service.py index c8c99fc..b21766f 100644 --- a/backend/app/services/diagram_service.py +++ b/backend/app/services/diagram_service.py @@ -4,7 +4,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from app.models.connection import Connection from app.models.diagram import Diagram, DiagramObject +from app.models.technology import Technology from app.schemas.diagram import ( DiagramCreate, DiagramObjectCreate, @@ -14,6 +16,65 @@ from app.services import activity_service +async def get_diagram_payload( + db: AsyncSession, diagram: Diagram +) -> dict: + """Load every row needed to render or export a diagram in one place. + + Returns: + placements: DiagramObjects on this diagram, with `.object` eager-loaded. + connections: Connections where both endpoints are placed on this diagram. + tech_names: id → display name for every technology referenced by an + object's technology_ids or a connection's protocol_ids. + """ + placements_q = ( + select(DiagramObject) + .where(DiagramObject.diagram_id == diagram.id) + .options(selectinload(DiagramObject.object)) + ) + placements = list((await db.execute(placements_q)).scalars().all()) + + object_ids = [p.object_id for p in placements] + if not object_ids: + return {"placements": [], "connections": [], "tech_names": {}} + + # Scope connections by the diagram's draft context so a forked diagram's + # in-progress edges never leak into a live export, and vice versa. Mirrors + # connection_service.get_connections(). + conn_q = select(Connection).where( + Connection.source_id.in_(object_ids), + Connection.target_id.in_(object_ids), + ) + if diagram.draft_id is None: + conn_q = conn_q.where(Connection.draft_id.is_(None)) + else: + conn_q = conn_q.where( + (Connection.draft_id.is_(None)) + | (Connection.draft_id == diagram.draft_id) + ) + connections = list((await db.execute(conn_q)).scalars().all()) + + tech_ids: set[uuid.UUID] = set() + for p in placements: + if p.object.technology_ids: + tech_ids.update(p.object.technology_ids) + for c in connections: + if c.protocol_ids: + tech_ids.update(c.protocol_ids) + + tech_names: dict[uuid.UUID, str] = {} + if tech_ids: + tech_q = select(Technology).where(Technology.id.in_(tech_ids)) + for t in (await db.execute(tech_q)).scalars().all(): + tech_names[t.id] = t.name + + return { + "placements": placements, + "connections": connections, + "tech_names": tech_names, + } + + async def get_diagrams( db: AsyncSession, scope_object_id: uuid.UUID | None = None, diff --git a/backend/app/services/mermaid_service.py b/backend/app/services/mermaid_service.py index af2dc03..621f41b 100644 --- a/backend/app/services/mermaid_service.py +++ b/backend/app/services/mermaid_service.py @@ -1,20 +1,8 @@ -"""Minimal Mermaid → ArchFlow importer. - -Supports two flavors of the Mermaid syntax most commonly used for -architecture sketches: - -1) C4 syntax: - C4Context - Person(u, "User") - System(s, "Foo System", "Description") - Container(c, "Web", "React") - Rel(u, s, "Uses") - -2) Flowchart syntax (TD/LR): - flowchart TD - A[User] --> B[Web API] - B --> C[(Database)] - B -->|HTTP| C +"""Mermaid bridge — both directions. + +Importer: parses Mermaid C4 and flowchart syntax into ArchFlow rows. +Exporter: walks a diagram's placements + connections and emits Mermaid +text (C4 syntax for C4 diagram types, flowchart for `custom`). """ import re @@ -22,8 +10,18 @@ from sqlalchemy.ext.asyncio import AsyncSession -from app.models.connection import Connection +from app.models.connection import Connection, ConnectionDirection +from app.models.diagram import Diagram, DiagramType from app.models.object import ModelObject, ObjectType +from app.services.c4_common import ( + alias as _alias, +) +from app.services.c4_common import ( + build_c4_args, + c4_keyword, + esc_c4_arg, + is_boundary_kw, +) # ── C4 flavour ────────────────────────────────────────── @@ -218,3 +216,187 @@ async def import_mermaid(db: AsyncSession, src: str) -> dict: "connections_created": created_rels, "alias_map": {k: str(v) for k, v in alias_to_id.items()}, } + + +# ── Exporter ──────────────────────────────────────────── + + +_C4_HEADER = { + DiagramType.SYSTEM_LANDSCAPE: "C4Context", + DiagramType.SYSTEM_CONTEXT: "C4Context", + DiagramType.CONTAINER: "C4Container", + DiagramType.COMPONENT: "C4Component", +} + + +def _esc_flow_label(s: str | None) -> str: + """Sanitize a Mermaid flowchart label. + + Labels appear inside `["..."]` node decls and `|...|` edge labels. We + swap the chars that terminate or unbalance those wrappers (`]`, `[`, + `|`), drop newlines, and swap backslashes to dodge any escape + interpretation. + """ + if not s: + return "" + return ( + s.replace("\\", "/") + .replace('"', "'") + .replace("[", "(") + .replace("]", ")") + .replace("|", "/") + .replace("\n", " ") + .replace("\r", " ") + .strip() + ) + + +def _tech_label(ids, tech_names: dict[uuid.UUID, str]) -> str | None: + if not ids: + return None + names = [tech_names[i] for i in ids if i in tech_names] + return ", ".join(names) if names else None + + +async def export_mermaid(db: AsyncSession, diagram: Diagram) -> str: + from app.services import diagram_service # local import to avoid cycle + + payload = await diagram_service.get_diagram_payload(db, diagram) + if diagram.type == DiagramType.CUSTOM: + return _export_flowchart(diagram, payload) + return _export_c4(diagram, payload) + + +def _header_comments(diagram: Diagram, payload: dict) -> list[str]: + return [ + "%% Exported from ArchFlow", + f"%% diagram_id: {diagram.id}", + f"%% diagram_type: {diagram.type.value}", + f"%% diagram_name: {esc_c4_arg(diagram.name)}", + f"%% objects: {len(payload['placements'])}; " + f"connections: {len(payload['connections'])}", + ] + + +def _build_parent_index(placements: list) -> tuple[dict, list]: + """Return (children_by_parent_id, top_level_placements). + + A placement is "top-level" when its object has no parent, or its parent + isn't placed on this diagram. + """ + placed_ids = {p.object_id for p in placements} + children_by_parent: dict = {} + top_level: list = [] + for p in placements: + parent_id = p.object.parent_id + if parent_id and parent_id in placed_ids: + children_by_parent.setdefault(parent_id, []).append(p) + else: + top_level.append(p) + return children_by_parent, top_level + + +def _export_c4(diagram: Diagram, payload: dict) -> str: + placements = payload["placements"] + connections = payload["connections"] + tech_names = payload["tech_names"] + header_kw = _C4_HEADER[diagram.type] + + lines: list[str] = list(_header_comments(diagram, payload)) + lines.append("") + lines.append(header_kw) + lines.append(f" title {esc_c4_arg(diagram.name)}") + + children_by_parent, top_level = _build_parent_index(placements) + + for p in top_level: + _emit_c4_placement( + p, diagram.type, tech_names, children_by_parent, lines, indent=2 + ) + + for c in connections: + src = _alias(c.source_id) + tgt = _alias(c.target_id) + label = esc_c4_arg(c.label) + tech = _tech_label(c.protocol_ids, tech_names) + rel_kw = "BiRel" if c.direction == ConnectionDirection.BIDIRECTIONAL else "Rel" + if tech: + lines.append(f' {rel_kw}({src}, {tgt}, "{label}", "{esc_c4_arg(tech)}")') + else: + lines.append(f' {rel_kw}({src}, {tgt}, "{label}")') + + return "\n".join(lines) + "\n" + + +def _emit_c4_placement( + placement, + diagram_type: DiagramType, + tech_names: dict, + children_by_parent: dict, + lines: list[str], + indent: int, +) -> None: + obj = placement.object + pad = " " * indent + a = _alias(obj.id) + kw = c4_keyword(obj.type, diagram_type) + name = esc_c4_arg(obj.name) + desc = esc_c4_arg(obj.description) + tech = _tech_label(obj.technology_ids, tech_names) + tech_arg = esc_c4_arg(tech) if tech else None + + lines.append( + f"{pad}%% {a} = {obj.id} (type={obj.type.value}, status={obj.status.value})" + ) + + if is_boundary_kw(kw): + lines.append(f'{pad}{kw}({a}, "{name}") {{') + for child in children_by_parent.get(obj.id, []): + _emit_c4_placement( + child, diagram_type, tech_names, children_by_parent, lines, indent + 2 + ) + lines.append(f"{pad}}}") + return + + args = build_c4_args(kw, a, name, tech_arg, desc) + lines.append(f"{pad}{kw}({args})") + + +def _export_flowchart(diagram: Diagram, payload: dict) -> str: + """Custom diagram → Mermaid flowchart. + + The output is restricted to `[label]` node declarations and `-->` arrows so + it round-trips through `mermaid_service.parse()`. The richer shapes + (`((actor))`, `[(db)]`, `{{ext}}`) the parser doesn't accept yet are dropped + in favour of comments that record the original ObjectType for any AI + consumer that needs to recover it. + """ + placements = payload["placements"] + connections = payload["connections"] + + lines: list[str] = list(_header_comments(diagram, payload)) + lines.append("") + lines.append("flowchart TD") + + for p in placements: + obj = p.object + alias = _alias(obj.id) + name = _esc_flow_label(obj.name) + lines.append( + f" %% {alias} = {obj.id} (type={obj.type.value}, status={obj.status.value})" + ) + lines.append(f' {alias}["{name}"]') + + for c in connections: + src = _alias(c.source_id) + tgt = _alias(c.target_id) + label = _esc_flow_label(c.label) + edge = f" {src} -->|{label}| {tgt}" if label else f" {src} --> {tgt}" + lines.append(edge) + if c.direction == ConnectionDirection.BIDIRECTIONAL: + back = ( + f" {tgt} -->|{label}| {src}" if label else f" {tgt} --> {src}" + ) + lines.append(back) + + return "\n".join(lines) + "\n" diff --git a/backend/app/services/plantuml_service.py b/backend/app/services/plantuml_service.py new file mode 100644 index 0000000..08985c7 --- /dev/null +++ b/backend/app/services/plantuml_service.py @@ -0,0 +1,128 @@ +"""C4-PlantUML exporter for ArchFlow diagrams. + +Emits PlantUML source that pulls in the C4-PlantUML stdlib so any PlantUML +renderer (plantuml.com, Kroki, the IntelliJ plugin) can render the result +without further configuration. +""" + +import uuid + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.connection import ConnectionDirection +from app.models.diagram import Diagram, DiagramType +from app.services.c4_common import ( + alias as _alias, +) +from app.services.c4_common import ( + build_c4_args, + c4_keyword, + esc_c4_arg, + is_boundary_kw, +) + +_INCLUDE = { + DiagramType.SYSTEM_LANDSCAPE: "C4_Context.puml", + DiagramType.SYSTEM_CONTEXT: "C4_Context.puml", + DiagramType.CONTAINER: "C4_Container.puml", + DiagramType.COMPONENT: "C4_Component.puml", + DiagramType.CUSTOM: "C4_Container.puml", +} + + +def _tech_label(ids, tech_names: dict[uuid.UUID, str]) -> str | None: + if not ids: + return None + names = [tech_names[i] for i in ids if i in tech_names] + return ", ".join(names) if names else None + + +def _build_parent_index(placements: list) -> tuple[dict, list]: + placed_ids = {p.object_id for p in placements} + children_by_parent: dict = {} + top_level: list = [] + for p in placements: + parent_id = p.object.parent_id + if parent_id and parent_id in placed_ids: + children_by_parent.setdefault(parent_id, []).append(p) + else: + top_level.append(p) + return children_by_parent, top_level + + +async def export_plantuml(db: AsyncSession, diagram: Diagram) -> str: + from app.services import diagram_service + + payload = await diagram_service.get_diagram_payload(db, diagram) + placements = payload["placements"] + connections = payload["connections"] + tech_names = payload["tech_names"] + + include = _INCLUDE.get(diagram.type, "C4_Container.puml") + lines: list[str] = ["@startuml"] + lines.append( + f"!include https://raw.githubusercontent.com/plantuml-stdlib/" + f"C4-PlantUML/master/{include}" + ) + lines.append("' Exported from ArchFlow") + lines.append(f"' diagram_id: {diagram.id}") + lines.append(f"' diagram_type: {diagram.type.value}") + lines.append( + f"' objects: {len(placements)}; connections: {len(connections)}" + ) + lines.append(f'title {esc_c4_arg(diagram.name)}') + + children_by_parent, top_level = _build_parent_index(placements) + + for p in top_level: + _emit_placement( + p, diagram.type, tech_names, children_by_parent, lines, indent=0 + ) + + for c in connections: + src = _alias(c.source_id) + tgt = _alias(c.target_id) + label = esc_c4_arg(c.label) + tech = _tech_label(c.protocol_ids, tech_names) + rel_kw = "BiRel" if c.direction == ConnectionDirection.BIDIRECTIONAL else "Rel" + if tech: + lines.append(f'{rel_kw}({src}, {tgt}, "{label}", "{esc_c4_arg(tech)}")') + else: + lines.append(f'{rel_kw}({src}, {tgt}, "{label}")') + + lines.append("@enduml") + return "\n".join(lines) + "\n" + + +def _emit_placement( + placement, + diagram_type: DiagramType, + tech_names: dict, + children_by_parent: dict, + lines: list[str], + indent: int, +) -> None: + obj = placement.object + pad = " " * indent + a = _alias(obj.id) + kw = c4_keyword(obj.type, diagram_type) + name = esc_c4_arg(obj.name) + desc = esc_c4_arg(obj.description) + tech = _tech_label(obj.technology_ids, tech_names) + tech_arg = esc_c4_arg(tech) if tech else None + + lines.append( + f"{pad}' {a} = {obj.id} (type={obj.type.value}, status={obj.status.value})" + ) + + if is_boundary_kw(kw): + lines.append(f'{pad}{kw}({a}, "{name}") {{') + for child in children_by_parent.get(obj.id, []): + _emit_placement( + child, diagram_type, tech_names, children_by_parent, lines, indent + 1 + ) + lines.append(f"{pad}}}") + return + + args = build_c4_args(kw, a, name, tech_arg, desc) + lines.append(f"{pad}{kw}({args})") diff --git a/backend/app/services/structurizr_service.py b/backend/app/services/structurizr_service.py index 0f0cb38..bcbc127 100644 --- a/backend/app/services/structurizr_service.py +++ b/backend/app/services/structurizr_service.py @@ -1,6 +1,10 @@ -"""Minimal Structurizr DSL importer. +"""Structurizr DSL bridge — importer + exporter. -Supports the subset of DSL that maps cleanly onto ArchFlow's model: +Importer: parses the subset of DSL that maps cleanly onto ArchFlow's model. +Exporter: emits the same subset, so an exported diagram round-trips through +`POST /import/structurizr` back into equivalent objects + connections. + +Importer-supported subset: workspace { model { @@ -22,7 +26,8 @@ from sqlalchemy.ext.asyncio import AsyncSession -from app.models.connection import Connection +from app.models.connection import Connection, ConnectionDirection +from app.models.diagram import Diagram from app.models.object import ModelObject, ObjectType _KEYWORD_TO_TYPE: dict[str, ObjectType] = { @@ -172,3 +177,150 @@ async def import_dsl(db: AsyncSession, dsl: str) -> dict: ), "alias_map": {k: str(v) for k, v in alias_to_id.items()}, } + + +# ── Exporter ──────────────────────────────────────────── + + +_OBJ_KEYWORD = { + ObjectType.ACTOR: "person", + ObjectType.SYSTEM: "softwareSystem", + ObjectType.EXTERNAL_SYSTEM: "softwareSystem", + ObjectType.APP: "container", + ObjectType.STORE: "container", + ObjectType.COMPONENT: "component", + ObjectType.GROUP: "group", +} + + +def _alias(obj_id: uuid.UUID) -> str: + return f"n_{obj_id.hex[:8]}" + + +def _esc_dsl(s: str | None) -> str: + """Sanitize a DSL double-quoted string. + + Structurizr DSL has no `\\"` escape, so we swap quotes for apostrophes, + drop newlines, and replace backslashes (which the lexer treats specially + in some implementations) with forward slashes. + """ + if not s: + return "" + return ( + s.replace("\\", "/") + .replace('"', "'") + .replace("\n", " ") + .replace("\r", " ") + .strip() + ) + + +def _tech_label(ids, tech_names: dict[uuid.UUID, str]) -> str | None: + if not ids: + return None + names = [tech_names[i] for i in ids if i in tech_names] + return ", ".join(names) if names else None + + +def _build_dsl_args(name: str, desc: str, tech: str | None) -> str: + """Positional DSL string args, padding earlier slots with `""` as needed.""" + parts = [f'"{name}"'] + if tech: + parts.append(f'"{desc}"') + parts.append(f'"{tech}"') + elif desc: + parts.append(f'"{desc}"') + return " ".join(parts) + + +async def export_dsl(db: AsyncSession, diagram: Diagram) -> str: + """Render `diagram` as Structurizr DSL. + + Parents-with-children get emitted as nested brace blocks so the importer + in this same file rebuilds the parent_id chain on round-trip — the + earlier inline `# parent: ...` trick collided with the importer's + line-anchored declaration regex. + + Note: the `alias = group "..."` form we emit for `ObjectType.GROUP` is + an ArchFlow extension. Vanilla Structurizr DSL uses bare + `group "..." { ... }` (no alias), but our importer needs the alias to + materialize the GROUP as a real ModelObject on round-trip. + """ + from app.services import diagram_service + + payload = await diagram_service.get_diagram_payload(db, diagram) + placements = payload["placements"] + connections = payload["connections"] + tech_names = payload["tech_names"] + + placed_ids = {p.object_id for p in placements} + children_by_parent: dict = {} + top_level: list = [] + for p in placements: + parent_id = p.object.parent_id + if parent_id and parent_id in placed_ids: + children_by_parent.setdefault(parent_id, []).append(p) + else: + top_level.append(p) + + lines: list[str] = [] + lines.append("# Exported from ArchFlow") + lines.append(f"# diagram_id: {diagram.id}") + lines.append(f"# diagram_type: {diagram.type.value}") + lines.append( + f"# objects: {len(placements)}; connections: {len(connections)}" + ) + lines.append(f'workspace "{_esc_dsl(diagram.name)}" {{') + lines.append(" model {") + + for p in top_level: + _emit_dsl_obj(p, children_by_parent, tech_names, lines, indent=4) + + for c in connections: + src = _alias(c.source_id) + tgt = _alias(c.target_id) + label = _esc_dsl(c.label) + tech = _tech_label(c.protocol_ids, tech_names) + tech_arg = _esc_dsl(tech) if tech else None + if tech_arg: + lines.append(f' {src} -> {tgt} "{label}" "{tech_arg}"') + else: + lines.append(f' {src} -> {tgt} "{label}"') + if c.direction == ConnectionDirection.BIDIRECTIONAL: + # DSL has no native bi-directional arrow; emit the reverse so + # round-trip preserves the symmetry. + if tech_arg: + lines.append(f' {tgt} -> {src} "{label}" "{tech_arg}"') + else: + lines.append(f' {tgt} -> {src} "{label}"') + + lines.append(" }") + lines.append("}") + return "\n".join(lines) + "\n" + + +def _emit_dsl_obj( + placement, + children_by_parent: dict, + tech_names: dict, + lines: list[str], + indent: int, +) -> None: + obj = placement.object + pad = " " * indent + a = _alias(obj.id) + kw = _OBJ_KEYWORD.get(obj.type, "softwareSystem") + name = _esc_dsl(obj.name) + desc = _esc_dsl(obj.description) + tech = _tech_label(obj.technology_ids, tech_names) + tech_arg = _esc_dsl(tech) if tech else None + args = _build_dsl_args(name, desc, tech_arg) + + children = children_by_parent.get(obj.id, []) + if children: + lines.append(f"{pad}{a} = {kw} {args} {{") + for child in children: + _emit_dsl_obj(child, children_by_parent, tech_names, lines, indent + 2) + lines.append(f"{pad}}}") + else: + lines.append(f"{pad}{a} = {kw} {args}") diff --git a/backend/tests/api/test_export.py b/backend/tests/api/test_export.py new file mode 100644 index 0000000..a377b71 --- /dev/null +++ b/backend/tests/api/test_export.py @@ -0,0 +1,316 @@ +"""End-to-end tests for `GET /diagrams/{id}/export`. + +Covers all four formats plus the 404 path. The fixtures register a user, +build a small system_landscape diagram (User → API → DB), and exercise the +endpoint without exercising team-ACL — workspace-scoped diagrams visible +to their owner is the common case. +""" +import uuid + + +async def _register(client, tag: str = "exp"): + email = f"{tag}-{uuid.uuid4().hex[:10]}@example.com" + r = await client.post( + "/api/v1/auth/register", + json={"email": email, "name": tag.title(), "password": "s3cret-pw!"}, + ) + assert r.status_code == 201, r.text + return r.json()["access_token"] + + +async def _workspace_id(client, token: str) -> str: + r = await client.get( + "/api/v1/workspaces", headers={"Authorization": f"Bearer {token}"} + ) + assert r.status_code == 200, r.text + return r.json()[0]["id"] + + +async def _build_landscape(client, auth: dict, ws_id: str) -> dict: + """Create one diagram with three objects + two connections. + + Returns dict with diagram_id and the three object UUIDs keyed by name. + """ + r = await client.post( + "/api/v1/diagrams", + json={"name": "Auth landscape", "type": "system_landscape"}, + headers=auth, + ) + assert r.status_code == 201, r.text + diagram_id = r.json()["id"] + + objects: dict[str, str] = {} + for name, type_ in [("User", "actor"), ("API", "system"), ("DB", "store")]: + r = await client.post( + "/api/v1/objects", + json={"name": name, "type": type_}, + headers=auth, + ) + assert r.status_code == 201, r.text + obj_id = r.json()["id"] + objects[name] = obj_id + await client.post( + f"/api/v1/diagrams/{diagram_id}/objects", + json={"object_id": obj_id, "position_x": 0, "position_y": 0}, + headers=auth, + ) + + # Connections: User → API (uses), API → DB (reads/writes) + for src, tgt, label in [ + ("User", "API", "Logs in"), + ("API", "DB", "Reads/writes"), + ]: + r = await client.post( + "/api/v1/connections", + json={ + "source_id": objects[src], + "target_id": objects[tgt], + "label": label, + }, + headers=auth, + ) + assert r.status_code == 201, r.text + + return {"diagram_id": diagram_id, "objects": objects} + + +async def test_export_mermaid(client): + token = await _register(client, "mermaid") + ws_id = await _workspace_id(client, token) + auth = {"Authorization": f"Bearer {token}", "X-Workspace-ID": ws_id} + fixture = await _build_landscape(client, auth, ws_id) + + r = await client.get( + f"/api/v1/diagrams/{fixture['diagram_id']}/export?format=mermaid", + headers=auth, + ) + assert r.status_code == 200, r.text + body = r.text + assert body.startswith("%% Exported from ArchFlow") + assert "C4Context" in body + assert 'Person(' in body and '"User"' in body + assert 'System(' in body and '"API"' in body + # On a system_landscape view STORE collapses to SystemDb (not ContainerDb, + # which is only legal under C4Container). + assert 'SystemDb(' in body and '"DB"' in body + assert 'ContainerDb(' not in body + assert 'Rel(' in body + assert '"Logs in"' in body + assert '"Reads/writes"' in body + + +async def test_export_plantuml(client): + token = await _register(client, "puml") + ws_id = await _workspace_id(client, token) + auth = {"Authorization": f"Bearer {token}", "X-Workspace-ID": ws_id} + fixture = await _build_landscape(client, auth, ws_id) + + r = await client.get( + f"/api/v1/diagrams/{fixture['diagram_id']}/export?format=plantuml", + headers=auth, + ) + assert r.status_code == 200, r.text + body = r.text + assert body.startswith("@startuml") + assert body.rstrip().endswith("@enduml") + assert "!include" in body and "C4-PlantUML" in body + assert 'Person(' in body and '"User"' in body + assert 'Rel(' in body and '"Logs in"' in body + + +async def test_export_structurizr(client): + token = await _register(client, "stz") + ws_id = await _workspace_id(client, token) + auth = {"Authorization": f"Bearer {token}", "X-Workspace-ID": ws_id} + fixture = await _build_landscape(client, auth, ws_id) + + r = await client.get( + f"/api/v1/diagrams/{fixture['diagram_id']}/export?format=structurizr", + headers=auth, + ) + assert r.status_code == 200, r.text + body = r.text + assert "workspace " in body and "model {" in body + assert 'person "User"' in body + assert 'softwareSystem "API"' in body + assert 'container "DB"' in body + assert '-> ' in body and '"Logs in"' in body + + +async def test_export_structurizr_round_trip(client): + """Exporter output must parse cleanly through the importer.""" + from app.services.structurizr_service import parse + + token = await _register(client, "stz-rt") + ws_id = await _workspace_id(client, token) + auth = {"Authorization": f"Bearer {token}", "X-Workspace-ID": ws_id} + fixture = await _build_landscape(client, auth, ws_id) + + r = await client.get( + f"/api/v1/diagrams/{fixture['diagram_id']}/export?format=structurizr", + headers=auth, + ) + objs, rels = parse(r.text) + assert len(objs) == 3 + assert len(rels) == 2 + names = sorted(o["name"] for o in objs) + assert names == ["API", "DB", "User"] + + +async def test_export_json(client): + token = await _register(client, "expjson") + ws_id = await _workspace_id(client, token) + auth = {"Authorization": f"Bearer {token}", "X-Workspace-ID": ws_id} + fixture = await _build_landscape(client, auth, ws_id) + + r = await client.get( + f"/api/v1/diagrams/{fixture['diagram_id']}/export?format=json", + headers=auth, + ) + assert r.status_code == 200, r.text + data = r.json() + assert data["version"] == "1.0" + assert data["diagram"]["id"] == fixture["diagram_id"] + assert data["diagram"]["type"] == "system_landscape" + assert len(data["objects"]) == 3 + assert len(data["connections"]) == 2 + # placement coords are merged onto each object row + assert "position_x" in data["objects"][0] + + +async def test_export_default_format_is_mermaid(client): + token = await _register(client, "expdef") + ws_id = await _workspace_id(client, token) + auth = {"Authorization": f"Bearer {token}", "X-Workspace-ID": ws_id} + fixture = await _build_landscape(client, auth, ws_id) + + r = await client.get( + f"/api/v1/diagrams/{fixture['diagram_id']}/export", headers=auth + ) + assert r.status_code == 200 + assert "C4Context" in r.text + + +async def test_export_unknown_diagram_returns_404(client): + token = await _register(client, "exp404") + ws_id = await _workspace_id(client, token) + auth = {"Authorization": f"Bearer {token}", "X-Workspace-ID": ws_id} + + r = await client.get( + f"/api/v1/diagrams/{uuid.uuid4()}/export", headers=auth + ) + assert r.status_code == 404 + + +async def test_export_anonymous_caller_blocked_on_workspace_diagram(client): + """Codex H1: an unauthenticated caller must not be able to export a + workspace-scoped diagram by guessing its UUID.""" + token = await _register(client, "anon") + ws_id = await _workspace_id(client, token) + auth = {"Authorization": f"Bearer {token}", "X-Workspace-ID": ws_id} + fixture = await _build_landscape(client, auth, ws_id) + + # Same URL, no Authorization header. + r = await client.get( + f"/api/v1/diagrams/{fixture['diagram_id']}/export?format=mermaid" + ) + assert r.status_code == 401, r.text + + +async def test_export_non_member_blocked(client): + """Codex H1: an authenticated user who isn't a member of the diagram's + workspace must get 403, not the diagram contents.""" + owner_token = await _register(client, "owner") + owner_ws = await _workspace_id(client, owner_token) + owner_auth = { + "Authorization": f"Bearer {owner_token}", + "X-Workspace-ID": owner_ws, + } + fixture = await _build_landscape(client, owner_auth, owner_ws) + + # Second user — has their own workspace, no membership in `owner_ws`. + outsider_token = await _register(client, "outsider") + outsider_ws = await _workspace_id(client, outsider_token) + outsider_auth = { + "Authorization": f"Bearer {outsider_token}", + "X-Workspace-ID": outsider_ws, + } + + r = await client.get( + f"/api/v1/diagrams/{fixture['diagram_id']}/export?format=mermaid", + headers=outsider_auth, + ) + assert r.status_code == 403, r.text + + +async def test_export_empty_diagram(client): + """A diagram with zero placements still emits a valid header + body.""" + token = await _register(client, "expempty") + ws_id = await _workspace_id(client, token) + auth = {"Authorization": f"Bearer {token}", "X-Workspace-ID": ws_id} + + r = await client.post( + "/api/v1/diagrams", + json={"name": "Empty", "type": "container"}, + headers=auth, + ) + diagram_id = r.json()["id"] + + r = await client.get( + f"/api/v1/diagrams/{diagram_id}/export?format=mermaid", headers=auth + ) + assert r.status_code == 200 + assert "C4Container" in r.text + assert "objects: 0; connections: 0" in r.text + + +async def test_export_live_diagram_excludes_draft_connections(client): + """Codex M1: a draft-scoped Connection that wires two live objects must + not appear in the live diagram's export. Without the fix in + diagram_service.get_diagram_payload, the connection set is built only + from object IDs and ignores Connection.draft_id, leaking unmerged work + into the live model.""" + token = await _register(client, "draftleak") + ws_id = await _workspace_id(client, token) + auth = {"Authorization": f"Bearer {token}", "X-Workspace-ID": ws_id} + fixture = await _build_landscape(client, auth, ws_id) + + # Fork the diagram so we have a real draft id to scope the rogue connection + # to (Connection.draft_id has an FK on drafts.id). + r = await client.post( + f"/api/v1/drafts/from-diagram/{fixture['diagram_id']}", + json={"name": "leak-feature"}, + headers=auth, + ) + assert r.status_code == 201, r.text + draft_id = r.json()["id"] + + # Wire a draft-scoped connection between two LIVE objects on this diagram. + r = await client.post( + f"/api/v1/connections?draft_id={draft_id}", + json={ + "source_id": fixture["objects"]["User"], + "target_id": fixture["objects"]["DB"], + "label": "DRAFT-ONLY-LEAK", + }, + headers=auth, + ) + assert r.status_code == 201, r.text + + r = await client.get( + f"/api/v1/diagrams/{fixture['diagram_id']}/export?format=mermaid", + headers=auth, + ) + assert r.status_code == 200, r.text + assert "DRAFT-ONLY-LEAK" not in r.text, ( + "Draft-scoped connection leaked into live export" + ) + + # The same caller exporting as JSON sees connection counts that exclude + # the draft-scoped row. + r = await client.get( + f"/api/v1/diagrams/{fixture['diagram_id']}/export?format=json", + headers=auth, + ) + assert r.status_code == 200 + assert len(r.json()["connections"]) == 2 diff --git a/backend/tests/services/test_export_services.py b/backend/tests/services/test_export_services.py new file mode 100644 index 0000000..1a4521b --- /dev/null +++ b/backend/tests/services/test_export_services.py @@ -0,0 +1,393 @@ +"""Unit tests for export service formatters that don't need a live DB. + +Each test stubs out `diagram_service.get_diagram_payload` so we can pin the +shape of the rendered text without spinning up Postgres rows. The service- +level coverage complements the end-to-end API tests, which exercise the +DB-backed path but only against the C4 diagram types. +""" +import uuid +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from app.models.connection import ConnectionDirection +from app.models.diagram import DiagramType +from app.models.object import ObjectStatus, ObjectType +from app.services import ( + c4_common, + mermaid_service, + plantuml_service, + structurizr_service, +) + + +def _obj(name, type_, *, description=None, technology_ids=None, parent_id=None): + return SimpleNamespace( + id=uuid.uuid4(), + name=name, + type=type_, + status=ObjectStatus.LIVE, + description=description, + technology_ids=technology_ids, + parent_id=parent_id, + ) + + +def _placement(obj): + return SimpleNamespace( + object_id=obj.id, object=obj, position_x=0.0, position_y=0.0, width=None, height=None + ) + + +def _conn(src, tgt, label, *, direction=ConnectionDirection.UNIDIRECTIONAL, protocol_ids=None): + return SimpleNamespace( + source_id=src.id, + target_id=tgt.id, + label=label, + direction=direction, + protocol_ids=protocol_ids, + ) + + +def _diagram(type_=DiagramType.SYSTEM_LANDSCAPE, name="Demo"): + return SimpleNamespace( + id=uuid.uuid4(), + name=name, + type=type_, + description=None, + scope_object_id=None, + ) + + +def _patch_payload(payload): + """Patch get_diagram_payload to return `payload` for any caller.""" + + async def fake(db, diagram): + return payload + + return patch("app.services.diagram_service.get_diagram_payload", new=fake) + + +# ─── c4_common keyword mapping ───────────────────────────── + + +@pytest.mark.parametrize( + "obj_type,diagram_type,expected", + [ + # Landscape / context: STORE collapses to SystemDb, APP/COMPONENT to System + (ObjectType.STORE, DiagramType.SYSTEM_LANDSCAPE, "SystemDb"), + (ObjectType.STORE, DiagramType.SYSTEM_CONTEXT, "SystemDb"), + (ObjectType.APP, DiagramType.SYSTEM_LANDSCAPE, "System"), + (ObjectType.COMPONENT, DiagramType.SYSTEM_CONTEXT, "System"), + # Container view: STORE is ContainerDb, APP/COMPONENT both Container + (ObjectType.STORE, DiagramType.CONTAINER, "ContainerDb"), + (ObjectType.APP, DiagramType.CONTAINER, "Container"), + (ObjectType.COMPONENT, DiagramType.CONTAINER, "Container"), + # Component view: COMPONENT renders as Component + (ObjectType.COMPONENT, DiagramType.COMPONENT, "Component"), + (ObjectType.APP, DiagramType.COMPONENT, "Container"), + (ObjectType.STORE, DiagramType.COMPONENT, "ContainerDb"), + # Universals + (ObjectType.ACTOR, DiagramType.CONTAINER, "Person"), + (ObjectType.SYSTEM, DiagramType.SYSTEM_LANDSCAPE, "System"), + (ObjectType.EXTERNAL_SYSTEM, DiagramType.CONTAINER, "System_Ext"), + (ObjectType.GROUP, DiagramType.SYSTEM_LANDSCAPE, "System_Boundary"), + ], +) +def test_c4_keyword_is_diagram_type_aware(obj_type, diagram_type, expected): + assert c4_common.c4_keyword(obj_type, diagram_type) == expected + + +# ─── Mermaid C4 ───────────────────────────────────────────── + + +async def test_mermaid_landscape_uses_systemdb_not_containerdb(): + """STORE on a landscape diagram must NOT emit ContainerDb (codex H2).""" + db = _obj("Postgres", ObjectType.STORE) + payload = { + "placements": [_placement(db)], + "connections": [], + "tech_names": {}, + } + diagram = _diagram(DiagramType.SYSTEM_LANDSCAPE) + + with _patch_payload(payload): + text = await mermaid_service.export_mermaid(db=None, diagram=diagram) + + assert "C4Context" in text + assert 'SystemDb(' in text + assert "ContainerDb(" not in text + + +async def test_mermaid_container_keeps_containerdb(): + db_obj = _obj("Postgres", ObjectType.STORE) + payload = { + "placements": [_placement(db_obj)], + "connections": [], + "tech_names": {}, + } + diagram = _diagram(DiagramType.CONTAINER) + + with _patch_payload(payload): + text = await mermaid_service.export_mermaid(db=None, diagram=diagram) + + assert "C4Container" in text + assert "ContainerDb(" in text + + +async def test_mermaid_group_emits_boundary_block_with_children(): + """ObjectType.GROUP must render as a System_Boundary { ... } block (codex H4).""" + boundary = _obj("Backend", ObjectType.GROUP) + api = _obj("API", ObjectType.APP, parent_id=boundary.id) + payload = { + "placements": [_placement(boundary), _placement(api)], + "connections": [], + "tech_names": {}, + } + diagram = _diagram(DiagramType.CONTAINER) + + with _patch_payload(payload): + text = await mermaid_service.export_mermaid(db=None, diagram=diagram) + + # Boundary opens with `{` and closes with `}` + assert 'System_Boundary(' in text + assert text.count("{") >= 1 and text.count("}") >= 1 + # The child Container line is indented further than the boundary line + boundary_line = next(line for line in text.splitlines() if "System_Boundary(" in line) + child_line = next(line for line in text.splitlines() if "Container(" in line) + assert (len(child_line) - len(child_line.lstrip())) > ( + len(boundary_line) - len(boundary_line.lstrip()) + ) + + +async def test_mermaid_c4_birel_for_bidirectional(): + user = _obj("User", ObjectType.ACTOR) + api = _obj("API", ObjectType.SYSTEM, description="Backend") + payload = { + "placements": [_placement(user), _placement(api)], + "connections": [ + _conn(user, api, "talks", direction=ConnectionDirection.BIDIRECTIONAL) + ], + "tech_names": {}, + } + diagram = _diagram(DiagramType.SYSTEM_LANDSCAPE) + + with _patch_payload(payload): + text = await mermaid_service.export_mermaid(db=None, diagram=diagram) + + assert "C4Context" in text + assert "BiRel(" in text + + +# ─── Mermaid flowchart round-trip ─────────────────────────── + + +async def test_mermaid_flowchart_round_trips_through_parser(): + """Custom-diagram flowchart export must parse via mermaid_service.parse() (codex M1).""" + user = _obj("User", ObjectType.ACTOR) + api = _obj("API", ObjectType.SYSTEM) + db_obj = _obj("DB", ObjectType.STORE) + payload = { + "placements": [_placement(user), _placement(api), _placement(db_obj)], + "connections": [ + _conn(user, api, "logs in"), + _conn(api, db_obj, "reads"), + ], + "tech_names": {}, + } + diagram = _diagram(DiagramType.CUSTOM) + + with _patch_payload(payload): + text = await mermaid_service.export_mermaid(db=None, diagram=diagram) + + objs, rels = mermaid_service.parse(text) + assert len(objs) == 3 + assert len(rels) == 2 + assert {o["name"] for o in objs} == {"User", "API", "DB"} + expected_rels = {("n_", "logs in"), ("n_", "reads")} + assert {(r["source_alias"][:2], r["label"]) for r in rels} == expected_rels + + +# ─── PlantUML ─────────────────────────────────────────────── + + +async def test_plantuml_landscape_uses_systemdb(): + db_obj = _obj("Postgres", ObjectType.STORE) + payload = { + "placements": [_placement(db_obj)], + "connections": [], + "tech_names": {}, + } + diagram = _diagram(DiagramType.SYSTEM_LANDSCAPE) + + with _patch_payload(payload): + text = await plantuml_service.export_plantuml(db=None, diagram=diagram) + + assert "C4_Context.puml" in text + assert "SystemDb(" in text + assert "ContainerDb(" not in text + + +async def test_plantuml_group_emits_boundary_block(): + boundary = _obj("Backend", ObjectType.GROUP) + api = _obj("API", ObjectType.APP, parent_id=boundary.id) + payload = { + "placements": [_placement(boundary), _placement(api)], + "connections": [], + "tech_names": {}, + } + diagram = _diagram(DiagramType.CONTAINER) + + with _patch_payload(payload): + text = await plantuml_service.export_plantuml(db=None, diagram=diagram) + + assert "System_Boundary(" in text + # Child Container lives inside braces + container_idx = text.index("Container(") + open_brace_idx = text.index("{") + close_brace_idx = text.rindex("}") + assert open_brace_idx < container_idx < close_brace_idx + + +async def test_plantuml_includes_tech_label(): + user = _obj("User", ObjectType.ACTOR) + api = _obj( + "API", + ObjectType.APP, + description="Backend", + technology_ids=[uuid.uuid4()], + ) + tech_id = api.technology_ids[0] + payload = { + "placements": [_placement(user), _placement(api)], + "connections": [_conn(user, api, "uses")], + "tech_names": {tech_id: "FastAPI"}, + } + diagram = _diagram(DiagramType.CONTAINER) + + with _patch_payload(payload): + text = await plantuml_service.export_plantuml(db=None, diagram=diagram) + + assert text.startswith("@startuml") + assert text.rstrip().endswith("@enduml") + assert '"FastAPI"' in text + assert 'Container(' in text + + +# ─── Structurizr round-trip ──────────────────────────────── + + +async def test_structurizr_nested_blocks_round_trip(): + """Parent objects with placed children render as nested DSL blocks + that re-parse through import_dsl (codex H3).""" + parent = _obj("Backend", ObjectType.SYSTEM, description="System") + child = _obj("API", ObjectType.APP, parent_id=parent.id) + payload = { + "placements": [_placement(parent), _placement(child)], + "connections": [], + "tech_names": {}, + } + diagram = _diagram(DiagramType.CONTAINER) + + with _patch_payload(payload): + text = await structurizr_service.export_dsl(db=None, diagram=diagram) + + # Nested form, not the broken inline `# parent:` comment + assert "# parent:" not in text + assert "{" in text and "}" in text + + # Re-parse: importer recovers parent_alias on the child + parsed_objs, parsed_rels = structurizr_service.parse(text) + by_name = {o["name"]: o for o in parsed_objs} + assert "Backend" in by_name and "API" in by_name + api = by_name["API"] + backend = by_name["Backend"] + assert api["parent_alias"] == backend["alias"] + + +async def test_structurizr_group_emits_block_with_alias(): + grp = _obj("Backend", ObjectType.GROUP) + api = _obj("API", ObjectType.APP, parent_id=grp.id) + payload = { + "placements": [_placement(grp), _placement(api)], + "connections": [], + "tech_names": {}, + } + diagram = _diagram(DiagramType.CONTAINER) + + with _patch_payload(payload): + text = await structurizr_service.export_dsl(db=None, diagram=diagram) + + assert " group " in text + parsed_objs, _ = structurizr_service.parse(text) + by_type = {o["name"]: o["type"] for o in parsed_objs} + assert by_type["Backend"] == ObjectType.GROUP + assert by_type["API"] == ObjectType.APP + + +# ─── Escaping ────────────────────────────────────────────── + + +async def test_mermaid_c4_escapes_quotes_in_names(): + obj = _obj('System "Prod"', ObjectType.SYSTEM, description='Has "quotes"') + payload = { + "placements": [_placement(obj)], + "connections": [], + "tech_names": {}, + } + diagram = _diagram(DiagramType.SYSTEM_LANDSCAPE) + + with _patch_payload(payload): + text = await mermaid_service.export_mermaid(db=None, diagram=diagram) + + # Original double-quotes get swapped for apostrophes — no embedded `"` inside macro args + assert 'System(n_' in text + # Each macro line should have an even number of double-quote chars (start+end of args) + for line in text.splitlines(): + if line.strip().startswith("System("): + assert line.count('"') % 2 == 0 + + +async def test_mermaid_flowchart_escapes_pipe_and_bracket_in_labels(): + user = _obj("User|Admin", ObjectType.ACTOR) + api = _obj("API[v1]", ObjectType.SYSTEM) + payload = { + "placements": [_placement(user), _placement(api)], + "connections": [_conn(user, api, "uses|HTTPS")], + "tech_names": {}, + } + diagram = _diagram(DiagramType.CUSTOM) + + with _patch_payload(payload): + text = await mermaid_service.export_mermaid(db=None, diagram=diagram) + + # `|` inside node labels and edge labels would terminate the syntax + # — the escape helper swaps it for `/`. + for line in text.splitlines(): + if line.strip().startswith("n_") and ("[" in line): + # Node lines: bracket pairs must balance + assert line.count("[") == line.count("]") + if "-->|" in line: + # Edge labels: exactly two pipes (open + close) per edge label + assert line.count("|") == 2 + + +async def test_structurizr_dsl_swaps_quotes_and_backslashes(): + obj = _obj(r'Foo "bar" \\baz', ObjectType.SYSTEM) + payload = { + "placements": [_placement(obj)], + "connections": [], + "tech_names": {}, + } + diagram = _diagram(DiagramType.SYSTEM_LANDSCAPE) + + with _patch_payload(payload): + text = await structurizr_service.export_dsl(db=None, diagram=diagram) + + # The DSL string must not contain raw backslashes or unescaped inner quotes + parsed_objs, _ = structurizr_service.parse(text) + assert len(parsed_objs) == 1 + assert "\\" not in parsed_objs[0]["name"] + # Parser strips outer quotes; inner quote must have been swapped to `'` + assert '"' not in parsed_objs[0]["name"] diff --git a/frontend/src/components/toolbar/ExportToolbar.tsx b/frontend/src/components/toolbar/ExportToolbar.tsx new file mode 100644 index 0000000..f9e6f79 --- /dev/null +++ b/frontend/src/components/toolbar/ExportToolbar.tsx @@ -0,0 +1,281 @@ +import { useEffect, useRef, useState } from 'react' +import { + fetchDiagramExport, + type DiagramExportFormat, +} from '../../hooks/use-api' +import { useDiagram } from '../../hooks/use-diagrams' + +interface ExportToolbarProps { + diagramId: string | undefined +} + +interface FormatRow { + id: DiagramExportFormat + label: string + ext: string + hint: string +} + +const FORMATS: FormatRow[] = [ + { id: 'mermaid', label: 'Mermaid', ext: 'mmd', hint: 'C4 / flowchart' }, + { id: 'plantuml', label: 'PlantUML', ext: 'puml', hint: 'C4-PlantUML' }, + { id: 'structurizr', label: 'Structurizr', ext: 'dsl', hint: 'DSL' }, + { id: 'json', label: 'JSON', ext: 'json', hint: 'Full payload' }, +] + +type FlashKind = 'copy' | 'download' | 'error' + +interface FlashState { + format: DiagramExportFormat + kind: FlashKind + text: string +} + +function safeFilename(name: string | undefined, fallback: string): string { + const base = (name || fallback).trim() + // Limit to characters safe across Windows / macOS / Linux. Anything else + // collapses to a single hyphen so the file is still recognisable. + const slug = base + .replace(/[^A-Za-z0-9 _.-]+/g, '-') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + return slug || fallback +} + +function describeError(err: unknown): string { + const e = err as { response?: { status?: number; data?: { detail?: string } }; message?: string } + const status = e?.response?.status + if (status === 401) return 'Sign in required' + if (status === 403) return 'No access' + if (status === 404) return 'Diagram not found' + return e?.response?.data?.detail || e?.message || 'Export failed' +} + +export function ExportToolbar({ diagramId }: ExportToolbarProps) { + const [open, setOpen] = useState(false) + const [busy, setBusy] = useState(null) + const [flash, setFlash] = useState(null) + const flashTimer = useRef(null) + const { data: diagram } = useDiagram(diagramId) + + useEffect(() => { + return () => { + if (flashTimer.current !== null) window.clearTimeout(flashTimer.current) + } + }, []) + + const showFlash = (state: FlashState) => { + setFlash(state) + if (flashTimer.current !== null) window.clearTimeout(flashTimer.current) + flashTimer.current = window.setTimeout(() => setFlash(null), 1800) + } + + const handleCopy = async (fmt: DiagramExportFormat) => { + if (!diagramId || busy) return + setBusy(fmt) + try { + const text = await fetchDiagramExport(diagramId, fmt) + await navigator.clipboard.writeText(text) + showFlash({ format: fmt, kind: 'copy', text: 'Copied' }) + } catch (err) { + showFlash({ format: fmt, kind: 'error', text: describeError(err) }) + } finally { + setBusy(null) + } + } + + const handleDownload = async (fmt: DiagramExportFormat, ext: string) => { + if (!diagramId || busy) return + setBusy(fmt) + try { + const text = await fetchDiagramExport(diagramId, fmt) + const mime = fmt === 'json' ? 'application/json' : 'text/plain' + const blob = new Blob([text], { type: `${mime};charset=utf-8` }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${safeFilename(diagram?.name, 'diagram')}.${ext}` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + showFlash({ format: fmt, kind: 'download', text: 'Downloaded' }) + } catch (err) { + showFlash({ format: fmt, kind: 'error', text: describeError(err) }) + } finally { + setBusy(null) + } + } + + if (!diagramId) return null + + return ( + // Position is owned by the parent flex row in DiagramPage; we only need + // `relative` here so the dropdown can anchor to this button. +
+ + + {open && ( + <> +
setOpen(false)} + /> +
+
+ Export as +
+
+ {FORMATS.map((f) => { + const isBusy = busy === f.id + const flashed = flash?.format === f.id ? flash : null + return ( +
+
+
+ {f.label} +
+
+ {flashed + ? flashed.text + : `${f.hint} · .${f.ext}`} +
+
+ handleCopy(f.id)} + disabled={isBusy || !!busy} + active={flashed?.kind === 'copy'} + error={flashed?.kind === 'error'} + /> + handleDownload(f.id, f.ext)} + disabled={isBusy || !!busy} + active={flashed?.kind === 'download'} + error={flashed?.kind === 'error'} + /> +
+ ) + })} +
+
+ + )} +
+ ) +} + +function FormatActionButton({ + label, + onClick, + disabled, + active, + error, +}: { + label: string + onClick: () => void + disabled?: boolean + active?: boolean + error?: boolean +}) { + let bg = '#262626' + let border = '#333' + let color = '#d4d4d4' + if (active) { + bg = '#1f3a23' + border = '#2f6b3a' + color = '#86efac' + } else if (error) { + bg = '#3a1f1f' + border = '#6b2f2f' + color = '#fca5a5' + } + return ( + + ) +} + +function DownloadIcon() { + return ( + + + + + + ) +} diff --git a/frontend/src/components/toolbar/FlowsPanel.tsx b/frontend/src/components/toolbar/FlowsPanel.tsx index c4d7857..811294a 100644 --- a/frontend/src/components/toolbar/FlowsPanel.tsx +++ b/frontend/src/components/toolbar/FlowsPanel.tsx @@ -33,7 +33,9 @@ export function FlowsPanel({ diagramId }: FlowsPanelProps) { } return ( -
+ // Position is owned by the parent flex row in DiagramPage; we only need + // `relative` here so the panel can anchor to this button. +
)} - {diagramId && } + {/* Top-right canvas button cluster. Children flow left-to-right + via flex; each one only owns its own width, so adding or + removing a button here doesn't require recalculating offsets. */} +
+ + {diagramId && } +
{diagramId && }