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:
2026-07-14 06:48:33 +02:00
parent 00d1f24709
commit 24fe8dfe00
11 changed files with 252 additions and 19 deletions

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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\"");
}
}

View File

@@ -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();