diff --git a/out_multi/click.kamil.multi.core.OrderStateMachineConfig/click.kamil.multi.core.OrderStateMachineConfig.html b/out_multi/click.kamil.multi.core.OrderStateMachineConfig/click.kamil.multi.core.OrderStateMachineConfig.html
new file mode 100644
index 0000000..4bf226e
--- /dev/null
+++ b/out_multi/click.kamil.multi.core.OrderStateMachineConfig/click.kamil.multi.core.OrderStateMachineConfig.html
@@ -0,0 +1,724 @@
+
+
+
+
+
+ State Machine Explorer - click.kamil.multi.core.OrderStateMachineConfig
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/out_multi/click.kamil.multi.core.OrderStateMachineConfig/click.kamil.multi.core.OrderStateMachineConfig.json b/out_multi/click.kamil.multi.core.OrderStateMachineConfig/click.kamil.multi.core.OrderStateMachineConfig.json
new file mode 100644
index 0000000..b0c7a90
--- /dev/null
+++ b/out_multi/click.kamil.multi.core.OrderStateMachineConfig/click.kamil.multi.core.OrderStateMachineConfig.json
@@ -0,0 +1,108 @@
+{
+ "metadata" : {
+ "triggers" : [ {
+ "event" : "SUBMIT",
+ "className" : "click.kamil.multi.core.OrderController",
+ "methodName" : "submitOrder",
+ "sourceModule" : null,
+ "stateMachineId" : null,
+ "lineNumber" : 18
+ } ],
+ "entryPoints" : [ {
+ "type" : "REST",
+ "name" : "POST /orders/submit",
+ "className" : "click.kamil.multi.core.OrderController",
+ "methodName" : "submitOrder",
+ "metadata" : {
+ "path" : "/orders/submit",
+ "verb" : "POST"
+ },
+ "parameters" : [ {
+ "name" : "orderId",
+ "type" : "String",
+ "annotations" : [ "RequestBody" ]
+ } ]
+ }, {
+ "type" : "REST",
+ "name" : "POST /orders/submit",
+ "className" : "click.kamil.multi.api.OrderApi",
+ "methodName" : "submitOrder",
+ "metadata" : {
+ "path" : "/orders/submit",
+ "verb" : "POST"
+ },
+ "parameters" : [ {
+ "name" : "orderId",
+ "type" : "String",
+ "annotations" : [ "RequestBody" ]
+ } ]
+ } ],
+ "callChains" : [ {
+ "entryPoint" : {
+ "type" : "REST",
+ "name" : "POST /orders/submit",
+ "className" : "click.kamil.multi.core.OrderController",
+ "methodName" : "submitOrder",
+ "metadata" : {
+ "path" : "/orders/submit",
+ "verb" : "POST"
+ },
+ "parameters" : [ {
+ "name" : "orderId",
+ "type" : "String",
+ "annotations" : [ "RequestBody" ]
+ } ]
+ },
+ "methodChain" : [ "click.kamil.multi.core.OrderController.submitOrder" ],
+ "triggerPoint" : {
+ "event" : "SUBMIT",
+ "className" : "click.kamil.multi.core.OrderController",
+ "methodName" : "submitOrder",
+ "sourceModule" : null,
+ "stateMachineId" : null,
+ "lineNumber" : 18
+ }
+ } ],
+ "properties" : {
+ "default" : { }
+ }
+ },
+ "name" : "click.kamil.multi.core.OrderStateMachineConfig",
+ "renderChoicesAsDiamonds" : true,
+ "startStates" : [ "NEW" ],
+ "transitions" : [ {
+ "type" : "EXTERNAL",
+ "sourceStates" : [ {
+ "rawName" : "\"NEW\"",
+ "fullIdentifier" : "NEW"
+ } ],
+ "targetStates" : [ {
+ "rawName" : "\"PROCESSING\"",
+ "fullIdentifier" : "PROCESSING"
+ } ],
+ "event" : "SUBMIT",
+ "guard" : null,
+ "actions" : [ {
+ "expression" : "processAction()",
+ "isLambda" : false,
+ "internalLogic" : "@Override default void execute(StateContext context){\n System.out.println(\"Processing order: \" + context.getMessageHeader(\"orderId\"));\n}\n",
+ "lineNumber" : 31
+ } ],
+ "order" : null
+ }, {
+ "type" : "EXTERNAL",
+ "sourceStates" : [ {
+ "rawName" : "\"PROCESSING\"",
+ "fullIdentifier" : "PROCESSING"
+ } ],
+ "targetStates" : [ {
+ "rawName" : "\"COMPLETED\"",
+ "fullIdentifier" : "COMPLETED"
+ } ],
+ "event" : "FINISH",
+ "guard" : null,
+ "actions" : [ ],
+ "order" : null
+ } ],
+ "endStates" : [ "COMPLETED" ]
+}
diff --git a/out_multi/click.kamil.multi.core.OrderStateMachineConfig/click.kamil.multi.core.OrderStateMachineConfig.puml b/out_multi/click.kamil.multi.core.OrderStateMachineConfig/click.kamil.multi.core.OrderStateMachineConfig.puml
new file mode 100644
index 0000000..3b59d0b
--- /dev/null
+++ b/out_multi/click.kamil.multi.core.OrderStateMachineConfig/click.kamil.multi.core.OrderStateMachineConfig.puml
@@ -0,0 +1,32 @@
+@startuml
+!pragma layout smetana
+set separator none
+hide empty description
+hide stereotype
+skinparam state {
+ BackgroundColor white
+ BorderColor #94a3b8
+ BorderThickness 1
+ FontName Inter
+ FontSize 9
+ FontStyle bold
+ RoundCorner 20
+ Padding 1
+}
+skinparam shadowing false
+skinparam ArrowFontName JetBrains Mono
+skinparam ArrowFontSize 8
+skinparam ArrowColor #cbd5e1
+skinparam ArrowThickness 1
+skinparam dpi 110
+skinparam svgLinkTarget _self
+
+[*] --> NEW
+
+
+NEW -[#1E90FF,bold]-> PROCESSING <> <> : SUBMIT / processAction()
+PROCESSING -[#1E90FF,bold]-> COMPLETED <> <> : FINISH
+
+COMPLETED --> [*]
+@enduml
+
diff --git a/settings.gradle b/settings.gradle
index 7e272bc..7bf430a 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -17,3 +17,5 @@ include ':state_machines:extended_analysis_sample'
include ':state_machines:inheritance_sample'
include ':state_machines:ultimate_ecosystem_sm'
include ':state_machines:enterprise_order_system'
+include ':state_machines:multi_module_sample:api-module'
+include ':state_machines:multi_module_sample:core-module'
diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/PropertyResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/PropertyResolver.java
index 65db461..cd590f0 100644
--- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/PropertyResolver.java
+++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/PropertyResolver.java
@@ -10,8 +10,10 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
+import java.util.Set;
import java.util.stream.Stream;
+
@Slf4j
public class PropertyResolver {
@@ -42,32 +44,33 @@ public class PropertyResolver {
return allProperties;
}
- public Map> resolveAllProperties(Path rootDir) {
+ public Map> resolveAllProperties(Set rootDirs) {
Map> profiles = new HashMap<>();
- // 1. Load default profile
- Map defaultProps = new HashMap<>();
- loadMatching(rootDir, "application.properties", defaultProps);
- loadMatching(rootDir, "application.yml", defaultProps);
- loadMatching(rootDir, "application.yaml", defaultProps);
- profiles.put("default", defaultProps);
+ for (Path rootDir : rootDirs) {
+ // 1. Load default profile
+ Map defaultProps = profiles.computeIfAbsent("default", k -> new HashMap<>());
+ loadMatching(rootDir, "application.properties", defaultProps);
+ loadMatching(rootDir, "application.yml", defaultProps);
+ loadMatching(rootDir, "application.yaml", defaultProps);
- // 2. Discover and load all other profiles
- try (Stream paths = Files.walk(rootDir)) {
- paths.filter(Files::isRegularFile)
- .filter(p -> {
- String name = p.getFileName().toString();
- return name.startsWith("application-") &&
- (name.endsWith(".properties") || name.endsWith(".yml") || name.endsWith(".yaml"));
- })
- .forEach(p -> {
- String name = p.getFileName().toString();
- String profile = name.substring("application-".length(), name.lastIndexOf('.'));
- Map loaded = p.toString().endsWith(".properties") ? loadProperties(p) : loadYaml(p);
- profiles.computeIfAbsent(profile, k -> new HashMap<>()).putAll(loaded);
- });
- } catch (IOException e) {
- log.error("Failed to scan for profiles in {}", rootDir, e);
+ // 2. Discover and load all other profiles
+ try (Stream paths = Files.walk(rootDir)) {
+ paths.filter(Files::isRegularFile)
+ .filter(p -> {
+ String name = p.getFileName().toString();
+ return name.startsWith("application-") &&
+ (name.endsWith(".properties") || name.endsWith(".yml") || name.endsWith(".yaml"));
+ })
+ .forEach(p -> {
+ String name = p.getFileName().toString();
+ String profile = name.substring("application-".length(), name.lastIndexOf('.'));
+ Map loaded = p.toString().endsWith(".properties") ? loadProperties(p) : loadYaml(p);
+ profiles.computeIfAbsent(profile, k -> new HashMap<>()).putAll(loaded);
+ });
+ } catch (IOException e) {
+ log.error("Failed to scan for profiles in {}", rootDir, e);
+ }
}
return profiles;
diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/SiblingDependencyResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/SiblingDependencyResolver.java
new file mode 100644
index 0000000..f0a7d59
--- /dev/null
+++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/SiblingDependencyResolver.java
@@ -0,0 +1,140 @@
+package click.kamil.springstatemachineexporter.analysis.resolver;
+
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+@Slf4j
+public class SiblingDependencyResolver {
+
+ private static final Pattern GRADLE_PROJECT_DEP = Pattern.compile("project\\(['\"]:(.*?)['\"]\\)");
+ private static final Pattern MAVEN_DEPENDENCY = Pattern.compile("\\s*(.*?)\\s*(.*?)\\s*(.*?)", Pattern.DOTALL);
+
+ public Set resolveSiblings(Path projectDir) {
+ Set siblings = new HashSet<>();
+ try {
+ siblings.addAll(resolveGradleSiblings(projectDir));
+ siblings.addAll(resolveMavenSiblings(projectDir));
+ } catch (Exception e) {
+ log.warn("Error resolving sibling dependencies for {}", projectDir, e);
+ }
+ return siblings;
+ }
+
+ private Set resolveGradleSiblings(Path projectDir) throws IOException {
+ Set siblings = new HashSet<>();
+ Path buildGradle = projectDir.resolve("build.gradle");
+ if (!Files.exists(buildGradle)) {
+ buildGradle = projectDir.resolve("build.gradle.kts");
+ }
+
+ if (Files.exists(buildGradle)) {
+ log.info("Found build.gradle at {}", buildGradle);
+ String content = Files.readString(buildGradle);
+ Matcher matcher = GRADLE_PROJECT_DEP.matcher(content);
+ Set gradlePaths = new HashSet<>();
+ while (matcher.find()) {
+ gradlePaths.add(matcher.group(1));
+ }
+ log.info("Found project dependencies: {}", gradlePaths);
+
+ if (!gradlePaths.isEmpty()) {
+ Path rootDir = findGradleRoot(projectDir);
+ log.info("Resolved Gradle root to: {}", rootDir);
+ if (rootDir != null) {
+ for (String gradlePath : gradlePaths) {
+ String relativePath = gradlePath.replace(':', '/');
+ Path resolvedSibling = rootDir.resolve(relativePath).normalize();
+ if (Files.exists(resolvedSibling)) {
+ log.info("Discovered local Gradle sibling dependency: {}", resolvedSibling);
+ siblings.add(resolvedSibling);
+ }
+ }
+ }
+ }
+ }
+ return siblings;
+ }
+
+ private Path findGradleRoot(Path start) {
+ Path current = start.toAbsolutePath();
+ Path lastSettingsDir = null;
+ while (current != null) {
+ if (Files.exists(current.resolve("settings.gradle")) || Files.exists(current.resolve("settings.gradle.kts"))) {
+ lastSettingsDir = current;
+ }
+ if (Files.exists(current.resolve(".git"))) {
+ return lastSettingsDir != null ? lastSettingsDir : current;
+ }
+ current = current.getParent();
+ }
+ return lastSettingsDir;
+ }
+
+ private Set resolveMavenSiblings(Path projectDir) throws IOException {
+ Set siblings = new HashSet<>();
+ Path pom = projectDir.resolve("pom.xml");
+ if (Files.exists(pom)) {
+ String content = Files.readString(pom);
+ Matcher matcher = MAVEN_DEPENDENCY.matcher(content);
+ Set targetArtifacts = new HashSet<>();
+ while (matcher.find()) {
+ targetArtifacts.add(matcher.group(2)); // Just tracking artifactId for simplicity
+ }
+
+ if (!targetArtifacts.isEmpty()) {
+ Path rootDir = findMavenRoot(projectDir);
+ if (rootDir != null) {
+ // Find all pom.xml files in the workspace (excluding node_modules, build, etc.)
+ try (Stream paths = Files.walk(rootDir)) {
+ paths.filter(p -> p.getFileName().toString().equals("pom.xml")
+ && !p.toString().contains("/target/")
+ && !p.toString().contains("/node_modules/"))
+ .forEach(p -> {
+ try {
+ String pContent = Files.readString(p);
+ Matcher artMatcher = Pattern.compile("(.*?)").matcher(pContent);
+ if (artMatcher.find()) {
+ String artifactId = artMatcher.group(1);
+ if (targetArtifacts.contains(artifactId)) {
+ Path siblingDir = p.getParent();
+ // Don't add ourselves
+ if (!siblingDir.equals(projectDir)) {
+ log.info("Discovered local Maven sibling dependency: {}", siblingDir);
+ siblings.add(siblingDir);
+ }
+ }
+ }
+ } catch (IOException e) {
+ // ignore
+ }
+ });
+ }
+ }
+ }
+ }
+ return siblings;
+ }
+
+ private Path findMavenRoot(Path start) {
+ Path current = start.toAbsolutePath();
+ Path lastPomDir = null;
+ while (current != null) {
+ if (Files.exists(current.resolve("pom.xml"))) {
+ lastPomDir = current;
+ }
+ if (Files.exists(current.resolve(".git"))) {
+ return lastPomDir != null ? lastPomDir : current;
+ }
+ current = current.getParent();
+ }
+ return lastPomDir;
+ }
+}
diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/SpringMvcDetector.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/SpringMvcDetector.java
index 62128df..0e53b8c 100644
--- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/SpringMvcDetector.java
+++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/SpringMvcDetector.java
@@ -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") || name.endsWith("Path")) {
+ if (name.endsWith("RestController") || name.endsWith("Controller") || name.endsWith("Path") || name.endsWith("RequestMapping")) {
return true;
}
}
diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/app/AstTransitionParser.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/app/AstTransitionParser.java
index a3260d6..5d21fa8 100644
--- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/app/AstTransitionParser.java
+++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/app/AstTransitionParser.java
@@ -29,7 +29,9 @@ import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
+import lombok.extern.slf4j.Slf4j;
+@Slf4j
public class AstTransitionParser {
private static final ConstantResolver constantResolver = new ConstantResolver();
@@ -258,6 +260,21 @@ public class AstTransitionParser {
if (expr instanceof MethodInvocation mi) {
IMethodBinding binding = mi.resolveMethodBinding();
if (binding != null) {
+ // Try to resolve deeper logic from the return type (e.g., a Bean returning an Action interface)
+ ITypeBinding returnType = binding.getReturnType();
+ if (returnType != null) {
+ String returnFqn = returnType.getTypeDeclaration().getQualifiedName();
+ TypeDeclaration returnTd = context.getTypeDeclaration(returnFqn);
+ if (returnTd != null) {
+ for (MethodDeclaration rmd : returnTd.getMethods()) {
+ String name = rmd.getName().getIdentifier();
+ if (name.equals("execute") || name.equals("evaluate")) {
+ return rmd.toString();
+ }
+ }
+ }
+ }
+
// Try same file
ASTNode declNode = cu.findDeclaringNode(binding.getKey());
if (declNode instanceof MethodDeclaration md) {
@@ -267,7 +284,7 @@ public class AstTransitionParser {
// Try other files via CodebaseContext
ITypeBinding declaringClass = binding.getDeclaringClass();
if (declaringClass != null) {
- String fqn = declaringClass.getQualifiedName();
+ String fqn = declaringClass.getTypeDeclaration().getQualifiedName();
TypeDeclaration td = context.getTypeDeclaration(fqn);
if (td != null) {
for (MethodDeclaration md : td.getMethods()) {
@@ -287,7 +304,7 @@ public class AstTransitionParser {
if (binding != null) {
ITypeBinding declaringClass = binding.getDeclaringClass();
if (declaringClass != null) {
- String fqn = declaringClass.getQualifiedName();
+ String fqn = declaringClass.getTypeDeclaration().getQualifiedName();
TypeDeclaration td = context.getTypeDeclaration(fqn);
if (td != null) {
for (MethodDeclaration md : td.getMethods()) {
diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java
index 5d115c1..cf01481 100644
--- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java
+++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java
@@ -69,20 +69,20 @@ public class CodebaseContext {
}
public void scan(Path rootDir) throws IOException {
- scan(rootDir, Collections.emptySet());
+ scan(Set.of(rootDir), Collections.emptySet());
}
private final List allCus = new ArrayList<>();
private final Map> interfaceToImpls = new HashMap<>();
- public void scan(Path rootDir, Set customIgnorePatterns) throws IOException {
- this.allProperties = propertyResolver.resolveAllProperties(rootDir);
+ public void scan(Set rootDirs, Set customIgnorePatterns) throws IOException {
+ this.allProperties = propertyResolver.resolveAllProperties(rootDirs);
Set activeIgnore = new HashSet<>(ignorePatterns);
activeIgnore.addAll(customIgnorePatterns);
- List javaFiles = FileUtils.findFilesWithExtension(Set.of(rootDir), ".java", activeIgnore);
+ List javaFiles = FileUtils.findFilesWithExtension(rootDirs, ".java", activeIgnore);
for (Path javaFile : javaFiles) {
String source = Files.readString(javaFile);
CompilationUnit cu = parse(source, javaFile.getFileName().toString());
diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java
index 28424ad..cb7422d 100644
--- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java
+++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java
@@ -26,6 +26,7 @@ import java.io.PrintWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
+import java.util.stream.Collectors;
@Slf4j
public class ExportService {
@@ -61,7 +62,16 @@ public class ExportService {
public void runExporter(Path inputDir, Path outputDir, List selectedFormats, boolean renderChoicesAsDiamonds, List activeProfiles, Path flowsOverride, String machineFilter) throws IOException {
CodebaseContext context = new CodebaseContext();
context.setActiveProfiles(activeProfiles);
- context.setSourcepath(List.of(inputDir.toString()));
+
+ click.kamil.springstatemachineexporter.analysis.resolver.SiblingDependencyResolver siblingResolver = new click.kamil.springstatemachineexporter.analysis.resolver.SiblingDependencyResolver();
+ Set allPaths = new HashSet<>();
+ allPaths.add(inputDir);
+ allPaths.addAll(siblingResolver.resolveSiblings(inputDir));
+
+ context.setSourcepath(allPaths.stream()
+ .map(p -> p.resolve("src/main/java").toString())
+ .filter(p -> Files.exists(Path.of(p)))
+ .collect(Collectors.toList()));
context.setResolveBindings(true);
Path hintsFile = inputDir.resolve("hints.json");
@@ -90,7 +100,7 @@ public class ExportService {
}
}
- context.scan(inputDir);
+ context.scan(allPaths, Collections.emptySet());
CodebaseIntelligenceProvider intelligence = new JdtIntelligenceProvider(context, inputDir);
exportAll(context, intelligence, outputDir, selectedFormats, renderChoicesAsDiamonds, flows, machineFilter, activeProfiles != null ? activeProfiles : Collections.emptyList());
diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/RegressionTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/RegressionTest.java
index 4f189f6..d37af92 100644
--- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/RegressionTest.java
+++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/RegressionTest.java
@@ -125,6 +125,12 @@ public class RegressionTest {
root.resolve("state_machines/enterprise_order_system"),
Path.of("src/test/resources/golden/EnterpriseStateMachineConfig"),
"EnterpriseStateMachineConfig"
+ ),
+ new TestScenario(
+ "Multi-Module Sample",
+ root.resolve("state_machines/multi_module_sample/core-module"),
+ Path.of("src/test/resources/golden/OrderStateMachineConfig"),
+ "OrderStateMachineConfig"
)
);
}
diff --git a/state_machine_exporter/src/test/resources/golden/EnterpriseStateMachineConfig/EnterpriseStateMachineConfig.json b/state_machine_exporter/src/test/resources/golden/EnterpriseStateMachineConfig/EnterpriseStateMachineConfig.json
index 74e2d02..0f23217 100644
--- a/state_machine_exporter/src/test/resources/golden/EnterpriseStateMachineConfig/EnterpriseStateMachineConfig.json
+++ b/state_machine_exporter/src/test/resources/golden/EnterpriseStateMachineConfig/EnterpriseStateMachineConfig.json
@@ -44,6 +44,30 @@
"lineNumber" : 17
} ],
"entryPoints" : [ {
+ "type" : "REST",
+ "name" : "POST /api/enterprise/orders/place",
+ "className" : "click.kamil.examples.enterprise.api.OrderApi",
+ "methodName" : "placeOrder",
+ "metadata" : {
+ "path" : "/api/enterprise/orders/place",
+ "verb" : "POST"
+ },
+ "parameters" : [ ]
+ }, {
+ "type" : "REST",
+ "name" : "POST /api/enterprise/orders/{id}/cancel",
+ "className" : "click.kamil.examples.enterprise.api.OrderApi",
+ "methodName" : "cancelOrder",
+ "metadata" : {
+ "path" : "/api/enterprise/orders/{id}/cancel",
+ "verb" : "POST"
+ },
+ "parameters" : [ {
+ "name" : "id",
+ "type" : "String",
+ "annotations" : [ "PathVariable" ]
+ } ]
+ }, {
"type" : "REST",
"name" : "POST /api/enterprise/orders/place",
"className" : "click.kamil.examples.enterprise.api.OrderController",
diff --git a/state_machine_exporter/src/test/resources/golden/InheritanceStateMachineConfig/InheritanceStateMachineConfig.json b/state_machine_exporter/src/test/resources/golden/InheritanceStateMachineConfig/InheritanceStateMachineConfig.json
index d9a7446..118d5b0 100644
--- a/state_machine_exporter/src/test/resources/golden/InheritanceStateMachineConfig/InheritanceStateMachineConfig.json
+++ b/state_machine_exporter/src/test/resources/golden/InheritanceStateMachineConfig/InheritanceStateMachineConfig.json
@@ -9,6 +9,16 @@
"lineNumber" : 15
} ],
"entryPoints" : [ {
+ "type" : "REST",
+ "name" : "POST /api/v2/orders/submit",
+ "className" : "click.kamil.examples.statemachine.inheritance.api.OrderApi",
+ "methodName" : "submitOrder",
+ "metadata" : {
+ "path" : "/api/v2/orders/submit",
+ "verb" : "POST"
+ },
+ "parameters" : [ ]
+ }, {
"type" : "REST",
"name" : "POST /api/v2/orders/submit",
"className" : "click.kamil.examples.statemachine.inheritance.api.OrderControllerImpl",
diff --git a/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/OrderStateMachineConfig.dot b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/OrderStateMachineConfig.dot
new file mode 100644
index 0000000..b515a49
--- /dev/null
+++ b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/OrderStateMachineConfig.dot
@@ -0,0 +1,12 @@
+digraph statemachine {
+ rankdir=LR;
+ node [shape=rounded, style=filled, fillcolor=white, fontname="Arial"];
+ edge [fontname="Arial", fontsize=10];
+
+ _start [shape=circle, label="", fillcolor=black, width=0.1];
+ _start -> NEW;
+ COMPLETED [fillcolor=lightgray];
+ NEW -> PROCESSING [label="SUBMIT / processAction()", style="solid", color="black"];
+ PROCESSING -> COMPLETED [label="FINISH", style="solid", color="black"];
+}
+
diff --git a/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/OrderStateMachineConfig.json b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/OrderStateMachineConfig.json
new file mode 100644
index 0000000..b0c7a90
--- /dev/null
+++ b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/OrderStateMachineConfig.json
@@ -0,0 +1,108 @@
+{
+ "metadata" : {
+ "triggers" : [ {
+ "event" : "SUBMIT",
+ "className" : "click.kamil.multi.core.OrderController",
+ "methodName" : "submitOrder",
+ "sourceModule" : null,
+ "stateMachineId" : null,
+ "lineNumber" : 18
+ } ],
+ "entryPoints" : [ {
+ "type" : "REST",
+ "name" : "POST /orders/submit",
+ "className" : "click.kamil.multi.core.OrderController",
+ "methodName" : "submitOrder",
+ "metadata" : {
+ "path" : "/orders/submit",
+ "verb" : "POST"
+ },
+ "parameters" : [ {
+ "name" : "orderId",
+ "type" : "String",
+ "annotations" : [ "RequestBody" ]
+ } ]
+ }, {
+ "type" : "REST",
+ "name" : "POST /orders/submit",
+ "className" : "click.kamil.multi.api.OrderApi",
+ "methodName" : "submitOrder",
+ "metadata" : {
+ "path" : "/orders/submit",
+ "verb" : "POST"
+ },
+ "parameters" : [ {
+ "name" : "orderId",
+ "type" : "String",
+ "annotations" : [ "RequestBody" ]
+ } ]
+ } ],
+ "callChains" : [ {
+ "entryPoint" : {
+ "type" : "REST",
+ "name" : "POST /orders/submit",
+ "className" : "click.kamil.multi.core.OrderController",
+ "methodName" : "submitOrder",
+ "metadata" : {
+ "path" : "/orders/submit",
+ "verb" : "POST"
+ },
+ "parameters" : [ {
+ "name" : "orderId",
+ "type" : "String",
+ "annotations" : [ "RequestBody" ]
+ } ]
+ },
+ "methodChain" : [ "click.kamil.multi.core.OrderController.submitOrder" ],
+ "triggerPoint" : {
+ "event" : "SUBMIT",
+ "className" : "click.kamil.multi.core.OrderController",
+ "methodName" : "submitOrder",
+ "sourceModule" : null,
+ "stateMachineId" : null,
+ "lineNumber" : 18
+ }
+ } ],
+ "properties" : {
+ "default" : { }
+ }
+ },
+ "name" : "click.kamil.multi.core.OrderStateMachineConfig",
+ "renderChoicesAsDiamonds" : true,
+ "startStates" : [ "NEW" ],
+ "transitions" : [ {
+ "type" : "EXTERNAL",
+ "sourceStates" : [ {
+ "rawName" : "\"NEW\"",
+ "fullIdentifier" : "NEW"
+ } ],
+ "targetStates" : [ {
+ "rawName" : "\"PROCESSING\"",
+ "fullIdentifier" : "PROCESSING"
+ } ],
+ "event" : "SUBMIT",
+ "guard" : null,
+ "actions" : [ {
+ "expression" : "processAction()",
+ "isLambda" : false,
+ "internalLogic" : "@Override default void execute(StateContext context){\n System.out.println(\"Processing order: \" + context.getMessageHeader(\"orderId\"));\n}\n",
+ "lineNumber" : 31
+ } ],
+ "order" : null
+ }, {
+ "type" : "EXTERNAL",
+ "sourceStates" : [ {
+ "rawName" : "\"PROCESSING\"",
+ "fullIdentifier" : "PROCESSING"
+ } ],
+ "targetStates" : [ {
+ "rawName" : "\"COMPLETED\"",
+ "fullIdentifier" : "COMPLETED"
+ } ],
+ "event" : "FINISH",
+ "guard" : null,
+ "actions" : [ ],
+ "order" : null
+ } ],
+ "endStates" : [ "COMPLETED" ]
+}
diff --git a/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/OrderStateMachineConfig.puml b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/OrderStateMachineConfig.puml
new file mode 100644
index 0000000..3b59d0b
--- /dev/null
+++ b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/OrderStateMachineConfig.puml
@@ -0,0 +1,32 @@
+@startuml
+!pragma layout smetana
+set separator none
+hide empty description
+hide stereotype
+skinparam state {
+ BackgroundColor white
+ BorderColor #94a3b8
+ BorderThickness 1
+ FontName Inter
+ FontSize 9
+ FontStyle bold
+ RoundCorner 20
+ Padding 1
+}
+skinparam shadowing false
+skinparam ArrowFontName JetBrains Mono
+skinparam ArrowFontSize 8
+skinparam ArrowColor #cbd5e1
+skinparam ArrowThickness 1
+skinparam dpi 110
+skinparam svgLinkTarget _self
+
+[*] --> NEW
+
+
+NEW -[#1E90FF,bold]-> PROCESSING <> <> : SUBMIT / processAction()
+PROCESSING -[#1E90FF,bold]-> COMPLETED <> <> : FINISH
+
+COMPLETED --> [*]
+@enduml
+
diff --git a/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/OrderStateMachineConfig.scxml.xml b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/OrderStateMachineConfig.scxml.xml
new file mode 100644
index 0000000..6549088
--- /dev/null
+++ b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/OrderStateMachineConfig.scxml.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/click.kamil.multi.core.OrderStateMachineConfig.html b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/click.kamil.multi.core.OrderStateMachineConfig.html
new file mode 100644
index 0000000..4bf226e
--- /dev/null
+++ b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/click.kamil.multi.core.OrderStateMachineConfig.html
@@ -0,0 +1,724 @@
+
+
+
+
+
+ State Machine Explorer - click.kamil.multi.core.OrderStateMachineConfig
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/click.kamil.multi.core.OrderStateMachineConfig.json b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/click.kamil.multi.core.OrderStateMachineConfig.json
new file mode 100644
index 0000000..b0c7a90
--- /dev/null
+++ b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/click.kamil.multi.core.OrderStateMachineConfig.json
@@ -0,0 +1,108 @@
+{
+ "metadata" : {
+ "triggers" : [ {
+ "event" : "SUBMIT",
+ "className" : "click.kamil.multi.core.OrderController",
+ "methodName" : "submitOrder",
+ "sourceModule" : null,
+ "stateMachineId" : null,
+ "lineNumber" : 18
+ } ],
+ "entryPoints" : [ {
+ "type" : "REST",
+ "name" : "POST /orders/submit",
+ "className" : "click.kamil.multi.core.OrderController",
+ "methodName" : "submitOrder",
+ "metadata" : {
+ "path" : "/orders/submit",
+ "verb" : "POST"
+ },
+ "parameters" : [ {
+ "name" : "orderId",
+ "type" : "String",
+ "annotations" : [ "RequestBody" ]
+ } ]
+ }, {
+ "type" : "REST",
+ "name" : "POST /orders/submit",
+ "className" : "click.kamil.multi.api.OrderApi",
+ "methodName" : "submitOrder",
+ "metadata" : {
+ "path" : "/orders/submit",
+ "verb" : "POST"
+ },
+ "parameters" : [ {
+ "name" : "orderId",
+ "type" : "String",
+ "annotations" : [ "RequestBody" ]
+ } ]
+ } ],
+ "callChains" : [ {
+ "entryPoint" : {
+ "type" : "REST",
+ "name" : "POST /orders/submit",
+ "className" : "click.kamil.multi.core.OrderController",
+ "methodName" : "submitOrder",
+ "metadata" : {
+ "path" : "/orders/submit",
+ "verb" : "POST"
+ },
+ "parameters" : [ {
+ "name" : "orderId",
+ "type" : "String",
+ "annotations" : [ "RequestBody" ]
+ } ]
+ },
+ "methodChain" : [ "click.kamil.multi.core.OrderController.submitOrder" ],
+ "triggerPoint" : {
+ "event" : "SUBMIT",
+ "className" : "click.kamil.multi.core.OrderController",
+ "methodName" : "submitOrder",
+ "sourceModule" : null,
+ "stateMachineId" : null,
+ "lineNumber" : 18
+ }
+ } ],
+ "properties" : {
+ "default" : { }
+ }
+ },
+ "name" : "click.kamil.multi.core.OrderStateMachineConfig",
+ "renderChoicesAsDiamonds" : true,
+ "startStates" : [ "NEW" ],
+ "transitions" : [ {
+ "type" : "EXTERNAL",
+ "sourceStates" : [ {
+ "rawName" : "\"NEW\"",
+ "fullIdentifier" : "NEW"
+ } ],
+ "targetStates" : [ {
+ "rawName" : "\"PROCESSING\"",
+ "fullIdentifier" : "PROCESSING"
+ } ],
+ "event" : "SUBMIT",
+ "guard" : null,
+ "actions" : [ {
+ "expression" : "processAction()",
+ "isLambda" : false,
+ "internalLogic" : "@Override default void execute(StateContext context){\n System.out.println(\"Processing order: \" + context.getMessageHeader(\"orderId\"));\n}\n",
+ "lineNumber" : 31
+ } ],
+ "order" : null
+ }, {
+ "type" : "EXTERNAL",
+ "sourceStates" : [ {
+ "rawName" : "\"PROCESSING\"",
+ "fullIdentifier" : "PROCESSING"
+ } ],
+ "targetStates" : [ {
+ "rawName" : "\"COMPLETED\"",
+ "fullIdentifier" : "COMPLETED"
+ } ],
+ "event" : "FINISH",
+ "guard" : null,
+ "actions" : [ ],
+ "order" : null
+ } ],
+ "endStates" : [ "COMPLETED" ]
+}
diff --git a/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/click.kamil.multi.core.OrderStateMachineConfig.puml b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/click.kamil.multi.core.OrderStateMachineConfig.puml
new file mode 100644
index 0000000..3b59d0b
--- /dev/null
+++ b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/click.kamil.multi.core.OrderStateMachineConfig.puml
@@ -0,0 +1,32 @@
+@startuml
+!pragma layout smetana
+set separator none
+hide empty description
+hide stereotype
+skinparam state {
+ BackgroundColor white
+ BorderColor #94a3b8
+ BorderThickness 1
+ FontName Inter
+ FontSize 9
+ FontStyle bold
+ RoundCorner 20
+ Padding 1
+}
+skinparam shadowing false
+skinparam ArrowFontName JetBrains Mono
+skinparam ArrowFontSize 8
+skinparam ArrowColor #cbd5e1
+skinparam ArrowThickness 1
+skinparam dpi 110
+skinparam svgLinkTarget _self
+
+[*] --> NEW
+
+
+NEW -[#1E90FF,bold]-> PROCESSING <> <> : SUBMIT / processAction()
+PROCESSING -[#1E90FF,bold]-> COMPLETED <> <> : FINISH
+
+COMPLETED --> [*]
+@enduml
+
diff --git a/state_machines/multi_module_sample/api-module/build.gradle b/state_machines/multi_module_sample/api-module/build.gradle
new file mode 100644
index 0000000..026c579
--- /dev/null
+++ b/state_machines/multi_module_sample/api-module/build.gradle
@@ -0,0 +1,12 @@
+plugins {
+ id 'java'
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ implementation 'org.springframework.boot:spring-boot-starter-web:3.2.0'
+ implementation 'org.springframework.statemachine:spring-statemachine-core:3.2.0'
+}
diff --git a/state_machines/multi_module_sample/api-module/src/main/java/click/kamil/multi/api/OrderApi.java b/state_machines/multi_module_sample/api-module/src/main/java/click/kamil/multi/api/OrderApi.java
new file mode 100644
index 0000000..41f7cf8
--- /dev/null
+++ b/state_machines/multi_module_sample/api-module/src/main/java/click/kamil/multi/api/OrderApi.java
@@ -0,0 +1,12 @@
+package click.kamil.multi.api;
+
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+@RequestMapping("/orders")
+public interface OrderApi {
+
+ @PostMapping("/submit")
+ void submitOrder(@RequestBody String orderId);
+}
diff --git a/state_machines/multi_module_sample/api-module/src/main/java/click/kamil/multi/api/ProcessAction.java b/state_machines/multi_module_sample/api-module/src/main/java/click/kamil/multi/api/ProcessAction.java
new file mode 100644
index 0000000..fa0ddb9
--- /dev/null
+++ b/state_machines/multi_module_sample/api-module/src/main/java/click/kamil/multi/api/ProcessAction.java
@@ -0,0 +1,12 @@
+package click.kamil.multi.api;
+
+import org.springframework.statemachine.StateContext;
+import org.springframework.statemachine.action.Action;
+
+public interface ProcessAction extends Action {
+
+ @Override
+ default void execute(StateContext context) {
+ System.out.println("Processing order: " + context.getMessageHeader("orderId"));
+ }
+}
diff --git a/state_machines/multi_module_sample/core-module/build.gradle b/state_machines/multi_module_sample/core-module/build.gradle
new file mode 100644
index 0000000..bb5d661
--- /dev/null
+++ b/state_machines/multi_module_sample/core-module/build.gradle
@@ -0,0 +1,13 @@
+plugins {
+ id 'java'
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ implementation project(':state_machines:multi_module_sample:api-module')
+ implementation 'org.springframework.boot:spring-boot-starter-web:3.2.0'
+ implementation 'org.springframework.statemachine:spring-statemachine-core:3.2.0'
+}
diff --git a/state_machines/multi_module_sample/core-module/src/main/java/click/kamil/multi/core/OrderController.java b/state_machines/multi_module_sample/core-module/src/main/java/click/kamil/multi/core/OrderController.java
new file mode 100644
index 0000000..5a55e0c
--- /dev/null
+++ b/state_machines/multi_module_sample/core-module/src/main/java/click/kamil/multi/core/OrderController.java
@@ -0,0 +1,20 @@
+package click.kamil.multi.core;
+
+import click.kamil.multi.api.OrderApi;
+import lombok.RequiredArgsConstructor;
+import org.springframework.statemachine.StateMachine;
+import org.springframework.statemachine.config.StateMachineFactory;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequiredArgsConstructor
+public class OrderController implements OrderApi {
+
+ private final StateMachineFactory factory;
+
+ @Override
+ public void submitOrder(String orderId) {
+ StateMachine sm = factory.getStateMachine(orderId);
+ sm.sendEvent("SUBMIT");
+ }
+}
diff --git a/state_machines/multi_module_sample/core-module/src/main/java/click/kamil/multi/core/OrderStateMachineConfig.java b/state_machines/multi_module_sample/core-module/src/main/java/click/kamil/multi/core/OrderStateMachineConfig.java
new file mode 100644
index 0000000..7c12e9e
--- /dev/null
+++ b/state_machines/multi_module_sample/core-module/src/main/java/click/kamil/multi/core/OrderStateMachineConfig.java
@@ -0,0 +1,43 @@
+package click.kamil.multi.core;
+
+import click.kamil.multi.api.ProcessAction;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.statemachine.config.EnableStateMachineFactory;
+import org.springframework.statemachine.config.StateMachineConfigurerAdapter;
+import org.springframework.statemachine.config.builders.StateMachineStateConfigurer;
+import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer;
+
+@Configuration
+@EnableStateMachineFactory
+public class OrderStateMachineConfig extends StateMachineConfigurerAdapter {
+
+ @Override
+ public void configure(StateMachineStateConfigurer states) throws Exception {
+ states
+ .withStates()
+ .initial("NEW")
+ .state("PROCESSING")
+ .end("COMPLETED");
+ }
+
+ @Override
+ public void configure(StateMachineTransitionConfigurer transitions) throws Exception {
+ transitions
+ .withExternal()
+ .source("NEW")
+ .target("PROCESSING")
+ .event("SUBMIT")
+ .action(processAction())
+ .and()
+ .withExternal()
+ .source("PROCESSING")
+ .target("COMPLETED")
+ .event("FINISH");
+ }
+
+ @Bean
+ public ProcessAction processAction() {
+ return new ProcessAction() {};
+ }
+}
diff --git a/state_machines/multi_module_sample/settings.gradle b/state_machines/multi_module_sample/settings.gradle
new file mode 100644
index 0000000..a140b73
--- /dev/null
+++ b/state_machines/multi_module_sample/settings.gradle
@@ -0,0 +1,3 @@
+rootProject.name = 'multi-module-sample'
+include 'api-module'
+include 'core-module'