diff --git a/ordo-api/pom.xml b/ordo-api/pom.xml
index bf9d048..1470c8e 100644
--- a/ordo-api/pom.xml
+++ b/ordo-api/pom.xml
@@ -12,6 +12,10 @@
ordo-api
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
org.junit.jupiter
junit-jupiter
diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessDefinitionParser.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessDefinitionParser.java
new file mode 100644
index 0000000..157b962
--- /dev/null
+++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessDefinitionParser.java
@@ -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 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 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 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 steps,
+ List transitions) {
+ }
+
+ private record StepDocument(String id, String name, List candidates, ApprovalPolicy policy) {
+ }
+
+ private record TransitionDocument(
+ @JsonProperty("from") String from,
+ @JsonProperty("to") String to,
+ @JsonProperty("when") String when,
+ Integer priority) {
+ }
+}
diff --git a/ordo-api/src/test/java/com/jetlumen/ordo/api/ProcessDefinitionParserTest.java b/ordo-api/src/test/java/com/jetlumen/ordo/api/ProcessDefinitionParserTest.java
new file mode 100644
index 0000000..e945498
--- /dev/null
+++ b/ordo-api/src/test/java/com/jetlumen/ordo/api/ProcessDefinitionParserTest.java
@@ -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("{"));
+ }
+}
diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoDefinitionLoader.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoDefinitionLoader.java
new file mode 100644
index 0000000..11a3fda
--- /dev/null
+++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoDefinitionLoader.java
@@ -0,0 +1,64 @@
+package com.jetlumen.ordo.spring;
+
+import com.jetlumen.ordo.api.OrdoEngine;
+import com.jetlumen.ordo.api.ProcessDefinition;
+import com.jetlumen.ordo.api.ProcessDefinitionParser;
+import com.jetlumen.ordo.api.exception.DefinitionAlreadyExistsException;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
+import org.springframework.core.io.support.ResourcePatternResolver;
+
+import java.io.InputStream;
+import java.util.Objects;
+
+/** Loads structural JSON process definitions and registers them if absent. */
+public final class OrdoDefinitionLoader implements ApplicationRunner {
+ private final OrdoEngine engine;
+ private final String location;
+ private final ResourcePatternResolver resolver;
+
+ public OrdoDefinitionLoader(OrdoEngine engine, String location) {
+ this(engine, location, new PathMatchingResourcePatternResolver());
+ }
+
+ OrdoDefinitionLoader(OrdoEngine engine, String location, ResourcePatternResolver resolver) {
+ this.engine = Objects.requireNonNull(engine, "engine must not be null");
+ this.location = location;
+ this.resolver = Objects.requireNonNull(resolver, "resolver must not be null");
+ }
+
+ @Override
+ public void run(ApplicationArguments args) throws Exception {
+ load();
+ }
+
+ public void load() throws Exception {
+ if (location == null || location.isBlank()) {
+ return;
+ }
+ Resource[] resources = resolver.getResources(location);
+ for (Resource resource : resources) {
+ if (!resource.exists() || !resource.isReadable()) {
+ continue;
+ }
+ ProcessDefinition definition;
+ try (InputStream in = resource.getInputStream()) {
+ definition = ProcessDefinitionParser.fromJson(in);
+ } catch (RuntimeException e) {
+ throw new IllegalStateException("failed to parse ordo definition from " + describe(resource), e);
+ }
+ try {
+ engine.register(definition);
+ } catch (DefinitionAlreadyExistsException ignored) {
+ // classpath JSON is an import source; existing ids stay as stored
+ }
+ }
+ }
+
+ private static String describe(Resource resource) {
+ String filename = resource.getFilename();
+ return filename == null ? resource.toString() : filename;
+ }
+}
diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfiguration.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfiguration.java
index 9150330..223934f 100644
--- a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfiguration.java
+++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfiguration.java
@@ -108,4 +108,11 @@ public class OrdoJdbcAutoConfiguration {
return new DefaultOrdoEngine(ordoClock, ordoAssigneeResolver, ordoRoutingCondition, ordoTransactionExecutor,
ordoProcessDefinitionRepository, ordoProcessInstanceRepository, ordoApprovalTaskRepository);
}
+
+ @Bean
+ @ConditionalOnMissingBean
+ @DependsOnDatabaseInitialization
+ public OrdoDefinitionLoader ordoDefinitionLoader(OrdoEngine ordoEngine, OrdoProperties ordoProperties) {
+ return new OrdoDefinitionLoader(ordoEngine, ordoProperties.getDefinitions().getLocation());
+ }
}
diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoProperties.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoProperties.java
index b7d353d..3951ded 100644
--- a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoProperties.java
+++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoProperties.java
@@ -9,6 +9,8 @@ public class OrdoProperties {
/** Whether the Ordo JDBC autoconfiguration should be applied. */
private boolean enabled = true;
+ private final Definitions definitions = new Definitions();
+
public boolean isEnabled() {
return enabled;
}
@@ -16,4 +18,20 @@ public class OrdoProperties {
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
+
+ public Definitions getDefinitions() {
+ return definitions;
+ }
+
+ public static class Definitions {
+ private String location = "classpath*:ordo/*.json";
+
+ public String getLocation() {
+ return location;
+ }
+
+ public void setLocation(String location) {
+ this.location = location;
+ }
+ }
}
diff --git a/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfigurationTest.java b/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfigurationTest.java
index f95f8e5..307b78a 100644
--- a/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfigurationTest.java
+++ b/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfigurationTest.java
@@ -7,6 +7,7 @@ import com.jetlumen.ordo.api.OrdoEngine;
import com.jetlumen.ordo.api.ProcessDefinition;
import com.jetlumen.ordo.api.ProcessInstance;
import com.jetlumen.ordo.api.RoutingCondition;
+import com.jetlumen.ordo.api.repository.ProcessDefinitionRepository;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
@@ -47,7 +48,7 @@ class OrdoJdbcAutoConfigurationTest {
List pending = engine.findPendingTasksByAssignee("maria");
assertThat(pending).hasSize(1);
- ApprovalTask approved = engine.approve(pending.get(0).id(), "maria", "looks good");
+ ApprovalTask approved = engine.approve(pending.getFirst().id(), "maria", "looks good");
assertThat(approved.status().name()).isEqualTo("APPROVED");
assertThat(engine.findInstance(instance.id())).isPresent();
});
@@ -77,6 +78,22 @@ class OrdoJdbcAutoConfigurationTest {
});
}
+ @Test
+ void loadsClasspathJsonDefinitionsIdempotently() {
+ withDataSourceRunner.run(context -> {
+ OrdoDefinitionLoader loader = context.getBean(OrdoDefinitionLoader.class);
+ loader.load();
+ loader.load();
+
+ ProcessDefinition definition = context.getBean(ProcessDefinitionRepository.class)
+ .findById("leave-request-routed")
+ .orElseThrow();
+ assertThat(definition.name()).isEqualTo("Leave request");
+ assertThat(definition.steps()).hasSize(2);
+ assertThat(definition.transitions()).hasSize(3);
+ });
+ }
+
@Test
void honoursUserDefinedRoutingCondition() {
withDataSourceRunner.withUserConfiguration(CustomRoutingConditionConfig.class)
diff --git a/ordo-spring-boot-autoconfigure/src/test/resources/ordo/leave-request-routed.json b/ordo-spring-boot-autoconfigure/src/test/resources/ordo/leave-request-routed.json
new file mode 100644
index 0000000..698d806
--- /dev/null
+++ b/ordo-spring-boot-autoconfigure/src/test/resources/ordo/leave-request-routed.json
@@ -0,0 +1,13 @@
+{
+ "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 }
+ ]
+}