From d7439399649083a03ba1083814151631a8bb37b5 Mon Sep 17 00:00:00 2001 From: 0264408 Date: Sun, 20 Sep 2026 11:07:57 +0800 Subject: [PATCH] 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 --- README.md | 9 +- docs/roadmap.md | 1 + docs/usage.md | 35 +++-- .../com/jetlumen/ordo/api/ActionHandler.java | 3 +- .../com/jetlumen/ordo/api/NamedAction.java | 26 ++++ .../com/jetlumen/ordo/api/NamedCondition.java | 27 ++++ .../jetlumen/ordo/api/RoutingCondition.java | 3 +- .../core/spi/DispatchingActionHandler.java | 30 ++++ .../core/spi/DispatchingRoutingCondition.java | 30 ++++ .../jetlumen/ordo/core/spi/NamedHandlers.java | 36 +++++ .../ordo/core/spi/RegistryOrdoCatalog.java | 38 +++++ .../core/spi/DispatchingHandlersTest.java | 134 ++++++++++++++++++ .../jdbc/OrdoJdbcAutoConfiguration.java | 15 +- .../rest/OrdoRestAutoConfiguration.java | 16 ++- .../jdbc/OrdoJdbcAutoConfigurationTest.java | 95 +++++++++++++ .../rest/OrdoRestAutoConfigurationTest.java | 98 +++++++++++++ 16 files changed, 570 insertions(+), 26 deletions(-) create mode 100644 ordo-api/src/main/java/com/jetlumen/ordo/api/NamedAction.java create mode 100644 ordo-api/src/main/java/com/jetlumen/ordo/api/NamedCondition.java create mode 100644 ordo-core/src/main/java/com/jetlumen/ordo/core/spi/DispatchingActionHandler.java create mode 100644 ordo-core/src/main/java/com/jetlumen/ordo/core/spi/DispatchingRoutingCondition.java create mode 100644 ordo-core/src/main/java/com/jetlumen/ordo/core/spi/NamedHandlers.java create mode 100644 ordo-core/src/main/java/com/jetlumen/ordo/core/spi/RegistryOrdoCatalog.java create mode 100644 ordo-core/src/test/java/com/jetlumen/ordo/core/spi/DispatchingHandlersTest.java diff --git a/README.md b/README.md index 7d6ddbc..a46abc7 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,14 @@ - 线性或多步图:`StepTransition` + 内置谓词 / 宿主 `RoutingCondition` - 会签/或签:`ApprovalPolicy.ALL` / `ANY`(多候选人) - 结构化并行:`kind: PARALLEL` 块(一层、join ALL) -- ACTION 步骤:事务提交后调用宿主 `ActionHandler` +- ACTION 步骤:事务提交后调用宿主 `ActionHandler`(可经 `NamedAction` 分发) - 发起人撤回:`WITHDRAWN`,待办任务 `SKIPPED` - 管理员/系统取消:`cancel` → `CANCELLED`,待办任务 `SKIPPED`(引擎不鉴权角色) - 任务转派:当前办理人 `reassign`,审计 `TASK_REASSIGNED` - 到期升级:步骤 `due` + `processDue`,审计 `TASK_ESCALATED` - 分页查询:任务 / 实例 / 流程定义 - 审计时间线:`ProcessEvent` + `queryHistory` -- 扩展点:`AssigneeResolver`、`RoutingCondition`、`ActionHandler`、`OrdoEventListener` +- 扩展点:`AssigneeResolver`、`RoutingCondition`、`ActionHandler`、`NamedCondition`、`NamedAction`、`OrdoEventListener` 开发计划:可选 REST + 目录 SPI。设计器为独立产品(不进本仓库),待 REST 与目录之后。多租户 **暂不在计划中**。见 [docs/roadmap.md](docs/roadmap.md)。 @@ -112,8 +112,9 @@ ordo: | Bean | 默认 | |---|---| | `AssigneeResolver` | 候选人即办理人 | -| `RoutingCondition` | 始终匹配(`ref` 边;宿主可按 key+args 分发) | -| `ActionHandler` | 空操作 | +| `NamedCondition` / `NamedAction` | 可多个;无对应门面时按 key 分发 | +| `RoutingCondition` | 无具名 condition 时始终匹配;有则未知 `ref` 为 false | +| `ActionHandler` | 无具名 action 时空操作;有则未知 key 抛错 | | `OrdoEventListener` | 可注册多个,提交后按顺序调用 | ## 运行时约定 diff --git a/docs/roadmap.md b/docs/roadmap.md index 06fd1c3..c2c2441 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -19,6 +19,7 @@ - 到期升级 `processDue` / `TASK_ESCALATED` - 管理员/系统取消 `cancel` / `CANCELLED` - 可选 REST(autoconfigure 条件装配)+ 目录 SPI `OrdoCatalog` +- `NamedAction` / `NamedCondition` 注册与官方 key 分发;默认 Catalog 从具名 Bean 投影 ## 开发计划(确定要做) diff --git a/docs/usage.md b/docs/usage.md index 581072e..713c37f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -76,12 +76,16 @@ ordo: | Bean | 默认 | |---|---| | `AssigneeResolver` | 候选人字符串即办理人 | -| `RoutingCondition` | `ref` 边默认恒 true;无 `when` 的边不经过该 Bean | -| `ActionHandler` | 空操作 | +| `NamedCondition` | 可有多个;无 `RoutingCondition` 门面且列表非空时装配 `DispatchingRoutingCondition` | +| `NamedAction` | 可有多个;无 `ActionHandler` 门面且列表非空时装配 `DispatchingActionHandler` | +| `RoutingCondition` | 无 `NamedCondition` 时 `ref` 边恒 true;有则按 key 分发,未知 `ref` 为 false | +| `ActionHandler` | 无 `NamedAction` 时空操作;有则按 key 分发,未知 key 抛 `IllegalArgumentException` | | `OrdoEventListener` | 可有多个,提交后按 Spring 顺序调用 | -| `OrdoCatalog` | 空列表(仅 REST 打开时装配) | +| `OrdoCatalog` | REST 打开时:从 `NamedCondition` / `NamedAction` 投影;皆空则为空列表。`assignees` 仍空 | -未提供自定义 Bean 时,ACTION 步骤仍会推进流程,但 handler 什么都不做。 +未提供 `NamedAction` 且未覆盖 `ActionHandler` 时,ACTION 步骤仍会推进流程,但 handler 什么都不做。不要同时提供门面 Bean 与对应 `Named*`(门面优先,具名 Bean 不参与运行时)。 + +内存引擎把 `DispatchingRoutingCondition.of(...)` / `DispatchingActionHandler.of(...)` 传入 `InMemoryOrdoEngine` 即可。 ### 2.3 可选 REST @@ -294,9 +298,9 @@ v1:至少 2 条分支;禁止套娃 PARALLEL;join 固定 ALL;任一分支 无条件边是默认分支;`priority` 只在同类边之间比较(多条条件边之间,或多条无条件边之间)。 -`RoutingCondition.matches` 看到 `args` 与 `ProcessRuntime`(含业务 `context`、发起人、定义与当前步)。看不到任务意见。上下文在 `start` 时写入,运行中引擎不会改 context。未知 `ref` 由宿主返回 `false`,该边不匹配。内置谓词仍只读 `ProcessContext` 变量。 +`RoutingCondition.matches` 看到 `args` 与 `ProcessRuntime`(含业务 `context`、发起人、定义与当前步)。看不到任务意见。上下文在 `start` 时写入,运行中引擎不会改 context。内置谓词仍只读 `ProcessContext` 变量。 -引擎只注入**一个** `RoutingCondition`。Spring 下多个该类型 Bean 会冲突。宿主用一个门面按 `ref` 分发;不要指望引擎按定义拆 bean。 +引擎仍只注入**一个** `RoutingCondition`。Spring 下注册多个 `NamedCondition` Bean(`key()` 对应 `when.ref`);无门面且列表非空时装配官方分发器。未知 `ref` 为 false,该边不匹配。重复 `key` 启动失败。也可继续提供单个 `RoutingCondition` 门面自行分发(不要与 `NamedCondition` 混用)。无 `NamedCondition` 时默认仍是恒 true。 ## 7. ACTION 步骤 @@ -307,22 +311,25 @@ v1:至少 2 条分支;禁止套娃 PARALLEL;join 固定 ALL;任一分支 3. 事务提交后调用 `ActionHandler.execute(actionKey, runtime)`。 4. 成功 → `SUCCESS` + 事件 `ACTION_SUCCEEDED`;失败 → `FAILED`(`errorMessage`)+ `ACTION_FAILED` + 日志 WARNING。 -**失败不回滚已提交的审批,不阻塞后续步骤,引擎不做重试。** 宿主用 `queryActionExecutions` 或 listener 自行补发。 +**失败不回滚已提交的审批,不阻塞后续步骤,引擎不做重试。** 宿主用 `queryActionExecutions` 或 listener 自行补发。分发器遇到未知 `actionKey` 会抛 `IllegalArgumentException`,记为该次 ACTION `FAILED`。 ```java @Component -public class MailActions implements ActionHandler { +public class LeaveApprovedMail implements NamedAction { @Override - public void execute(String actionKey, ProcessRuntime runtime) { - switch (actionKey) { - case "leave-submitted-mail" -> { /* ... */ } - case "leave-approved-mail" -> { /* ... */ } - default -> throw new IllegalStateException("unknown action: " + actionKey); - } + public String key() { + return "leave-approved-mail"; + } + + @Override + public void execute(ProcessRuntime runtime) { + /* ... */ } } ``` +仍可提供单个 `ActionHandler` 按 key 自行分发;不要与 `NamedAction` 混用。 + ## 8. 审计与监听 `queryHistory(instanceId, page)`:该实例事件,**发生时间升序**。 diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/ActionHandler.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/ActionHandler.java index 4766d41..edd2dde 100644 --- a/ordo-api/src/main/java/com/jetlumen/ordo/api/ActionHandler.java +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/ActionHandler.java @@ -3,7 +3,8 @@ package com.jetlumen.ordo.api; /** * Executes a named action step against the running instance. Hosts supply a * singleton implementation (same pattern as {@link RoutingCondition}); the database only stores - * the {@code actionKey} string. + * the {@code actionKey} string. Prefer {@link NamedAction} beans; Spring wires a dispatcher + * when this facade is not defined. */ @FunctionalInterface public interface ActionHandler { diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/NamedAction.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/NamedAction.java new file mode 100644 index 0000000..a37db41 --- /dev/null +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/NamedAction.java @@ -0,0 +1,26 @@ +package com.jetlumen.ordo.api; + +import java.util.List; + +/** + * A single ACTION implementation keyed by {@link #key()}, matching {@code action} on the step + * (and {@code due.then.notify} keys). Spring collects these beans into a dispatching + * {@link ActionHandler} when the host does not define that facade itself. + */ +public interface NamedAction { + String key(); + + void execute(ProcessRuntime runtime); + + default String label() { + return key(); + } + + default List params() { + return List.of(); + } + + default OrdoCatalog.Item catalogItem() { + return new OrdoCatalog.Item(key(), label(), params()); + } +} diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/NamedCondition.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/NamedCondition.java new file mode 100644 index 0000000..7aefa6b --- /dev/null +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/NamedCondition.java @@ -0,0 +1,27 @@ +package com.jetlumen.ordo.api; + +import java.util.List; +import java.util.Map; + +/** + * A single routing condition keyed by {@link #key()}, matching {@code when.ref}. Spring collects + * these beans into a dispatching {@link RoutingCondition} when the host does not define that + * facade itself. + */ +public interface NamedCondition { + String key(); + + boolean matches(Map args, ProcessRuntime runtime); + + default String label() { + return key(); + } + + default List params() { + return List.of(); + } + + default OrdoCatalog.Item catalogItem() { + return new OrdoCatalog.Item(key(), label(), params()); + } +} diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/RoutingCondition.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/RoutingCondition.java index 51a4abf..24a041c 100644 --- a/ordo-api/src/main/java/com/jetlumen/ordo/api/RoutingCondition.java +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/RoutingCondition.java @@ -5,7 +5,8 @@ import java.util.Map; /** * Evaluates a named routing condition ({@code when.ref}) against the running instance. * Hosts supply a singleton implementation (same pattern as {@link AssigneeResolver}); the - * definition stores the {@code ref} key and optional {@code args}. + * definition stores the {@code ref} key and optional {@code args}. Prefer {@link NamedCondition} + * beans; Spring wires a dispatcher when this facade is not defined. */ @FunctionalInterface public interface RoutingCondition { diff --git a/ordo-core/src/main/java/com/jetlumen/ordo/core/spi/DispatchingActionHandler.java b/ordo-core/src/main/java/com/jetlumen/ordo/core/spi/DispatchingActionHandler.java new file mode 100644 index 0000000..169db5a --- /dev/null +++ b/ordo-core/src/main/java/com/jetlumen/ordo/core/spi/DispatchingActionHandler.java @@ -0,0 +1,30 @@ +package com.jetlumen.ordo.core.spi; + +import com.jetlumen.ordo.api.ActionHandler; +import com.jetlumen.ordo.api.NamedAction; +import com.jetlumen.ordo.api.ProcessRuntime; + +import java.util.List; +import java.util.Map; + +/** {@link ActionHandler} that dispatches by {@link NamedAction#key()}. */ +public final class DispatchingActionHandler implements ActionHandler { + private final Map actions; + + private DispatchingActionHandler(Map actions) { + this.actions = actions; + } + + public static DispatchingActionHandler of(List actions) { + return new DispatchingActionHandler(NamedHandlers.indexByKey(actions, NamedAction::key, "action")); + } + + @Override + public void execute(String actionKey, ProcessRuntime runtime) { + NamedAction action = actions.get(NamedHandlers.lookupKey(actionKey)); + if (action == null) { + throw new IllegalArgumentException("unknown action: " + actionKey); + } + action.execute(runtime); + } +} diff --git a/ordo-core/src/main/java/com/jetlumen/ordo/core/spi/DispatchingRoutingCondition.java b/ordo-core/src/main/java/com/jetlumen/ordo/core/spi/DispatchingRoutingCondition.java new file mode 100644 index 0000000..eab993a --- /dev/null +++ b/ordo-core/src/main/java/com/jetlumen/ordo/core/spi/DispatchingRoutingCondition.java @@ -0,0 +1,30 @@ +package com.jetlumen.ordo.core.spi; + +import com.jetlumen.ordo.api.NamedCondition; +import com.jetlumen.ordo.api.ProcessRuntime; +import com.jetlumen.ordo.api.RoutingCondition; + +import java.util.List; +import java.util.Map; + +/** {@link RoutingCondition} that dispatches by {@link NamedCondition#key()}. Unknown keys are false. */ +public final class DispatchingRoutingCondition implements RoutingCondition { + private final Map conditions; + + private DispatchingRoutingCondition(Map conditions) { + this.conditions = conditions; + } + + public static DispatchingRoutingCondition of(List conditions) { + return new DispatchingRoutingCondition(NamedHandlers.indexByKey(conditions, NamedCondition::key, "condition")); + } + + @Override + public boolean matches(String conditionKey, Map args, ProcessRuntime runtime) { + NamedCondition condition = conditions.get(NamedHandlers.lookupKey(conditionKey)); + if (condition == null) { + return false; + } + return condition.matches(args, runtime); + } +} diff --git a/ordo-core/src/main/java/com/jetlumen/ordo/core/spi/NamedHandlers.java b/ordo-core/src/main/java/com/jetlumen/ordo/core/spi/NamedHandlers.java new file mode 100644 index 0000000..51bd161 --- /dev/null +++ b/ordo-core/src/main/java/com/jetlumen/ordo/core/spi/NamedHandlers.java @@ -0,0 +1,36 @@ +package com.jetlumen.ordo.core.spi; + +import com.jetlumen.ordo.api.Texts; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; + +final class NamedHandlers { + private NamedHandlers() { + } + + static String requireKey(String key) { + Texts.requireText(key, "key"); + return key.strip(); + } + + static String lookupKey(String key) { + return key == null ? "" : key.strip(); + } + + static Map indexByKey(List items, Function keyExtractor, String kind) { + Objects.requireNonNull(items, kind + "s must not be null"); + Map map = new LinkedHashMap<>(); + for (T item : items) { + Objects.requireNonNull(item, kind + " must not be null"); + String key = requireKey(keyExtractor.apply(item)); + if (map.put(key, item) != null) { + throw new IllegalStateException("duplicate " + kind + " key: " + key); + } + } + return Map.copyOf(map); + } +} diff --git a/ordo-core/src/main/java/com/jetlumen/ordo/core/spi/RegistryOrdoCatalog.java b/ordo-core/src/main/java/com/jetlumen/ordo/core/spi/RegistryOrdoCatalog.java new file mode 100644 index 0000000..e0f3335 --- /dev/null +++ b/ordo-core/src/main/java/com/jetlumen/ordo/core/spi/RegistryOrdoCatalog.java @@ -0,0 +1,38 @@ +package com.jetlumen.ordo.core.spi; + +import com.jetlumen.ordo.api.NamedAction; +import com.jetlumen.ordo.api.NamedCondition; +import com.jetlumen.ordo.api.OrdoCatalog; + +import java.util.List; +import java.util.Objects; + +/** Catalog projected from registered {@link NamedCondition} and {@link NamedAction} instances. */ +public final class RegistryOrdoCatalog implements OrdoCatalog { + private final List conditions; + private final List actions; + + public RegistryOrdoCatalog(List conditions, List actions) { + this.conditions = Objects.requireNonNull(conditions, "conditions must not be null").stream() + .map(NamedCondition::catalogItem) + .toList(); + this.actions = Objects.requireNonNull(actions, "actions must not be null").stream() + .map(NamedAction::catalogItem) + .toList(); + } + + @Override + public List conditions() { + return conditions; + } + + @Override + public List actions() { + return actions; + } + + @Override + public List assignees() { + return List.of(); + } +} diff --git a/ordo-core/src/test/java/com/jetlumen/ordo/core/spi/DispatchingHandlersTest.java b/ordo-core/src/test/java/com/jetlumen/ordo/core/spi/DispatchingHandlersTest.java new file mode 100644 index 0000000..2b3c931 --- /dev/null +++ b/ordo-core/src/test/java/com/jetlumen/ordo/core/spi/DispatchingHandlersTest.java @@ -0,0 +1,134 @@ +package com.jetlumen.ordo.core.spi; + +import com.jetlumen.ordo.api.NamedAction; +import com.jetlumen.ordo.api.NamedCondition; +import com.jetlumen.ordo.api.ProcessContext; +import com.jetlumen.ordo.api.ProcessRuntime; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DispatchingHandlersTest { + + private static final ProcessRuntime RUNTIME = + new ProcessRuntime("inst", "def", 1, "alice", "step", ProcessContext.empty()); + + @Test + void actionDispatchesByKey() { + List executed = new ArrayList<>(); + DispatchingActionHandler.of(List.of(action("mail", executed::add))) + .execute("mail", RUNTIME); + assertEquals(List.of("mail"), executed); + } + + @Test + void actionStripsKeyOnRegisterAndLookup() { + List executed = new ArrayList<>(); + DispatchingActionHandler.of(List.of(action(" mail ", executed::add))) + .execute("mail", RUNTIME); + assertEquals(List.of(" mail "), executed); + } + + @Test + void unknownActionThrows() { + DispatchingActionHandler handler = DispatchingActionHandler.of(List.of(action("mail", key -> { + }))); + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> handler.execute("other", RUNTIME)); + assertEquals("unknown action: other", error.getMessage()); + } + + @Test + void duplicateActionKeyFailsFast() { + IllegalStateException error = assertThrows(IllegalStateException.class, + () -> DispatchingActionHandler.of(List.of(action("mail", key -> { + }), action("mail", key -> { + })))); + assertEquals("duplicate action key: mail", error.getMessage()); + } + + @Test + void blankActionKeyRejected() { + assertThrows(IllegalArgumentException.class, + () -> DispatchingActionHandler.of(List.of(action(" ", key -> { + })))); + } + + @Test + void conditionDispatchesByKey() { + DispatchingRoutingCondition condition = DispatchingRoutingCondition.of(List.of( + namedCondition("amountGt", (args, runtime) -> ((Number) args.get("threshold")).intValue() < 10))); + assertTrue(condition.matches("amountGt", Map.of("threshold", 5), RUNTIME)); + assertFalse(condition.matches("amountGt", Map.of("threshold", 20), RUNTIME)); + } + + @Test + void unknownConditionIsFalse() { + DispatchingRoutingCondition condition = DispatchingRoutingCondition.of(List.of( + namedCondition("amountGt", (args, runtime) -> true))); + assertFalse(condition.matches("missing", Map.of(), RUNTIME)); + } + + @Test + void duplicateConditionKeyFailsFast() { + IllegalStateException error = assertThrows(IllegalStateException.class, + () -> DispatchingRoutingCondition.of(List.of( + namedCondition("amountGt", (args, runtime) -> true), + namedCondition("amountGt", (args, runtime) -> false)))); + assertEquals("duplicate condition key: amountGt", error.getMessage()); + } + + @Test + void blankConditionKeyRejected() { + assertThrows(IllegalArgumentException.class, + () -> DispatchingRoutingCondition.of(List.of(namedCondition("", (args, runtime) -> true)))); + } + + @Test + void catalogProjectsNamedHandlers() { + RegistryOrdoCatalog catalog = new RegistryOrdoCatalog( + List.of(namedCondition("amountGt", (args, runtime) -> true)), + List.of(action("mail", key -> { + }))); + assertEquals("amountGt", catalog.conditions().get(0).key()); + assertEquals("mail", catalog.actions().get(0).key()); + assertTrue(catalog.assignees().isEmpty()); + } + + private static NamedAction action(String key, Consumer onExecute) { + return new NamedAction() { + @Override + public String key() { + return key; + } + + @Override + public void execute(ProcessRuntime runtime) { + onExecute.accept(key); + } + }; + } + + private static NamedCondition namedCondition(String key, BiFunction, ProcessRuntime, Boolean> delegate) { + return new NamedCondition() { + @Override + public String key() { + return key; + } + + @Override + public boolean matches(Map args, ProcessRuntime runtime) { + return delegate.apply(args, runtime); + } + }; + } +} diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/jdbc/OrdoJdbcAutoConfiguration.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/jdbc/OrdoJdbcAutoConfiguration.java index c1bc660..e1a39db 100644 --- a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/jdbc/OrdoJdbcAutoConfiguration.java +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/jdbc/OrdoJdbcAutoConfiguration.java @@ -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 namedConditions) { + List conditions = namedConditions.orderedStream().toList(); + return conditions.isEmpty() ? RoutingCondition.always() : DispatchingRoutingCondition.of(conditions); } @Bean @ConditionalOnMissingBean - public ActionHandler ordoActionHandler() { - return ActionHandler.noop(); + public ActionHandler ordoActionHandler(ObjectProvider namedActions) { + List actions = namedActions.orderedStream().toList(); + return actions.isEmpty() ? ActionHandler.noop() : DispatchingActionHandler.of(actions); } @Bean diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoRestAutoConfiguration.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoRestAutoConfiguration.java index 36e3676..53310ba 100644 --- a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoRestAutoConfiguration.java +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoRestAutoConfiguration.java @@ -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 namedActions, + ObjectProvider namedConditions) { + List actions = namedActions.orderedStream().toList(); + List conditions = namedConditions.orderedStream().toList(); + if (actions.isEmpty() && conditions.isEmpty()) { + return OrdoCatalog.empty(); + } + return new RegistryOrdoCatalog(conditions, actions); } } diff --git a/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/jdbc/OrdoJdbcAutoConfigurationTest.java b/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/jdbc/OrdoJdbcAutoConfigurationTest.java index 787b53a..be12662 100644 --- a/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/jdbc/OrdoJdbcAutoConfigurationTest.java +++ b/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/jdbc/OrdoJdbcAutoConfigurationTest.java @@ -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 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 args, ProcessRuntime runtime) { + return true; + } + }; + } + } + @Configuration static class RecordingListenerConfig { @Bean diff --git a/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/rest/OrdoRestAutoConfigurationTest.java b/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/rest/OrdoRestAutoConfigurationTest.java index 5bb4da8..21b0e94 100644 --- a/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/rest/OrdoRestAutoConfigurationTest.java +++ b/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/rest/OrdoRestAutoConfigurationTest.java @@ -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 args, ProcessRuntime runtime) { + return true; + } + }; + } + } + + @Configuration + static class CustomCatalogConfig { + @Bean + OrdoCatalog ordoCatalog() { + return new OrdoCatalog() { + @Override + public java.util.List conditions() { + return java.util.List.of(); + } + + @Override + public java.util.List actions() { + return java.util.List.of(new OrdoCatalog.Item("custom-action", "Custom")); + } + + @Override + public java.util.List assignees() { + return java.util.List.of(); + } + }; + } + } }