feat: add ACTION steps that run after the approval transaction commits
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
package com.jetlumen.ordo.api;
|
||||
|
||||
/**
|
||||
* Executes a named action step against the running instance's context. Hosts supply a
|
||||
* singleton implementation (same pattern as {@link RoutingCondition}); the database only stores
|
||||
* the {@code actionKey} string.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface ActionHandler {
|
||||
void execute(String actionKey, ProcessContext context);
|
||||
|
||||
static ActionHandler noop() {
|
||||
return (key, context) -> {
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,28 +6,44 @@ import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* A single, named approval step in a process definition. A step has one or more candidate
|
||||
* assignees; when there is more than one candidate, {@link #policy()} decides whether any
|
||||
* single approval is enough ({@link ApprovalPolicy#ANY}) or every candidate must approve
|
||||
* ({@link ApprovalPolicy#ALL}).
|
||||
* A named step in a process definition. Approval steps have one or more candidate assignees;
|
||||
* action steps have an {@code actionKey} invoked by the host {@link ActionHandler}.
|
||||
*/
|
||||
public record ApprovalStep(String id, String name, List<String> candidates, ApprovalPolicy policy) {
|
||||
public record ApprovalStep(String id, String name, List<String> candidates, ApprovalPolicy policy, StepKind kind,
|
||||
String actionKey) {
|
||||
public ApprovalStep {
|
||||
requireText(id, "step id");
|
||||
requireText(name, "step name");
|
||||
kind = kind == null ? StepKind.APPROVAL : kind;
|
||||
Objects.requireNonNull(policy, "policy must not be null");
|
||||
Objects.requireNonNull(candidates, "candidates must not be null");
|
||||
candidates = List.copyOf(candidates);
|
||||
if (candidates.isEmpty()) {
|
||||
throw new IllegalArgumentException("a step must have at least one candidate");
|
||||
}
|
||||
Set<String> distinct = new HashSet<>();
|
||||
for (String candidate : candidates) {
|
||||
requireText(candidate, "candidate");
|
||||
if (!distinct.add(candidate)) {
|
||||
throw new IllegalArgumentException("duplicate candidate in step " + id + ": " + candidate);
|
||||
if (kind == StepKind.ACTION) {
|
||||
if (!candidates.isEmpty()) {
|
||||
throw new IllegalArgumentException("an action step must not have candidates");
|
||||
}
|
||||
requireText(actionKey, "action key");
|
||||
actionKey = actionKey.strip();
|
||||
} else {
|
||||
if (actionKey != null && !actionKey.isBlank()) {
|
||||
throw new IllegalArgumentException("an approval step must not have an action key");
|
||||
}
|
||||
actionKey = null;
|
||||
if (candidates.isEmpty()) {
|
||||
throw new IllegalArgumentException("a step must have at least one candidate");
|
||||
}
|
||||
Set<String> distinct = new HashSet<>();
|
||||
for (String candidate : candidates) {
|
||||
requireText(candidate, "candidate");
|
||||
if (!distinct.add(candidate)) {
|
||||
throw new IllegalArgumentException("duplicate candidate in step " + id + ": " + candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
Objects.requireNonNull(policy, "policy must not be null");
|
||||
}
|
||||
|
||||
public ApprovalStep(String id, String name, List<String> candidates, ApprovalPolicy policy) {
|
||||
this(id, name, candidates, policy, StepKind.APPROVAL, null);
|
||||
}
|
||||
|
||||
/** Convenience factory for the common case of a single, fixed approver. */
|
||||
@@ -35,6 +51,10 @@ public record ApprovalStep(String id, String name, List<String> candidates, Appr
|
||||
return new ApprovalStep(id, name, List.of(assignee), ApprovalPolicy.ANY);
|
||||
}
|
||||
|
||||
public static ApprovalStep action(String id, String name, String actionKey) {
|
||||
return new ApprovalStep(id, name, List.of(), ApprovalPolicy.ANY, StepKind.ACTION, actionKey);
|
||||
}
|
||||
|
||||
static void requireText(String value, String field) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
|
||||
@@ -14,7 +14,7 @@ public record ProcessDefinition(String id, String name, List<ApprovalStep> steps
|
||||
ApprovalStep.requireText(name, "definition name");
|
||||
steps = List.copyOf(steps);
|
||||
if (steps.isEmpty()) {
|
||||
throw new IllegalArgumentException("a definition must contain at least one approval step");
|
||||
throw new IllegalArgumentException("a definition must contain at least one step");
|
||||
}
|
||||
Set<String> ids = new HashSet<>();
|
||||
for (ApprovalStep step : steps) {
|
||||
|
||||
@@ -37,7 +37,7 @@ public final class ProcessDefinitionParser {
|
||||
|
||||
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");
|
||||
throw new IllegalArgumentException("a definition must contain at least one step");
|
||||
}
|
||||
List<ApprovalStep> steps = new ArrayList<>(document.steps().size());
|
||||
for (StepDocument step : document.steps()) {
|
||||
@@ -45,7 +45,9 @@ public final class ProcessDefinitionParser {
|
||||
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));
|
||||
StepKind kind = step.kind() == null ? StepKind.APPROVAL : step.kind();
|
||||
List<String> candidates = step.candidates() == null ? List.of() : step.candidates();
|
||||
steps.add(new ApprovalStep(step.id(), step.name(), candidates, policy, kind, step.action()));
|
||||
}
|
||||
rotateStartStep(steps, document.startStep());
|
||||
List<StepTransition> transitions = new ArrayList<>();
|
||||
@@ -90,7 +92,13 @@ public final class ProcessDefinitionParser {
|
||||
List<TransitionDocument> transitions) {
|
||||
}
|
||||
|
||||
private record StepDocument(String id, String name, List<String> candidates, ApprovalPolicy policy) {
|
||||
private record StepDocument(
|
||||
String id,
|
||||
String name,
|
||||
List<String> candidates,
|
||||
ApprovalPolicy policy,
|
||||
StepKind kind,
|
||||
@JsonProperty("action") String action) {
|
||||
}
|
||||
|
||||
private record TransitionDocument(
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.jetlumen.ordo.api;
|
||||
|
||||
public enum StepKind {
|
||||
APPROVAL, ACTION
|
||||
}
|
||||
@@ -153,4 +153,25 @@ class ProcessDefinitionParserTest {
|
||||
void rejectsMalformedJson() {
|
||||
assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson("{"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesActionStepsAndDefaultsKindToApproval() {
|
||||
String json = """
|
||||
{
|
||||
"id": "leave",
|
||||
"name": "Leave request",
|
||||
"steps": [
|
||||
{ "id": "manager", "name": "Manager approval", "candidates": ["maria"] },
|
||||
{ "id": "notify", "name": "Notify HR", "kind": "ACTION", "action": "leave-approved-mail" }
|
||||
],
|
||||
"transitions": [
|
||||
{ "from": "manager", "to": "notify" },
|
||||
{ "from": "notify", "to": null }
|
||||
]
|
||||
}
|
||||
""";
|
||||
ProcessDefinition definition = ProcessDefinitionParser.fromJson(json);
|
||||
assertEquals(StepKind.APPROVAL, definition.steps().getFirst().kind());
|
||||
assertEquals(ApprovalStep.action("notify", "Notify HR", "leave-approved-mail"), definition.steps().get(1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,4 +94,28 @@ class ProcessDefinitionTest {
|
||||
StepTransition.end("hr"),
|
||||
StepTransition.always("manager", "hr")), definition.transitions());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsActionStepsWithCandidatesOrBlankActionKey() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new ApprovalStep("notify", "Notify", List.of("maria"), ApprovalPolicy.ANY, StepKind.ACTION,
|
||||
"mail"));
|
||||
assertThrows(IllegalArgumentException.class, () -> ApprovalStep.action("notify", "Notify", " "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsApprovalStepsWithAnActionKey() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new ApprovalStep("manager", "Manager", List.of("maria"), ApprovalPolicy.ANY, StepKind.APPROVAL,
|
||||
"mail"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsADefinitionThatIsOnlyActionSteps() {
|
||||
ProcessDefinition definition = new ProcessDefinition("notify", "Notify", List.of(
|
||||
ApprovalStep.action("mail", "Send mail", "leave-approved-mail")),
|
||||
List.of(StepTransition.end("mail")));
|
||||
assertEquals(StepKind.ACTION, definition.steps().getFirst().kind());
|
||||
assertEquals("leave-approved-mail", definition.steps().getFirst().actionKey());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user