Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 93 additions & 3 deletions backend/app/api/v1/export.py
Original file line number Diff line number Diff line change
@@ -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))
Expand Down
125 changes: 125 additions & 0 deletions backend/app/services/c4_common.py
Original file line number Diff line number Diff line change
@@ -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)
61 changes: 61 additions & 0 deletions backend/app/services/diagram_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading