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:
@@ -12,6 +12,10 @@
|
||||
<artifactId>ordo-api</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
|
||||
@@ -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("{"));
|
||||
}
|
||||
}
|
||||
+64
@@ -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;
|
||||
}
|
||||
}
|
||||
+7
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
-1
@@ -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<ApprovalTask> 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)
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user