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:
@@ -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.
|
||||
* <p>
|
||||
* Supports content negotiation via Accept header:
|
||||
* <ul>
|
||||
* <li>{@code image/svg+xml} or default: returns SVG document</li>
|
||||
* <li>{@code application/json}: returns JSON layout with node positions</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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<RouteGraph> 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.
|
||||
* <p>
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user