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 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-11 16:17:13 +01:00
parent f6ff279a60
commit c1bc32d50a
6 changed files with 998 additions and 0 deletions

View File

@@ -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<String> 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("<svg");
}
@Test
void getJson_withAcceptHeader_returnsJson() {
HttpHeaders headers = new HttpHeaders();
headers.set("Accept", "application/json");
headers.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity<String> 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<String> 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<String> 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("<svg");
}
}

View File

@@ -0,0 +1,183 @@
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.PositionedNode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for {@link ElkDiagramRenderer}.
* No Spring context needed -- pure unit test.
*/
class ElkDiagramRendererTest {
private ElkDiagramRenderer renderer;
@BeforeEach
void setUp() {
renderer = new ElkDiagramRenderer();
}
/**
* Build a simple 3-node route: from(endpoint) -> 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"), "SVG should contain <svg element");
assertTrue(svg.contains("</svg>"), "SVG should be properly closed");
}
@Test
void renderSvg_simpleGraph_containsNodeShapes() {
String svg = renderer.renderSvg(buildSimpleGraph());
// Should contain rect elements for nodes
assertTrue(svg.contains("<rect") || svg.contains("<path"),
"SVG should contain rect or path elements for nodes");
}
@Test
void renderSvg_simpleGraph_containsNodeLabels() {
String svg = renderer.renderSvg(buildSimpleGraph());
assertTrue(svg.contains("timer:tick"), "SVG should contain endpoint label");
assertTrue(svg.contains("myProcessor"), "SVG should contain processor label");
assertTrue(svg.contains("log:output"), "SVG should contain to label");
}
@Test
void renderSvg_endpointNodes_haveBlueColor() {
String svg = renderer.renderSvg(buildSimpleGraph());
// Endpoint nodes should have blue fill (#3B82F6)
assertTrue(svg.contains("#3B82F6") || svg.contains("#3b82f6") ||
svg.contains("rgb(59,130,246)") || svg.contains("rgb(59, 130, 246)"),
"Endpoint nodes should have blue fill color (#3B82F6)");
}
@Test
void renderSvg_containsEdgeLines() {
String svg = renderer.renderSvg(buildSimpleGraph());
// Edges should be drawn as lines or paths
assertTrue(svg.contains("<line") || svg.contains("<polyline") || svg.contains("<path"),
"SVG should contain line/path elements for edges");
}
@Test
void layoutJson_simpleGraph_returnsCorrectNodeCount() {
DiagramLayout layout = renderer.layoutJson(buildSimpleGraph());
assertNotNull(layout);
assertEquals(3, layout.nodes().size(), "Should have 3 positioned nodes");
}
@Test
void layoutJson_simpleGraph_nodesHavePositiveCoordinates() {
DiagramLayout layout = renderer.layoutJson(buildSimpleGraph());
for (PositionedNode node : layout.nodes()) {
assertTrue(node.x() >= 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("<svg"), "Compound SVG should contain <svg element");
assertTrue(svg.contains("choice"), "SVG should contain choice label");
}
}