Improve REST path binding for enum path variables and valueOf
Treat enum @PathVariable parameters as internal triggers, expand multiple path placeholders via cartesian bindings, derive event cases from proven Enum.valueOf chains, and add enterprise HTML regression coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -50,6 +50,8 @@ public final class EntryPointBindingExpander {
|
|||||||
if (entryPoint == null || entryPoint.getParameters() == null || entryPoint.getParameters().isEmpty()) {
|
if (entryPoint == null || entryPoint.getParameters() == null || entryPoint.getParameters().isEmpty()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
|
List<Map<String, String>> variants = new ArrayList<>();
|
||||||
|
variants.add(new LinkedHashMap<>());
|
||||||
for (EntryPoint.Parameter parameter : entryPoint.getParameters()) {
|
for (EntryPoint.Parameter parameter : entryPoint.getParameters()) {
|
||||||
if (!isPathOrQueryVariable(parameter)) {
|
if (!isPathOrQueryVariable(parameter)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -57,7 +59,7 @@ public final class EntryPointBindingExpander {
|
|||||||
if (isPlaceholderResolvedInPath(entryPoint, parameter.getName())) {
|
if (isPlaceholderResolvedInPath(entryPoint, parameter.getName())) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
List<String> cases = resolveStringCasesForParameter(
|
List<String> cases = resolveBindingCasesForParameter(
|
||||||
entryPoint.getClassName() + "." + entryPoint.getMethodName(),
|
entryPoint.getClassName() + "." + entryPoint.getMethodName(),
|
||||||
parameter.getName(),
|
parameter.getName(),
|
||||||
context,
|
context,
|
||||||
@@ -65,13 +67,17 @@ public final class EntryPointBindingExpander {
|
|||||||
if (cases.isEmpty()) {
|
if (cases.isEmpty()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
List<Map<String, String>> variants = new ArrayList<>();
|
List<Map<String, String>> expanded = new ArrayList<>();
|
||||||
|
for (Map<String, String> base : variants) {
|
||||||
for (String caseValue : cases) {
|
for (String caseValue : cases) {
|
||||||
variants.add(Map.of(parameter.getName(), caseValue));
|
Map<String, String> merged = new LinkedHashMap<>(base);
|
||||||
|
merged.put(parameter.getName(), caseValue);
|
||||||
|
expanded.add(merged);
|
||||||
}
|
}
|
||||||
return variants;
|
|
||||||
}
|
}
|
||||||
return List.of();
|
variants = expanded;
|
||||||
|
}
|
||||||
|
return variants.stream().filter(map -> !map.isEmpty()).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static EntryPoint withResolvedPath(EntryPoint entryPoint, Map<String, String> bindings) {
|
public static EntryPoint withResolvedPath(EntryPoint entryPoint, Map<String, String> bindings) {
|
||||||
@@ -260,6 +266,18 @@ public final class EntryPointBindingExpander {
|
|||||||
.anyMatch(a -> "PathVariable".equals(a) || "RequestParam".equals(a));
|
.anyMatch(a -> "PathVariable".equals(a) || "RequestParam".equals(a));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<String> resolveBindingCasesForParameter(
|
||||||
|
String startMethod,
|
||||||
|
String paramName,
|
||||||
|
CodebaseContext context,
|
||||||
|
Map<String, List<CallEdge>> callGraph) {
|
||||||
|
List<String> stringCases = resolveStringCasesForParameter(startMethod, paramName, context, callGraph);
|
||||||
|
if (!stringCases.isEmpty()) {
|
||||||
|
return stringCases;
|
||||||
|
}
|
||||||
|
return resolveEnumValueOfCasesForParameter(startMethod, paramName, context, callGraph);
|
||||||
|
}
|
||||||
|
|
||||||
private static List<String> resolveStringCasesForParameter(
|
private static List<String> resolveStringCasesForParameter(
|
||||||
String startMethod,
|
String startMethod,
|
||||||
String paramName,
|
String paramName,
|
||||||
@@ -293,6 +311,122 @@ public final class EntryPointBindingExpander {
|
|||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<String> resolveEnumValueOfCasesForParameter(
|
||||||
|
String startMethod,
|
||||||
|
String paramName,
|
||||||
|
CodebaseContext context,
|
||||||
|
Map<String, List<CallEdge>> callGraph) {
|
||||||
|
ArrayDeque<String> queue = new ArrayDeque<>();
|
||||||
|
Set<String> visited = new HashSet<>();
|
||||||
|
queue.add(startMethod);
|
||||||
|
while (!queue.isEmpty()) {
|
||||||
|
String methodFqn = queue.poll();
|
||||||
|
if (!visited.add(methodFqn)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (visited.size() > 12) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
List<String> localCases = extractEnumValueOfCases(methodFqn, paramName, context);
|
||||||
|
if (!localCases.isEmpty()) {
|
||||||
|
return localCases;
|
||||||
|
}
|
||||||
|
List<CallEdge> 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<String> 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<String> 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<String> constants = context.getEnumValues(enumTypes.iterator().next());
|
||||||
|
if (constants == null || constants.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
LinkedHashSet<String> 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<String> values = context.getEnumValues(simpleName.getIdentifier());
|
||||||
|
if (values != null && !values.isEmpty()) {
|
||||||
|
return simpleName.getIdentifier();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
static List<String> extractStringSwitchCases(String methodFqn, String paramName, CodebaseContext context) {
|
static List<String> extractStringSwitchCases(String methodFqn, String paramName, CodebaseContext context) {
|
||||||
MethodDeclaration method = findMethodDeclaration(methodFqn, context);
|
MethodDeclaration method = findMethodDeclaration(methodFqn, context);
|
||||||
if (method == null || method.getBody() == null) {
|
if (method == null || method.getBody() == null) {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public final class ExternalTriggerPolicy {
|
|||||||
if (paramName != null) {
|
if (paramName != null) {
|
||||||
RestParamBinding binding = findRestParameterBinding(entryPoint, paramName, context);
|
RestParamBinding binding = findRestParameterBinding(entryPoint, paramName, context);
|
||||||
if (binding != null) {
|
if (binding != null) {
|
||||||
if (binding.pathOrQueryVariable()) {
|
if (binding.pathOrQueryVariable() && !binding.enumType()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (binding.requestBodyEnum()) {
|
if (binding.requestBodyEnum()) {
|
||||||
@@ -56,13 +56,19 @@ public final class ExternalTriggerPolicy {
|
|||||||
}
|
}
|
||||||
if (entryPoint.getName() != null && entryPoint.getName().contains("{")
|
if (entryPoint.getName() != null && entryPoint.getName().contains("{")
|
||||||
&& MachineEnumCanonicalizer.isDynamicTriggerExpression(trigger.getEvent())) {
|
&& MachineEnumCanonicalizer.isDynamicTriggerExpression(trigger.getEvent())) {
|
||||||
|
if (paramName != null) {
|
||||||
|
RestParamBinding pathBinding = findRestParameterBinding(entryPoint, paramName, context);
|
||||||
|
if (pathBinding != null && pathBinding.pathVariable() && pathBinding.enumType()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (entryMethodFqn != null && resolvedEventParamName != null) {
|
if (entryMethodFqn != null && resolvedEventParamName != null) {
|
||||||
RestParamBinding binding = findMethodParameterBinding(entryMethodFqn, resolvedEventParamName, context);
|
RestParamBinding binding = findMethodParameterBinding(entryMethodFqn, resolvedEventParamName, context);
|
||||||
if (binding != null) {
|
if (binding != null) {
|
||||||
if (binding.pathOrQueryVariable()) {
|
if (binding.pathOrQueryVariable() && !binding.enumType()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (binding.requestBodyEnum()) {
|
if (binding.requestBodyEnum()) {
|
||||||
|
|||||||
@@ -198,4 +198,50 @@ class EntryPointBindingExpanderTest {
|
|||||||
assertThat(variants).extracting(map -> map.get("machineType"))
|
assertThat(variants).extracting(map -> map.get("machineType"))
|
||||||
.containsExactlyInAnyOrder("ORDER", "DOCUMENT");
|
.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<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> 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<Map<String, String>> 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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,43 @@ class ExternalTriggerPolicyTest {
|
|||||||
entryPoint, trigger, "com.example.Api.trigger", "event", null)).isTrue();
|
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
|
@Test
|
||||||
void requestBodyEnumShouldBeInternal(@TempDir Path tempDir) throws Exception {
|
void requestBodyEnumShouldBeInternal(@TempDir Path tempDir) throws Exception {
|
||||||
String source = """
|
String source = """
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user