Support structured source/event steps in business flows
Add FlowStep with backward-compatible JSON (strings or objects), precompute linkKey in HTML export, and document precise flow highlighting in flows.json. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -56,11 +56,17 @@ Define sequence of events in `src/main/resources/flows.json`:
|
||||
{
|
||||
"name": "Order Success",
|
||||
"description": "Happy path for order placement",
|
||||
"steps": ["PAY", "CHECK_AVAILABILITY->PENDING", "SHIP"]
|
||||
"steps": [
|
||||
"PAY",
|
||||
"CHECK_AVAILABILITY->PENDING",
|
||||
{ "source": "com.example.order.OrderState.PAID", "event": "com.example.order.OrderEvent.SHIP" }
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Event-only strings remain supported but do not highlight in the HTML explorer unless paired with a source state. Use `{source, event}` objects for precise transition highlighting.
|
||||
|
||||
## JSON Structure
|
||||
- `metadata.entryPoints`: REST, WebFlux, and JMS entry points.
|
||||
- `metadata.callChains`: Trace from API call to machine trigger (`sendEvent`).
|
||||
|
||||
@@ -8,11 +8,11 @@ import lombok.extern.jackson.Jacksonized;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@Builder(toBuilder = true)
|
||||
@Jacksonized
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class BusinessFlow {
|
||||
private final String name;
|
||||
private final String description;
|
||||
private final List<String> steps; // List of Event names in order
|
||||
private final List<FlowStep> steps;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Builder(toBuilder = true)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@JsonDeserialize(using = FlowStepDeserializer.class)
|
||||
@JsonSerialize(using = FlowStepSerializer.class)
|
||||
public class FlowStep {
|
||||
/** Source state identifier (package-canonical FQN or short form). */
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private final String source;
|
||||
/** Event identifier or {@code Source->Target} for anonymous transitions. */
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private final String event;
|
||||
/** Precomputed {@code #link_*} suffix for HTML/SVG highlight. */
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private final String linkKey;
|
||||
|
||||
public static FlowStep ofEvent(String event) {
|
||||
return FlowStep.builder().event(event).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
class FlowStepDeserializer extends JsonDeserializer<FlowStep> {
|
||||
|
||||
@Override
|
||||
public FlowStep deserialize(JsonParser parser, DeserializationContext context) throws IOException {
|
||||
JsonNode node = parser.getCodec().readTree(parser);
|
||||
if (node.isTextual()) {
|
||||
return FlowStep.builder().event(node.asText()).build();
|
||||
}
|
||||
return FlowStep.builder()
|
||||
.source(textOrNull(node, "source"))
|
||||
.event(textOrNull(node, "event"))
|
||||
.linkKey(textOrNull(node, "linkKey"))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static String textOrNull(JsonNode node, String field) {
|
||||
JsonNode value = node.get(field);
|
||||
if (value == null || value.isNull()) {
|
||||
return null;
|
||||
}
|
||||
String text = value.asText();
|
||||
return text.isBlank() ? null : text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
class FlowStepSerializer extends JsonSerializer<FlowStep> {
|
||||
|
||||
@Override
|
||||
public void serialize(FlowStep step, JsonGenerator generator, SerializerProvider serializers) throws IOException {
|
||||
if (step == null) {
|
||||
generator.writeNull();
|
||||
return;
|
||||
}
|
||||
boolean structured = step.getSource() != null && !step.getSource().isBlank()
|
||||
|| step.getLinkKey() != null && !step.getLinkKey().isBlank();
|
||||
if (!structured && step.getEvent() != null && !step.getEvent().isBlank()) {
|
||||
generator.writeString(step.getEvent());
|
||||
return;
|
||||
}
|
||||
generator.writeStartObject();
|
||||
if (step.getSource() != null && !step.getSource().isBlank()) {
|
||||
generator.writeStringField("source", step.getSource());
|
||||
}
|
||||
if (step.getEvent() != null && !step.getEvent().isBlank()) {
|
||||
generator.writeStringField("event", step.getEvent());
|
||||
}
|
||||
if (step.getLinkKey() != null && !step.getLinkKey().isBlank()) {
|
||||
generator.writeStringField("linkKey", step.getLinkKey());
|
||||
}
|
||||
generator.writeEndObject();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FlowStepJsonTest {
|
||||
|
||||
private final com.fasterxml.jackson.databind.ObjectMapper mapper =
|
||||
new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
|
||||
@Test
|
||||
void shouldDeserializeLegacyStringStep() throws Exception {
|
||||
FlowStep step = mapper.readValue("\"PAY\"", FlowStep.class);
|
||||
assertThat(step.getEvent()).isEqualTo("PAY");
|
||||
assertThat(step.getSource()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDeserializeStructuredStep() throws Exception {
|
||||
FlowStep step = mapper.readValue(
|
||||
"{\"source\":\"com.example.order.OrderState.PAID\",\"event\":\"com.example.order.OrderEvent.SHIP\"}",
|
||||
FlowStep.class);
|
||||
assertThat(step.getSource()).isEqualTo("com.example.order.OrderState.PAID");
|
||||
assertThat(step.getEvent()).isEqualTo("com.example.order.OrderEvent.SHIP");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSerializeEventOnlyStepAsString() throws Exception {
|
||||
String json = mapper.writeValueAsString(FlowStep.ofEvent("PAY"));
|
||||
assertThat(json).isEqualTo("\"PAY\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSerializeStructuredStepAsObject() throws Exception {
|
||||
FlowStep step = FlowStep.builder()
|
||||
.source("com.example.order.OrderState.PAID")
|
||||
.event("com.example.order.OrderEvent.SHIP")
|
||||
.linkKey("OrderState_PAID__OrderEvent_SHIP")
|
||||
.build();
|
||||
String json = mapper.writeValueAsString(step);
|
||||
assertThat(json).contains("\"source\"");
|
||||
assertThat(json).contains("\"event\"");
|
||||
assertThat(json).contains("\"linkKey\"");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package click.kamil.springstatemachineexporter.exporter;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.BusinessFlow;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.FlowStep;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
||||
import click.kamil.springstatemachineexporter.model.Event;
|
||||
import click.kamil.springstatemachineexporter.model.State;
|
||||
@@ -35,7 +36,7 @@ class JsonInterchangeContractTest {
|
||||
.startStates(Set.of("com.example.order.OrderState.NEW"))
|
||||
.endStates(Set.of("com.example.order.OrderState.PAID"))
|
||||
.renderChoicesAsDiamonds(true)
|
||||
.flows(List.of(BusinessFlow.builder().name("Pay flow").steps(List.of("PAY")).build()))
|
||||
.flows(List.of(BusinessFlow.builder().name("Pay flow").steps(List.of(FlowStep.ofEvent("PAY"))).build()))
|
||||
.metadata(CodebaseMetadata.empty())
|
||||
.build();
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package click.kamil.springstatemachineexporter.html.exporter;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.BusinessFlow;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.FlowStep;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
@@ -152,16 +154,26 @@ public class HtmlExporter implements StateMachineExporter {
|
||||
}
|
||||
|
||||
private static AnalysisResult enrichLinkKeys(AnalysisResult result, ExportOptions options) {
|
||||
AnalysisResult.AnalysisResultBuilder builder = result.toBuilder();
|
||||
boolean changed = false;
|
||||
|
||||
CodebaseMetadata metadata = result.getMetadata();
|
||||
if (metadata == null || metadata.getCallChains() == null || metadata.getCallChains().isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
if (metadata != null && metadata.getCallChains() != null && !metadata.getCallChains().isEmpty()) {
|
||||
List<CallChain> enrichedChains = metadata.getCallChains().stream()
|
||||
.map(chain -> enrichChainLinkKeys(chain, options))
|
||||
.toList();
|
||||
return result.toBuilder()
|
||||
.metadata(metadata.toBuilder().callChains(enrichedChains).build())
|
||||
.build();
|
||||
builder.metadata(metadata.toBuilder().callChains(enrichedChains).build());
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (result.getFlows() != null && !result.getFlows().isEmpty()) {
|
||||
builder.flows(result.getFlows().stream()
|
||||
.map(flow -> enrichFlowLinkKeys(flow, options))
|
||||
.toList());
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed ? builder.build() : result;
|
||||
}
|
||||
|
||||
private static CallChain enrichChainLinkKeys(CallChain chain, ExportOptions options) {
|
||||
@@ -186,6 +198,33 @@ public class HtmlExporter implements StateMachineExporter {
|
||||
return mt.toBuilder().linkKey(linkKey).build();
|
||||
}
|
||||
|
||||
private static BusinessFlow enrichFlowLinkKeys(BusinessFlow flow, ExportOptions options) {
|
||||
if (flow.getSteps() == null || flow.getSteps().isEmpty()) {
|
||||
return flow;
|
||||
}
|
||||
List<FlowStep> enrichedSteps = flow.getSteps().stream()
|
||||
.map(step -> enrichFlowStepLinkKey(step, options))
|
||||
.toList();
|
||||
return flow.toBuilder().steps(enrichedSteps).build();
|
||||
}
|
||||
|
||||
private static FlowStep enrichFlowStepLinkKey(FlowStep step, ExportOptions options) {
|
||||
if (step.getLinkKey() != null && !step.getLinkKey().isBlank()) {
|
||||
return step;
|
||||
}
|
||||
if (step.getSource() == null || step.getSource().isBlank()
|
||||
|| step.getEvent() == null || step.getEvent().isBlank()
|
||||
|| step.getEvent().contains("->")) {
|
||||
return step;
|
||||
}
|
||||
String linkKey = TransitionLinkKey.build(
|
||||
step.getSource(),
|
||||
step.getEvent(),
|
||||
options.getStateFormat(),
|
||||
options.getEventFormat());
|
||||
return step.toBuilder().linkKey(linkKey).build();
|
||||
}
|
||||
|
||||
private String loadTemplate() {
|
||||
try (InputStream is = getClass().getResourceAsStream("/html/template.html")) {
|
||||
if (is == null) throw new RuntimeException("HTML template not found");
|
||||
|
||||
@@ -531,10 +531,14 @@
|
||||
if (typeof step === 'object') {
|
||||
targetLinkKey = step.linkKey || buildLinkKey(step.source, step.event);
|
||||
isAmbiguous = step.ambiguous;
|
||||
} else if (step.includes("->")) {
|
||||
const parts = step.split("->");
|
||||
targetAnon = "#link_anon_" + normalize(parts[0]) + "_" + normalize(parts[1]);
|
||||
} else {
|
||||
if (!targetLinkKey && step.event && step.event.includes('->')) {
|
||||
const parts = step.event.split('->');
|
||||
targetAnon = '#link_anon_' + normalize(parts[0]) + '_' + normalize(parts[1]);
|
||||
}
|
||||
} else if (typeof step === 'string' && step.includes('->')) {
|
||||
const parts = step.split('->');
|
||||
targetAnon = '#link_anon_' + normalize(parts[0]) + '_' + normalize(parts[1]);
|
||||
} else if (typeof step === 'string') {
|
||||
// Event-only string steps without source are not precise enough to highlight.
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import click.kamil.springstatemachineexporter.analysis.enricher.PropertyEnricher
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.BusinessFlow;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.FlowStep;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
|
||||
@@ -49,7 +50,7 @@ public class HtmlExporterTest {
|
||||
|
||||
// 3. Documented Flows
|
||||
List<BusinessFlow> flows = List.of(
|
||||
BusinessFlow.builder().name("Test Flow").description("Desc").steps(List.of("CREATE")).build()
|
||||
BusinessFlow.builder().name("Test Flow").description("Desc").steps(List.of(FlowStep.ofEvent("CREATE"))).build()
|
||||
);
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package click.kamil.springstatemachineexporter.html;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.BusinessFlow;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.FlowStep;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
@@ -134,10 +136,10 @@ class HtmlTransitionLinkConsistencyTest {
|
||||
"com.example.order.OrderState.SHIPPED")))
|
||||
.startStates(Set.of("com.example.order.OrderState.NEW"))
|
||||
.endStates(Set.of("com.example.order.OrderState.SHIPPED"))
|
||||
.flows(List.of(click.kamil.springstatemachineexporter.analysis.model.BusinessFlow.builder()
|
||||
.flows(List.of(BusinessFlow.builder()
|
||||
.name("Ship only")
|
||||
.description("Should not light PAY")
|
||||
.steps(List.of("SHIP"))
|
||||
.steps(List.of(FlowStep.ofEvent("SHIP")))
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
@@ -147,6 +149,43 @@ class HtmlTransitionLinkConsistencyTest {
|
||||
assertThat(html).doesNotContain("match that event from any source");
|
||||
}
|
||||
|
||||
@Test
|
||||
void flowHighlightShouldUseStructuredSourceAndEventSteps(@TempDir Path tempDir) throws Exception {
|
||||
writeSampleProject(tempDir);
|
||||
|
||||
AnalysisResult finalizedShape = AnalysisResult.builder()
|
||||
.name("com.example.config.OrderStateMachineConfiguration")
|
||||
.stateTypeFqn("com.example.order.OrderState")
|
||||
.eventTypeFqn("com.example.order.OrderEvent")
|
||||
.transitions(List.of(
|
||||
shortTransition(
|
||||
"com.example.order.OrderEvent.PAY",
|
||||
"com.example.order.OrderState.NEW",
|
||||
"com.example.order.OrderState.PAID"),
|
||||
shortTransition(
|
||||
"com.example.order.OrderEvent.SHIP",
|
||||
"com.example.order.OrderState.PAID",
|
||||
"com.example.order.OrderState.SHIPPED")))
|
||||
.startStates(Set.of("com.example.order.OrderState.NEW"))
|
||||
.endStates(Set.of("com.example.order.OrderState.SHIPPED"))
|
||||
.flows(List.of(BusinessFlow.builder()
|
||||
.name("Ship from paid")
|
||||
.description("Highlights SHIP only")
|
||||
.steps(List.of(FlowStep.builder()
|
||||
.source("com.example.order.OrderState.PAID")
|
||||
.event("com.example.order.OrderEvent.SHIP")
|
||||
.build()))
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
String html = new HtmlExporter().export(finalizedShape, ExportOptions.builder().build());
|
||||
|
||||
assertThat(html).contains("#link_OrderState_PAID__OrderEvent_SHIP");
|
||||
assertThat(html).contains("\"linkKey\" : \"OrderState_PAID__OrderEvent_SHIP\"");
|
||||
assertThat(html).contains("\"source\" : \"com.example.order.OrderState.PAID\"");
|
||||
assertThat(html).contains("\"event\" : \"com.example.order.OrderEvent.SHIP\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void layeredDispatcherHtmlShouldContainLinkKeysForEveryMatchedTransition(@TempDir Path tempDir) throws Exception {
|
||||
Path sampleRoot = findProjectRoot().resolve("state_machines/layered_dispatcher_sample");
|
||||
|
||||
Reference in New Issue
Block a user