feat: add optional Spring REST adapter and catalog SPI

Expose OrdoEngine over conditionally registered HTTP endpoints in autoconfigure, keep JDBC wiring in spring.jdbc, and share definition JSON via ProcessDefinitionParser.toJson.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
0264408
2026-09-17 10:30:09 +08:00
co-authored by Cursor
parent 7d17157633
commit 0288a8b3dd
29 changed files with 1295 additions and 27 deletions
+5
View File
@@ -66,6 +66,11 @@
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
@@ -12,6 +12,7 @@ public class OrdoProperties {
private final Definitions definitions = new Definitions();
private final Due due = new Due();
private final Jdbc jdbc = new Jdbc();
private final Rest rest = new Rest();
public boolean isEnabled() {
return enabled;
@@ -33,6 +34,10 @@ public class OrdoProperties {
return jdbc;
}
public Rest getRest() {
return rest;
}
public static class Definitions {
private String location = "classpath*:ordo/*.json";
@@ -70,4 +75,27 @@ public class OrdoProperties {
this.dialect = dialect;
}
}
public static class Rest {
/** When false, REST beans are not registered even if the web stack is present. */
private boolean enabled;
private String basePath = "/ordo";
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getBasePath() {
return basePath;
}
public void setBasePath(String basePath) {
this.basePath = basePath;
}
}
}
@@ -1,4 +1,4 @@
package com.jetlumen.ordo.spring;
package com.jetlumen.ordo.spring.jdbc;
import com.jetlumen.ordo.api.OrdoEngine;
import com.jetlumen.ordo.api.ProcessDefinition;
@@ -1,4 +1,4 @@
package com.jetlumen.ordo.spring;
package com.jetlumen.ordo.spring.jdbc;
import com.jetlumen.ordo.api.OrdoEngine;
import org.springframework.context.SmartLifecycle;
@@ -1,5 +1,6 @@
package com.jetlumen.ordo.spring;
package com.jetlumen.ordo.spring.jdbc;
import com.jetlumen.ordo.spring.OrdoProperties;
import com.jetlumen.ordo.storage.jdbc.dialect.SqlDialect;
import com.jetlumen.ordo.storage.jdbc.dialect.SqlDialects;
import org.flywaydb.core.Flyway;
@@ -1,4 +1,4 @@
package com.jetlumen.ordo.spring;
package com.jetlumen.ordo.spring.jdbc;
import com.jetlumen.ordo.api.ActionHandler;
import com.jetlumen.ordo.api.AssigneeResolver;
@@ -12,6 +12,7 @@ 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.spring.OrdoProperties;
import com.jetlumen.ordo.storage.jdbc.JdbcActionExecutionRepository;
import com.jetlumen.ordo.storage.jdbc.JdbcApprovalTaskRepository;
import com.jetlumen.ordo.storage.jdbc.JdbcConnectionProvider;
@@ -46,13 +47,11 @@ import java.time.Clock;
@ConditionalOnClass(DataSource.class)
@ConditionalOnBean(DataSource.class)
@AutoConfigureAfter(name = {
// Spring Boot 3.x locations
"org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration",
"org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration",
// Spring Boot 4.x locations (JDBC/Flyway autoconfiguration moved to dedicated modules)
"org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration",
"org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration",
"com.jetlumen.ordo.spring.OrdoFlywayAutoConfiguration"
"com.jetlumen.ordo.spring.jdbc.OrdoFlywayAutoConfiguration"
})
@EnableConfigurationProperties(OrdoProperties.class)
public class OrdoJdbcAutoConfiguration {
@@ -0,0 +1,4 @@
package com.jetlumen.ordo.spring.rest;
record ActorCommentRequest(String actor, String comment) {
}
@@ -0,0 +1,34 @@
package com.jetlumen.ordo.spring.rest;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.jetlumen.ordo.api.ProcessDefinition;
import com.jetlumen.ordo.api.ProcessDefinitionParser;
import java.util.Map;
final class DefinitionJson {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final TypeReference<Map<String, Object>> MAP = new TypeReference<>() {
};
private DefinitionJson() {
}
static Map<String, Object> map(ProcessDefinition definition) {
try {
return MAPPER.readValue(ProcessDefinitionParser.toJson(definition), MAP);
} catch (JsonProcessingException e) {
throw new IllegalStateException(e);
}
}
static String write(Map<String, Object> body) {
try {
return MAPPER.writeValueAsString(body);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("invalid process definition json", e);
}
}
}
@@ -0,0 +1,4 @@
package com.jetlumen.ordo.spring.rest;
record ErrorBody(String error, String message) {
}
@@ -0,0 +1,33 @@
package com.jetlumen.ordo.spring.rest;
import com.jetlumen.ordo.api.OrdoCatalog;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("${ordo.rest.base-path:/ordo}")
public class OrdoCatalogController {
private final OrdoCatalog catalog;
public OrdoCatalogController(OrdoCatalog catalog) {
this.catalog = catalog;
}
@GetMapping("/catalog/conditions")
public List<OrdoCatalog.Item> conditions() {
return catalog.conditions();
}
@GetMapping("/catalog/actions")
public List<OrdoCatalog.Item> actions() {
return catalog.actions();
}
@GetMapping("/catalog/assignees")
public List<OrdoCatalog.Item> assignees() {
return catalog.assignees();
}
}
@@ -0,0 +1,79 @@
package com.jetlumen.ordo.spring.rest;
import com.jetlumen.ordo.api.OrdoEngine;
import com.jetlumen.ordo.api.ProcessDefinition;
import com.jetlumen.ordo.api.ProcessDefinitionParser;
import com.jetlumen.ordo.api.query.PageRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("${ordo.rest.base-path:/ordo}")
public class OrdoDefinitionController {
private final OrdoEngine engine;
public OrdoDefinitionController(OrdoEngine engine) {
this.engine = engine;
}
@PostMapping("/definitions/parse")
public Map<String, Object> parse(@RequestBody Map<String, Object> body) {
return DefinitionJson.map(ProcessDefinitionParser.fromJson(DefinitionJson.write(body)));
}
@PostMapping("/definitions")
public ResponseEntity<Map<String, Object>> publish(@RequestBody Map<String, Object> body) {
ProcessDefinition published = engine.publish(ProcessDefinitionParser.fromJson(DefinitionJson.write(body)));
return ResponseEntity.status(HttpStatus.CREATED).body(DefinitionJson.map(published));
}
@GetMapping("/definitions")
public PageResponse<Map<String, Object>> query(
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "20") int size) {
var result = engine.queryDefinitions(PageRequest.of(page, size));
List<Map<String, Object>> content = result.content().stream()
.map(DefinitionJson::map)
.toList();
return new PageResponse<>(content, result.totalElements(), result.page(), result.size(),
result.totalPages(), result.hasNext());
}
@GetMapping("/definitions/{definitionId}")
public ResponseEntity<Map<String, Object>> findLatest(@PathVariable("definitionId") String definitionId) {
return engine.findDefinition(definitionId)
.map(definition -> ResponseEntity.ok(DefinitionJson.map(definition)))
.orElseGet(() -> ResponseEntity.notFound().build());
}
@GetMapping("/definitions/{definitionId}/versions")
public PageResponse<Map<String, Object>> versions(
@PathVariable("definitionId") String definitionId,
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "20") int size) {
var result = engine.queryDefinitionVersions(definitionId, PageRequest.of(page, size));
List<Map<String, Object>> content = result.content().stream()
.map(DefinitionJson::map)
.toList();
return new PageResponse<>(content, result.totalElements(), result.page(), result.size(),
result.totalPages(), result.hasNext());
}
@GetMapping("/definitions/{definitionId}/versions/{version}")
public ResponseEntity<Map<String, Object>> findVersion(@PathVariable("definitionId") String definitionId,
@PathVariable("version") int version) {
return engine.findDefinition(definitionId, version)
.map(definition -> ResponseEntity.ok(DefinitionJson.map(definition)))
.orElseGet(() -> ResponseEntity.notFound().build());
}
}
@@ -0,0 +1,32 @@
package com.jetlumen.ordo.spring.rest;
import com.jetlumen.ordo.api.OrdoEngine;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("${ordo.rest.base-path:/ordo}")
public class OrdoDueController {
static final int DEFAULT_LIMIT = 100;
private final OrdoEngine engine;
public OrdoDueController(OrdoEngine engine) {
this.engine = engine;
}
@PostMapping("/due")
public Map<String, Integer> processDue(
@RequestParam(name = "limit", required = false) Integer limit,
@RequestBody(required = false) ProcessDueRequest body) {
int resolved = limit != null ? limit
: body != null && body.limit() != null ? body.limit()
: DEFAULT_LIMIT;
return Map.of("processed", engine.processDue(resolved));
}
}
@@ -0,0 +1,103 @@
package com.jetlumen.ordo.spring.rest;
import com.jetlumen.ordo.api.ActionExecution;
import com.jetlumen.ordo.api.ApprovalTask;
import com.jetlumen.ordo.api.OrdoEngine;
import com.jetlumen.ordo.api.ProcessContext;
import com.jetlumen.ordo.api.ProcessEvent;
import com.jetlumen.ordo.api.ProcessInstance;
import com.jetlumen.ordo.api.ProcessStatus;
import com.jetlumen.ordo.api.query.InstanceQuery;
import com.jetlumen.ordo.api.query.PageRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("${ordo.rest.base-path:/ordo}")
public class OrdoInstanceController {
private final OrdoEngine engine;
public OrdoInstanceController(OrdoEngine engine) {
this.engine = engine;
}
@PostMapping("/instances")
public ProcessInstance start(@RequestBody StartInstanceRequest request) {
Map<String, Object> variables = request.context() == null || request.context().variables() == null
? Map.of()
: request.context().variables();
return engine.start(request.definitionId(), request.initiator(), new ProcessContext(variables));
}
@GetMapping("/instances/{instanceId}")
public ResponseEntity<ProcessInstance> find(@PathVariable("instanceId") String instanceId) {
return engine.findInstance(instanceId)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@GetMapping("/instances")
public PageResponse<ProcessInstance> query(
@RequestParam(name = "definitionId", required = false) String definitionId,
@RequestParam(name = "status", required = false) ProcessStatus status,
@RequestParam(name = "initiator", required = false) String initiator,
@RequestParam(name = "startedFrom", required = false) Instant startedFrom,
@RequestParam(name = "startedTo", required = false) Instant startedTo,
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "20") int size) {
InstanceQuery query = InstanceQuery.any()
.withDefinitionId(definitionId)
.withStatus(status)
.withInitiator(initiator)
.withStartedBetween(startedFrom, startedTo);
return PageResponse.of(engine.queryInstances(query, PageRequest.of(page, size)));
}
@PostMapping("/instances/{instanceId}/withdraw")
public ProcessInstance withdraw(@PathVariable("instanceId") String instanceId,
@RequestBody ActorCommentRequest request) {
return engine.withdraw(instanceId, request.actor(), request.comment());
}
@PostMapping("/instances/{instanceId}/cancel")
public ProcessInstance cancel(@PathVariable("instanceId") String instanceId,
@RequestBody ActorCommentRequest request) {
return engine.cancel(instanceId, request.actor(), request.comment());
}
@GetMapping("/instances/{instanceId}/tasks")
public List<ApprovalTask> tasks(@PathVariable("instanceId") String instanceId) {
return engine.findTasks(instanceId);
}
@GetMapping("/instances/{instanceId}/tasks/pending")
public List<ApprovalTask> pendingTasks(@PathVariable("instanceId") String instanceId) {
return engine.findPendingTasksByInstanceId(instanceId);
}
@GetMapping("/instances/{instanceId}/history")
public PageResponse<ProcessEvent> history(
@PathVariable("instanceId") String instanceId,
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "20") int size) {
return PageResponse.of(engine.queryHistory(instanceId, PageRequest.of(page, size)));
}
@GetMapping("/instances/{instanceId}/action-executions")
public PageResponse<ActionExecution> actionExecutions(
@PathVariable("instanceId") String instanceId,
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "20") int size) {
return PageResponse.of(engine.queryActionExecutions(instanceId, PageRequest.of(page, size)));
}
}
@@ -0,0 +1,39 @@
package com.jetlumen.ordo.spring.rest;
import com.jetlumen.ordo.api.OrdoCatalog;
import com.jetlumen.ordo.api.OrdoEngine;
import com.jetlumen.ordo.spring.OrdoProperties;
import com.jetlumen.ordo.spring.jdbc.OrdoJdbcAutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.web.servlet.DispatcherServlet;
@AutoConfiguration
@ConditionalOnClass(DispatcherServlet.class)
@ConditionalOnProperty(prefix = "ordo.rest", name = "enabled", havingValue = "true")
@ConditionalOnBean(OrdoEngine.class)
@AutoConfigureAfter(OrdoJdbcAutoConfiguration.class)
@EnableConfigurationProperties(OrdoProperties.class)
@Import({
OrdoDefinitionController.class,
OrdoInstanceController.class,
OrdoTaskController.class,
OrdoDueController.class,
OrdoCatalogController.class,
OrdoRestExceptionHandler.class
})
public class OrdoRestAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public OrdoCatalog ordoCatalog() {
return OrdoCatalog.empty();
}
}
@@ -0,0 +1,61 @@
package com.jetlumen.ordo.spring.rest;
import com.jetlumen.ordo.api.exception.DefinitionNotFoundException;
import com.jetlumen.ordo.api.exception.InstanceAlreadyCompletedException;
import com.jetlumen.ordo.api.exception.InstanceNotFoundException;
import com.jetlumen.ordo.api.exception.NoRouteFoundException;
import com.jetlumen.ordo.api.exception.OrdoException;
import com.jetlumen.ordo.api.exception.TaskAlreadyCompletedException;
import com.jetlumen.ordo.api.exception.TaskNotFoundException;
import com.jetlumen.ordo.api.exception.UnauthorizedInstanceOperationException;
import com.jetlumen.ordo.api.exception.UnauthorizedTaskOperationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice(basePackageClasses = OrdoRestAutoConfiguration.class)
public class OrdoRestExceptionHandler {
@ExceptionHandler({
DefinitionNotFoundException.class,
InstanceNotFoundException.class,
TaskNotFoundException.class
})
public ResponseEntity<ErrorBody> notFound(OrdoException exception) {
return respond(HttpStatus.NOT_FOUND, exception);
}
@ExceptionHandler({
UnauthorizedTaskOperationException.class,
UnauthorizedInstanceOperationException.class
})
public ResponseEntity<ErrorBody> forbidden(OrdoException exception) {
return respond(HttpStatus.FORBIDDEN, exception);
}
@ExceptionHandler({
TaskAlreadyCompletedException.class,
InstanceAlreadyCompletedException.class,
NoRouteFoundException.class
})
public ResponseEntity<ErrorBody> conflict(OrdoException exception) {
return respond(HttpStatus.CONFLICT, exception);
}
@ExceptionHandler({IllegalArgumentException.class, HttpMessageNotReadableException.class})
public ResponseEntity<ErrorBody> badRequest(Exception exception) {
return respond(HttpStatus.BAD_REQUEST, exception);
}
@ExceptionHandler({OrdoException.class, IllegalStateException.class})
public ResponseEntity<ErrorBody> serverError(RuntimeException exception) {
return respond(HttpStatus.INTERNAL_SERVER_ERROR, exception);
}
private static ResponseEntity<ErrorBody> respond(HttpStatus status, Exception exception) {
return ResponseEntity.status(status)
.body(new ErrorBody(exception.getClass().getSimpleName(), exception.getMessage()));
}
}
@@ -0,0 +1,74 @@
package com.jetlumen.ordo.spring.rest;
import com.jetlumen.ordo.api.ApprovalTask;
import com.jetlumen.ordo.api.OrdoEngine;
import com.jetlumen.ordo.api.TaskStatus;
import com.jetlumen.ordo.api.query.PageRequest;
import com.jetlumen.ordo.api.query.TaskQuery;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.List;
@RestController
@RequestMapping("${ordo.rest.base-path:/ordo}")
public class OrdoTaskController {
private final OrdoEngine engine;
public OrdoTaskController(OrdoEngine engine) {
this.engine = engine;
}
@GetMapping("/tasks/pending")
public List<ApprovalTask> pending(@RequestParam("assignee") String assignee) {
return engine.findPendingTasksByAssignee(assignee);
}
@GetMapping("/tasks/{taskId}")
public ResponseEntity<ApprovalTask> find(@PathVariable("taskId") String taskId) {
return engine.findTask(taskId)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@GetMapping("/tasks")
public PageResponse<ApprovalTask> query(
@RequestParam(name = "assignee", required = false) String assignee,
@RequestParam(name = "instanceId", required = false) String instanceId,
@RequestParam(name = "definitionId", required = false) String definitionId,
@RequestParam(name = "status", required = false) TaskStatus status,
@RequestParam(name = "createdFrom", required = false) Instant createdFrom,
@RequestParam(name = "createdTo", required = false) Instant createdTo,
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "20") int size) {
TaskQuery query = TaskQuery.any()
.withAssignee(assignee)
.withInstanceId(instanceId)
.withDefinitionId(definitionId)
.withStatus(status)
.withCreatedBetween(createdFrom, createdTo);
return PageResponse.of(engine.queryTasks(query, PageRequest.of(page, size)));
}
@PostMapping("/tasks/{taskId}/approve")
public ApprovalTask approve(@PathVariable("taskId") String taskId, @RequestBody ActorCommentRequest request) {
return engine.approve(taskId, request.actor(), request.comment());
}
@PostMapping("/tasks/{taskId}/reject")
public ApprovalTask reject(@PathVariable("taskId") String taskId, @RequestBody ActorCommentRequest request) {
return engine.reject(taskId, request.actor(), request.comment());
}
@PostMapping("/tasks/{taskId}/reassign")
public ApprovalTask reassign(@PathVariable("taskId") String taskId, @RequestBody ReassignTaskRequest request) {
return engine.reassign(taskId, request.actor(), request.newAssignee());
}
}
@@ -0,0 +1,12 @@
package com.jetlumen.ordo.spring.rest;
import com.jetlumen.ordo.api.query.Page;
import java.util.List;
record PageResponse<T>(List<T> content, long totalElements, int page, int size, int totalPages, boolean hasNext) {
static <T> PageResponse<T> of(Page<T> page) {
return new PageResponse<>(page.content(), page.totalElements(), page.page(), page.size(),
page.totalPages(), page.hasNext());
}
}
@@ -0,0 +1,4 @@
package com.jetlumen.ordo.spring.rest;
record ProcessDueRequest(Integer limit) {
}
@@ -0,0 +1,4 @@
package com.jetlumen.ordo.spring.rest;
record ReassignTaskRequest(String actor, String newAssignee) {
}
@@ -0,0 +1,8 @@
package com.jetlumen.ordo.spring.rest;
import java.util.Map;
record StartInstanceRequest(String definitionId, String initiator, ContextBody context) {
record ContextBody(Map<String, Object> variables) {
}
}
@@ -1,2 +1,3 @@
com.jetlumen.ordo.spring.OrdoFlywayAutoConfiguration
com.jetlumen.ordo.spring.OrdoJdbcAutoConfiguration
com.jetlumen.ordo.spring.jdbc.OrdoFlywayAutoConfiguration
com.jetlumen.ordo.spring.jdbc.OrdoJdbcAutoConfiguration
com.jetlumen.ordo.spring.rest.OrdoRestAutoConfiguration
@@ -1,4 +1,4 @@
package com.jetlumen.ordo.spring;
package com.jetlumen.ordo.spring.jdbc;
import com.jetlumen.ordo.api.ActionHandler;
import com.jetlumen.ordo.api.ApprovalStep;
@@ -0,0 +1,152 @@
package com.jetlumen.ordo.spring.rest;
import com.jetlumen.ordo.api.OrdoEngine;
import com.jetlumen.ordo.spring.jdbc.OrdoFlywayAutoConfiguration;
import com.jetlumen.ordo.spring.jdbc.OrdoJdbcAutoConfiguration;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration;
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
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.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.UUID;
import com.jayway.jsonpath.JsonPath;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class OrdoRestAutoConfigurationTest {
private static final String LINEAR_JSON = """
{
"id": "leave-request",
"name": "Leave request",
"steps": [
{ "id": "manager", "name": "Manager approval", "candidates": ["maria"] }
],
"transitions": [
{ "from": "manager", "to": null }
]
}
""";
private final WebApplicationContextRunner runner = new WebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(
DataSourceAutoConfiguration.class,
OrdoFlywayAutoConfiguration.class,
FlywayAutoConfiguration.class,
OrdoJdbcAutoConfiguration.class,
JacksonAutoConfiguration.class,
HttpMessageConvertersAutoConfiguration.class,
DispatcherServletAutoConfiguration.class,
WebMvcAutoConfiguration.class,
OrdoRestAutoConfiguration.class))
.withPropertyValues(
"spring.datasource.url=jdbc:h2:mem:ordo_rest_" + UUID.randomUUID()
+ ";MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1",
"spring.datasource.username=sa");
@Test
void doesNotRegisterControllersWhenRestDisabled() {
runner.run(context -> {
assertThat(context).hasSingleBean(OrdoEngine.class);
assertThat(context).doesNotHaveBean(OrdoDefinitionController.class);
});
}
@Test
void registersControllersWhenEnabled() {
runner.withPropertyValues("ordo.rest.enabled=true").run(context ->
assertThat(context).hasSingleBean(OrdoDefinitionController.class)
.hasSingleBean(OrdoInstanceController.class)
.hasSingleBean(OrdoTaskController.class)
.hasSingleBean(OrdoDueController.class)
.hasSingleBean(OrdoCatalogController.class));
}
@Test
void parsePublishStartApproveAndErrorMappings() {
runner.withPropertyValues("ordo.rest.enabled=true").run(context -> {
MockMvc mvc = MockMvcBuilders.webAppContextSetup(context).build();
mvc.perform(post("/ordo/definitions/parse")
.contentType(MediaType.APPLICATION_JSON)
.content(LINEAR_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value("leave-request"))
.andExpect(jsonPath("$.steps[0].id").value("manager"));
mvc.perform(post("/ordo/definitions")
.contentType(MediaType.APPLICATION_JSON)
.content(LINEAR_JSON))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.version").value(1));
String startBody = mvc.perform(post("/ordo/instances")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"definitionId":"leave-request","initiator":"alice"}
"""))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString();
assertThat(startBody).contains("\"initiator\":\"alice\"");
MvcResult pending = mvc.perform(get("/ordo/tasks/pending").param("assignee", "maria"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").exists())
.andReturn();
String id = JsonPath.read(pending.getResponse().getContentAsString(), "$[0].id");
mvc.perform(post("/ordo/tasks/" + id + "/approve")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"actor":"bob"}
"""))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error").value("UnauthorizedTaskOperationException"));
mvc.perform(post("/ordo/tasks/" + id + "/approve")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"actor":"maria","comment":"ok"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("APPROVED"));
mvc.perform(post("/ordo/tasks/" + id + "/approve")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"actor":"maria"}
"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.error").value("TaskAlreadyCompletedException"));
mvc.perform(post("/ordo/instances")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"definitionId":"missing","initiator":"alice"}
"""))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error").value("DefinitionNotFoundException"));
mvc.perform(post("/ordo/definitions/parse")
.contentType(MediaType.APPLICATION_JSON)
.content("{"))
.andExpect(status().isBadRequest());
});
}
}