diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java index ae3b33b..3d7104a 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java @@ -50,6 +50,8 @@ public final class EntryPointBindingExpander { if (entryPoint == null || entryPoint.getParameters() == null || entryPoint.getParameters().isEmpty()) { return List.of(); } + List> variants = new ArrayList<>(); + variants.add(new LinkedHashMap<>()); for (EntryPoint.Parameter parameter : entryPoint.getParameters()) { if (!isPathOrQueryVariable(parameter)) { continue; @@ -57,7 +59,7 @@ public final class EntryPointBindingExpander { if (isPlaceholderResolvedInPath(entryPoint, parameter.getName())) { continue; } - List cases = resolveStringCasesForParameter( + List cases = resolveBindingCasesForParameter( entryPoint.getClassName() + "." + entryPoint.getMethodName(), parameter.getName(), context, @@ -65,13 +67,17 @@ public final class EntryPointBindingExpander { if (cases.isEmpty()) { continue; } - List> variants = new ArrayList<>(); - for (String caseValue : cases) { - variants.add(Map.of(parameter.getName(), caseValue)); + List> expanded = new ArrayList<>(); + for (Map base : variants) { + for (String caseValue : cases) { + Map merged = new LinkedHashMap<>(base); + merged.put(parameter.getName(), caseValue); + expanded.add(merged); + } } - return variants; + variants = expanded; } - return List.of(); + return variants.stream().filter(map -> !map.isEmpty()).toList(); } public static EntryPoint withResolvedPath(EntryPoint entryPoint, Map bindings) { @@ -260,6 +266,18 @@ public final class EntryPointBindingExpander { .anyMatch(a -> "PathVariable".equals(a) || "RequestParam".equals(a)); } + private static List resolveBindingCasesForParameter( + String startMethod, + String paramName, + CodebaseContext context, + Map> callGraph) { + List stringCases = resolveStringCasesForParameter(startMethod, paramName, context, callGraph); + if (!stringCases.isEmpty()) { + return stringCases; + } + return resolveEnumValueOfCasesForParameter(startMethod, paramName, context, callGraph); + } + private static List resolveStringCasesForParameter( String startMethod, String paramName, @@ -293,6 +311,122 @@ public final class EntryPointBindingExpander { return List.of(); } + private static List resolveEnumValueOfCasesForParameter( + String startMethod, + String paramName, + CodebaseContext context, + Map> callGraph) { + ArrayDeque queue = new ArrayDeque<>(); + Set visited = new HashSet<>(); + queue.add(startMethod); + while (!queue.isEmpty()) { + String methodFqn = queue.poll(); + if (!visited.add(methodFqn)) { + continue; + } + if (visited.size() > 12) { + break; + } + List localCases = extractEnumValueOfCases(methodFqn, paramName, context); + if (!localCases.isEmpty()) { + return localCases; + } + List edges = callGraph.get(methodFqn); + if (edges == null) { + continue; + } + for (CallEdge edge : edges) { + if (edge.getTargetMethod() != null) { + queue.add(edge.getTargetMethod()); + } + } + } + return List.of(); + } + + static List extractEnumValueOfCases(String methodFqn, String paramName, CodebaseContext context) { + MethodDeclaration method = findMethodDeclaration(methodFqn, context); + if (method == null || method.getBody() == null || context == null) { + return List.of(); + } + if (!hasParameter(method, paramName)) { + return List.of(); + } + LinkedHashSet enumTypes = new LinkedHashSet<>(); + method.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(MethodInvocation node) { + if (!"valueOf".equals(node.getName().getIdentifier()) || node.arguments().size() != 1) { + return super.visit(node); + } + Expression argument = (Expression) node.arguments().get(0); + if (!argumentReferencesParameter(argument, paramName)) { + return super.visit(node); + } + String enumType = resolveValueOfEnumType(node, context); + if (enumType != null) { + enumTypes.add(enumType); + } + return super.visit(node); + } + }); + if (enumTypes.size() != 1) { + return List.of(); + } + List constants = context.getEnumValues(enumTypes.iterator().next()); + if (constants == null || constants.isEmpty()) { + return List.of(); + } + LinkedHashSet pathValues = new LinkedHashSet<>(); + for (String constant : constants) { + if (constant == null || constant.isBlank()) { + continue; + } + int lastDot = constant.lastIndexOf('.'); + pathValues.add(lastDot >= 0 ? constant.substring(lastDot + 1) : constant); + } + return List.copyOf(pathValues); + } + + private static boolean argumentReferencesParameter(Expression argument, String paramName) { + if (argument instanceof SimpleName simpleName) { + return paramName.equals(simpleName.getIdentifier()); + } + if (argument instanceof MethodInvocation invocation + && "toUpperCase".equals(invocation.getName().getIdentifier()) + && invocation.arguments().isEmpty() + && invocation.getExpression() instanceof SimpleName simpleName) { + return paramName.equals(simpleName.getIdentifier()); + } + if (argument instanceof MethodInvocation invocation + && "toLowerCase".equals(invocation.getName().getIdentifier()) + && invocation.arguments().isEmpty() + && invocation.getExpression() instanceof SimpleName simpleName) { + return paramName.equals(simpleName.getIdentifier()); + } + return false; + } + + private static String resolveValueOfEnumType(MethodInvocation valueOfInvocation, CodebaseContext context) { + Expression receiver = valueOfInvocation.getExpression(); + if (receiver != null) { + org.eclipse.jdt.core.dom.ITypeBinding binding = receiver.resolveTypeBinding(); + if (binding != null && binding.isEnum()) { + return binding.getQualifiedName(); + } + } + if (receiver instanceof QualifiedName qualifiedName) { + return qualifiedName.getFullyQualifiedName(); + } + if (receiver instanceof SimpleName simpleName && context != null) { + List values = context.getEnumValues(simpleName.getIdentifier()); + if (values != null && !values.isEmpty()) { + return simpleName.getIdentifier(); + } + } + return null; + } + static List extractStringSwitchCases(String methodFqn, String paramName, CodebaseContext context) { MethodDeclaration method = findMethodDeclaration(methodFqn, context); if (method == null || method.getBody() == null) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicy.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicy.java index a67cd61..0210423 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicy.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicy.java @@ -39,7 +39,7 @@ public final class ExternalTriggerPolicy { if (paramName != null) { RestParamBinding binding = findRestParameterBinding(entryPoint, paramName, context); if (binding != null) { - if (binding.pathOrQueryVariable()) { + if (binding.pathOrQueryVariable() && !binding.enumType()) { return true; } if (binding.requestBodyEnum()) { @@ -56,13 +56,19 @@ public final class ExternalTriggerPolicy { } if (entryPoint.getName() != null && entryPoint.getName().contains("{") && MachineEnumCanonicalizer.isDynamicTriggerExpression(trigger.getEvent())) { + if (paramName != null) { + RestParamBinding pathBinding = findRestParameterBinding(entryPoint, paramName, context); + if (pathBinding != null && pathBinding.pathVariable() && pathBinding.enumType()) { + return false; + } + } return true; } } if (entryMethodFqn != null && resolvedEventParamName != null) { RestParamBinding binding = findMethodParameterBinding(entryMethodFqn, resolvedEventParamName, context); if (binding != null) { - if (binding.pathOrQueryVariable()) { + if (binding.pathOrQueryVariable() && !binding.enumType()) { return true; } if (binding.requestBodyEnum()) { diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpanderTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpanderTest.java index b74e202..937a3bb 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpanderTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpanderTest.java @@ -198,4 +198,50 @@ class EntryPointBindingExpanderTest { assertThat(variants).extracting(map -> map.get("machineType")) .containsExactlyInAnyOrder("ORDER", "DOCUMENT"); } + + @Test + void shouldExpandEventPathVariableFromPreservedEnumValueOfParam(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class OrderController { + OrderGateway gateway; + public void transition(String event) { + gateway.trigger(event); + } + } + class OrderGateway { + void trigger(String event) { + OrderEvent.valueOf(event.toUpperCase()); + } + } + enum OrderEvent { PAY, SHIP } + """; + Files.writeString(tempDir.resolve("App.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + Map> graph = engine.buildCallGraph(); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.OrderController") + .methodName("transition") + .name("POST /api/order/{event}") + .parameters(List.of(EntryPoint.Parameter.builder() + .name("event") + .type("String") + .annotations(List.of("PathVariable")) + .build())) + .build(); + + List> variants = + EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph); + + assertThat(variants).extracting(map -> map.get("event")) + .containsExactlyInAnyOrder("PAY", "SHIP"); + assertThat(EntryPointBindingExpander.withResolvedPath(entryPoint, variants.get(0)).getName()) + .isEqualTo("POST /api/order/PAY"); + } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicyTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicyTest.java index 5a8cf4d..9baf770 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicyTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicyTest.java @@ -35,6 +35,43 @@ class ExternalTriggerPolicyTest { entryPoint, trigger, "com.example.Api.trigger", "event", null)).isTrue(); } + @Test + void pathVariableEnumParameterShouldBeInternal(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import org.springframework.web.bind.annotation.*; + public class Api { + @PostMapping("/api/order/{event}") + public void transition(@PathVariable OrderEvent event) { + fire(event); + } + void fire(OrderEvent event) {} + } + enum OrderEvent { PAY, SHIP } + """; + Files.writeString(tempDir.resolve("Api.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + EntryPoint entryPoint = EntryPoint.builder() + .type(EntryPoint.Type.REST) + .name("POST /api/order/{event}") + .className("com.example.Api") + .methodName("transition") + .parameters(List.of(EntryPoint.Parameter.builder() + .name("event") + .type("com.example.OrderEvent") + .annotations(List.of("PathVariable")) + .build())) + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .event("event") + .build(); + + assertThat(ExternalTriggerPolicy.isExternalFromSource( + entryPoint, trigger, "com.example.Api.transition", "event", context)).isFalse(); + } + @Test void requestBodyEnumShouldBeInternal(@TempDir Path tempDir) throws Exception { String source = """ diff --git a/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/EnterpriseDedicatedEndpointHtmlTest.java b/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/EnterpriseDedicatedEndpointHtmlTest.java new file mode 100644 index 0000000..7b5514a --- /dev/null +++ b/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/EnterpriseDedicatedEndpointHtmlTest.java @@ -0,0 +1,64 @@ +package click.kamil.springstatemachineexporter.html; + +import click.kamil.springstatemachineexporter.exporter.JsonExporter; +import click.kamil.springstatemachineexporter.html.exporter.HtmlExporter; +import click.kamil.springstatemachineexporter.service.ExportService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Enterprise dedicated REST endpoints must produce HTML explorer link keys end-to-end; + * generic {@code {event}} dispatchers remain unresolved external. + */ +class EnterpriseDedicatedEndpointHtmlTest { + + private static final String PAY_ENDPOINT = "POST /api/machine/order/pay"; + private static final String GENERIC_ENDPOINT = "POST /api/machine/{machineType}/transition/{event}"; + + @Test + void htmlShouldExposeLinkKeysForDedicatedPayEndpoint(@TempDir Path tempDir) throws Exception { + Path enterprise = findProjectRoot().resolve("state_machines/state_machine_enterprise"); + ExportService exportService = new ExportService(List.of(new HtmlExporter(), new JsonExporter())); + exportService.runExporter(enterprise, tempDir, List.of("html"), true, List.of(), null, null, + click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, + click.kamil.springstatemachineexporter.exporter.EnumFormat.fn); + + Path htmlFile = findOrderMachineHtml(tempDir); + String html = Files.readString(htmlFile); + + assertThat(html).contains(PAY_ENDPOINT); + assertThat(html).contains("\"linkResolution\" : \"RESOLVED\""); + assertThat(html).contains("#link_"); + assertThat(html).contains("\"linkKey\""); + assertThat(html).contains(GENERIC_ENDPOINT); + assertThat(html).contains("linkResolution === 'UNRESOLVED_EXTERNAL'"); + } + + private static Path findOrderMachineHtml(Path outputDir) throws Exception { + Path machineDir; + try (var stream = Files.list(outputDir)) { + machineDir = stream + .filter(Files::isDirectory) + .filter(path -> path.getFileName().toString().endsWith("OrderStateMachineConfiguration")) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Order machine output not found")); + } + Path htmlFile = machineDir.resolve(machineDir.getFileName() + ".html"); + assertThat(htmlFile).exists(); + return htmlFile; + } + + private static Path findProjectRoot() { + Path current = Path.of(".").toAbsolutePath(); + while (current != null && !Files.exists(current.resolve("settings.gradle"))) { + current = current.getParent(); + } + return current; + } +}