v2 extended analysis render

This commit is contained in:
2026-06-14 21:15:04 +02:00
parent 744439c99d
commit 2b6b727de6
18 changed files with 1540 additions and 66 deletions

View File

@@ -15,4 +15,5 @@ include ':state_machines:inheritance_extra_functions3_state_machine'
include ':state_machines:simple_state_machine'
include ':state_machines:extended_analysis_sample'
include ':state_machines:inheritance_sample'
include ':state_machines:ultimate_ecosystem_sm'
include ':state_machines:enterprise_order_system'

View File

@@ -5,6 +5,7 @@ import lombok.Builder;
import lombok.Data;
import lombok.extern.jackson.Jacksonized;
import java.util.List;
import java.util.Map;
@Data
@@ -12,11 +13,23 @@ import java.util.Map;
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class EntryPoint {
public enum Type { REST, KAFKA, RABBIT, JMS, CUSTOM }
public enum Type { REST, KAFKA, RABBIT, JMS, SQS, SNS, CUSTOM }
@Data
@Builder
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Parameter {
private final String name;
private final String type;
private final List<String> annotations;
}
private final Type type;
private final String name; // e.g., "POST /orders" or "Kafka Topic: orders"
private final String className;
private final String methodName;
private final Map<String, String> metadata; // e.g., path, topic, exchange
@Builder.Default
private final List<Parameter> parameters = java.util.Collections.emptyList();
}

View File

@@ -4,13 +4,12 @@ import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.CompilationUnit;
import java.nio.file.Path;
import java.util.Collections;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -21,6 +20,7 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
private final ConstantResolver constantResolver = new ConstantResolver();
private final GenericEventDetector eventDetector;
private final SpringMvcDetector mvcDetector;
private final MessagingDetector messagingDetector;
private final CallGraphBuilder callGraphBuilder;
private final LifecycleDetector lifecycleDetector;
private final InterceptorDetector interceptorDetector;
@@ -30,6 +30,7 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
this.rootDir = rootDir;
this.eventDetector = new GenericEventDetector(context, constantResolver, context.getLibraryHints());
this.mvcDetector = new SpringMvcDetector(context, constantResolver);
this.messagingDetector = new MessagingDetector(context);
this.callGraphBuilder = new CallGraphBuilder(context);
this.lifecycleDetector = new LifecycleDetector(context);
this.interceptorDetector = new InterceptorDetector(context);
@@ -37,7 +38,7 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
@Override
public List<TriggerPoint> findTriggerPoints() {
List<TriggerPoint> allTriggers = new java.util.ArrayList<>();
List<TriggerPoint> allTriggers = new ArrayList<>();
var cus = context.getCompilationUnits();
log.info("JdtIntelligenceProvider scanning {} compilation units for triggers", cus.size());
for (CompilationUnit cu : cus) {
@@ -50,14 +51,15 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
@Override
public List<EntryPoint> findEntryPoints() {
List<EntryPoint> allEntryPoints = new java.util.ArrayList<>();
List<EntryPoint> allEntryPoints = new ArrayList<>();
var cus = context.getCompilationUnits();
log.info("JdtIntelligenceProvider scanning {} compilation units for entry points", cus.size());
for (CompilationUnit cu : cus) {
allEntryPoints.addAll(mvcDetector.detect(cu));
allEntryPoints.addAll(messagingDetector.detect(cu));
allEntryPoints.addAll(interceptorDetector.detect(cu));
}
log.info("Found {} entry points (including interceptors) in total", allEntryPoints.size());
log.info("Found {} entry points (including interceptors and listeners) in total", allEntryPoints.size());
return allEntryPoints;
}

View File

@@ -0,0 +1,132 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.RequiredArgsConstructor;
import org.eclipse.jdt.core.dom.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RequiredArgsConstructor
public class MessagingDetector {
private final CodebaseContext context;
private final ConstantResolver constantResolver = new ConstantResolver();
public List<EntryPoint> detect(CompilationUnit cu) {
List<EntryPoint> entryPoints = new ArrayList<>();
cu.accept(new ASTVisitor() {
@Override
public boolean visit(MethodDeclaration node) {
processMethod(node, entryPoints);
return super.visit(node);
}
});
return entryPoints;
}
private void processMethod(MethodDeclaration method, List<EntryPoint> entryPoints) {
for (Object modifier : method.modifiers()) {
if (modifier instanceof Annotation annotation) {
String typeName = annotation.getTypeName().getFullyQualifiedName();
EntryPoint.Type type = null;
String destination = "";
if (typeName.endsWith("JmsListener")) {
type = EntryPoint.Type.JMS;
destination = resolveValue(annotation, "destination");
} else if (typeName.endsWith("RabbitListener")) {
type = EntryPoint.Type.RABBIT;
destination = resolveValue(annotation, "queues");
} else if (typeName.endsWith("SqsListener")) {
type = EntryPoint.Type.SQS;
destination = resolveValue(annotation, "value");
if ("unknown".equals(destination)) {
destination = resolveValue(annotation, "queueNames");
}
} else if (typeName.endsWith("SnsListener")) {
type = EntryPoint.Type.SNS;
destination = resolveValue(annotation, "value");
if ("unknown".equals(destination)) {
destination = resolveValue(annotation, "topicNames");
}
} else if (typeName.endsWith("KafkaListener")) {
type = EntryPoint.Type.KAFKA;
destination = resolveValue(annotation, "topics");
}
if (type != null) {
Map<String, String> metadata = new HashMap<>();
metadata.put("destination", destination);
String protocolName = type.name();
if (typeName.endsWith("SqsListener")) protocolName = "SQS";
if (typeName.endsWith("SnsListener")) protocolName = "SNS";
metadata.put("protocol", protocolName);
entryPoints.add(EntryPoint.builder()
.type(type)
.name(protocolName + ": " + destination)
.className(context.getFqn((TypeDeclaration) method.getParent()))
.methodName(method.getName().getIdentifier())
.metadata(metadata)
.parameters(extractParameters(method))
.build());
}
}
}
}
private String resolveValue(Annotation annotation, String memberName) {
Expression value = null;
if (annotation instanceof SingleMemberAnnotation sma) {
value = sma.getValue();
} else if (annotation instanceof NormalAnnotation na) {
for (Object pairObj : na.values()) {
MemberValuePair pair = (MemberValuePair) pairObj;
if (memberName.equals(pair.getName().getIdentifier())) {
value = pair.getValue();
break;
}
}
}
if (value != null) {
if (value instanceof ArrayInitializer ai) {
List<String> values = new ArrayList<>();
for (Object o : ai.expressions()) {
String resolved = constantResolver.resolve((Expression) o, context);
values.add(resolved != null ? resolved : o.toString());
}
return String.join(", ", values);
}
String resolved = constantResolver.resolve(value, context);
return resolved != null ? resolved : value.toString();
}
return "unknown";
}
private List<EntryPoint.Parameter> extractParameters(MethodDeclaration method) {
List<EntryPoint.Parameter> parameters = new ArrayList<>();
for (Object paramObj : method.parameters()) {
if (paramObj instanceof SingleVariableDeclaration param) {
List<String> annotations = new ArrayList<>();
for (Object mod : param.modifiers()) {
if (mod instanceof Annotation ann) {
annotations.add(ann.getTypeName().getFullyQualifiedName());
}
}
parameters.add(EntryPoint.Parameter.builder()
.name(param.getName().getIdentifier())
.type(param.getType().toString())
.annotations(annotations)
.build());
}
}
return parameters;
}
}

View File

@@ -36,7 +36,7 @@ public class SpringMvcDetector {
for (Object modifier : node.modifiers()) {
if (modifier instanceof Annotation annotation) {
String name = annotation.getTypeName().getFullyQualifiedName();
if (name.endsWith("RestController") || name.endsWith("Controller")) {
if (name.endsWith("RestController") || name.endsWith("Controller") || name.endsWith("Path")) {
return true;
}
}
@@ -46,6 +46,10 @@ public class SpringMvcDetector {
private void processController(TypeDeclaration controller, List<EntryPoint> entryPoints) {
String basePath = getMappingPathHierarchical(controller);
if (basePath.isEmpty()) {
basePath = getJaxRsPath(controller);
}
for (MethodDeclaration method : controller.getMethods()) {
EntryPoint ep = processMethodHierarchical(method, basePath, controller);
if (ep != null) {
@@ -55,11 +59,9 @@ public class SpringMvcDetector {
}
private String getMappingPathHierarchical(TypeDeclaration td) {
// 1. Check class itself
String path = getMappingPath(td);
if (!path.isEmpty()) return path;
// 2. Check interfaces
for (Object interfaceType : td.superInterfaceTypes()) {
TypeDeclaration itd = context.getTypeDeclaration(interfaceType.toString());
if (itd != null) {
@@ -68,7 +70,6 @@ public class SpringMvcDetector {
}
}
// 3. Check superclass
if (td.getSuperclassType() != null) {
TypeDeclaration std = context.getTypeDeclaration(td.getSuperclassType().toString());
if (std != null) {
@@ -80,11 +81,9 @@ public class SpringMvcDetector {
}
private EntryPoint processMethodHierarchical(MethodDeclaration method, String basePath, TypeDeclaration controller) {
// 1. Check the method itself
EntryPoint ep = processMethod(method, basePath, controller);
if (ep != null) return ep;
// 2. Check interface methods
String methodName = method.getName().getIdentifier();
for (Object interfaceType : controller.superInterfaceTypes()) {
TypeDeclaration itd = context.getTypeDeclaration(interfaceType.toString());
@@ -108,18 +107,24 @@ public class SpringMvcDetector {
String verb = getHttpVerb(name);
if (verb != null) {
String methodPath = getMappingPath(annotation);
if (methodPath.isEmpty()) {
methodPath = getJaxRsPath(method);
}
String fullPath = combinePaths(basePath, methodPath);
Map<String, String> metadata = new HashMap<>();
metadata.put("path", fullPath);
metadata.put("verb", verb);
List<EntryPoint.Parameter> parameters = extractParameters(method);
return EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name(verb + " " + fullPath)
.className(context.getFqn(controller))
.methodName(method.getName().getIdentifier())
.metadata(metadata)
.parameters(parameters)
.build();
}
}
@@ -127,12 +132,21 @@ public class SpringMvcDetector {
return null;
}
private String getJaxRsPath(BodyDeclaration node) {
for (Object mod : node.modifiers()) {
if (mod instanceof Annotation ann && ann.getTypeName().getFullyQualifiedName().endsWith("Path")) {
return getMappingPath(ann);
}
}
return "";
}
private String getHttpVerb(String annotationName) {
if (annotationName.endsWith("PostMapping")) return "POST";
if (annotationName.endsWith("GetMapping")) return "GET";
if (annotationName.endsWith("PutMapping")) return "PUT";
if (annotationName.endsWith("DeleteMapping")) return "DELETE";
if (annotationName.endsWith("PatchMapping")) return "PATCH";
if (annotationName.endsWith("PostMapping") || annotationName.endsWith("POST")) return "POST";
if (annotationName.endsWith("GetMapping") || annotationName.endsWith("GET")) return "GET";
if (annotationName.endsWith("PutMapping") || annotationName.endsWith("PUT")) return "PUT";
if (annotationName.endsWith("DeleteMapping") || annotationName.endsWith("DELETE")) return "DELETE";
if (annotationName.endsWith("PatchMapping") || annotationName.endsWith("PATCH")) return "PATCH";
if (annotationName.endsWith("RequestMapping")) return "REQUEST";
return null;
}
@@ -140,7 +154,8 @@ public class SpringMvcDetector {
private String getMappingPath(TypeDeclaration td) {
for (Object modifier : td.modifiers()) {
if (modifier instanceof Annotation annotation) {
if (annotation.getTypeName().getFullyQualifiedName().endsWith("RequestMapping")) {
String name = annotation.getTypeName().getFullyQualifiedName();
if (name.endsWith("RequestMapping") || name.endsWith("Path")) {
return getMappingPath(annotation);
}
}
@@ -169,6 +184,29 @@ public class SpringMvcDetector {
return "";
}
private List<EntryPoint.Parameter> extractParameters(MethodDeclaration method) {
List<EntryPoint.Parameter> parameters = new ArrayList<>();
for (Object paramObj : method.parameters()) {
if (paramObj instanceof SingleVariableDeclaration param) {
List<String> annotations = new ArrayList<>();
for (Object mod : param.modifiers()) {
if (mod instanceof Annotation ann) {
String name = ann.getTypeName().getFullyQualifiedName();
if (name.endsWith("PathParam")) name = "PathVariable";
if (name.endsWith("QueryParam")) name = "RequestParam";
annotations.add(name);
}
}
parameters.add(EntryPoint.Parameter.builder()
.name(param.getName().getIdentifier())
.type(param.getType().toString())
.annotations(annotations)
.build());
}
}
return parameters;
}
private String combinePaths(String base, String method) {
String b = base.startsWith("/") ? base : "/" + base;
if (b.endsWith("/")) b = b.substring(0, b.length() - 1);

View File

@@ -98,13 +98,13 @@
display: block;
}
/* SVG Highlighting - Sleek Technical Tracers */
svg .stereotype { display: none !important; }
/* SVG Interactivity Styles */
svg a { text-decoration: none !important; }
.active-path path {
.active-path {
stroke: var(--accent) !important;
stroke-width: 2.0px !important;
filter: drop-shadow(0 0 6px rgba(239, 68, 68, 0.3));
stroke-width: 2.5px !important;
filter: drop-shadow(0 0 8px rgba(239, 68, 68, 0.3));
transition: all 0.3s;
}
@@ -117,7 +117,7 @@
}
.dimmed {
opacity: 0.25 !important;
opacity: 0.2 !important;
filter: grayscale(1);
transition: opacity 0.4s ease;
pointer-events: none;
@@ -151,6 +151,11 @@
line-height: 1.5;
margin-top: 10px;
}
.payload-param {
margin-bottom: 4px;
}
.payload-param:last-child { margin-bottom: 0; }
</style>
</head>
<body>
@@ -344,7 +349,8 @@
"metadata" : {
"path" : "/api/enterprise/orders/place",
"verb" : "POST"
}
},
"parameters" : [ ]
}, {
"type" : "REST",
"name" : "POST /api/enterprise/orders/{id}/cancel",
@@ -353,7 +359,12 @@
"metadata" : {
"path" : "/api/enterprise/orders/{id}/cancel",
"verb" : "POST"
}
},
"parameters" : [ {
"name" : "id",
"type" : "String",
"annotations" : [ "PathVariable" ]
} ]
}, {
"type" : "CUSTOM",
"name" : "INTERCEPTOR: SecurityInterceptor.preHandle",
@@ -361,7 +372,8 @@
"methodName" : "preHandle",
"metadata" : {
"interceptorType" : "Spring MVC Interceptor"
}
},
"parameters" : [ ]
}, {
"type" : "REST",
"name" : "POST /api/enterprise/payments",
@@ -370,7 +382,12 @@
"metadata" : {
"path" : "/api/enterprise/payments",
"verb" : "POST"
}
},
"parameters" : [ {
"name" : "orderId",
"type" : "String",
"annotations" : [ "RequestBody" ]
} ]
} ],
"callChains" : [ {
"entryPoint" : {
@@ -381,7 +398,8 @@
"metadata" : {
"path" : "/api/enterprise/orders/place",
"verb" : "POST"
}
},
"parameters" : [ ]
},
"methodChain" : [ "click.kamil.examples.enterprise.api.OrderController.placeOrder", "click.kamil.examples.enterprise.service.OrderServiceImpl.place" ],
"triggerPoint" : {
@@ -401,7 +419,12 @@
"metadata" : {
"path" : "/api/enterprise/orders/{id}/cancel",
"verb" : "POST"
}
},
"parameters" : [ {
"name" : "id",
"type" : "String",
"annotations" : [ "PathVariable" ]
} ]
},
"methodChain" : [ "click.kamil.examples.enterprise.api.OrderController.cancelOrder", "click.kamil.examples.enterprise.service.OrderServiceImpl.cancel" ],
"triggerPoint" : {
@@ -420,7 +443,8 @@
"methodName" : "preHandle",
"metadata" : {
"interceptorType" : "Spring MVC Interceptor"
}
},
"parameters" : [ ]
},
"methodChain" : [ "click.kamil.examples.enterprise.api.SecurityInterceptor.preHandle" ],
"triggerPoint" : {
@@ -440,7 +464,12 @@
"metadata" : {
"path" : "/api/enterprise/payments",
"verb" : "POST"
}
},
"parameters" : [ {
"name" : "orderId",
"type" : "String",
"annotations" : [ "RequestBody" ]
} ]
},
"methodChain" : [ "click.kamil.examples.enterprise.api.ReactivePaymentController.pay", "click.kamil.examples.enterprise.service.ReactivePaymentService.processPayment" ],
"triggerPoint" : {
@@ -500,7 +529,7 @@
const svg = document.querySelector('svg');
// Dim all shapes and texts
// Dim everything first
svg.querySelectorAll('path, rect, circle, ellipse, text, polygon').forEach(el => {
el.classList.add('dimmed');
});
@@ -618,24 +647,40 @@
if (chains && chains.length > 0) {
html += `<div style="font-size:0.7rem; font-weight:800; color:#94a3b8; text-transform:uppercase; margin-bottom:10px; border-top:1px solid #eee; padding-top:10px">Triggered by</div>`;
chains.forEach(c => {
html += `<div style="margin-bottom:15px">
html += `<div style="margin-bottom:20px">
<div style="font-weight:700; font-size:0.85rem; color:var(--text);">${c.entryPoint.name}</div>
<div style="margin-left:10px; border-left:2px solid #e2e8f0; padding-left:12px; margin-top:8px">
${renderParameters(c.entryPoint.parameters)}
<div style="margin-left:10px; border-left:2px solid #e2e8f0; padding-left:12px; margin-top:10px">
${c.methodChain.map((m, i) => `<div style="font-size:0.7rem; color:#475569; margin-top:4px; font-family:'JetBrains Mono', monospace;"><b>${i+1}</b> ${m}</div>`).join('')}
</div>
</div>`;
});
}
// Placeholder for Data Object
html += `<div style="font-size:0.7rem; font-weight:800; color:#94a3b8; text-transform:uppercase; margin-bottom:8px; border-top:1px solid #eee; padding-top:10px">Data Payload (Planned)</div>
<div class="payload-box">
<span style="color:#ef4444">OrderRequest</span> {<br>
&nbsp;&nbsp;<span style="color:#64748b">// Full AST field mapping to be implemented</span><br>
&nbsp;&nbsp;id: "ORD-123",<br>
&nbsp;&nbsp;quantity: 5<br>
}
</div></div>`;
html += `</div>`;
return html;
}
function renderParameters(params) {
if (!params || params.length === 0) return "";
let html = `<div style="font-size:0.65rem; font-weight:700; color:#94a3b8; margin-top:8px; margin-bottom:4px; text-transform:uppercase;">Input Data</div>`;
html += `<div class="payload-box">`;
params.forEach(p => {
const annotation = (p.annotations || []).find(a => a.endsWith("RequestBody") || a.endsWith("PathVariable") || a.endsWith("RequestParam"));
const cleanAnn = annotation ? "@" + annotation.substring(annotation.lastIndexOf('.') + 1) : "";
html += `<div class="payload-param">
<span style="color:#f43f5e; font-size:0.65rem; font-weight:bold;">${cleanAnn}</span>
<span style="color:#38bdf8;">${p.type}</span>
<span style="color:#a3e635;">${p.name}</span>
</div>`;
});
html += `</div>`;
return html;
}
</script>

View File

@@ -79,6 +79,10 @@
.type-rest { background: #eff6ff; color: #1d4ed8; }
.type-custom { background: #fef2f2; color: #b91c1c; }
.type-jms { background: #f5f3ff; color: #7c3aed; }
.type-sqs { background: #fff7ed; color: #ea580c; }
.type-sns { background: #fff1f2; color: #e11d48; }
.type-kafka { background: #fefce8; color: #854d0e; }
.type-rabbit { background: #f0fdf4; color: #166534; }
.ep-name { font-weight: 700; font-size: 0.95rem; margin-bottom: 6px; color: var(--text); }
.ep-fqn { font-family: 'JetBrains Mono', monospace; font-size: 0.65rem; color: var(--text-muted); opacity: 0.8; }
@@ -98,13 +102,13 @@
display: block;
}
/* SVG Highlighting - Sleek Technical Tracers */
svg .stereotype { display: none !important; }
/* SVG Interactivity Styles */
svg a { text-decoration: none !important; }
.active-path path {
.active-path {
stroke: var(--accent) !important;
stroke-width: 2.0px !important;
filter: drop-shadow(0 0 6px rgba(239, 68, 68, 0.3));
stroke-width: 2.5px !important;
filter: drop-shadow(0 0 8px rgba(239, 68, 68, 0.3));
transition: all 0.3s;
}
@@ -117,7 +121,7 @@
}
.dimmed {
opacity: 0.25 !important;
opacity: 0.2 !important;
filter: grayscale(1);
transition: opacity 0.4s ease;
pointer-events: none;
@@ -151,6 +155,11 @@
line-height: 1.5;
margin-top: 10px;
}
.payload-param {
margin-bottom: 4px;
}
.payload-param:last-child { margin-bottom: 0; }
</style>
</head>
<body>
@@ -217,7 +226,7 @@
const svg = document.querySelector('svg');
// Dim all shapes and texts
// Dim everything first
svg.querySelectorAll('path, rect, circle, ellipse, text, polygon').forEach(el => {
el.classList.add('dimmed');
});
@@ -335,24 +344,40 @@
if (chains && chains.length > 0) {
html += `<div style="font-size:0.7rem; font-weight:800; color:#94a3b8; text-transform:uppercase; margin-bottom:10px; border-top:1px solid #eee; padding-top:10px">Triggered by</div>`;
chains.forEach(c => {
html += `<div style="margin-bottom:15px">
html += `<div style="margin-bottom:20px">
<div style="font-weight:700; font-size:0.85rem; color:var(--text);">${c.entryPoint.name}</div>
<div style="margin-left:10px; border-left:2px solid #e2e8f0; padding-left:12px; margin-top:8px">
${renderParameters(c.entryPoint.parameters)}
<div style="margin-left:10px; border-left:2px solid #e2e8f0; padding-left:12px; margin-top:10px">
${c.methodChain.map((m, i) => `<div style="font-size:0.7rem; color:#475569; margin-top:4px; font-family:'JetBrains Mono', monospace;"><b>${i+1}</b> ${m}</div>`).join('')}
</div>
</div>`;
});
}
// Placeholder for Data Object
html += `<div style="font-size:0.7rem; font-weight:800; color:#94a3b8; text-transform:uppercase; margin-bottom:8px; border-top:1px solid #eee; padding-top:10px">Data Payload (Planned)</div>
<div class="payload-box">
<span style="color:#ef4444">OrderRequest</span> {<br>
&nbsp;&nbsp;<span style="color:#64748b">// Full AST field mapping to be implemented</span><br>
&nbsp;&nbsp;id: "ORD-123",<br>
&nbsp;&nbsp;quantity: 5<br>
}
</div></div>`;
html += `</div>`;
return html;
}
function renderParameters(params) {
if (!params || params.length === 0) return "";
let html = `<div style="font-size:0.65rem; font-weight:700; color:#94a3b8; margin-top:8px; margin-bottom:4px; text-transform:uppercase;">Input Data</div>`;
html += `<div class="payload-box">`;
params.forEach(p => {
const annotation = (p.annotations || []).find(a => a.endsWith("RequestBody") || a.endsWith("PathVariable") || a.endsWith("RequestParam"));
const cleanAnn = annotation ? "@" + annotation.substring(annotation.lastIndexOf('.') + 1) : "";
html += `<div class="payload-param">
<span style="color:#f43f5e; font-size:0.65rem; font-weight:bold;">${cleanAnn}</span>
<span style="color:#38bdf8;">${p.type}</span>
<span style="color:#a3e635;">${p.name}</span>
</div>`;
});
html += `</div>`;
return html;
}
</script>

View File

@@ -0,0 +1,41 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.3'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'click.kamil'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.statemachine:spring-statemachine-starter:4.0.0'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-activemq'
implementation 'org.springframework.boot:spring-boot-starter-amqp'
implementation 'org.springframework.boot:spring-boot-starter-jersey'
// AWS SQS/SNS
implementation 'io.awspring.cloud:spring-cloud-aws-starter-sqs:3.1.1'
implementation 'io.awspring.cloud:spring-cloud-aws-starter-sns:3.1.1'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
}
tasks.named('test') {
useJUnitPlatform()
}
bootJar { enabled = false }
jar { enabled = true }

View File

@@ -0,0 +1,26 @@
package click.kamil.ultimate.api;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import lombok.RequiredArgsConstructor;
import org.springframework.statemachine.StateMachine;
import org.springframework.stereotype.Component;
@Path("/jaxrs/ticket")
@Component
@RequiredArgsConstructor
public class JerseyResource {
private final StateMachine<String, String> stateMachine;
@POST
@Consumes(MediaType.APPLICATION_JSON)
public void createJaxRs(String payload) {
stateMachine.sendEvent("CREATE_JAXRS");
}
@PUT
@Path("/{id}/resolve")
public void resolve(@PathParam("id") String id) {
stateMachine.sendEvent("RESOLVE");
}
}

View File

@@ -0,0 +1,60 @@
package click.kamil.ultimate.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.statemachine.config.EnableStateMachine;
import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter;
import org.springframework.statemachine.config.builders.StateMachineStateConfigurer;
import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer;
import java.util.EnumSet;
@Configuration
@EnableStateMachine
public class UltimateStateMachineConfig extends EnumStateMachineConfigurerAdapter<String, String> {
@Override
public void configure(StateMachineStateConfigurer<String, String> states) throws Exception {
states
.withStates()
.initial("NEW")
.state("TRIAGE")
.state("IN_PROGRESS")
.state("ESCALATED")
.end("RESOLVED")
.end("CLOSED");
}
@Override
public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception {
transitions
.withExternal()
.source("NEW").target("TRIAGE").event("CREATE")
.and()
.withExternal()
.source("NEW").target("IN_PROGRESS").event("CREATE_REACTIVE")
.and()
.withExternal()
.source("NEW").target("IN_PROGRESS").event("CREATE_JAXRS")
.and()
.withExternal()
.source("TRIAGE").target("IN_PROGRESS").event("ASSIGN")
.and()
.withExternal()
.source("IN_PROGRESS").target("ESCALATED").event("ESCALATE")
.and()
.withExternal()
.source("IN_PROGRESS").target("RESOLVED").event("RESOLVE")
.and()
.withExternal()
.source("ESCALATED").target("RESOLVED").event("RESOLVE")
.and()
.withExternal()
.source("RESOLVED").target("CLOSED").event("CLOSE_TICKET")
.and()
.withExternal()
.source("IN_PROGRESS").target("TRIAGE").event("SNS_UPDATE")
.and()
.withExternal()
.source("TRIAGE").target("ESCALATED").event("SQS_UPDATE");
}
}

View File

@@ -0,0 +1,17 @@
package click.kamil.ultimate.messaging;
import lombok.RequiredArgsConstructor;
import org.springframework.jms.annotation.JmsListener;
import org.springframework.statemachine.StateMachine;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class JmsTicketListener {
private final StateMachine<String, String> stateMachine;
@JmsListener(destination = "ticket.close.queue")
public void onCloseMessage(String ticketId) {
stateMachine.sendEvent("CLOSE_TICKET");
}
}

View File

@@ -0,0 +1,17 @@
package click.kamil.ultimate.messaging;
import lombok.RequiredArgsConstructor;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.statemachine.StateMachine;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class RabbitTicketListener {
private final StateMachine<String, String> stateMachine;
@RabbitListener(queues = "ticket.escalate.exchange")
public void onEscalate(String payload) {
stateMachine.sendEvent("ESCALATE");
}
}

View File

@@ -0,0 +1,17 @@
package click.kamil.ultimate.messaging;
import io.awspring.cloud.sns.annotation.SnsListener;
import lombok.RequiredArgsConstructor;
import org.springframework.statemachine.StateMachine;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class SnsTicketListener {
private final StateMachine<String, String> stateMachine;
@SnsListener("ticket-sns-topic")
public void onSnsMessage(String subject, String message) {
stateMachine.sendEvent("SNS_UPDATE");
}
}

View File

@@ -0,0 +1,17 @@
package click.kamil.ultimate.messaging;
import io.awspring.cloud.sqs.annotation.SqsListener;
import lombok.RequiredArgsConstructor;
import org.springframework.statemachine.StateMachine;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class SqsTicketListener {
private final StateMachine<String, String> stateMachine;
@SqsListener("ticket-updates-queue")
public void onUpdate(String message) {
stateMachine.sendEvent("SQS_UPDATE");
}
}

View File

@@ -0,0 +1,12 @@
package click.kamil.ultimate.model;
import lombok.Data;
@Data
public class TicketRequest {
private String subject;
private String description;
private Priority priority;
public enum Priority { LOW, MEDIUM, HIGH }
}

View File

@@ -0,0 +1,21 @@
package click.kamil.ultimate.web;
import lombok.RequiredArgsConstructor;
import org.springframework.statemachine.StateMachine;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/api/ticket/reactive")
@RequiredArgsConstructor
public class ReactiveController {
private final StateMachine<String, String> stateMachine;
@PostMapping
public Mono<Void> createReactive(@RequestBody String data) {
return Mono.fromRunnable(() -> stateMachine.sendEvent("CREATE_REACTIVE"));
}
}

View File

@@ -0,0 +1,23 @@
package click.kamil.ultimate.web;
import click.kamil.ultimate.model.TicketRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.statemachine.StateMachine;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/ticket")
@RequiredArgsConstructor
public class WebController {
private final StateMachine<String, String> stateMachine;
@PostMapping("/create")
public void createTicket(@RequestBody TicketRequest request) {
stateMachine.sendEvent("CREATE");
}
@PostMapping("/{id}/assign")
public void assignTicket(@PathVariable String id, @RequestParam String userId) {
stateMachine.sendEvent("ASSIGN");
}
}