Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e177e9592a | ||
|
|
0288a8b3dd |
@@ -1,131 +0,0 @@
|
|||||||
建议将 JDBC 做成独立模块,并在实现前先补上“跨仓储事务”和“并发安全”两个能力;否则一次审批会拆成多次独立数据库操作,容易留下半完成流程。
|
|
||||||
|
|
||||||
1. 新建模块
|
|
||||||
|
|
||||||
```text
|
|
||||||
ordo-storage-jdbc/
|
|
||||||
├── pom.xml
|
|
||||||
└── src/main/
|
|
||||||
├── java/com/jetlumen/ordo/storage/jdbc/
|
|
||||||
│ ├── JdbcTransactionExecutor.java
|
|
||||||
│ ├── JdbcProcessDefinitionRepository.java
|
|
||||||
│ ├── JdbcProcessInstanceRepository.java
|
|
||||||
│ ├── JdbcApprovalTaskRepository.java
|
|
||||||
│ ├── JdbcConnectionProvider.java
|
|
||||||
│ └── mapper/
|
|
||||||
└── resources/db/migration/
|
|
||||||
└── V1__create_ordo_tables.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
依赖只需要 `ordo-api`、`javax.sql.DataSource` 和 JDBC 驱动;先不要依赖 Spring。
|
|
||||||
|
|
||||||
2. 先补事务边界
|
|
||||||
|
|
||||||
在 `ordo-api` 增加一个通用端口:
|
|
||||||
|
|
||||||
```java
|
|
||||||
public interface TransactionExecutor {
|
|
||||||
<T> T execute(Supplier<T> action);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`DefaultOrdoEngine` 的 `start`、`approve`、`reject` 应在同一个事务中执行。
|
|
||||||
|
|
||||||
这保证:
|
|
||||||
|
|
||||||
- 发起流程时,“创建实例 + 创建第一条任务”要么都成功,要么都回滚。
|
|
||||||
- 审批时,“完成旧任务 + 创建下一任务 / 结束实例”要么都成功,要么都回滚。
|
|
||||||
|
|
||||||
内存实现提供无操作事务执行器;JDBC 实现使用同一条线程绑定的 `Connection`,执行 `commit` 或 `rollback`。
|
|
||||||
|
|
||||||
3. 调整仓储 SPI 的并发语义
|
|
||||||
|
|
||||||
当前 `save` 是覆盖式写入,JDBC 下无法避免两个用户同时审批同一任务。建议在落 JDBC 前调整:
|
|
||||||
|
|
||||||
```java
|
|
||||||
boolean insertIfAbsent(ProcessDefinition definition);
|
|
||||||
|
|
||||||
boolean completeIfPending(ApprovalTask completedTask);
|
|
||||||
```
|
|
||||||
|
|
||||||
`completeIfPending` 对应 SQL:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
UPDATE ordo_approval_task
|
|
||||||
SET status = ?, completed_at = ?, action_actor = ?, action_comment = ?, action_at = ?
|
|
||||||
WHERE id = ? AND status = 'PENDING'
|
|
||||||
```
|
|
||||||
|
|
||||||
受影响行数为 `0` 时,抛出 `TaskAlreadyCompletedException`。这比仅依赖 JVM 内的 `synchronized` 更可靠。
|
|
||||||
|
|
||||||
4. 数据库模型
|
|
||||||
|
|
||||||
采用规范化表,不把步骤和任务都塞进 JSON。
|
|
||||||
|
|
||||||
```text
|
|
||||||
ordo_process_definition
|
|
||||||
- id varchar(64) primary key
|
|
||||||
- name varchar(255) not null
|
|
||||||
|
|
||||||
ordo_approval_step
|
|
||||||
- definition_id varchar(64) not null
|
|
||||||
- step_id varchar(64) not null
|
|
||||||
- step_name varchar(255) not null
|
|
||||||
- assignee varchar(255) not null
|
|
||||||
- step_order integer not null
|
|
||||||
- primary key (definition_id, step_id)
|
|
||||||
|
|
||||||
ordo_process_instance
|
|
||||||
- id varchar(36) primary key
|
|
||||||
- definition_id varchar(64) not null
|
|
||||||
- initiator varchar(255) not null
|
|
||||||
- status varchar(32) not null
|
|
||||||
- context_json text not null
|
|
||||||
- started_at timestamp not null
|
|
||||||
- finished_at timestamp null
|
|
||||||
|
|
||||||
ordo_approval_task
|
|
||||||
- id varchar(36) primary key
|
|
||||||
- instance_id varchar(36) not null
|
|
||||||
- step_id varchar(64) not null
|
|
||||||
- task_name varchar(255) not null
|
|
||||||
- assignee varchar(255) not null
|
|
||||||
- status varchar(32) not null
|
|
||||||
- created_at timestamp not null
|
|
||||||
- completed_at timestamp null
|
|
||||||
- action_actor varchar(255) null
|
|
||||||
- action_comment text null
|
|
||||||
- action_at timestamp null
|
|
||||||
```
|
|
||||||
|
|
||||||
至少建立:
|
|
||||||
|
|
||||||
```text
|
|
||||||
ordo_approval_task(instance_id)
|
|
||||||
ordo_approval_task(status, assignee)
|
|
||||||
ordo_process_instance(definition_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
5. `ProcessContext` 的持久化
|
|
||||||
|
|
||||||
`ProcessContext.variables` 适合存为 `context_json`。在 JDBC 模块内部使用 Jackson 做序列化与反序列化,不要让 `ordo-api` 依赖 Jackson。
|
|
||||||
|
|
||||||
v0.1 可以约定上下文仅支持 JSON 兼容值:字符串、数字、布尔值、列表、嵌套 Map。日期、枚举和自定义 Java 对象以后再通过可插拔 `ContextCodec` 解决。
|
|
||||||
|
|
||||||
6. JDBC 实现顺序
|
|
||||||
|
|
||||||
- 先写 `V1__create_ordo_tables.sql`
|
|
||||||
- 实现 `JdbcTransactionExecutor`
|
|
||||||
- 实现定义仓储与步骤读写
|
|
||||||
- 实现实例仓储及 `context_json`
|
|
||||||
- 实现任务仓储及待办查询
|
|
||||||
- 调整 `DefaultOrdoEngine` 使用事务和条件更新
|
|
||||||
- 为 JDBC 仓储添加集成测试
|
|
||||||
|
|
||||||
7. 测试策略
|
|
||||||
|
|
||||||
先用 H2 快速验证 CRUD 和映射;并发条件更新、时间类型、唯一约束等最终应使用 Testcontainers 加 PostgreSQL 或 MySQL 验证。
|
|
||||||
|
|
||||||
建议第一版目标是 PostgreSQL;表结构、`timestamp` 语义和 JSON 支持都会更明确。等 JDBC 实现稳定后,再创建 Spring Boot Starter:Starter 只负责注入 `DataSource`、JDBC 仓储、事务执行器和 `DefaultOrdoEngine`。
|
|
||||||
|
|
||||||
帮忙按照这个实现一下JDBC模块
|
|
||||||
@@ -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
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
# Plan: 条件路由 (Conditional Routing / RoutingCondition)
|
|
||||||
|
|
||||||
## 已与用户确认的关键决策
|
|
||||||
1. transitions 挂在 `ProcessDefinition` 级别(独立列表),不改动 `ApprovalStep`。
|
|
||||||
2. `RoutingCondition` 采用与 `AssigneeResolver` 一致的模式:全局单例 bean,
|
|
||||||
`boolean matches(String conditionKey, ProcessContext context)`;数据库只存 `conditionKey`
|
|
||||||
字符串,不持久化任何 Java lambda/表达式(因为 JDBC 场景下引擎重启要从数据库重新装配
|
|
||||||
`ProcessDefinition`,无法反序列化任意代码逻辑)。
|
|
||||||
3. **不保留线性回退**:每个 `ProcessDefinition` 必须显式声明 transitions,覆盖每个 step 的
|
|
||||||
出边;没有 transitions 完全是非法状态(构造期校验报错),不再依赖 `steps()` 列表顺序做
|
|
||||||
执行期推进(`step_order`/列表顺序仅用于候选人/去重校验和展示,不再决定执行流)。
|
|
||||||
4. 匹配不到任何 transition 时,fail-fast 抛出 `NoRouteFoundException`(不静默回退)。
|
|
||||||
|
|
||||||
## 模型设计 (ordo-api)
|
|
||||||
|
|
||||||
### 新增 `StepTransition` record
|
|
||||||
```
|
|
||||||
record StepTransition(String fromStepId, String toStepId, String conditionKey, int priority)
|
|
||||||
```
|
|
||||||
- `toStepId == null` 表示"终止流程"(当匹配到这条边时,实例被判 APPROVED 并完成)。
|
|
||||||
- `conditionKey == null/blank` 表示无条件(总是匹配),用作 else/默认分支。
|
|
||||||
- 便捷工厂:
|
|
||||||
- `StepTransition.always(fromStepId, toStepId)` — 无条件跳转,priority=0
|
|
||||||
- `StepTransition.when(fromStepId, toStepId, conditionKey, priority)` — 条件跳转
|
|
||||||
- `StepTransition.end(fromStepId)` — 无条件终止
|
|
||||||
- `StepTransition.endWhen(fromStepId, conditionKey, priority)` — 条件终止
|
|
||||||
- 评估顺序:按 `priority` 升序,第一个匹配的生效(无条件 = 恒真)。
|
|
||||||
|
|
||||||
### 新增 `RoutingCondition` functional interface(与 AssigneeResolver 同目录/同风格)
|
|
||||||
```
|
|
||||||
@FunctionalInterface
|
|
||||||
public interface RoutingCondition {
|
|
||||||
boolean matches(String conditionKey, ProcessContext context);
|
|
||||||
static RoutingCondition always() { return (key, context) -> true; }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `ProcessDefinition` 改为 4 个分量(破坏性变更)
|
|
||||||
```
|
|
||||||
record ProcessDefinition(String id, String name, List<ApprovalStep> steps, List<StepTransition> transitions)
|
|
||||||
```
|
|
||||||
构造期校验(在现有 id/name/steps 非空+去重校验基础上新增):
|
|
||||||
- `transitions` 非 null,`List.copyOf` 防御性拷贝。
|
|
||||||
- 每条 transition 的 `fromStepId` 必须是 `steps` 中存在的 id;`toStepId`(非 null 时)也必须存在。
|
|
||||||
- 每个 step 的 id 必须至少作为一条 transition 的 `fromStepId` 出现一次(强制显式声明出边),
|
|
||||||
否则 `IllegalArgumentException("step X has no outgoing transition")`。
|
|
||||||
- 同一 `(fromStepId, priority)` 不能重复(避免评估顺序有歧义)。
|
|
||||||
- 新增便捷工厂 `ProcessDefinition.linear(id, name, steps)`:自动生成"上一步无条件指向下一步、
|
|
||||||
最后一步无条件终止"的 transitions,等价于旧的线性行为,用于降低无需真正条件路由的测试/demo
|
|
||||||
的迁移成本(生成的仍然是显式 transitions,满足"强制显式"的约束,只是自动拼装)。
|
|
||||||
|
|
||||||
### 新增异常 `NoRouteFoundException extends OrdoException`
|
|
||||||
- message 形如 `"no matching transition for step: <stepId> in instance: <instanceId>"`。
|
|
||||||
- 放在 `com.jetlumen.ordo.api.exception` 包,命名/风格对齐现有 `DefinitionNotFoundException` 等。
|
|
||||||
|
|
||||||
## 引擎改造 (ordo-core)
|
|
||||||
|
|
||||||
### `DefaultOrdoEngine`
|
|
||||||
- 构造函数新增 `RoutingCondition routingCondition` 参数(在 `assigneeResolver` 之后插入,
|
|
||||||
与 Spring autoconfigure bean 顺序一致)。
|
|
||||||
- 删除 `indexOf(...)` 私有方法(不再需要基于下标推进)。
|
|
||||||
- `advanceOrComplete(instance, definition, step, now)` 重写为:
|
|
||||||
1. 调用新的私有 `resolveTransition(definition, step, context)`:
|
|
||||||
过滤 `definition.transitions()` 中 `fromStepId == step.id()` 的项,按 `priority` 升序排序,
|
|
||||||
依次判断 `conditionKey` 为空/blank(无条件恒真)或 `routingCondition.matches(conditionKey, context)`,
|
|
||||||
返回第一个匹配的 `StepTransition`;若无匹配,抛出 `NoRouteFoundException`。
|
|
||||||
2. 若匹配结果 `toStepId() == null` → `completeInstance(instance, ProcessStatus.APPROVED, now)`。
|
|
||||||
3. 否则 → `createStepTasks(instance, requireStep(definition, matched.toStepId()), now)`。
|
|
||||||
- `advanceAfterDecision` 中原有 ANY/ALL 策略判断逻辑不变,只是"推进到下一步"的调用点改为走上面的
|
|
||||||
transition 解析,不再用 `stepIndex == size-1` 判断是否最后一步。
|
|
||||||
- `NoRouteFoundException` 在 `transactionExecutor.execute(...)` 内抛出,天然触发整个 approve/reject
|
|
||||||
事务回滚(复用现有 `rollsBackTheWholeApprovalWhenTheNextStepCannotBeCreated` 验证过的回滚机制)。
|
|
||||||
- `start()` 的起始 step 仍取 `definition.steps().getFirst()`(未改变,属于本次范围外的假设,
|
|
||||||
如需显式声明起始 step 可后续再提出)。
|
|
||||||
|
|
||||||
### `InMemoryOrdoEngine`
|
|
||||||
- 仿照现有 `AssigneeResolver` 构造函数重载模式,新增:
|
|
||||||
- `InMemoryOrdoEngine(RoutingCondition routingCondition)`
|
|
||||||
- `InMemoryOrdoEngine(AssigneeResolver assigneeResolver, RoutingCondition routingCondition)`
|
|
||||||
- `InMemoryOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, RoutingCondition routingCondition)`
|
|
||||||
- 默认无参/仅 Clock 的构造函数内部使用 `RoutingCondition.always()`。
|
|
||||||
|
|
||||||
## 持久化 (ordo-storage-jdbc)
|
|
||||||
|
|
||||||
### 新迁移 `V3__add_step_transitions.sql`
|
|
||||||
```sql
|
|
||||||
CREATE TABLE ordo_step_transition (
|
|
||||||
definition_id VARCHAR(64) NOT NULL,
|
|
||||||
from_step_id VARCHAR(64) NOT NULL,
|
|
||||||
to_step_id VARCHAR(64),
|
|
||||||
condition_key VARCHAR(255),
|
|
||||||
priority INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (definition_id, from_step_id, priority),
|
|
||||||
CONSTRAINT fk_transition_from FOREIGN KEY (definition_id, from_step_id)
|
|
||||||
REFERENCES ordo_approval_step (definition_id, step_id),
|
|
||||||
CONSTRAINT fk_transition_to FOREIGN KEY (definition_id, to_step_id)
|
|
||||||
REFERENCES ordo_approval_step (definition_id, step_id)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
**重要提醒(已踩过的坑,来自 /memories/repo/any-all-multi-approval.md)**:
|
|
||||||
- 必须同步把这个新迁移文件加进 `ordo-storage-jdbc/src/test/java/.../JdbcTestSupport.java` 的
|
|
||||||
`MIGRATIONS` 数组,否则测试库缺表导致全部 JDBC 测试失败。
|
|
||||||
- 注释里不要出现分号(或依赖已有的按行剥离 `--` 注释逻辑,已经是健壮的,无需再担心)。
|
|
||||||
|
|
||||||
### 新增 `StepTransitionMapper`(仿照 `ApprovalStepMapper` 的 `StepRow` 模式)
|
|
||||||
- `readRow(ResultSet) -> TransitionRow(fromStepId, toStepId, conditionKey, priority)`。
|
|
||||||
|
|
||||||
### `JdbcProcessDefinitionRepository`
|
|
||||||
- `insertIfAbsent`:在插入 steps/candidates 之后,遍历 `definition.transitions()` 执行
|
|
||||||
`INSERT INTO ordo_step_transition (...)`。
|
|
||||||
- `findById`:新增 `SELECT_TRANSITIONS`(按 `from_step_id, priority` 排序),组装
|
|
||||||
`List<StepTransition>` 传入新的 4 参 `ProcessDefinition` 构造函数。
|
|
||||||
|
|
||||||
## Spring Boot 自动配置 (ordo-spring-boot-autoconfigure)
|
|
||||||
- `OrdoJdbcAutoConfiguration` 新增:
|
|
||||||
```java
|
|
||||||
@Bean
|
|
||||||
@ConditionalOnMissingBean
|
|
||||||
public RoutingCondition ordoRoutingCondition() {
|
|
||||||
return RoutingCondition.always();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- `ordoEngine(...)` 方法签名新增 `RoutingCondition ordoRoutingCondition` 参数,传给
|
|
||||||
`DefaultOrdoEngine` 构造函数。
|
|
||||||
- `OrdoJdbcAutoConfigurationTest`:更新 `LEAVE_REQUEST` 定义使用 `ProcessDefinition.linear(...)`;
|
|
||||||
新增一个 `honoursUserDefinedRoutingCondition` 测试,仿照现有
|
|
||||||
`honoursUserDefinedAssigneeResolver` 的写法。
|
|
||||||
|
|
||||||
## 调用点迁移(约 29 处 `new ProcessDefinition(...)`)
|
|
||||||
- 绝大多数纯线性场景(测试/demo 不需要真正的条件路由)直接把
|
|
||||||
`new ProcessDefinition(id, name, steps)` 换成 `ProcessDefinition.linear(id, name, steps)`。
|
|
||||||
- 受影响文件:
|
|
||||||
- `ordo-api/src/test/java/.../ProcessDefinitionTest.java`(同时新增 transitions 相关校验用例:
|
|
||||||
缺少出边报错、fromStepId/toStepId 引用不存在的 step 报错、重复 priority 报错)
|
|
||||||
- `ordo-core/src/test/java/.../InMemoryOrdoEngineTest.java`(8 处调用点 + 新增条件路由专项测试)
|
|
||||||
- `ordo-spring-boot-autoconfigure/src/test/java/.../OrdoJdbcAutoConfigurationTest.java`
|
|
||||||
- `ordo-storage-jdbc/src/test/java/.../Jdbc*Test.java`(约 10 处,含
|
|
||||||
`JdbcProcessDefinitionRepositoryTest` 需要新增"读写 transitions 往返"专项用例)
|
|
||||||
- `ordo-storage-jdbc/src/main/java/.../JdbcProcessDefinitionRepository.java`(`findById` 内部
|
|
||||||
组装 `ProcessDefinition` 那一处,改为传入真实加载到的 transitions)
|
|
||||||
- `ordo-example/src/main/java/.../LeaveRequestExample.java`
|
|
||||||
- `d:\Projects\Personal\java\rhizome\src\main\java\...\LeaveRequestDemoService.java`
|
|
||||||
|
|
||||||
## 新增测试(条件路由专项,主要在 `InMemoryOrdoEngineTest` 或新建
|
|
||||||
`ConditionalRoutingTest`)
|
|
||||||
1. 基础分支:manager 通过后,根据 `conditionKey`(如 `"amount-gt-1000"`)走向不同的下一 step
|
|
||||||
(高额 -> director 审批 step;低额 -> 直接 end),验证 `RoutingCondition.matches` 被正确调用
|
|
||||||
并影响推进结果。
|
|
||||||
2. 无匹配 transition 时抛出 `NoRouteFoundException`,且整个 approve 事务回滚(沿用现有
|
|
||||||
rollback 测试模式)。
|
|
||||||
3. `ProcessDefinition` 构造期校验:
|
|
||||||
- 某 step 没有任何出边 -> `IllegalArgumentException`。
|
|
||||||
- transition 引用不存在的 `fromStepId`/`toStepId` -> `IllegalArgumentException`。
|
|
||||||
- 同一 `(fromStepId, priority)` 重复 -> `IllegalArgumentException`。
|
|
||||||
4. JDBC 往返测试:`JdbcProcessDefinitionRepositoryTest` 新增用例,插入一个带条件分支的
|
|
||||||
definition,`findById` 读回后 `transitions()` 内容与写入一致(包括 `toStepId == null` 终止边)。
|
|
||||||
|
|
||||||
## 范围外 / 明确不做
|
|
||||||
- 不支持在 transition 上直接内嵌 Java lambda(会破坏 JDBC 持久化重启场景,已由用户确认)。
|
|
||||||
- 不引入显式 `startStepId` 概念,起始步骤仍是 `steps().getFirst()`。
|
|
||||||
- 不做并行分支/合并(fork-join),仅支持"单一当前 step -> 单一下一 step 或终止"的有向图推进,
|
|
||||||
与现有 ANY/ALL 多候选人机制正交(transition 发生在"某个 step 整体被判定通过/拒绝"之后)。
|
|
||||||
|
|
||||||
## 实施顺序(有依赖关系,需按序执行)
|
|
||||||
1. ordo-api:`StepTransition`、`RoutingCondition`、`ProcessDefinition`(含 `linear` 工厂)、
|
|
||||||
`NoRouteFoundException`。
|
|
||||||
2. ordo-core:`DefaultOrdoEngine` 改造、`InMemoryOrdoEngine` 构造函数重载。
|
|
||||||
3. ordo-storage-jdbc:`V3` 迁移 + `StepTransitionMapper` + `JdbcProcessDefinitionRepository` 改造
|
|
||||||
+ 同步更新 `JdbcTestSupport.MIGRATIONS`。
|
|
||||||
4. ordo-spring-boot-autoconfigure:`RoutingCondition` bean 及 `ordoEngine(...)` 装配。
|
|
||||||
5. 迁移全部 ~29 处旧调用点(`ProcessDefinition.linear(...)`),含 rhizome demo。
|
|
||||||
6. 新增条件路由专项测试(引擎行为 + 校验 + JDBC 往返)。
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
- `ordo-api`/`ordo-core`/`ordo-storage-jdbc`/`ordo-spring-boot-autoconfigure` 全量单元测试通过。
|
|
||||||
- 手动确认 rhizome 端到端 curl 流程(线性场景切到 `ProcessDefinition.linear` 后)仍然可用
|
|
||||||
(非阻塞,可在实现完成后单独验证)。
|
|
||||||
+1
-8
@@ -17,19 +17,12 @@
|
|||||||
- 任务转派 `reassign` / `TASK_REASSIGNED`
|
- 任务转派 `reassign` / `TASK_REASSIGNED`
|
||||||
- 到期升级 `processDue` / `TASK_ESCALATED`
|
- 到期升级 `processDue` / `TASK_ESCALATED`
|
||||||
- 管理员/系统取消 `cancel` / `CANCELLED`
|
- 管理员/系统取消 `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`。
|
流程设计器是**单独产品**,消费上述 REST 与目录,不做成 ordo 模块。画布对齐引擎图(审批步、ACTION 步、边上的 `when`/`priority`,结束为 `to: null`),不引入 BPMN 网关/并行等引擎没有的语义。节点坐标等 layout 由设计器自存,不进入 `ProcessDefinition`。
|
||||||
|
|||||||
+42
-6
@@ -10,9 +10,7 @@ Ordo 是嵌入宿主进程的审批引擎,入口是 `OrdoEngine`。
|
|||||||
|
|
||||||
做:流程定义、实例推进、待办任务、条件路由、ACTION 副作用、审计事件、分页查询。
|
做:流程定义、实例推进、待办任务、条件路由、ACTION 副作用、审计事件、分页查询。
|
||||||
|
|
||||||
产品边界:不做业务表单、用户体系、多租户;不内置设计器 UI。业务字段放在 `ProcessContext`(不可变 `Map<String, Object>`)。当前也**没有** REST;HTTP 仍由宿主自建。计划中的可选 REST 与独立设计器见 [roadmap.md](roadmap.md)。
|
产品边界:不做业务表单、用户体系、多租户;不内置设计器 UI。业务字段放在 `ProcessContext`(不可变 `Map<String, Object>`)。可选 HTTP 见 §2.3;独立设计器见 [roadmap.md](roadmap.md)。
|
||||||
|
|
||||||
开发计划(尚未提供,见 [roadmap.md](roadmap.md)):可选 REST + 目录 SPI。
|
|
||||||
|
|
||||||
## 2. 模块与接入
|
## 2. 模块与接入
|
||||||
|
|
||||||
@@ -66,6 +64,9 @@ ordo:
|
|||||||
location: classpath*:ordo/*.json # 启动时对每个 JSON 调用 publish
|
location: classpath*:ordo/*.json # 启动时对每个 JSON 调用 publish
|
||||||
due:
|
due:
|
||||||
poll-ms: 0 # >0 时轮询 processDue;默认不调度
|
poll-ms: 0 # >0 时轮询 processDue;默认不调度
|
||||||
|
rest:
|
||||||
|
enabled: false
|
||||||
|
base-path: /ordo
|
||||||
```
|
```
|
||||||
|
|
||||||
启动加载使用 `publish`:图与 latest 相同则不升版本;不同则写入新版本。运行中实例继续锁定发起时所用版本。
|
启动加载使用 `publish`:图与 latest 相同则不升版本;不同则写入新版本。运行中实例继续锁定发起时所用版本。
|
||||||
@@ -78,9 +79,44 @@ ordo:
|
|||||||
| `RoutingCondition` | 始终匹配(无条件边) |
|
| `RoutingCondition` | 始终匹配(无条件边) |
|
||||||
| `ActionHandler` | 空操作 |
|
| `ActionHandler` | 空操作 |
|
||||||
| `OrdoEventListener` | 可有多个,提交后按 Spring 顺序调用 |
|
| `OrdoEventListener` | 可有多个,提交后按 Spring 顺序调用 |
|
||||||
|
| `OrdoCatalog` | 空列表(仅 REST 打开时装配) |
|
||||||
|
|
||||||
未提供自定义 Bean 时,ACTION 步骤仍会推进流程,但 handler 什么都不做。
|
未提供自定义 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. 流程定义
|
## 3. 流程定义
|
||||||
|
|
||||||
每个定义有 `id`、引擎分配的 `version`、`name`、步骤列表、转移列表。步骤 id 在定义内唯一。每个步骤必须至少有一条出边(结束用 `to = null`)。同一 `from` 上 `priority` 不能重复。
|
每个定义有 `id`、引擎分配的 `version`、`name`、步骤列表、转移列表。步骤 id 在定义内唯一。每个步骤必须至少有一条出边(结束用 `to = null`)。同一 `from` 上 `priority` 不能重复。
|
||||||
@@ -121,7 +157,7 @@ new ProcessDefinition("leave-request-routed", "Leave request",
|
|||||||
|
|
||||||
### 3.2 JSON
|
### 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
|
```json
|
||||||
{
|
{
|
||||||
@@ -355,6 +391,6 @@ Flyway 脚本按方言分目录:`db/postgresql/migration`、`db/mysql/migratio
|
|||||||
|
|
||||||
## 13. 未提供能力
|
## 13. 未提供能力
|
||||||
|
|
||||||
开发计划中(见 [roadmap.md](roadmap.md)):可选 REST + 目录 SPI。独立设计器不进本仓库,等 REST 与目录之后再做。
|
独立设计器不进本仓库,等 REST 与目录之后由单独产品消费(见 [roadmap.md](roadmap.md))。
|
||||||
|
|
||||||
暂不在计划中:多租户、设计器 UI。当前 REST 由宿主自建。
|
暂不在计划中:多租户、设计器 UI。
|
||||||
|
|||||||
@@ -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<Item> conditions();
|
||||||
|
|
||||||
|
List<Item> actions();
|
||||||
|
|
||||||
|
List<Item> assignees();
|
||||||
|
|
||||||
|
record Item(String key, String label) {
|
||||||
|
}
|
||||||
|
|
||||||
|
static OrdoCatalog empty() {
|
||||||
|
return new OrdoCatalog() {
|
||||||
|
@Override
|
||||||
|
public List<Item> conditions() {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Item> actions() {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Item> assignees() {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.jetlumen.ordo.api;
|
package com.jetlumen.ordo.api;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -10,11 +11,13 @@ import java.time.DateTimeException;
|
|||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/** Parses a structural JSON process graph into a {@link ProcessDefinition}. */
|
/** Parses a structural JSON process graph into a {@link ProcessDefinition}. */
|
||||||
public final class ProcessDefinitionParser {
|
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() {
|
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) {
|
public static ProcessDefinition fromJson(InputStream json) {
|
||||||
Objects.requireNonNull(json, "json must not be null");
|
Objects.requireNonNull(json, "json must not be null");
|
||||||
try {
|
try {
|
||||||
@@ -66,6 +78,27 @@ public final class ProcessDefinitionParser {
|
|||||||
return new ProcessDefinition(document.id(), document.name(), steps, transitions);
|
return new ProcessDefinition(document.id(), document.name(), steps, transitions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static DefinitionDocument fromDefinition(ProcessDefinition definition) {
|
||||||
|
List<StepDocument> 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<TransitionDocument> 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<ApprovalStep> steps, String startStepId) {
|
private static void rotateStartStep(List<ApprovalStep> steps, String startStepId) {
|
||||||
if (startStepId == null || startStepId.isBlank()) {
|
if (startStepId == null || startStepId.isBlank()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import java.util.List;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
class ProcessDefinitionParserTest {
|
class ProcessDefinitionParserTest {
|
||||||
|
|
||||||
@@ -37,8 +38,12 @@ class ProcessDefinitionParserTest {
|
|||||||
StepTransition.end("hr")
|
StepTransition.end("hr")
|
||||||
));
|
));
|
||||||
|
|
||||||
assertEquals(expected, ProcessDefinitionParser.fromJson(LEAVE_REQUEST_JSON));
|
ProcessDefinition definition = ProcessDefinitionParser.fromJson(LEAVE_REQUEST_JSON);
|
||||||
assertEquals(0, ProcessDefinitionParser.fromJson(LEAVE_REQUEST_JSON).version());
|
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
|
@Test
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
<groupId>org.flywaydb</groupId>
|
<groupId>org.flywaydb</groupId>
|
||||||
<artifactId>flyway-mysql</artifactId>
|
<artifactId>flyway-mysql</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||||
|
|||||||
+28
@@ -12,6 +12,7 @@ public class OrdoProperties {
|
|||||||
private final Definitions definitions = new Definitions();
|
private final Definitions definitions = new Definitions();
|
||||||
private final Due due = new Due();
|
private final Due due = new Due();
|
||||||
private final Jdbc jdbc = new Jdbc();
|
private final Jdbc jdbc = new Jdbc();
|
||||||
|
private final Rest rest = new Rest();
|
||||||
|
|
||||||
public boolean isEnabled() {
|
public boolean isEnabled() {
|
||||||
return enabled;
|
return enabled;
|
||||||
@@ -33,6 +34,10 @@ public class OrdoProperties {
|
|||||||
return jdbc;
|
return jdbc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Rest getRest() {
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
public static class Definitions {
|
public static class Definitions {
|
||||||
private String location = "classpath*:ordo/*.json";
|
private String location = "classpath*:ordo/*.json";
|
||||||
|
|
||||||
@@ -70,4 +75,27 @@ public class OrdoProperties {
|
|||||||
this.dialect = dialect;
|
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
-1
@@ -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.OrdoEngine;
|
||||||
import com.jetlumen.ordo.api.ProcessDefinition;
|
import com.jetlumen.ordo.api.ProcessDefinition;
|
||||||
+1
-1
@@ -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.OrdoEngine;
|
||||||
import org.springframework.context.SmartLifecycle;
|
import org.springframework.context.SmartLifecycle;
|
||||||
+2
-1
@@ -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.SqlDialect;
|
||||||
import com.jetlumen.ordo.storage.jdbc.dialect.SqlDialects;
|
import com.jetlumen.ordo.storage.jdbc.dialect.SqlDialects;
|
||||||
import org.flywaydb.core.Flyway;
|
import org.flywaydb.core.Flyway;
|
||||||
+3
-4
@@ -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.ActionHandler;
|
||||||
import com.jetlumen.ordo.api.AssigneeResolver;
|
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.ProcessHistoryRepository;
|
||||||
import com.jetlumen.ordo.api.repository.ProcessInstanceRepository;
|
import com.jetlumen.ordo.api.repository.ProcessInstanceRepository;
|
||||||
import com.jetlumen.ordo.core.DefaultOrdoEngine;
|
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.JdbcActionExecutionRepository;
|
||||||
import com.jetlumen.ordo.storage.jdbc.JdbcApprovalTaskRepository;
|
import com.jetlumen.ordo.storage.jdbc.JdbcApprovalTaskRepository;
|
||||||
import com.jetlumen.ordo.storage.jdbc.JdbcConnectionProvider;
|
import com.jetlumen.ordo.storage.jdbc.JdbcConnectionProvider;
|
||||||
@@ -46,13 +47,11 @@ import java.time.Clock;
|
|||||||
@ConditionalOnClass(DataSource.class)
|
@ConditionalOnClass(DataSource.class)
|
||||||
@ConditionalOnBean(DataSource.class)
|
@ConditionalOnBean(DataSource.class)
|
||||||
@AutoConfigureAfter(name = {
|
@AutoConfigureAfter(name = {
|
||||||
// Spring Boot 3.x locations
|
|
||||||
"org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration",
|
"org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration",
|
||||||
"org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration",
|
"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.jdbc.autoconfigure.DataSourceAutoConfiguration",
|
||||||
"org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration",
|
"org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration",
|
||||||
"com.jetlumen.ordo.spring.OrdoFlywayAutoConfiguration"
|
"com.jetlumen.ordo.spring.jdbc.OrdoFlywayAutoConfiguration"
|
||||||
})
|
})
|
||||||
@EnableConfigurationProperties(OrdoProperties.class)
|
@EnableConfigurationProperties(OrdoProperties.class)
|
||||||
public class OrdoJdbcAutoConfiguration {
|
public class OrdoJdbcAutoConfiguration {
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
package com.jetlumen.ordo.spring.rest;
|
||||||
|
|
||||||
|
record ActorCommentRequest(String actor, String comment) {
|
||||||
|
}
|
||||||
+34
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
package com.jetlumen.ordo.spring.rest;
|
||||||
|
|
||||||
|
record ErrorBody(String error, String message) {
|
||||||
|
}
|
||||||
+33
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
package com.jetlumen.ordo.spring.rest;
|
||||||
|
|
||||||
|
record ProcessDueRequest(Integer limit) {
|
||||||
|
}
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
package com.jetlumen.ordo.spring.rest;
|
||||||
|
|
||||||
|
record ReassignTaskRequest(String actor, String newAssignee) {
|
||||||
|
}
|
||||||
+8
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-2
@@ -1,2 +1,3 @@
|
|||||||
com.jetlumen.ordo.spring.OrdoFlywayAutoConfiguration
|
com.jetlumen.ordo.spring.jdbc.OrdoFlywayAutoConfiguration
|
||||||
com.jetlumen.ordo.spring.OrdoJdbcAutoConfiguration
|
com.jetlumen.ordo.spring.jdbc.OrdoJdbcAutoConfiguration
|
||||||
|
com.jetlumen.ordo.spring.rest.OrdoRestAutoConfiguration
|
||||||
|
|||||||
+1
-1
@@ -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.ActionHandler;
|
||||||
import com.jetlumen.ordo.api.ApprovalStep;
|
import com.jetlumen.ordo.api.ApprovalStep;
|
||||||
+152
@@ -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());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user