From c1bc32d50a55194086fa578e59035391f2a85008 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:17:13 +0100 Subject: [PATCH] feat(02-02): implement ELK diagram renderer with SVG/JSON content negotiation - ElkDiagramRenderer: ELK layered layout (top-to-bottom) with JFreeSVG rendering - Color-coded nodes: blue endpoints, green processors, red errors, purple EIPs, cyan cross-route - Compound node support for CHOICE/SPLIT/TRY_CATCH swimlane groups - DiagramRenderController: GET /api/v1/diagrams/{hash}/render with Accept header negotiation - DiagramBeanConfig for Spring wiring - 11 unit tests (layout, SVG structure, colors, compound nodes) - 4 integration tests (SVG, JSON, 404, default format) - Added xtext xbase lib dependency for ELK compatibility Co-Authored-By: Claude Opus 4.6 --- cameleer3-server-app/pom.xml | 5 + .../server/app/config/DiagramBeanConfig.java | 18 + .../controller/DiagramRenderController.java | 93 +++ .../app/diagram/ElkDiagramRenderer.java | 560 ++++++++++++++++++ .../controller/DiagramRenderControllerIT.java | 139 +++++ .../app/diagram/ElkDiagramRendererTest.java | 183 ++++++ 6 files changed, 998 insertions(+) create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/DiagramBeanConfig.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DiagramRenderController.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/diagram/ElkDiagramRenderer.java create mode 100644 cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DiagramRenderControllerIT.java create mode 100644 cameleer3-server-app/src/test/java/com/cameleer3/server/app/diagram/ElkDiagramRendererTest.java diff --git a/cameleer3-server-app/pom.xml b/cameleer3-server-app/pom.xml index e8f3be10..ca66696b 100644 --- a/cameleer3-server-app/pom.xml +++ b/cameleer3-server-app/pom.xml @@ -61,6 +61,11 @@ org.jfree.svg 5.0.7 + + org.eclipse.xtext + org.eclipse.xtext.xbase.lib + 2.37.0 + org.springframework.boot spring-boot-starter-test diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/DiagramBeanConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/DiagramBeanConfig.java new file mode 100644 index 00000000..1d847215 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/DiagramBeanConfig.java @@ -0,0 +1,18 @@ +package com.cameleer3.server.app.config; + +import com.cameleer3.server.app.diagram.ElkDiagramRenderer; +import com.cameleer3.server.core.diagram.DiagramRenderer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Creates beans for diagram rendering. + */ +@Configuration +public class DiagramBeanConfig { + + @Bean + public DiagramRenderer diagramRenderer() { + return new ElkDiagramRenderer(); + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DiagramRenderController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DiagramRenderController.java new file mode 100644 index 00000000..0762ce86 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DiagramRenderController.java @@ -0,0 +1,93 @@ +package com.cameleer3.server.app.controller; + +import com.cameleer3.common.graph.RouteGraph; +import com.cameleer3.server.core.diagram.DiagramLayout; +import com.cameleer3.server.core.diagram.DiagramRenderer; +import com.cameleer3.server.core.storage.DiagramRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Optional; + +/** + * REST endpoint for rendering route diagrams. + *

+ * Supports content negotiation via Accept header: + *

+ */ +@RestController +@RequestMapping("/api/v1/diagrams") +@Tag(name = "Diagrams", description = "Diagram rendering endpoints") +public class DiagramRenderController { + + private static final MediaType SVG_MEDIA_TYPE = MediaType.valueOf("image/svg+xml"); + + private final DiagramRepository diagramRepository; + private final DiagramRenderer diagramRenderer; + + public DiagramRenderController(DiagramRepository diagramRepository, + DiagramRenderer diagramRenderer) { + this.diagramRepository = diagramRepository; + this.diagramRenderer = diagramRenderer; + } + + @GetMapping("/{contentHash}/render") + @Operation(summary = "Render a route diagram", + description = "Returns SVG (default) or JSON layout based on Accept header") + @ApiResponse(responseCode = "200", description = "Diagram rendered successfully") + @ApiResponse(responseCode = "404", description = "Diagram not found") + public ResponseEntity renderDiagram( + @PathVariable String contentHash, + HttpServletRequest request) { + + Optional graphOpt = diagramRepository.findByContentHash(contentHash); + if (graphOpt.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + RouteGraph graph = graphOpt.get(); + String accept = request.getHeader("Accept"); + + // Return JSON only when the client explicitly requests application/json + // without also accepting everything (*/*). This means "application/json" + // must appear and wildcards must not dominate the preference. + if (accept != null && isJsonPreferred(accept)) { + DiagramLayout layout = diagramRenderer.layoutJson(graph); + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body(layout); + } + + // Default to SVG for image/svg+xml, */* or no Accept header + String svg = diagramRenderer.renderSvg(graph); + return ResponseEntity.ok() + .contentType(SVG_MEDIA_TYPE) + .body(svg); + } + + /** + * Determine if JSON is the explicitly preferred format. + *

+ * Returns true only when the first media type in the Accept header is + * "application/json". Clients sending broad Accept lists like + * "text/plain, application/json, */*" are treated as unspecific + * and receive the SVG default. + */ + private boolean isJsonPreferred(String accept) { + String[] parts = accept.split(","); + if (parts.length == 0) return false; + String first = parts[0].trim().split(";")[0].trim(); + return "application/json".equalsIgnoreCase(first); + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/diagram/ElkDiagramRenderer.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/diagram/ElkDiagramRenderer.java new file mode 100644 index 00000000..44fdb598 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/diagram/ElkDiagramRenderer.java @@ -0,0 +1,560 @@ +package com.cameleer3.server.app.diagram; + +import com.cameleer3.common.graph.NodeType; +import com.cameleer3.common.graph.RouteEdge; +import com.cameleer3.common.graph.RouteGraph; +import com.cameleer3.common.graph.RouteNode; +import com.cameleer3.server.core.diagram.DiagramLayout; +import com.cameleer3.server.core.diagram.DiagramRenderer; +import com.cameleer3.server.core.diagram.PositionedEdge; +import com.cameleer3.server.core.diagram.PositionedNode; +import org.eclipse.elk.alg.layered.options.LayeredMetaDataProvider; +import org.eclipse.elk.core.RecursiveGraphLayoutEngine; +import org.eclipse.elk.core.options.CoreOptions; +import org.eclipse.elk.core.options.Direction; +import org.eclipse.elk.core.options.HierarchyHandling; +import org.eclipse.elk.core.util.BasicProgressMonitor; +import org.eclipse.elk.graph.ElkBendPoint; +import org.eclipse.elk.graph.ElkEdge; +import org.eclipse.elk.graph.ElkEdgeSection; +import org.eclipse.elk.graph.ElkGraphFactory; +import org.eclipse.elk.graph.ElkNode; +import org.jfree.svg.SVGGraphics2D; + +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.geom.GeneralPath; +import java.awt.geom.RoundRectangle2D; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * ELK + JFreeSVG implementation of {@link DiagramRenderer}. + *

+ * Uses Eclipse ELK layered algorithm for top-to-bottom layout computation + * and JFreeSVG for SVG document generation with color-coded nodes. + */ +public class ElkDiagramRenderer implements DiagramRenderer { + + private static final int PADDING = 20; + private static final int NODE_HEIGHT = 40; + private static final int MIN_NODE_WIDTH = 80; + private static final int CHAR_WIDTH = 8; + private static final int LABEL_PADDING = 32; + private static final int COMPOUND_TOP_PADDING = 30; + private static final int COMPOUND_SIDE_PADDING = 10; + private static final int CORNER_RADIUS = 8; + private static final double NODE_SPACING = 40.0; + private static final double EDGE_SPACING = 20.0; + + // Blue: endpoints + private static final Color BLUE = Color.decode("#3B82F6"); + // Green: processors + private static final Color GREEN = Color.decode("#22C55E"); + // Red: error handling + private static final Color RED = Color.decode("#EF4444"); + // Purple: EIP patterns + private static final Color PURPLE = Color.decode("#A855F7"); + // Cyan: cross-route + private static final Color CYAN = Color.decode("#06B6D4"); + // Gray: edges + private static final Color EDGE_GRAY = Color.decode("#9CA3AF"); + + private static final Set ENDPOINT_TYPES = EnumSet.of( + NodeType.ENDPOINT, NodeType.TO, NodeType.TO_DYNAMIC, NodeType.DIRECT, NodeType.SEDA + ); + + private static final Set PROCESSOR_TYPES = EnumSet.of( + NodeType.PROCESSOR, NodeType.BEAN, NodeType.LOG, + NodeType.SET_HEADER, NodeType.SET_BODY, NodeType.TRANSFORM, + NodeType.MARSHAL, NodeType.UNMARSHAL + ); + + private static final Set ERROR_TYPES = EnumSet.of( + NodeType.ERROR_HANDLER, NodeType.ON_EXCEPTION, NodeType.TRY_CATCH, + NodeType.DO_TRY, NodeType.DO_CATCH, NodeType.DO_FINALLY + ); + + private static final Set EIP_TYPES = EnumSet.of( + NodeType.EIP_CHOICE, NodeType.EIP_WHEN, NodeType.EIP_OTHERWISE, + NodeType.EIP_SPLIT, NodeType.EIP_AGGREGATE, NodeType.EIP_MULTICAST, + NodeType.EIP_FILTER, NodeType.EIP_RECIPIENT_LIST, NodeType.EIP_ROUTING_SLIP, + NodeType.EIP_DYNAMIC_ROUTER, NodeType.EIP_LOAD_BALANCE, NodeType.EIP_THROTTLE, + NodeType.EIP_DELAY, NodeType.EIP_LOOP, NodeType.EIP_IDEMPOTENT_CONSUMER, + NodeType.EIP_CIRCUIT_BREAKER, NodeType.EIP_PIPELINE + ); + + private static final Set CROSS_ROUTE_TYPES = EnumSet.of( + NodeType.EIP_WIRE_TAP, NodeType.EIP_ENRICH, NodeType.EIP_POLL_ENRICH + ); + + /** NodeTypes that act as compound containers with children. */ + private static final Set COMPOUND_TYPES = EnumSet.of( + NodeType.EIP_CHOICE, NodeType.EIP_SPLIT, NodeType.TRY_CATCH, + NodeType.DO_TRY, NodeType.EIP_LOOP, NodeType.EIP_MULTICAST, + NodeType.EIP_AGGREGATE + ); + + public ElkDiagramRenderer() { + // Ensure the layered algorithm meta data provider is registered. + // LayoutMetaDataService uses ServiceLoader, but explicit registration + // guarantees availability regardless of classpath ordering. + org.eclipse.elk.core.data.LayoutMetaDataService.getInstance() + .registerLayoutMetaDataProviders(new LayeredMetaDataProvider()); + } + + @Override + public String renderSvg(RouteGraph graph) { + LayoutResult result = computeLayout(graph); + DiagramLayout layout = result.layout; + + int svgWidth = (int) Math.ceil(layout.width()) + 2 * PADDING; + int svgHeight = (int) Math.ceil(layout.height()) + 2 * PADDING; + + SVGGraphics2D g2 = new SVGGraphics2D(svgWidth, svgHeight); + g2.translate(PADDING, PADDING); + + // Draw edges first (behind nodes) + g2.setStroke(new BasicStroke(2.0f)); + g2.setColor(EDGE_GRAY); + for (PositionedEdge edge : layout.edges()) { + drawEdge(g2, edge); + } + + // Draw nodes + Font labelFont = new Font("SansSerif", Font.PLAIN, 12); + g2.setFont(labelFont); + + // Draw compound containers first (background) + for (Map.Entry entry : result.compoundInfos.entrySet()) { + CompoundInfo ci = entry.getValue(); + PositionedNode pn = findNode(layout.nodes(), ci.nodeId); + if (pn != null) { + drawCompoundContainer(g2, pn, ci.color); + } + } + + // Draw leaf nodes + for (PositionedNode node : allNodes(layout.nodes())) { + if (!result.compoundInfos.containsKey(node.id()) || node.children().isEmpty()) { + drawNode(g2, node, result.nodeColors.getOrDefault(node.id(), PURPLE)); + } + } + + return g2.getSVGDocument(); + } + + @Override + public DiagramLayout layoutJson(RouteGraph graph) { + return computeLayout(graph).layout; + } + + // ---------------------------------------------------------------- + // Layout computation + // ---------------------------------------------------------------- + + private LayoutResult computeLayout(RouteGraph graph) { + ElkGraphFactory factory = ElkGraphFactory.eINSTANCE; + + // Create root node + ElkNode rootNode = factory.createElkNode(); + rootNode.setIdentifier("root"); + rootNode.setProperty(CoreOptions.ALGORITHM, "org.eclipse.elk.layered"); + rootNode.setProperty(CoreOptions.DIRECTION, Direction.DOWN); + rootNode.setProperty(CoreOptions.SPACING_NODE_NODE, NODE_SPACING); + rootNode.setProperty(CoreOptions.SPACING_EDGE_NODE, EDGE_SPACING); + rootNode.setProperty(CoreOptions.HIERARCHY_HANDLING, HierarchyHandling.INCLUDE_CHILDREN); + + // Build index of RouteNodes + Map routeNodeMap = new HashMap<>(); + if (graph.getNodes() != null) { + for (RouteNode rn : graph.getNodes()) { + routeNodeMap.put(rn.getId(), rn); + } + } + + // Identify compound node IDs and their children + Set compoundNodeIds = new HashSet<>(); + Map childToParent = new HashMap<>(); + for (RouteNode rn : routeNodeMap.values()) { + if (rn.getType() != null && COMPOUND_TYPES.contains(rn.getType()) + && rn.getChildren() != null && !rn.getChildren().isEmpty()) { + compoundNodeIds.add(rn.getId()); + for (RouteNode child : rn.getChildren()) { + childToParent.put(child.getId(), rn.getId()); + } + } + } + + // Create ELK nodes + Map elkNodeMap = new HashMap<>(); + Map nodeColors = new HashMap<>(); + + // First, create compound (parent) nodes + for (String compoundId : compoundNodeIds) { + RouteNode rn = routeNodeMap.get(compoundId); + ElkNode elkCompound = factory.createElkNode(); + elkCompound.setIdentifier(rn.getId()); + elkCompound.setParent(rootNode); + + // Compound nodes are larger initially -- ELK will resize + elkCompound.setWidth(200); + elkCompound.setHeight(100); + + // Set properties for compound layout + elkCompound.setProperty(CoreOptions.ALGORITHM, "org.eclipse.elk.layered"); + elkCompound.setProperty(CoreOptions.DIRECTION, Direction.DOWN); + elkCompound.setProperty(CoreOptions.SPACING_NODE_NODE, NODE_SPACING * 0.5); + elkCompound.setProperty(CoreOptions.SPACING_EDGE_NODE, EDGE_SPACING * 0.5); + elkCompound.setProperty(CoreOptions.PADDING, + new org.eclipse.elk.core.math.ElkPadding(COMPOUND_TOP_PADDING, + COMPOUND_SIDE_PADDING, COMPOUND_SIDE_PADDING, COMPOUND_SIDE_PADDING)); + + elkNodeMap.put(rn.getId(), elkCompound); + nodeColors.put(rn.getId(), colorForType(rn.getType())); + + // Create child nodes inside compound + for (RouteNode child : rn.getChildren()) { + ElkNode elkChild = factory.createElkNode(); + elkChild.setIdentifier(child.getId()); + elkChild.setParent(elkCompound); + int w = Math.max(MIN_NODE_WIDTH, (child.getLabel() != null ? child.getLabel().length() : 0) * CHAR_WIDTH + LABEL_PADDING); + elkChild.setWidth(w); + elkChild.setHeight(NODE_HEIGHT); + elkNodeMap.put(child.getId(), elkChild); + nodeColors.put(child.getId(), colorForType(child.getType())); + } + } + + // Then, create non-compound, non-child nodes + for (RouteNode rn : routeNodeMap.values()) { + if (!elkNodeMap.containsKey(rn.getId())) { + ElkNode elkNode = factory.createElkNode(); + elkNode.setIdentifier(rn.getId()); + elkNode.setParent(rootNode); + int w = Math.max(MIN_NODE_WIDTH, (rn.getLabel() != null ? rn.getLabel().length() : 0) * CHAR_WIDTH + LABEL_PADDING); + elkNode.setWidth(w); + elkNode.setHeight(NODE_HEIGHT); + elkNodeMap.put(rn.getId(), elkNode); + nodeColors.put(rn.getId(), colorForType(rn.getType())); + } + } + + // Create ELK edges + if (graph.getEdges() != null) { + for (RouteEdge re : graph.getEdges()) { + ElkNode sourceElk = elkNodeMap.get(re.getSource()); + ElkNode targetElk = elkNodeMap.get(re.getTarget()); + if (sourceElk == null || targetElk == null) { + continue; + } + + // Determine the containing node for the edge + ElkNode containingNode = findCommonParent(sourceElk, targetElk); + + ElkEdge elkEdge = factory.createElkEdge(); + elkEdge.setContainingNode(containingNode); + elkEdge.getSources().add(sourceElk); + elkEdge.getTargets().add(targetElk); + } + } + + // Run layout + RecursiveGraphLayoutEngine engine = new RecursiveGraphLayoutEngine(); + engine.layout(rootNode, new BasicProgressMonitor()); + + // Extract results + List positionedNodes = new ArrayList<>(); + Map compoundInfos = new HashMap<>(); + + for (RouteNode rn : routeNodeMap.values()) { + if (childToParent.containsKey(rn.getId())) { + // Skip children -- they are collected under their parent + continue; + } + + ElkNode elkNode = elkNodeMap.get(rn.getId()); + if (elkNode == null) continue; + + if (compoundNodeIds.contains(rn.getId())) { + // Compound node: collect children + List children = new ArrayList<>(); + if (rn.getChildren() != null) { + for (RouteNode child : rn.getChildren()) { + ElkNode childElk = elkNodeMap.get(child.getId()); + if (childElk != null) { + children.add(new PositionedNode( + child.getId(), + child.getLabel() != null ? child.getLabel() : "", + child.getType() != null ? child.getType().name() : "UNKNOWN", + elkNode.getX() + childElk.getX(), + elkNode.getY() + childElk.getY(), + childElk.getWidth(), + childElk.getHeight(), + List.of() + )); + } + } + } + + positionedNodes.add(new PositionedNode( + rn.getId(), + rn.getLabel() != null ? rn.getLabel() : "", + rn.getType() != null ? rn.getType().name() : "UNKNOWN", + elkNode.getX(), + elkNode.getY(), + elkNode.getWidth(), + elkNode.getHeight(), + children + )); + + compoundInfos.put(rn.getId(), new CompoundInfo( + rn.getId(), colorForType(rn.getType()))); + } else { + positionedNodes.add(new PositionedNode( + rn.getId(), + rn.getLabel() != null ? rn.getLabel() : "", + rn.getType() != null ? rn.getType().name() : "UNKNOWN", + elkNode.getX(), + elkNode.getY(), + elkNode.getWidth(), + elkNode.getHeight(), + List.of() + )); + } + } + + // Extract edges + List positionedEdges = new ArrayList<>(); + for (ElkEdge elkEdge : collectAllEdges(rootNode)) { + String sourceId = elkEdge.getSources().isEmpty() ? "" : elkEdge.getSources().get(0).getIdentifier(); + String targetId = elkEdge.getTargets().isEmpty() ? "" : elkEdge.getTargets().get(0).getIdentifier(); + + List points = new ArrayList<>(); + for (ElkEdgeSection section : elkEdge.getSections()) { + points.add(new double[]{ + section.getStartX() + getAbsoluteX(elkEdge.getContainingNode(), rootNode), + section.getStartY() + getAbsoluteY(elkEdge.getContainingNode(), rootNode) + }); + for (ElkBendPoint bp : section.getBendPoints()) { + points.add(new double[]{ + bp.getX() + getAbsoluteX(elkEdge.getContainingNode(), rootNode), + bp.getY() + getAbsoluteY(elkEdge.getContainingNode(), rootNode) + }); + } + points.add(new double[]{ + section.getEndX() + getAbsoluteX(elkEdge.getContainingNode(), rootNode), + section.getEndY() + getAbsoluteY(elkEdge.getContainingNode(), rootNode) + }); + } + + // Find label from original edge + String label = ""; + if (graph.getEdges() != null) { + for (RouteEdge re : graph.getEdges()) { + if (re.getSource().equals(sourceId) && re.getTarget().equals(targetId)) { + label = re.getLabel() != null ? re.getLabel() : ""; + break; + } + } + } + + positionedEdges.add(new PositionedEdge(sourceId, targetId, label, points)); + } + + double totalWidth = rootNode.getWidth(); + double totalHeight = rootNode.getHeight(); + + DiagramLayout layout = new DiagramLayout(totalWidth, totalHeight, positionedNodes, positionedEdges); + return new LayoutResult(layout, nodeColors, compoundInfos); + } + + // ---------------------------------------------------------------- + // SVG drawing helpers + // ---------------------------------------------------------------- + + private void drawNode(SVGGraphics2D g2, PositionedNode node, Color color) { + g2.setColor(color); + g2.fill(new RoundRectangle2D.Double( + node.x(), node.y(), node.width(), node.height(), + CORNER_RADIUS, CORNER_RADIUS)); + + // White label + g2.setColor(Color.WHITE); + FontMetrics fm = g2.getFontMetrics(); + String label = node.label(); + int labelWidth = fm.stringWidth(label); + float labelX = (float) (node.x() + (node.width() - labelWidth) / 2.0); + float labelY = (float) (node.y() + 24); + g2.drawString(label, labelX, labelY); + } + + private void drawCompoundContainer(SVGGraphics2D g2, PositionedNode node, Color color) { + // Semi-transparent background + Color bg = new Color(color.getRed(), color.getGreen(), color.getBlue(), 38); // ~15% alpha + g2.setColor(bg); + g2.fill(new RoundRectangle2D.Double( + node.x(), node.y(), node.width(), node.height(), + CORNER_RADIUS, CORNER_RADIUS)); + + // Border + g2.setColor(color); + g2.setStroke(new BasicStroke(1.5f)); + g2.draw(new RoundRectangle2D.Double( + node.x(), node.y(), node.width(), node.height(), + CORNER_RADIUS, CORNER_RADIUS)); + + // Label at top + g2.setColor(color); + FontMetrics fm = g2.getFontMetrics(); + float labelX = (float) (node.x() + COMPOUND_SIDE_PADDING); + float labelY = (float) (node.y() + 18); + g2.drawString(node.label(), labelX, labelY); + + // Draw children inside + for (PositionedNode child : node.children()) { + Color childColor = colorForTypeName(child.type()); + drawNode(g2, child, childColor); + } + } + + private void drawEdge(SVGGraphics2D g2, PositionedEdge edge) { + List points = edge.points(); + if (points.size() < 2) return; + + GeneralPath path = new GeneralPath(); + path.moveTo(points.get(0)[0], points.get(0)[1]); + for (int i = 1; i < points.size(); i++) { + path.lineTo(points.get(i)[0], points.get(i)[1]); + } + g2.draw(path); + + // Arrowhead at the last point + if (points.size() >= 2) { + double[] end = points.get(points.size() - 1); + double[] prev = points.get(points.size() - 2); + drawArrowhead(g2, prev[0], prev[1], end[0], end[1]); + } + } + + private void drawArrowhead(SVGGraphics2D g2, double fromX, double fromY, double toX, double toY) { + double angle = Math.atan2(toY - fromY, toX - fromX); + int arrowSize = 8; + + GeneralPath arrow = new GeneralPath(); + arrow.moveTo(toX, toY); + arrow.lineTo(toX - arrowSize * Math.cos(angle - Math.PI / 6), + toY - arrowSize * Math.sin(angle - Math.PI / 6)); + arrow.lineTo(toX - arrowSize * Math.cos(angle + Math.PI / 6), + toY - arrowSize * Math.sin(angle + Math.PI / 6)); + arrow.closePath(); + g2.fill(arrow); + } + + // ---------------------------------------------------------------- + // Color mapping + // ---------------------------------------------------------------- + + private Color colorForType(NodeType type) { + if (type == null) return PURPLE; + if (ENDPOINT_TYPES.contains(type)) return BLUE; + if (PROCESSOR_TYPES.contains(type)) return GREEN; + if (ERROR_TYPES.contains(type)) return RED; + if (EIP_TYPES.contains(type)) return EIP_TYPES.contains(type) ? PURPLE : PURPLE; + if (CROSS_ROUTE_TYPES.contains(type)) return CYAN; + return PURPLE; + } + + private Color colorForTypeName(String typeName) { + try { + NodeType type = NodeType.valueOf(typeName); + return colorForType(type); + } catch (IllegalArgumentException e) { + return PURPLE; + } + } + + // ---------------------------------------------------------------- + // ELK graph helpers + // ---------------------------------------------------------------- + + private ElkNode findCommonParent(ElkNode a, ElkNode b) { + if (a.getParent() == b.getParent()) { + return a.getParent(); + } + // If one is the parent of the other + if (a.getParent() != null && a.getParent() == b) return b; + if (b.getParent() != null && b.getParent() == a) return a; + // Default: root (grandparent) + ElkNode parent = a.getParent(); + while (parent != null && parent.getParent() != null) { + parent = parent.getParent(); + } + return parent != null ? parent : a.getParent(); + } + + private double getAbsoluteX(ElkNode node, ElkNode root) { + double x = 0; + ElkNode current = node; + while (current != null && current != root) { + x += current.getX(); + current = current.getParent(); + } + return x; + } + + private double getAbsoluteY(ElkNode node, ElkNode root) { + double y = 0; + ElkNode current = node; + while (current != null && current != root) { + y += current.getY(); + current = current.getParent(); + } + return y; + } + + private List collectAllEdges(ElkNode node) { + List edges = new ArrayList<>(node.getContainedEdges()); + for (ElkNode child : node.getChildren()) { + edges.addAll(collectAllEdges(child)); + } + return edges; + } + + private PositionedNode findNode(List nodes, String id) { + for (PositionedNode n : nodes) { + if (n.id().equals(id)) return n; + } + return null; + } + + private List allNodes(List nodes) { + List all = new ArrayList<>(); + for (PositionedNode n : nodes) { + all.add(n); + if (n.children() != null) { + all.addAll(n.children()); + } + } + return all; + } + + // ---------------------------------------------------------------- + // Internal data classes + // ---------------------------------------------------------------- + + private record LayoutResult( + DiagramLayout layout, + Map nodeColors, + Map compoundInfos + ) {} + + private record CompoundInfo(String nodeId, Color color) {} +} diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DiagramRenderControllerIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DiagramRenderControllerIT.java new file mode 100644 index 00000000..2f5995ba --- /dev/null +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DiagramRenderControllerIT.java @@ -0,0 +1,139 @@ +package com.cameleer3.server.app.controller; + +import com.cameleer3.server.app.AbstractClickHouseIT; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Integration tests for {@link DiagramRenderController}. + * Seeds a diagram via the ingestion endpoint, then tests rendering. + */ +class DiagramRenderControllerIT extends AbstractClickHouseIT { + + @Autowired + private TestRestTemplate restTemplate; + + private String contentHash; + + /** + * Seed a diagram and compute its content hash for render tests. + */ + @BeforeEach + void seedDiagram() { + String json = """ + { + "routeId": "render-test-route", + "description": "Render test", + "version": 1, + "nodes": [ + {"id": "n1", "type": "ENDPOINT", "label": "timer:tick"}, + {"id": "n2", "type": "BEAN", "label": "myBean"}, + {"id": "n3", "type": "TO", "label": "log:out"} + ], + "edges": [ + {"source": "n1", "target": "n2", "edgeType": "FLOW"}, + {"source": "n2", "target": "n3", "edgeType": "FLOW"} + ], + "processorNodeMapping": {} + } + """; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-Cameleer-Protocol-Version", "1"); + + restTemplate.postForEntity( + "/api/v1/data/diagrams", + new HttpEntity<>(json, headers), + String.class); + + // Wait for flush to ClickHouse and retrieve the content hash + await().atMost(10, SECONDS).untilAsserted(() -> { + String hash = jdbcTemplate.queryForObject( + "SELECT content_hash FROM route_diagrams WHERE route_id = 'render-test-route' LIMIT 1", + String.class); + assertThat(hash).isNotNull(); + contentHash = hash; + }); + } + + @Test + void getSvg_withAcceptHeader_returnsSvg() { + HttpHeaders headers = new HttpHeaders(); + headers.set("Accept", "image/svg+xml"); + headers.set("X-Cameleer-Protocol-Version", "1"); + + ResponseEntity response = restTemplate.exchange( + "/api/v1/diagrams/{hash}/render", + HttpMethod.GET, + new HttpEntity<>(headers), + String.class, + contentHash); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getHeaders().getContentType().toString()).contains("svg"); + assertThat(response.getBody()).contains(" response = restTemplate.exchange( + "/api/v1/diagrams/{hash}/render", + HttpMethod.GET, + new HttpEntity<>(headers), + String.class, + contentHash); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).contains("nodes"); + assertThat(response.getBody()).contains("edges"); + } + + @Test + void getNonExistentHash_returns404() { + HttpHeaders headers = new HttpHeaders(); + headers.set("Accept", "image/svg+xml"); + headers.set("X-Cameleer-Protocol-Version", "1"); + + ResponseEntity response = restTemplate.exchange( + "/api/v1/diagrams/{hash}/render", + HttpMethod.GET, + new HttpEntity<>(headers), + String.class, + "nonexistent-hash-12345"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void getWithNoAcceptHeader_defaultsToSvg() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Cameleer-Protocol-Version", "1"); + + ResponseEntity response = restTemplate.exchange( + "/api/v1/diagrams/{hash}/render", + HttpMethod.GET, + new HttpEntity<>(headers), + String.class, + contentHash); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).contains(" process(bean) -> to(endpoint) + */ + private RouteGraph buildSimpleGraph() { + RouteGraph graph = new RouteGraph("test-route"); + graph.setExtractedAt(Instant.now()); + graph.setVersion(1); + + RouteNode from = new RouteNode("node-1", NodeType.ENDPOINT, "timer:tick"); + RouteNode process = new RouteNode("node-2", NodeType.BEAN, "myProcessor"); + RouteNode to = new RouteNode("node-3", NodeType.TO, "log:output"); + + graph.setNodes(List.of(from, process, to)); + graph.setEdges(List.of( + new RouteEdge("node-1", "node-2", RouteEdge.EdgeType.FLOW), + new RouteEdge("node-2", "node-3", RouteEdge.EdgeType.FLOW) + )); + + return graph; + } + + /** + * Build a compound graph: from -> choice -> (when, otherwise) -> to + */ + private RouteGraph buildCompoundGraph() { + RouteGraph graph = new RouteGraph("compound-route"); + graph.setExtractedAt(Instant.now()); + graph.setVersion(1); + + RouteNode from = new RouteNode("node-1", NodeType.ENDPOINT, "direct:start"); + RouteNode choice = new RouteNode("node-2", NodeType.EIP_CHOICE, "choice"); + RouteNode when = new RouteNode("node-3", NodeType.EIP_WHEN, "when(simple)"); + RouteNode otherwise = new RouteNode("node-4", NodeType.EIP_OTHERWISE, "otherwise"); + RouteNode to = new RouteNode("node-5", NodeType.TO, "log:result"); + + // Set children on the choice node + choice.setChildren(List.of(when, otherwise)); + + graph.setNodes(List.of(from, choice, when, otherwise, to)); + graph.setEdges(List.of( + new RouteEdge("node-1", "node-2", RouteEdge.EdgeType.FLOW), + new RouteEdge("node-2", "node-3", RouteEdge.EdgeType.FLOW), + new RouteEdge("node-2", "node-4", RouteEdge.EdgeType.FLOW), + new RouteEdge("node-3", "node-5", RouteEdge.EdgeType.FLOW), + new RouteEdge("node-4", "node-5", RouteEdge.EdgeType.FLOW) + )); + + return graph; + } + + @Test + void renderSvg_simpleGraph_producesValidSvg() { + String svg = renderer.renderSvg(buildSimpleGraph()); + + assertNotNull(svg); + assertTrue(svg.contains(""), "SVG should be properly closed"); + } + + @Test + void renderSvg_simpleGraph_containsNodeShapes() { + String svg = renderer.renderSvg(buildSimpleGraph()); + + // Should contain rect elements for nodes + assertTrue(svg.contains("= 0, "Node x should be >= 0: " + node.id()); + assertTrue(node.y() >= 0, "Node y should be >= 0: " + node.id()); + assertTrue(node.width() > 0, "Node width should be > 0: " + node.id()); + assertTrue(node.height() > 0, "Node height should be > 0: " + node.id()); + } + } + + @Test + void layoutJson_simpleGraph_hasPositiveDimensions() { + DiagramLayout layout = renderer.layoutJson(buildSimpleGraph()); + + assertTrue(layout.width() > 0, "Layout width should be positive"); + assertTrue(layout.height() > 0, "Layout height should be positive"); + } + + @Test + void layoutJson_simpleGraph_hasEdges() { + DiagramLayout layout = renderer.layoutJson(buildSimpleGraph()); + + assertEquals(2, layout.edges().size(), "Should have 2 edges"); + } + + @Test + void layoutJson_compoundGraph_choiceNodeHasChildren() { + DiagramLayout layout = renderer.layoutJson(buildCompoundGraph()); + + PositionedNode choiceNode = layout.nodes().stream() + .filter(n -> "node-2".equals(n.id())) + .findFirst() + .orElseThrow(() -> new AssertionError("Choice node not found")); + + assertNotNull(choiceNode.children(), "Choice node should have children"); + assertFalse(choiceNode.children().isEmpty(), "Choice node should have non-empty children"); + assertEquals(2, choiceNode.children().size(), "Choice node should have 2 children (when, otherwise)"); + } + + @Test + void renderSvg_compoundGraph_producesValidSvg() { + String svg = renderer.renderSvg(buildCompoundGraph()); + + assertNotNull(svg); + assertTrue(svg.contains("