A
This commit is contained in:
@@ -88,10 +88,11 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tp.isExternal() && isUnresolvedGenericRestEndpoint(chain.getEntryPoint())) {
|
// Only unbound *event* path templates (e.g. /{event}) stay unresolved-external.
|
||||||
// Unbound {event}/{machineType} templates stay external with no matches.
|
// Resource placeholders like /{id}/pay must still link when the event is known.
|
||||||
// Dedicated/expanded endpoints (no placeholders) continue to linking even if
|
if (tp.isExternal()
|
||||||
// the external flag was set from an unrelated RequestParam.
|
&& isUnresolvedGenericRestEndpoint(chain.getEntryPoint())
|
||||||
|
&& !hasConcreteEventEvidence(tp)) {
|
||||||
updatedChains.add(chain.toBuilder()
|
updatedChains.add(chain.toBuilder()
|
||||||
.triggerPoint(tp)
|
.triggerPoint(tp)
|
||||||
.matchedTransitions(null)
|
.matchedTransitions(null)
|
||||||
@@ -100,8 +101,9 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dedicated REST endpoints must not stay stuck as external once placeholders are gone.
|
// Clear spurious external flags (unrelated PathVariable/RequestParam) once we can link.
|
||||||
if (tp.isExternal() && !isUnresolvedGenericRestEndpoint(chain.getEntryPoint())) {
|
if (tp.isExternal()
|
||||||
|
&& (!isUnresolvedGenericRestEndpoint(chain.getEntryPoint()) || hasConcreteEventEvidence(tp))) {
|
||||||
tp = tp.toBuilder().external(false).build();
|
tp = tp.toBuilder().external(false).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,20 +343,77 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
|||||||
return event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName();
|
return event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the entry path still has an unbound event-carrying placeholder
|
||||||
|
* ({@code {event}}, {@code {commandKey}}, …). Resource ids like {@code {id}} do not count.
|
||||||
|
*/
|
||||||
private static boolean isUnresolvedGenericRestEndpoint(EntryPoint entryPoint) {
|
private static boolean isUnresolvedGenericRestEndpoint(EntryPoint entryPoint) {
|
||||||
if (entryPoint == null) {
|
if (entryPoint == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (entryPoint.getName() != null && entryPoint.getName().contains("{")) {
|
if (hasUnboundEventPlaceholder(entryPoint.getName())) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (entryPoint.getMetadata() != null) {
|
if (entryPoint.getMetadata() != null) {
|
||||||
Object path = entryPoint.getMetadata().get("path");
|
Object path = entryPoint.getMetadata().get("path");
|
||||||
if (path instanceof String pathValue && pathValue.contains("{")) {
|
if (path instanceof String pathValue && hasUnboundEventPlaceholder(pathValue)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean hasUnboundEventPlaceholder(String pathOrName) {
|
||||||
|
if (pathOrName == null || pathOrName.isBlank() || !pathOrName.contains("{")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int start = 0;
|
||||||
|
while (true) {
|
||||||
|
int open = pathOrName.indexOf('{', start);
|
||||||
|
if (open < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int close = pathOrName.indexOf('}', open + 1);
|
||||||
|
if (close < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String placeholder = pathOrName.substring(open + 1, close).trim();
|
||||||
|
if (isEventLikePlaceholder(placeholder)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
start = close + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isEventLikePlaceholder(String placeholder) {
|
||||||
|
if (placeholder == null || placeholder.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String lower = placeholder.toLowerCase();
|
||||||
|
// Strip matrix/regex extras: event:.+ or event:.*
|
||||||
|
int colon = lower.indexOf(':');
|
||||||
|
if (colon > 0) {
|
||||||
|
lower = lower.substring(0, colon);
|
||||||
|
}
|
||||||
|
return lower.equals("event")
|
||||||
|
|| lower.equals("e")
|
||||||
|
|| lower.endsWith("event")
|
||||||
|
|| lower.equals("command")
|
||||||
|
|| lower.equals("commandkey")
|
||||||
|
|| lower.equals("transition")
|
||||||
|
|| lower.equals("action")
|
||||||
|
|| lower.equals("machinetype");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasConcreteEventEvidence(TriggerPoint trigger) {
|
||||||
|
if (trigger == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Only a resolved enum constant on the trigger event itself bypasses unbound {event}
|
||||||
|
// templates. Polymorphic widens / single-transition inference must not count — otherwise
|
||||||
|
// every generic dispatcher template becomes RESOLVED against a one-transition machine.
|
||||||
|
return MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent())
|
||||||
|
== MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -68,7 +68,7 @@ public final class ExternalTriggerPolicy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (entryPoint.getName() != null && entryPoint.getName().contains("{")
|
if (entryPoint.getName() != null && entryPointHasUnboundPlaceholders(entryPoint)
|
||||||
&& MachineEnumCanonicalizer.isDynamicTriggerExpression(trigger.getEvent())) {
|
&& MachineEnumCanonicalizer.isDynamicTriggerExpression(trigger.getEvent())) {
|
||||||
if (paramName != null) {
|
if (paramName != null) {
|
||||||
RestParamBinding pathBinding = findRestParameterBinding(entryPoint, paramName, context);
|
RestParamBinding pathBinding = findRestParameterBinding(entryPoint, paramName, context);
|
||||||
@@ -107,18 +107,48 @@ public final class ExternalTriggerPolicy {
|
|||||||
if (entryPoint == null) {
|
if (entryPoint == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (entryPoint.getName() != null && entryPoint.getName().contains("{")) {
|
if (hasUnboundEventPlaceholder(entryPoint.getName())) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (entryPoint.getMetadata() != null) {
|
if (entryPoint.getMetadata() != null) {
|
||||||
String path = entryPoint.getMetadata().get("path");
|
String path = entryPoint.getMetadata().get("path");
|
||||||
if (path != null && path.contains("{")) {
|
if (hasUnboundEventPlaceholder(path)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean hasUnboundEventPlaceholder(String pathOrName) {
|
||||||
|
if (pathOrName == null || pathOrName.isBlank() || !pathOrName.contains("{")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int start = 0;
|
||||||
|
while (true) {
|
||||||
|
int open = pathOrName.indexOf('{', start);
|
||||||
|
if (open < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int close = pathOrName.indexOf('}', open + 1);
|
||||||
|
if (close < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String placeholder = pathOrName.substring(open + 1, close).trim();
|
||||||
|
if (isEventLikeParameterName(placeholderName(placeholder))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
start = close + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String placeholderName(String placeholder) {
|
||||||
|
if (placeholder == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int colon = placeholder.indexOf(':');
|
||||||
|
return colon > 0 ? placeholder.substring(0, colon).trim() : placeholder;
|
||||||
|
}
|
||||||
|
|
||||||
private static String resolveRestEventParameterName(EntryPoint entryPoint, String resolvedEventParamName) {
|
private static String resolveRestEventParameterName(EntryPoint entryPoint, String resolvedEventParamName) {
|
||||||
if (resolvedEventParamName != null && !resolvedEventParamName.isBlank()) {
|
if (resolvedEventParamName != null && !resolvedEventParamName.isBlank()) {
|
||||||
// Only accept identifiers that look like method parameter names, not event FQNs/expressions.
|
// Only accept identifiers that look like method parameter names, not event FQNs/expressions.
|
||||||
@@ -129,8 +159,8 @@ public final class ExternalTriggerPolicy {
|
|||||||
if (entryPoint.getParameters() == null) {
|
if (entryPoint.getParameters() == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// Prefer PathVariable params that look like event carriers over unrelated RequestParams (userId).
|
// Prefer PathVariable/RequestParam params that look like event carriers.
|
||||||
String pathVariableFallback = null;
|
// Do NOT fall back to unrelated resource ids ({id}, orderId, userId).
|
||||||
for (EntryPoint.Parameter parameter : entryPoint.getParameters()) {
|
for (EntryPoint.Parameter parameter : entryPoint.getParameters()) {
|
||||||
if (parameter.getAnnotations() == null) {
|
if (parameter.getAnnotations() == null) {
|
||||||
continue;
|
continue;
|
||||||
@@ -143,11 +173,8 @@ public final class ExternalTriggerPolicy {
|
|||||||
if (isEventLikeParameterName(parameter.getName())) {
|
if (isEventLikeParameterName(parameter.getName())) {
|
||||||
return parameter.getName();
|
return parameter.getName();
|
||||||
}
|
}
|
||||||
if (pathVariable && pathVariableFallback == null) {
|
|
||||||
pathVariableFallback = parameter.getName();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return pathVariableFallback;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean looksLikeParameterName(String value) {
|
private static boolean looksLikeParameterName(String value) {
|
||||||
@@ -171,8 +198,10 @@ public final class ExternalTriggerPolicy {
|
|||||||
|| lower.equals("e")
|
|| lower.equals("e")
|
||||||
|| lower.endsWith("event")
|
|| lower.endsWith("event")
|
||||||
|| lower.equals("command")
|
|| lower.equals("command")
|
||||||
|
|| lower.equals("commandkey")
|
||||||
|| lower.equals("transition")
|
|| lower.equals("transition")
|
||||||
|| lower.equals("action");
|
|| lower.equals("action")
|
||||||
|
|| lower.equals("machinetype");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RestParamBinding findRestParameterBinding(
|
private static RestParamBinding findRestParameterBinding(
|
||||||
|
|||||||
@@ -153,6 +153,88 @@ class TransitionLinkerEnricherTest {
|
|||||||
assertThat(updated.getTriggerPoint().isExternal()).isFalse();
|
assertThat(updated.getTriggerPoint().isExternal()).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldLinkDedicatedEventEndpointEvenWithResourceIdPlaceholder() {
|
||||||
|
Transition assign = new Transition();
|
||||||
|
assign.setSourceStates(List.of(State.of("OPEN", "com.example.TicketState.OPEN")));
|
||||||
|
assign.setTargetStates(List.of(State.of("ASSIGNED", "com.example.TicketState.ASSIGNED")));
|
||||||
|
assign.setEvent(Event.of("ASSIGN", "com.example.TicketEvent.ASSIGN"));
|
||||||
|
|
||||||
|
// /{id}/assign has a path placeholder, but it is a resource id — not an unbound event.
|
||||||
|
CallChain chain = CallChain.builder()
|
||||||
|
.entryPoint(EntryPoint.builder()
|
||||||
|
.type(EntryPoint.Type.REST)
|
||||||
|
.name("POST /api/ticket/{id}/assign")
|
||||||
|
.className("com.example.WebController")
|
||||||
|
.methodName("assignTicket")
|
||||||
|
.parameters(List.of(
|
||||||
|
EntryPoint.Parameter.builder()
|
||||||
|
.name("id")
|
||||||
|
.type("String")
|
||||||
|
.annotations(List.of("PathVariable"))
|
||||||
|
.build(),
|
||||||
|
EntryPoint.Parameter.builder()
|
||||||
|
.name("userId")
|
||||||
|
.type("String")
|
||||||
|
.annotations(List.of("RequestParam"))
|
||||||
|
.build()))
|
||||||
|
.build())
|
||||||
|
.triggerPoint(TriggerPoint.builder()
|
||||||
|
.event("com.example.TicketEvent.ASSIGN")
|
||||||
|
.polymorphicEvents(List.of("com.example.TicketEvent.ASSIGN"))
|
||||||
|
.external(true)
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
AnalysisResult result = AnalysisResult.builder()
|
||||||
|
.name("TicketStateMachineConfig")
|
||||||
|
.eventTypeFqn("com.example.TicketEvent")
|
||||||
|
.transitions(List.of(assign))
|
||||||
|
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
enricher.enrich(result, null, null);
|
||||||
|
|
||||||
|
CallChain updated = result.getMetadata().getCallChains().get(0);
|
||||||
|
assertThat(updated.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
|
||||||
|
assertThat(updated.getMatchedTransitions()).hasSize(1);
|
||||||
|
assertThat(updated.getTriggerPoint().isExternal()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldStillFailClosedOnUnboundEventPathTemplate() {
|
||||||
|
Transition payT = new Transition();
|
||||||
|
payT.setSourceStates(List.of(State.of("NEW", "OrderState.NEW")));
|
||||||
|
payT.setTargetStates(List.of(State.of("PAID", "OrderState.PAID")));
|
||||||
|
payT.setEvent(Event.of("OrderEvent.PAY", "com.example.OrderEvent.PAY"));
|
||||||
|
|
||||||
|
CallChain chain = CallChain.builder()
|
||||||
|
.entryPoint(EntryPoint.builder()
|
||||||
|
.type(EntryPoint.Type.REST)
|
||||||
|
.name("POST /api/machine/{machineType}/transition/{event}")
|
||||||
|
.className("com.example.Api")
|
||||||
|
.methodName("transition")
|
||||||
|
.build())
|
||||||
|
.triggerPoint(TriggerPoint.builder()
|
||||||
|
.event("OrderEvent.valueOf(eventStr)")
|
||||||
|
.external(true)
|
||||||
|
.ambiguous(true)
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
AnalysisResult result = AnalysisResult.builder()
|
||||||
|
.name("OrderStateMachineConfig")
|
||||||
|
.transitions(List.of(payT))
|
||||||
|
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
enricher.enrich(result, null, null);
|
||||||
|
|
||||||
|
CallChain updated = result.getMetadata().getCallChains().get(0);
|
||||||
|
assertThat(updated.getLinkResolution()).isEqualTo(LinkResolution.UNRESOLVED_EXTERNAL);
|
||||||
|
assertThat(updated.getMatchedTransitions()).isNullOrEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldLinkWhenSymbolicPolymorphicEventsExpandToMachineEnumConstants() {
|
void shouldLinkWhenSymbolicPolymorphicEventsExpandToMachineEnumConstants() {
|
||||||
Transition payT = new Transition();
|
Transition payT = new Transition();
|
||||||
|
|||||||
@@ -155,6 +155,35 @@ class ExternalTriggerPolicyTest {
|
|||||||
entryPoint, trigger, "com.example.Api.payOrder", null, null)).isFalse();
|
entryPoint, trigger, "com.example.Api.payOrder", null, null)).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resourceIdPathVariableShouldNotMakeDedicatedEndpointExternal() {
|
||||||
|
EntryPoint entryPoint = EntryPoint.builder()
|
||||||
|
.type(EntryPoint.Type.REST)
|
||||||
|
.name("POST /api/ticket/{id}/assign")
|
||||||
|
.className("com.example.WebController")
|
||||||
|
.methodName("assignTicket")
|
||||||
|
.parameters(List.of(
|
||||||
|
EntryPoint.Parameter.builder()
|
||||||
|
.name("id")
|
||||||
|
.type("String")
|
||||||
|
.annotations(List.of("PathVariable"))
|
||||||
|
.build(),
|
||||||
|
EntryPoint.Parameter.builder()
|
||||||
|
.name("userId")
|
||||||
|
.type("String")
|
||||||
|
.annotations(List.of("RequestParam"))
|
||||||
|
.build()))
|
||||||
|
.build();
|
||||||
|
TriggerPoint trigger = TriggerPoint.builder()
|
||||||
|
.event("com.example.TicketEvent.ASSIGN")
|
||||||
|
.polymorphicEvents(List.of("com.example.TicketEvent.ASSIGN"))
|
||||||
|
.external(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
assertThat(ExternalTriggerPolicy.isExternalFromSource(
|
||||||
|
entryPoint, trigger, "com.example.WebController.assignTicket", null, null)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void concreteEnumLiteralFromDedicatedEndpointShouldBeInternal() {
|
void concreteEnumLiteralFromDedicatedEndpointShouldBeInternal() {
|
||||||
EntryPoint entryPoint = EntryPoint.builder()
|
EntryPoint entryPoint = EntryPoint.builder()
|
||||||
|
|||||||
Reference in New Issue
Block a user