From 0288a8b3dd0bedb331792e00f072807d01b72e63 Mon Sep 17 00:00:00 2001 From: 0264408 Date: Thu, 17 Sep 2026 10:30:09 +0800 Subject: [PATCH] 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 --- docs/ordo-rest.openapi.yaml | 490 ++++++++++++++++++ docs/roadmap.md | 9 +- docs/usage.md | 48 +- .../com/jetlumen/ordo/api/OrdoCatalog.java | 34 ++ .../ordo/api/ProcessDefinitionParser.java | 35 +- .../ordo/api/ProcessDefinitionParserTest.java | 9 +- ordo-spring-boot-autoconfigure/pom.xml | 5 + .../jetlumen/ordo/spring/OrdoProperties.java | 28 + .../{ => jdbc}/OrdoDefinitionLoader.java | 2 +- .../ordo/spring/{ => jdbc}/OrdoDuePoller.java | 2 +- .../OrdoFlywayAutoConfiguration.java | 3 +- .../{ => jdbc}/OrdoJdbcAutoConfiguration.java | 7 +- .../ordo/spring/rest/ActorCommentRequest.java | 4 + .../ordo/spring/rest/DefinitionJson.java | 34 ++ .../jetlumen/ordo/spring/rest/ErrorBody.java | 4 + .../spring/rest/OrdoCatalogController.java | 33 ++ .../spring/rest/OrdoDefinitionController.java | 79 +++ .../ordo/spring/rest/OrdoDueController.java | 32 ++ .../spring/rest/OrdoInstanceController.java | 103 ++++ .../rest/OrdoRestAutoConfiguration.java | 39 ++ .../spring/rest/OrdoRestExceptionHandler.java | 61 +++ .../ordo/spring/rest/OrdoTaskController.java | 74 +++ .../ordo/spring/rest/PageResponse.java | 12 + .../ordo/spring/rest/ProcessDueRequest.java | 4 + .../ordo/spring/rest/ReassignTaskRequest.java | 4 + .../spring/rest/StartInstanceRequest.java | 8 + ...ot.autoconfigure.AutoConfiguration.imports | 5 +- .../OrdoJdbcAutoConfigurationTest.java | 2 +- .../rest/OrdoRestAutoConfigurationTest.java | 152 ++++++ 29 files changed, 1295 insertions(+), 27 deletions(-) create mode 100644 docs/ordo-rest.openapi.yaml create mode 100644 ordo-api/src/main/java/com/jetlumen/ordo/api/OrdoCatalog.java rename ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/{ => jdbc}/OrdoDefinitionLoader.java (98%) rename ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/{ => jdbc}/OrdoDuePoller.java (98%) rename ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/{ => jdbc}/OrdoFlywayAutoConfiguration.java (98%) rename ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/{ => jdbc}/OrdoJdbcAutoConfiguration.java (96%) create mode 100644 ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/ActorCommentRequest.java create mode 100644 ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/DefinitionJson.java create mode 100644 ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/ErrorBody.java create mode 100644 ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoCatalogController.java create mode 100644 ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoDefinitionController.java create mode 100644 ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoDueController.java create mode 100644 ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoInstanceController.java create mode 100644 ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoRestAutoConfiguration.java create mode 100644 ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoRestExceptionHandler.java create mode 100644 ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoTaskController.java create mode 100644 ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/PageResponse.java create mode 100644 ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/ProcessDueRequest.java create mode 100644 ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/ReassignTaskRequest.java create mode 100644 ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/StartInstanceRequest.java rename ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/{ => jdbc}/OrdoJdbcAutoConfigurationTest.java (99%) create mode 100644 ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/rest/OrdoRestAutoConfigurationTest.java diff --git a/docs/ordo-rest.openapi.yaml b/docs/ordo-rest.openapi.yaml new file mode 100644 index 0000000..dc01d4b --- /dev/null +++ b/docs/ordo-rest.openapi.yaml @@ -0,0 +1,490 @@ +openapi: 3.0.3 +info: + title: Ordo REST + version: 0.0.1-SNAPSHOT + description: Optional Spring adapter over OrdoEngine. No authentication. Default base path `/ordo`. +servers: + - url: /ordo +paths: + /definitions/parse: + post: + summary: Parse definition JSON without publishing + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProcessDefinitionDocument' + responses: + '200': + description: Parsed graph (version 0) + content: + application/json: + schema: + $ref: '#/components/schemas/ProcessDefinitionDocument' + '400': + $ref: '#/components/responses/Error' + /definitions: + post: + summary: Parse and publish + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProcessDefinitionDocument' + responses: + '201': + description: Published definition + content: + application/json: + schema: + $ref: '#/components/schemas/ProcessDefinitionDocument' + '400': + $ref: '#/components/responses/Error' + get: + summary: Latest version of each definition + parameters: + - $ref: '#/components/parameters/page' + - $ref: '#/components/parameters/size' + responses: + '200': + description: Page of definitions + /definitions/{definitionId}: + get: + parameters: + - $ref: '#/components/parameters/definitionId' + responses: + '200': + description: Latest definition + content: + application/json: + schema: + $ref: '#/components/schemas/ProcessDefinitionDocument' + '404': + description: Not found + /definitions/{definitionId}/versions: + get: + parameters: + - $ref: '#/components/parameters/definitionId' + - $ref: '#/components/parameters/page' + - $ref: '#/components/parameters/size' + responses: + '200': + description: Versions newest first + /definitions/{definitionId}/versions/{version}: + get: + parameters: + - $ref: '#/components/parameters/definitionId' + - name: version + in: path + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Specific version + '404': + description: Not found + /instances: + post: + summary: Start an instance + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [definitionId, initiator] + properties: + definitionId: + type: string + initiator: + type: string + context: + type: object + properties: + variables: + type: object + additionalProperties: true + responses: + '200': + description: Started instance + '404': + $ref: '#/components/responses/Error' + get: + parameters: + - name: definitionId + in: query + schema: + type: string + - name: status + in: query + schema: + type: string + - name: initiator + in: query + schema: + type: string + - name: startedFrom + in: query + schema: + type: string + format: date-time + - name: startedTo + in: query + schema: + type: string + format: date-time + - $ref: '#/components/parameters/page' + - $ref: '#/components/parameters/size' + responses: + '200': + description: Page of instances + /instances/{instanceId}: + get: + parameters: + - $ref: '#/components/parameters/instanceId' + responses: + '200': + description: Instance + '404': + description: Not found + /instances/{instanceId}/withdraw: + post: + parameters: + - $ref: '#/components/parameters/instanceId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ActorComment' + responses: + '200': + description: Withdrawn + '403': + $ref: '#/components/responses/Error' + '404': + $ref: '#/components/responses/Error' + '409': + $ref: '#/components/responses/Error' + /instances/{instanceId}/cancel: + post: + parameters: + - $ref: '#/components/parameters/instanceId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ActorComment' + responses: + '200': + description: Cancelled + '404': + $ref: '#/components/responses/Error' + '409': + $ref: '#/components/responses/Error' + /instances/{instanceId}/tasks: + get: + parameters: + - $ref: '#/components/parameters/instanceId' + responses: + '200': + description: All tasks + /instances/{instanceId}/tasks/pending: + get: + parameters: + - $ref: '#/components/parameters/instanceId' + responses: + '200': + description: Pending tasks + /instances/{instanceId}/history: + get: + parameters: + - $ref: '#/components/parameters/instanceId' + - $ref: '#/components/parameters/page' + - $ref: '#/components/parameters/size' + responses: + '200': + description: Timeline + /instances/{instanceId}/action-executions: + get: + parameters: + - $ref: '#/components/parameters/instanceId' + - $ref: '#/components/parameters/page' + - $ref: '#/components/parameters/size' + responses: + '200': + description: ACTION executions + /tasks: + get: + parameters: + - name: assignee + in: query + schema: + type: string + - name: instanceId + in: query + schema: + type: string + - name: definitionId + in: query + schema: + type: string + - name: status + in: query + schema: + type: string + - name: createdFrom + in: query + schema: + type: string + format: date-time + - name: createdTo + in: query + schema: + type: string + format: date-time + - $ref: '#/components/parameters/page' + - $ref: '#/components/parameters/size' + responses: + '200': + description: Page of tasks + /tasks/pending: + get: + parameters: + - name: assignee + in: query + required: true + schema: + type: string + responses: + '200': + description: Pending tasks for assignee + /tasks/{taskId}: + get: + parameters: + - $ref: '#/components/parameters/taskId' + responses: + '200': + description: Task + '404': + description: Not found + /tasks/{taskId}/approve: + post: + parameters: + - $ref: '#/components/parameters/taskId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ActorComment' + responses: + '200': + description: Approved + '403': + $ref: '#/components/responses/Error' + '409': + $ref: '#/components/responses/Error' + /tasks/{taskId}/reject: + post: + parameters: + - $ref: '#/components/parameters/taskId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ActorComment' + responses: + '200': + description: Rejected + '403': + $ref: '#/components/responses/Error' + '409': + $ref: '#/components/responses/Error' + /tasks/{taskId}/reassign: + post: + parameters: + - $ref: '#/components/parameters/taskId' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [actor, newAssignee] + properties: + actor: + type: string + newAssignee: + type: string + responses: + '200': + description: Reassigned + '403': + $ref: '#/components/responses/Error' + '409': + $ref: '#/components/responses/Error' + /due: + post: + parameters: + - name: limit + in: query + schema: + type: integer + minimum: 1 + default: 100 + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + limit: + type: integer + minimum: 1 + responses: + '200': + description: Batch result + content: + application/json: + schema: + type: object + properties: + processed: + type: integer + '400': + $ref: '#/components/responses/Error' + /catalog/conditions: + get: + responses: + '200': + description: conditionKey catalog + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CatalogItem' + /catalog/actions: + get: + responses: + '200': + description: actionKey catalog + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CatalogItem' + /catalog/assignees: + get: + responses: + '200': + description: Assignee catalog + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CatalogItem' +components: + parameters: + page: + name: page + in: query + schema: + type: integer + minimum: 0 + default: 0 + size: + name: size + in: query + schema: + type: integer + minimum: 1 + default: 20 + definitionId: + name: definitionId + in: path + required: true + schema: + type: string + instanceId: + name: instanceId + in: path + required: true + schema: + type: string + taskId: + name: taskId + in: path + required: true + schema: + type: string + responses: + Error: + description: Error body + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + schemas: + ErrorBody: + type: object + properties: + error: + type: string + message: + type: string + ActorComment: + type: object + required: [actor] + properties: + actor: + type: string + comment: + type: string + CatalogItem: + type: object + properties: + key: + type: string + label: + type: string + ProcessDefinitionDocument: + type: object + required: [id, name, steps, transitions] + properties: + id: + type: string + name: + type: string + version: + type: integer + startStep: + type: string + steps: + type: array + items: + type: object + transitions: + type: array + items: + type: object + properties: + from: + type: string + to: + type: string + nullable: true + when: + type: string + priority: + type: integer diff --git a/docs/roadmap.md b/docs/roadmap.md index 1677d8a..983ad4b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -17,19 +17,12 @@ - 任务转派 `reassign` / `TASK_REASSIGNED` - 到期升级 `processDue` / `TASK_ESCALATED` - 管理员/系统取消 `cancel` / `CANCELLED` +- 可选 REST(autoconfigure 条件装配)+ 目录 SPI `OrdoCatalog` ## 开发计划(确定要做) 下列能力已纳入计划,尚未实现。实现顺序可按依赖调整,但范围本身不从计划中拿掉。 -### 可选 REST + 目录 SPI - -引擎入口仍是 `OrdoEngine`。计划提供**可选、极薄**的 REST 适配(例如独立 starter),带 OpenAPI,覆盖定义读写/解析校验、实例与任务查询,不包含鉴权、RBAC、业务表单。 - -配套 **目录 SPI**:宿主登记可用的 `conditionKey` / `actionKey` / 候选人(或角色)项,供 REST 与外部设计器下拉,而不是在 JSON 里写引擎无法执行的表达式。 - -`RoutingCondition` / `ActionHandler` / `AssigneeResolver` 保持全局单例;流程隔离由宿主用 key 约定(建议前缀)+ 门面分发,引擎不按流程定义拆 bean。 - ### 独立设计器(不进本仓库) 流程设计器是**单独产品**,消费上述 REST 与目录,不做成 ordo 模块。画布对齐引擎图(审批步、ACTION 步、边上的 `when`/`priority`,结束为 `to: null`),不引入 BPMN 网关/并行等引擎没有的语义。节点坐标等 layout 由设计器自存,不进入 `ProcessDefinition`。 diff --git a/docs/usage.md b/docs/usage.md index dce4567..ff37c36 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -10,9 +10,7 @@ Ordo 是嵌入宿主进程的审批引擎,入口是 `OrdoEngine`。 做:流程定义、实例推进、待办任务、条件路由、ACTION 副作用、审计事件、分页查询。 -产品边界:不做业务表单、用户体系、多租户;不内置设计器 UI。业务字段放在 `ProcessContext`(不可变 `Map`)。当前也**没有** REST;HTTP 仍由宿主自建。计划中的可选 REST 与独立设计器见 [roadmap.md](roadmap.md)。 - -开发计划(尚未提供,见 [roadmap.md](roadmap.md)):可选 REST + 目录 SPI。 +产品边界:不做业务表单、用户体系、多租户;不内置设计器 UI。业务字段放在 `ProcessContext`(不可变 `Map`)。可选 HTTP 见 §2.3;独立设计器见 [roadmap.md](roadmap.md)。 ## 2. 模块与接入 @@ -66,6 +64,9 @@ ordo: location: classpath*:ordo/*.json # 启动时对每个 JSON 调用 publish due: poll-ms: 0 # >0 时轮询 processDue;默认不调度 + rest: + enabled: false + base-path: /ordo ``` 启动加载使用 `publish`:图与 latest 相同则不升版本;不同则写入新版本。运行中实例继续锁定发起时所用版本。 @@ -78,9 +79,44 @@ ordo: | `RoutingCondition` | 始终匹配(无条件边) | | `ActionHandler` | 空操作 | | `OrdoEventListener` | 可有多个,提交后按 Spring 顺序调用 | +| `OrdoCatalog` | 空列表(仅 REST 打开时装配) | 未提供自定义 Bean 时,ACTION 步骤仍会推进流程,但 handler 什么都不做。 +### 2.3 可选 REST + +`spring-boot-starter-web` 对 autoconfigure 为 optional,starter **不**传递 Web。宿主已有 Web 且 `ordo.rest.enabled=true` 时注册 `com.jetlumen.ordo.spring.rest` 下的接口。不包含鉴权;`actor` / `initiator` 放在 JSON 体。OpenAPI:[ordo-rest.openapi.yaml](ordo-rest.openapi.yaml)。 + +默认前缀 `/ordo`: + +| 方法 | 路径 | 引擎 | +|---|---|---| +| POST | `/definitions/parse` | `ProcessDefinitionParser.fromJson` | +| POST | `/definitions` | `publish` | +| GET | `/definitions` | `queryDefinitions` | +| GET | `/definitions/{id}` | `findDefinition`(latest) | +| GET | `/definitions/{id}/versions` | `queryDefinitionVersions` | +| GET | `/definitions/{id}/versions/{version}` | `findDefinition(id, version)` | +| POST | `/instances` | `start` | +| GET | `/instances` | `queryInstances` | +| GET | `/instances/{id}` | `findInstance` | +| POST | `/instances/{id}/withdraw` | `withdraw` | +| POST | `/instances/{id}/cancel` | `cancel` | +| GET | `/instances/{id}/tasks` | `findTasks` | +| GET | `/instances/{id}/tasks/pending` | `findPendingTasksByInstanceId` | +| GET | `/instances/{id}/history` | `queryHistory` | +| GET | `/instances/{id}/action-executions` | `queryActionExecutions` | +| GET | `/tasks` | `queryTasks` | +| GET | `/tasks/pending?assignee=` | `findPendingTasksByAssignee` | +| GET | `/tasks/{id}` | `findTask` | +| POST | `/tasks/{id}/approve` | `approve` | +| POST | `/tasks/{id}/reject` | `reject` | +| POST | `/tasks/{id}/reassign` | `reassign` | +| POST | `/due` | `processDue`(默认 limit 100) | +| GET | `/catalog/conditions\|actions\|assignees` | `OrdoCatalog` | + +定义读写 JSON 与 §3.2 相同。HTTP:404 找不到;403 越权;409 已完成/无路由;400 非法参数。 + ## 3. 流程定义 每个定义有 `id`、引擎分配的 `version`、`name`、步骤列表、转移列表。步骤 id 在定义内唯一。每个步骤必须至少有一条出边(结束用 `to = null`)。同一 `from` 上 `priority` 不能重复。 @@ -121,7 +157,7 @@ new ProcessDefinition("leave-request-routed", "Leave request", ### 3.2 JSON -`ProcessDefinitionParser.fromJson(String|InputStream)`。Spring 默认扫 `classpath*:ordo/*.json`。 +`ProcessDefinitionParser.fromJson(String|InputStream)` / `toJson(ProcessDefinition)`。Spring 默认扫 `classpath*:ordo/*.json`。`toJson` 写出 `version` 与 `startStep`(当前步骤列表首位);`fromJson` 仍忽略 JSON 里的 `version`。 ```json { @@ -355,6 +391,6 @@ Flyway 脚本按方言分目录:`db/postgresql/migration`、`db/mysql/migratio ## 13. 未提供能力 -开发计划中(见 [roadmap.md](roadmap.md)):可选 REST + 目录 SPI。独立设计器不进本仓库,等 REST 与目录之后再做。 +独立设计器不进本仓库,等 REST 与目录之后由单独产品消费(见 [roadmap.md](roadmap.md))。 -暂不在计划中:多租户、设计器 UI。当前 REST 由宿主自建。 +暂不在计划中:多租户、设计器 UI。 diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/OrdoCatalog.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/OrdoCatalog.java new file mode 100644 index 0000000..1a19212 --- /dev/null +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/OrdoCatalog.java @@ -0,0 +1,34 @@ +package com.jetlumen.ordo.api; + +import java.util.List; + +/** Host-registered keys for designers and REST dropdowns; not used by the engine at runtime. */ +public interface OrdoCatalog { + List conditions(); + + List actions(); + + List assignees(); + + record Item(String key, String label) { + } + + static OrdoCatalog empty() { + return new OrdoCatalog() { + @Override + public List conditions() { + return List.of(); + } + + @Override + public List actions() { + return List.of(); + } + + @Override + public List assignees() { + return List.of(); + } + }; + } +} 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 index e147ed5..8c03f20 100644 --- a/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessDefinitionParser.java +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessDefinitionParser.java @@ -1,5 +1,6 @@ package com.jetlumen.ordo.api; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -10,11 +11,13 @@ import java.time.DateTimeException; import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.Locale; 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 static final ObjectMapper MAPPER = new ObjectMapper() + .setSerializationInclusion(JsonInclude.Include.NON_NULL); private ProcessDefinitionParser() { } @@ -28,6 +31,15 @@ public final class ProcessDefinitionParser { } } + public static String toJson(ProcessDefinition definition) { + Objects.requireNonNull(definition, "definition must not be null"); + try { + return MAPPER.writeValueAsString(fromDefinition(definition)); + } 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 { @@ -66,6 +78,27 @@ public final class ProcessDefinitionParser { return new ProcessDefinition(document.id(), document.name(), steps, transitions); } + private static DefinitionDocument fromDefinition(ProcessDefinition definition) { + List steps = new ArrayList<>(definition.steps().size()); + for (ApprovalStep step : definition.steps()) { + DueDocument due = null; + if (step.due() != null) { + StepDue stepDue = step.due(); + due = new DueDocument(stepDue.after().toString(), stepDue.then().name().toLowerCase(Locale.ROOT), + stepDue.to(), stepDue.action()); + } + steps.add(new StepDocument(step.id(), step.name(), step.candidates(), step.policy(), step.kind(), + step.actionKey(), due)); + } + List transitions = new ArrayList<>(definition.transitions().size()); + for (StepTransition transition : definition.transitions()) { + transitions.add(new TransitionDocument(transition.fromStepId(), transition.toStepId(), + transition.conditionKey(), transition.priority())); + } + return new DefinitionDocument(definition.id(), definition.name(), definition.version(), + definition.steps().get(0).id(), steps, transitions); + } + private static void rotateStartStep(List steps, String startStepId) { if (startStepId == null || startStepId.isBlank()) { return; 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 index 92de964..743f23a 100644 --- a/ordo-api/src/test/java/com/jetlumen/ordo/api/ProcessDefinitionParserTest.java +++ b/ordo-api/src/test/java/com/jetlumen/ordo/api/ProcessDefinitionParserTest.java @@ -7,6 +7,7 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class ProcessDefinitionParserTest { @@ -37,8 +38,12 @@ class ProcessDefinitionParserTest { StepTransition.end("hr") )); - assertEquals(expected, ProcessDefinitionParser.fromJson(LEAVE_REQUEST_JSON)); - assertEquals(0, ProcessDefinitionParser.fromJson(LEAVE_REQUEST_JSON).version()); + ProcessDefinition definition = ProcessDefinitionParser.fromJson(LEAVE_REQUEST_JSON); + assertEquals(expected, definition); + assertEquals(0, definition.version()); + ProcessDefinition roundTrip = ProcessDefinitionParser.fromJson(ProcessDefinitionParser.toJson(definition)); + assertTrue(definition.sameGraph(roundTrip)); + assertEquals("manager", roundTrip.steps().get(0).id()); } @Test diff --git a/ordo-spring-boot-autoconfigure/pom.xml b/ordo-spring-boot-autoconfigure/pom.xml index 6170f3c..3b26154 100644 --- a/ordo-spring-boot-autoconfigure/pom.xml +++ b/ordo-spring-boot-autoconfigure/pom.xml @@ -66,6 +66,11 @@ org.flywaydb flyway-mysql + + org.springframework.boot + spring-boot-starter-web + true + org.springframework.boot spring-boot-configuration-processor 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 8f1e856..192da80 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 @@ -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; + } + } } 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/jdbc/OrdoDefinitionLoader.java similarity index 98% rename from ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoDefinitionLoader.java rename to ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/jdbc/OrdoDefinitionLoader.java index bb945e4..aefbc30 100644 --- 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/jdbc/OrdoDefinitionLoader.java @@ -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; diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoDuePoller.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/jdbc/OrdoDuePoller.java similarity index 98% rename from ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoDuePoller.java rename to ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/jdbc/OrdoDuePoller.java index 6ece463..2e700e3 100644 --- a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoDuePoller.java +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/jdbc/OrdoDuePoller.java @@ -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; diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoFlywayAutoConfiguration.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/jdbc/OrdoFlywayAutoConfiguration.java similarity index 98% rename from ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoFlywayAutoConfiguration.java rename to ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/jdbc/OrdoFlywayAutoConfiguration.java index 7ae9532..a98e83c 100644 --- a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoFlywayAutoConfiguration.java +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/jdbc/OrdoFlywayAutoConfiguration.java @@ -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; 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/jdbc/OrdoJdbcAutoConfiguration.java similarity index 96% rename from ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfiguration.java rename to ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/jdbc/OrdoJdbcAutoConfiguration.java index e418809..4f821ee 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/jdbc/OrdoJdbcAutoConfiguration.java @@ -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 { diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/ActorCommentRequest.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/ActorCommentRequest.java new file mode 100644 index 0000000..82a8117 --- /dev/null +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/ActorCommentRequest.java @@ -0,0 +1,4 @@ +package com.jetlumen.ordo.spring.rest; + +record ActorCommentRequest(String actor, String comment) { +} diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/DefinitionJson.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/DefinitionJson.java new file mode 100644 index 0000000..f57b989 --- /dev/null +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/DefinitionJson.java @@ -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 = new TypeReference<>() { + }; + + private DefinitionJson() { + } + + static Map map(ProcessDefinition definition) { + try { + return MAPPER.readValue(ProcessDefinitionParser.toJson(definition), MAP); + } catch (JsonProcessingException e) { + throw new IllegalStateException(e); + } + } + + static String write(Map body) { + try { + return MAPPER.writeValueAsString(body); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("invalid process definition json", e); + } + } +} diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/ErrorBody.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/ErrorBody.java new file mode 100644 index 0000000..dd80d35 --- /dev/null +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/ErrorBody.java @@ -0,0 +1,4 @@ +package com.jetlumen.ordo.spring.rest; + +record ErrorBody(String error, String message) { +} diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoCatalogController.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoCatalogController.java new file mode 100644 index 0000000..415b568 --- /dev/null +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoCatalogController.java @@ -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 conditions() { + return catalog.conditions(); + } + + @GetMapping("/catalog/actions") + public List actions() { + return catalog.actions(); + } + + @GetMapping("/catalog/assignees") + public List assignees() { + return catalog.assignees(); + } +} diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoDefinitionController.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoDefinitionController.java new file mode 100644 index 0000000..ee843df --- /dev/null +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoDefinitionController.java @@ -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 parse(@RequestBody Map body) { + return DefinitionJson.map(ProcessDefinitionParser.fromJson(DefinitionJson.write(body))); + } + + @PostMapping("/definitions") + public ResponseEntity> publish(@RequestBody Map body) { + ProcessDefinition published = engine.publish(ProcessDefinitionParser.fromJson(DefinitionJson.write(body))); + return ResponseEntity.status(HttpStatus.CREATED).body(DefinitionJson.map(published)); + } + + @GetMapping("/definitions") + public PageResponse> query( + @RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "size", defaultValue = "20") int size) { + var result = engine.queryDefinitions(PageRequest.of(page, size)); + List> 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> 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> 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> 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> 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()); + } +} diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoDueController.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoDueController.java new file mode 100644 index 0000000..c81bf13 --- /dev/null +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoDueController.java @@ -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 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)); + } +} diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoInstanceController.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoInstanceController.java new file mode 100644 index 0000000..2c196c2 --- /dev/null +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoInstanceController.java @@ -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 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 find(@PathVariable("instanceId") String instanceId) { + return engine.findInstance(instanceId) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @GetMapping("/instances") + public PageResponse 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 tasks(@PathVariable("instanceId") String instanceId) { + return engine.findTasks(instanceId); + } + + @GetMapping("/instances/{instanceId}/tasks/pending") + public List pendingTasks(@PathVariable("instanceId") String instanceId) { + return engine.findPendingTasksByInstanceId(instanceId); + } + + @GetMapping("/instances/{instanceId}/history") + public PageResponse 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 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))); + } +} 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 new file mode 100644 index 0000000..36e3676 --- /dev/null +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoRestAutoConfiguration.java @@ -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(); + } +} diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoRestExceptionHandler.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoRestExceptionHandler.java new file mode 100644 index 0000000..e765c69 --- /dev/null +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoRestExceptionHandler.java @@ -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 notFound(OrdoException exception) { + return respond(HttpStatus.NOT_FOUND, exception); + } + + @ExceptionHandler({ + UnauthorizedTaskOperationException.class, + UnauthorizedInstanceOperationException.class + }) + public ResponseEntity forbidden(OrdoException exception) { + return respond(HttpStatus.FORBIDDEN, exception); + } + + @ExceptionHandler({ + TaskAlreadyCompletedException.class, + InstanceAlreadyCompletedException.class, + NoRouteFoundException.class + }) + public ResponseEntity conflict(OrdoException exception) { + return respond(HttpStatus.CONFLICT, exception); + } + + @ExceptionHandler({IllegalArgumentException.class, HttpMessageNotReadableException.class}) + public ResponseEntity badRequest(Exception exception) { + return respond(HttpStatus.BAD_REQUEST, exception); + } + + @ExceptionHandler({OrdoException.class, IllegalStateException.class}) + public ResponseEntity serverError(RuntimeException exception) { + return respond(HttpStatus.INTERNAL_SERVER_ERROR, exception); + } + + private static ResponseEntity respond(HttpStatus status, Exception exception) { + return ResponseEntity.status(status) + .body(new ErrorBody(exception.getClass().getSimpleName(), exception.getMessage())); + } +} diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoTaskController.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoTaskController.java new file mode 100644 index 0000000..e5274fe --- /dev/null +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/OrdoTaskController.java @@ -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 pending(@RequestParam("assignee") String assignee) { + return engine.findPendingTasksByAssignee(assignee); + } + + @GetMapping("/tasks/{taskId}") + public ResponseEntity find(@PathVariable("taskId") String taskId) { + return engine.findTask(taskId) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @GetMapping("/tasks") + public PageResponse 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()); + } +} diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/PageResponse.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/PageResponse.java new file mode 100644 index 0000000..b017503 --- /dev/null +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/PageResponse.java @@ -0,0 +1,12 @@ +package com.jetlumen.ordo.spring.rest; + +import com.jetlumen.ordo.api.query.Page; + +import java.util.List; + +record PageResponse(List content, long totalElements, int page, int size, int totalPages, boolean hasNext) { + static PageResponse of(Page page) { + return new PageResponse<>(page.content(), page.totalElements(), page.page(), page.size(), + page.totalPages(), page.hasNext()); + } +} diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/ProcessDueRequest.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/ProcessDueRequest.java new file mode 100644 index 0000000..7ae7013 --- /dev/null +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/ProcessDueRequest.java @@ -0,0 +1,4 @@ +package com.jetlumen.ordo.spring.rest; + +record ProcessDueRequest(Integer limit) { +} diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/ReassignTaskRequest.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/ReassignTaskRequest.java new file mode 100644 index 0000000..285c15b --- /dev/null +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/ReassignTaskRequest.java @@ -0,0 +1,4 @@ +package com.jetlumen.ordo.spring.rest; + +record ReassignTaskRequest(String actor, String newAssignee) { +} diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/StartInstanceRequest.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/StartInstanceRequest.java new file mode 100644 index 0000000..4c083c9 --- /dev/null +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/rest/StartInstanceRequest.java @@ -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 variables) { + } +} diff --git a/ordo-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/ordo-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 588fcc1..c5303e1 100644 --- a/ordo-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/ordo-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -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 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/jdbc/OrdoJdbcAutoConfigurationTest.java similarity index 99% rename from ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfigurationTest.java rename to ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/jdbc/OrdoJdbcAutoConfigurationTest.java index 3a5b05b..370af6d 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/jdbc/OrdoJdbcAutoConfigurationTest.java @@ -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; 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 new file mode 100644 index 0000000..5bb4da8 --- /dev/null +++ b/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/rest/OrdoRestAutoConfigurationTest.java @@ -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()); + }); + } +}