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
@@ -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<String, NamedAction> actions;
private DispatchingActionHandler(Map<String, NamedAction> actions) {
this.actions = actions;
}
public static DispatchingActionHandler of(List<? extends NamedAction> 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);
}
}
@@ -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<String, NamedCondition> conditions;
private DispatchingRoutingCondition(Map<String, NamedCondition> conditions) {
this.conditions = conditions;
}
public static DispatchingRoutingCondition of(List<? extends NamedCondition> conditions) {
return new DispatchingRoutingCondition(NamedHandlers.indexByKey(conditions, NamedCondition::key, "condition"));
}
@Override
public boolean matches(String conditionKey, Map<String, Object> args, ProcessRuntime runtime) {
NamedCondition condition = conditions.get(NamedHandlers.lookupKey(conditionKey));
if (condition == null) {
return false;
}
return condition.matches(args, runtime);
}
}
@@ -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 <T> Map<String, T> indexByKey(List<? extends T> items, Function<T, String> keyExtractor, String kind) {
Objects.requireNonNull(items, kind + "s must not be null");
Map<String, T> 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);
}
}
@@ -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<Item> conditions;
private final List<Item> actions;
public RegistryOrdoCatalog(List<? extends NamedCondition> conditions, List<? extends NamedAction> 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<Item> conditions() {
return conditions;
}
@Override
public List<Item> actions() {
return actions;
}
@Override
public List<Item> assignees() {
return List.of();
}
}
@@ -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<String> executed = new ArrayList<>();
DispatchingActionHandler.of(List.of(action("mail", executed::add)))
.execute("mail", RUNTIME);
assertEquals(List.of("mail"), executed);
}
@Test
void actionStripsKeyOnRegisterAndLookup() {
List<String> 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<String> 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<Map<String, Object>, ProcessRuntime, Boolean> delegate) {
return new NamedCondition() {
@Override
public String key() {
return key;
}
@Override
public boolean matches(Map<String, Object> args, ProcessRuntime runtime) {
return delegate.apply(args, runtime);
}
};
}
}