feat: dispatch NamedAction and NamedCondition by key

Hosts register one bean per action/condition; Spring wires the dispatcher when no facade is defined, and REST catalog projects from those beans.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
0264408
2026-09-20 11:07:57 +08:00
co-authored by Cursor
parent e4e2c6ed9b
commit d743939964
16 changed files with 570 additions and 26 deletions
@@ -2,6 +2,8 @@ package com.jetlumen.ordo.spring.jdbc;
import com.jetlumen.ordo.api.ActionHandler;
import com.jetlumen.ordo.api.AssigneeResolver;
import com.jetlumen.ordo.api.NamedAction;
import com.jetlumen.ordo.api.NamedCondition;
import com.jetlumen.ordo.api.OrdoEngine;
import com.jetlumen.ordo.api.OrdoEventListener;
import com.jetlumen.ordo.api.RoutingCondition;
@@ -13,6 +15,8 @@ import com.jetlumen.ordo.api.repository.ProcessDefinitionRepository;
import com.jetlumen.ordo.api.repository.ProcessHistoryRepository;
import com.jetlumen.ordo.api.repository.ProcessInstanceRepository;
import com.jetlumen.ordo.core.DefaultOrdoEngine;
import com.jetlumen.ordo.core.spi.DispatchingActionHandler;
import com.jetlumen.ordo.core.spi.DispatchingRoutingCondition;
import com.jetlumen.ordo.spring.OrdoProperties;
import com.jetlumen.ordo.storage.jdbc.JdbcActionExecutionRepository;
import com.jetlumen.ordo.storage.jdbc.JdbcApprovalTaskRepository;
@@ -37,6 +41,7 @@ import org.springframework.context.annotation.Bean;
import javax.sql.DataSource;
import java.time.Clock;
import java.util.List;
/**
* Autoconfigures a JDBC-backed {@link OrdoEngine} when a {@link DataSource} bean is
@@ -72,14 +77,16 @@ public class OrdoJdbcAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public RoutingCondition ordoRoutingCondition() {
return RoutingCondition.always();
public RoutingCondition ordoRoutingCondition(ObjectProvider<NamedCondition> namedConditions) {
List<NamedCondition> conditions = namedConditions.orderedStream().toList();
return conditions.isEmpty() ? RoutingCondition.always() : DispatchingRoutingCondition.of(conditions);
}
@Bean
@ConditionalOnMissingBean
public ActionHandler ordoActionHandler() {
return ActionHandler.noop();
public ActionHandler ordoActionHandler(ObjectProvider<NamedAction> namedActions) {
List<NamedAction> actions = namedActions.orderedStream().toList();
return actions.isEmpty() ? ActionHandler.noop() : DispatchingActionHandler.of(actions);
}
@Bean
@@ -1,9 +1,13 @@
package com.jetlumen.ordo.spring.rest;
import com.jetlumen.ordo.api.NamedAction;
import com.jetlumen.ordo.api.NamedCondition;
import com.jetlumen.ordo.api.OrdoCatalog;
import com.jetlumen.ordo.api.OrdoEngine;
import com.jetlumen.ordo.core.spi.RegistryOrdoCatalog;
import com.jetlumen.ordo.spring.OrdoProperties;
import com.jetlumen.ordo.spring.jdbc.OrdoJdbcAutoConfiguration;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
@@ -15,6 +19,8 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.web.servlet.DispatcherServlet;
import java.util.List;
@AutoConfiguration
@ConditionalOnClass(DispatcherServlet.class)
@ConditionalOnProperty(prefix = "ordo.rest", name = "enabled", havingValue = "true")
@@ -33,7 +39,13 @@ public class OrdoRestAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public OrdoCatalog ordoCatalog() {
return OrdoCatalog.empty();
public OrdoCatalog ordoCatalog(ObjectProvider<NamedAction> namedActions,
ObjectProvider<NamedCondition> namedConditions) {
List<NamedAction> actions = namedActions.orderedStream().toList();
List<NamedCondition> conditions = namedConditions.orderedStream().toList();
if (actions.isEmpty() && conditions.isEmpty()) {
return OrdoCatalog.empty();
}
return new RegistryOrdoCatalog(conditions, actions);
}
}
@@ -4,15 +4,21 @@ import com.jetlumen.ordo.api.ActionHandler;
import com.jetlumen.ordo.api.ApprovalStep;
import com.jetlumen.ordo.api.ApprovalTask;
import com.jetlumen.ordo.api.AssigneeResolver;
import com.jetlumen.ordo.api.NamedAction;
import com.jetlumen.ordo.api.NamedCondition;
import com.jetlumen.ordo.api.OrdoEngine;
import com.jetlumen.ordo.api.OrdoEventListener;
import com.jetlumen.ordo.api.ProcessDefinition;
import com.jetlumen.ordo.api.ProcessEvent;
import com.jetlumen.ordo.api.ProcessEventType;
import com.jetlumen.ordo.api.ProcessInstance;
import com.jetlumen.ordo.api.ProcessRuntime;
import com.jetlumen.ordo.api.RoutingCondition;
import com.jetlumen.ordo.api.StepTransition;
import com.jetlumen.ordo.api.query.PageRequest;
import com.jetlumen.ordo.api.repository.ProcessDefinitionRepository;
import com.jetlumen.ordo.core.spi.DispatchingActionHandler;
import com.jetlumen.ordo.core.spi.DispatchingRoutingCondition;
import com.jetlumen.ordo.storage.jdbc.dialect.SqlDialect;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
@@ -23,6 +29,7 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@@ -168,6 +175,54 @@ class OrdoJdbcAutoConfigurationTest {
});
}
@Test
void keepsNoopAndAlwaysWhenNoNamedHandlers() {
withDataSourceRunner.run(context -> {
context.getBean(ActionHandler.class).execute("any", null);
assertThat(context.getBean(RoutingCondition.class).matches("any", Map.of(), null)).isTrue();
});
}
@Test
void assemblesDispatchingActionHandlerFromNamedActions() {
withDataSourceRunner.withUserConfiguration(NamedActionConfig.class)
.run(context -> {
assertThat(context.getBean(ActionHandler.class)).isInstanceOf(DispatchingActionHandler.class);
OrdoEngine engine = context.getBean(OrdoEngine.class);
engine.publish(new ProcessDefinition("leave", "Leave",
List.of(ApprovalStep.action("notify", "Notify", "leave-mail")),
List.of(StepTransition.end("notify"))));
engine.start("leave", "alice");
assertThat(context.getBean(RecordingNamedAction.class).executed).containsExactly("leave-mail");
});
}
@Test
void honoursUserDefinedActionHandlerOverNamedActions() {
withDataSourceRunner.withUserConfiguration(CustomActionHandlerConfig.class, NamedActionConfig.class)
.run(context -> assertThatThrownBy(() -> context.getBean(ActionHandler.class).execute("any", null))
.isInstanceOf(IllegalStateException.class)
.hasMessage("custom-action"));
}
@Test
void assemblesDispatchingRoutingConditionFromNamedConditions() {
withDataSourceRunner.withUserConfiguration(NamedConditionConfig.class)
.run(context -> {
RoutingCondition routing = context.getBean(RoutingCondition.class);
assertThat(routing).isInstanceOf(DispatchingRoutingCondition.class);
assertThat(routing.matches("amountGt", Map.of(), null)).isTrue();
assertThat(routing.matches("other", Map.of(), null)).isFalse();
});
}
@Test
void honoursUserDefinedRoutingConditionOverNamedConditions() {
withDataSourceRunner.withUserConfiguration(CustomRoutingConditionConfig.class, NamedConditionConfig.class)
.run(context -> assertThat(context.getBean(RoutingCondition.class)
.matches("other", Map.of(), null)).isTrue());
}
@Test
void invokesOrdoEventListenerBeans() {
withDataSourceRunner.withUserConfiguration(RecordingListenerConfig.class)
@@ -224,6 +279,46 @@ class OrdoJdbcAutoConfigurationTest {
}
}
@Configuration
static class NamedActionConfig {
@Bean
RecordingNamedAction leaveMailAction() {
return new RecordingNamedAction();
}
}
static class RecordingNamedAction implements NamedAction {
final List<String> executed = new java.util.concurrent.CopyOnWriteArrayList<>();
@Override
public String key() {
return "leave-mail";
}
@Override
public void execute(ProcessRuntime runtime) {
executed.add(key());
}
}
@Configuration
static class NamedConditionConfig {
@Bean
NamedCondition amountGt() {
return new NamedCondition() {
@Override
public String key() {
return "amountGt";
}
@Override
public boolean matches(Map<String, Object> args, ProcessRuntime runtime) {
return true;
}
};
}
}
@Configuration
static class RecordingListenerConfig {
@Bean
@@ -1,6 +1,10 @@
package com.jetlumen.ordo.spring.rest;
import com.jetlumen.ordo.api.NamedAction;
import com.jetlumen.ordo.api.NamedCondition;
import com.jetlumen.ordo.api.OrdoCatalog;
import com.jetlumen.ordo.api.OrdoEngine;
import com.jetlumen.ordo.api.ProcessRuntime;
import com.jetlumen.ordo.spring.jdbc.OrdoFlywayAutoConfiguration;
import com.jetlumen.ordo.spring.jdbc.OrdoJdbcAutoConfiguration;
import org.junit.jupiter.api.Test;
@@ -12,11 +16,14 @@ import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration;
import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.Map;
import java.util.UUID;
import com.jayway.jsonpath.JsonPath;
@@ -76,6 +83,37 @@ class OrdoRestAutoConfigurationTest {
.hasSingleBean(OrdoCatalogController.class));
}
@Test
void projectsCatalogFromNamedHandlers() {
runner.withPropertyValues("ordo.rest.enabled=true")
.withUserConfiguration(NamedCatalogConfig.class)
.run(context -> {
MockMvc mvc = MockMvcBuilders.webAppContextSetup(context).build();
mvc.perform(get("/ordo/catalog/actions"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].key").value("leave-mail"))
.andExpect(jsonPath("$[0].label").value("Notify HR"));
mvc.perform(get("/ordo/catalog/conditions"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].key").value("amountGt"));
mvc.perform(get("/ordo/catalog/assignees"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isEmpty());
});
}
@Test
void honoursUserDefinedCatalog() {
runner.withPropertyValues("ordo.rest.enabled=true")
.withUserConfiguration(NamedCatalogConfig.class, CustomCatalogConfig.class)
.run(context -> {
MockMvc mvc = MockMvcBuilders.webAppContextSetup(context).build();
mvc.perform(get("/ordo/catalog/actions"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].key").value("custom-action"));
});
}
@Test
void parsePublishStartApproveAndErrorMappings() {
runner.withPropertyValues("ordo.rest.enabled=true").run(context -> {
@@ -149,4 +187,64 @@ class OrdoRestAutoConfigurationTest {
.andExpect(status().isBadRequest());
});
}
@Configuration
static class NamedCatalogConfig {
@Bean
NamedAction leaveMail() {
return new NamedAction() {
@Override
public String key() {
return "leave-mail";
}
@Override
public String label() {
return "Notify HR";
}
@Override
public void execute(ProcessRuntime runtime) {
}
};
}
@Bean
NamedCondition amountGt() {
return new NamedCondition() {
@Override
public String key() {
return "amountGt";
}
@Override
public boolean matches(Map<String, Object> args, ProcessRuntime runtime) {
return true;
}
};
}
}
@Configuration
static class CustomCatalogConfig {
@Bean
OrdoCatalog ordoCatalog() {
return new OrdoCatalog() {
@Override
public java.util.List<OrdoCatalog.Item> conditions() {
return java.util.List.of();
}
@Override
public java.util.List<OrdoCatalog.Item> actions() {
return java.util.List.of(new OrdoCatalog.Item("custom-action", "Custom"));
}
@Override
public java.util.List<OrdoCatalog.Item> assignees() {
return java.util.List.of();
}
};
}
}
}