From 24fe8dfe0062a502ec612398f7fde908294d62c2 Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Tue, 14 Jul 2026 06:48:33 +0200 Subject: [PATCH] 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 --- README.md | 8 ++- .../analysis/model/BusinessFlow.java | 4 +- .../analysis/model/FlowStep.java | 29 ++++++++++ .../analysis/model/FlowStepDeserializer.java | 33 +++++++++++ .../analysis/model/FlowStepSerializer.java | 35 ++++++++++++ .../analysis/model/FlowStepJsonTest.java | 46 ++++++++++++++++ .../exporter/JsonInterchangeContractTest.java | 3 +- .../html/exporter/HtmlExporter.java | 55 ++++++++++++++++--- .../src/main/resources/html/template.html | 12 ++-- .../html/HtmlExporterTest.java | 3 +- .../HtmlTransitionLinkConsistencyTest.java | 43 ++++++++++++++- 11 files changed, 252 insertions(+), 19 deletions(-) create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/FlowStep.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/FlowStepDeserializer.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/FlowStepSerializer.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/model/FlowStepJsonTest.java diff --git a/README.md b/README.md index be7c238..ea9415f 100644 --- a/README.md +++ b/README.md @@ -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`). diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/BusinessFlow.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/BusinessFlow.java index 79cec28..5b7331e 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/BusinessFlow.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/BusinessFlow.java @@ -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 steps; // List of Event names in order + private final List steps; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/FlowStep.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/FlowStep.java new file mode 100644 index 0000000..982d8fb --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/FlowStep.java @@ -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(); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/FlowStepDeserializer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/FlowStepDeserializer.java new file mode 100644 index 0000000..042030a --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/FlowStepDeserializer.java @@ -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 { + + @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; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/FlowStepSerializer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/FlowStepSerializer.java new file mode 100644 index 0000000..8a11197 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/FlowStepSerializer.java @@ -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 { + + @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(); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/model/FlowStepJsonTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/model/FlowStepJsonTest.java new file mode 100644 index 0000000..5d01470 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/model/FlowStepJsonTest.java @@ -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\""); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/exporter/JsonInterchangeContractTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/exporter/JsonInterchangeContractTest.java index 52cde64..9581a3d 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/exporter/JsonInterchangeContractTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/exporter/JsonInterchangeContractTest.java @@ -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(); diff --git a/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/exporter/HtmlExporter.java b/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/exporter/HtmlExporter.java index 49ba604..efbf8b1 100644 --- a/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/exporter/HtmlExporter.java +++ b/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/exporter/HtmlExporter.java @@ -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 enrichedChains = metadata.getCallChains().stream() + .map(chain -> enrichChainLinkKeys(chain, options)) + .toList(); + builder.metadata(metadata.toBuilder().callChains(enrichedChains).build()); + changed = true; } - List enrichedChains = metadata.getCallChains().stream() - .map(chain -> enrichChainLinkKeys(chain, options)) - .toList(); - return result.toBuilder() - .metadata(metadata.toBuilder().callChains(enrichedChains).build()) - .build(); + + 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 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"); diff --git a/state_machine_exporter_html/src/main/resources/html/template.html b/state_machine_exporter_html/src/main/resources/html/template.html index 158df4f..b6e1c11 100644 --- a/state_machine_exporter_html/src/main/resources/html/template.html +++ b/state_machine_exporter_html/src/main/resources/html/template.html @@ -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; } diff --git a/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlExporterTest.java b/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlExporterTest.java index f6c0aa1..dd867d6 100644 --- a/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlExporterTest.java +++ b/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlExporterTest.java @@ -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 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() diff --git a/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlTransitionLinkConsistencyTest.java b/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlTransitionLinkConsistencyTest.java index b3c30ad..d1d04e0 100644 --- a/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlTransitionLinkConsistencyTest.java +++ b/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlTransitionLinkConsistencyTest.java @@ -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");