feat: load process definitions from classpath JSON

Map the existing graph model from files so hosts can register flows without Java literals. Existing ids still insert-only on restart.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
0264408
2026-09-10 19:48:25 +08:00
co-authored by Cursor
parent 58ec867eab
commit a6410b3dc4
8 changed files with 382 additions and 1 deletions
@@ -0,0 +1,102 @@
package com.jetlumen.ordo.api;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/** Parses a structural JSON process graph into a {@link ProcessDefinition}. */
public final class ProcessDefinitionParser {
private static final ObjectMapper MAPPER = new ObjectMapper();
private ProcessDefinitionParser() {
}
public static ProcessDefinition fromJson(String json) {
Objects.requireNonNull(json, "json must not be null");
try {
return toDefinition(MAPPER.readValue(json, DefinitionDocument.class));
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("invalid process definition json", e);
}
}
public static ProcessDefinition fromJson(InputStream json) {
Objects.requireNonNull(json, "json must not be null");
try {
return toDefinition(MAPPER.readValue(json, DefinitionDocument.class));
} catch (IOException e) {
throw new IllegalArgumentException("invalid process definition json", e);
}
}
private static ProcessDefinition toDefinition(DefinitionDocument document) {
if (document.steps() == null || document.steps().isEmpty()) {
throw new IllegalArgumentException("a definition must contain at least one approval step");
}
List<ApprovalStep> steps = new ArrayList<>(document.steps().size());
for (StepDocument step : document.steps()) {
if (step == null) {
throw new IllegalArgumentException("step must not be null");
}
ApprovalPolicy policy = step.policy() == null ? ApprovalPolicy.ANY : step.policy();
steps.add(new ApprovalStep(step.id(), step.name(), step.candidates(), policy));
}
rotateStartStep(steps, document.startStep());
List<StepTransition> transitions = new ArrayList<>();
if (document.transitions() != null) {
for (TransitionDocument transition : document.transitions()) {
if (transition == null) {
throw new IllegalArgumentException("transition must not be null");
}
int priority = transition.priority() == null ? 0 : transition.priority();
transitions.add(new StepTransition(transition.from(), transition.to(), transition.when(), priority));
}
}
return new ProcessDefinition(document.id(), document.name(), steps, transitions);
}
private static void rotateStartStep(List<ApprovalStep> steps, String startStepId) {
if (startStepId == null || startStepId.isBlank()) {
return;
}
int index = -1;
for (int i = 0; i < steps.size(); i++) {
if (startStepId.equals(steps.get(i).id())) {
index = i;
break;
}
}
if (index < 0) {
throw new IllegalArgumentException("unknown startStep: " + startStepId);
}
if (index == 0) {
return;
}
ApprovalStep start = steps.remove(index);
steps.add(0, start);
}
private record DefinitionDocument(
String id,
String name,
String startStep,
List<StepDocument> steps,
List<TransitionDocument> transitions) {
}
private record StepDocument(String id, String name, List<String> candidates, ApprovalPolicy policy) {
}
private record TransitionDocument(
@JsonProperty("from") String from,
@JsonProperty("to") String to,
@JsonProperty("when") String when,
Integer priority) {
}
}
@@ -0,0 +1,156 @@
package com.jetlumen.ordo.api;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class ProcessDefinitionParserTest {
private static final String LEAVE_REQUEST_JSON = """
{
"id": "leave-request-routed",
"name": "Leave request",
"steps": [
{ "id": "manager", "name": "Manager approval", "candidates": ["maria"], "policy": "ANY" },
{ "id": "hr", "name": "HR approval", "candidates": ["henry"], "policy": "ANY" }
],
"transitions": [
{ "from": "manager", "to": "hr", "when": "days-gt-3", "priority": 0 },
{ "from": "manager", "to": null, "priority": 1 },
{ "from": "hr", "to": null }
]
}
""";
@Test
void parsesLeaveRequestGraph() {
ProcessDefinition expected = new ProcessDefinition("leave-request-routed", "Leave request", List.of(
ApprovalStep.single("manager", "Manager approval", "maria"),
ApprovalStep.single("hr", "HR approval", "henry")
), List.of(
StepTransition.when("manager", "hr", "days-gt-3", 0),
new StepTransition("manager", null, null, 1),
StepTransition.end("hr")
));
assertEquals(expected, ProcessDefinitionParser.fromJson(LEAVE_REQUEST_JSON));
}
@Test
void rotatesStartStepToTheFront() {
String json = """
{
"id": "leave-request-routed",
"name": "Leave request",
"startStep": "manager",
"steps": [
{ "id": "hr", "name": "HR approval", "candidates": ["henry"] },
{ "id": "manager", "name": "Manager approval", "candidates": ["maria"] }
],
"transitions": [
{ "from": "manager", "to": "hr", "when": "days-gt-3", "priority": 0 },
{ "from": "manager", "to": null, "priority": 1 },
{ "from": "hr", "to": null }
]
}
""";
ProcessDefinition definition = ProcessDefinitionParser.fromJson(json);
assertEquals("manager", definition.steps().getFirst().id());
assertEquals("hr", definition.steps().get(1).id());
}
@Test
void rejectsUnknownStartStep() {
String json = """
{
"id": "leave",
"name": "Leave request",
"startStep": "missing",
"steps": [
{ "id": "manager", "name": "Manager approval", "candidates": ["maria"] }
],
"transitions": [
{ "from": "manager", "to": null }
]
}
""";
assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson(json));
}
@Test
void rejectsMissingOutgoingTransition() {
String json = """
{
"id": "leave",
"name": "Leave request",
"steps": [
{ "id": "manager", "name": "Manager approval", "candidates": ["maria"] },
{ "id": "hr", "name": "HR approval", "candidates": ["henry"] }
],
"transitions": [
{ "from": "manager", "to": null }
]
}
""";
assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson(json));
}
@Test
void rejectsUnknownFromAndTo() {
String unknownFrom = """
{
"id": "leave",
"name": "Leave request",
"steps": [
{ "id": "manager", "name": "Manager approval", "candidates": ["maria"] }
],
"transitions": [
{ "from": "missing", "to": null }
]
}
""";
String unknownTo = """
{
"id": "leave",
"name": "Leave request",
"steps": [
{ "id": "manager", "name": "Manager approval", "candidates": ["maria"] }
],
"transitions": [
{ "from": "manager", "to": "missing" }
]
}
""";
assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson(unknownFrom));
assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson(unknownTo));
}
@Test
void rejectsDuplicatePriorityForTheSameFrom() {
String json = """
{
"id": "leave",
"name": "Leave request",
"steps": [
{ "id": "manager", "name": "Manager approval", "candidates": ["maria"] },
{ "id": "hr", "name": "HR approval", "candidates": ["henry"] }
],
"transitions": [
{ "from": "manager", "to": "hr", "when": "high", "priority": 1 },
{ "from": "manager", "to": null, "when": "low", "priority": 1 },
{ "from": "hr", "to": null }
]
}
""";
assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson(json));
}
@Test
void rejectsMalformedJson() {
assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson("{"));
}
}