diff --git a/ordo-api/pom.xml b/ordo-api/pom.xml index bf9d048..1470c8e 100644 --- a/ordo-api/pom.xml +++ b/ordo-api/pom.xml @@ -12,6 +12,10 @@ ordo-api + + com.fasterxml.jackson.core + jackson-databind + org.junit.jupiter junit-jupiter diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessDefinitionParser.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessDefinitionParser.java new file mode 100644 index 0000000..157b962 --- /dev/null +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessDefinitionParser.java @@ -0,0 +1,102 @@ +package com.jetlumen.ordo.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** Parses a structural JSON process graph into a {@link ProcessDefinition}. */ +public final class ProcessDefinitionParser { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private ProcessDefinitionParser() { + } + + public static ProcessDefinition fromJson(String json) { + Objects.requireNonNull(json, "json must not be null"); + try { + return toDefinition(MAPPER.readValue(json, DefinitionDocument.class)); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("invalid process definition json", e); + } + } + + public static ProcessDefinition fromJson(InputStream json) { + Objects.requireNonNull(json, "json must not be null"); + try { + return toDefinition(MAPPER.readValue(json, DefinitionDocument.class)); + } catch (IOException e) { + throw new IllegalArgumentException("invalid process definition json", e); + } + } + + 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"); + } + List steps = new ArrayList<>(document.steps().size()); + for (StepDocument step : document.steps()) { + if (step == null) { + 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)); + } + rotateStartStep(steps, document.startStep()); + List transitions = new ArrayList<>(); + if (document.transitions() != null) { + for (TransitionDocument transition : document.transitions()) { + if (transition == null) { + throw new IllegalArgumentException("transition must not be null"); + } + int priority = transition.priority() == null ? 0 : transition.priority(); + transitions.add(new StepTransition(transition.from(), transition.to(), transition.when(), priority)); + } + } + return new ProcessDefinition(document.id(), document.name(), steps, transitions); + } + + private static void rotateStartStep(List steps, String startStepId) { + if (startStepId == null || startStepId.isBlank()) { + return; + } + int index = -1; + for (int i = 0; i < steps.size(); i++) { + if (startStepId.equals(steps.get(i).id())) { + index = i; + break; + } + } + if (index < 0) { + throw new IllegalArgumentException("unknown startStep: " + startStepId); + } + if (index == 0) { + return; + } + ApprovalStep start = steps.remove(index); + steps.add(0, start); + } + + private record DefinitionDocument( + String id, + String name, + String startStep, + List steps, + List transitions) { + } + + private record StepDocument(String id, String name, List candidates, ApprovalPolicy policy) { + } + + private record TransitionDocument( + @JsonProperty("from") String from, + @JsonProperty("to") String to, + @JsonProperty("when") String when, + Integer priority) { + } +} diff --git a/ordo-api/src/test/java/com/jetlumen/ordo/api/ProcessDefinitionParserTest.java b/ordo-api/src/test/java/com/jetlumen/ordo/api/ProcessDefinitionParserTest.java new file mode 100644 index 0000000..e945498 --- /dev/null +++ b/ordo-api/src/test/java/com/jetlumen/ordo/api/ProcessDefinitionParserTest.java @@ -0,0 +1,156 @@ +package com.jetlumen.ordo.api; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ProcessDefinitionParserTest { + + private static final String LEAVE_REQUEST_JSON = """ + { + "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 } + ] + } + """; + + @Test + void parsesLeaveRequestGraph() { + ProcessDefinition expected = new ProcessDefinition("leave-request-routed", "Leave request", List.of( + ApprovalStep.single("manager", "Manager approval", "maria"), + ApprovalStep.single("hr", "HR approval", "henry") + ), List.of( + StepTransition.when("manager", "hr", "days-gt-3", 0), + new StepTransition("manager", null, null, 1), + StepTransition.end("hr") + )); + + assertEquals(expected, ProcessDefinitionParser.fromJson(LEAVE_REQUEST_JSON)); + } + + @Test + void rotatesStartStepToTheFront() { + String json = """ + { + "id": "leave-request-routed", + "name": "Leave request", + "startStep": "manager", + "steps": [ + { "id": "hr", "name": "HR approval", "candidates": ["henry"] }, + { "id": "manager", "name": "Manager approval", "candidates": ["maria"] } + ], + "transitions": [ + { "from": "manager", "to": "hr", "when": "days-gt-3", "priority": 0 }, + { "from": "manager", "to": null, "priority": 1 }, + { "from": "hr", "to": null } + ] + } + """; + + ProcessDefinition definition = ProcessDefinitionParser.fromJson(json); + assertEquals("manager", definition.steps().getFirst().id()); + assertEquals("hr", definition.steps().get(1).id()); + } + + @Test + void rejectsUnknownStartStep() { + String json = """ + { + "id": "leave", + "name": "Leave request", + "startStep": "missing", + "steps": [ + { "id": "manager", "name": "Manager approval", "candidates": ["maria"] } + ], + "transitions": [ + { "from": "manager", "to": null } + ] + } + """; + assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson(json)); + } + + @Test + void rejectsMissingOutgoingTransition() { + String json = """ + { + "id": "leave", + "name": "Leave request", + "steps": [ + { "id": "manager", "name": "Manager approval", "candidates": ["maria"] }, + { "id": "hr", "name": "HR approval", "candidates": ["henry"] } + ], + "transitions": [ + { "from": "manager", "to": null } + ] + } + """; + assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson(json)); + } + + @Test + void rejectsUnknownFromAndTo() { + String unknownFrom = """ + { + "id": "leave", + "name": "Leave request", + "steps": [ + { "id": "manager", "name": "Manager approval", "candidates": ["maria"] } + ], + "transitions": [ + { "from": "missing", "to": null } + ] + } + """; + String unknownTo = """ + { + "id": "leave", + "name": "Leave request", + "steps": [ + { "id": "manager", "name": "Manager approval", "candidates": ["maria"] } + ], + "transitions": [ + { "from": "manager", "to": "missing" } + ] + } + """; + assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson(unknownFrom)); + assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson(unknownTo)); + } + + @Test + void rejectsDuplicatePriorityForTheSameFrom() { + String json = """ + { + "id": "leave", + "name": "Leave request", + "steps": [ + { "id": "manager", "name": "Manager approval", "candidates": ["maria"] }, + { "id": "hr", "name": "HR approval", "candidates": ["henry"] } + ], + "transitions": [ + { "from": "manager", "to": "hr", "when": "high", "priority": 1 }, + { "from": "manager", "to": null, "when": "low", "priority": 1 }, + { "from": "hr", "to": null } + ] + } + """; + assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson(json)); + } + + @Test + void rejectsMalformedJson() { + assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson("{")); + } +} diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoDefinitionLoader.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoDefinitionLoader.java new file mode 100644 index 0000000..11a3fda --- /dev/null +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoDefinitionLoader.java @@ -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; + } +} diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfiguration.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfiguration.java index 9150330..223934f 100644 --- a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfiguration.java +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfiguration.java @@ -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()); + } } diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoProperties.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoProperties.java index b7d353d..3951ded 100644 --- a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoProperties.java +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoProperties.java @@ -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; + } + } } diff --git a/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfigurationTest.java b/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfigurationTest.java index f95f8e5..307b78a 100644 --- a/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfigurationTest.java +++ b/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfigurationTest.java @@ -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 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) diff --git a/ordo-spring-boot-autoconfigure/src/test/resources/ordo/leave-request-routed.json b/ordo-spring-boot-autoconfigure/src/test/resources/ordo/leave-request-routed.json new file mode 100644 index 0000000..698d806 --- /dev/null +++ b/ordo-spring-boot-autoconfigure/src/test/resources/ordo/leave-request-routed.json @@ -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 } + ] +}