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