Add StellaOps.Workflow engine: 14 libraries, WebService, 8 test projects
Extract product-agnostic workflow engine from Ablera.Serdica.Workflow into standalone StellaOps.Workflow.* libraries targeting net10.0. Libraries (14): - Contracts, Abstractions (compiler, decompiler, expression runtime) - Engine (execution, signaling, scheduling, projections, hosted services) - ElkSharp (generic graph layout algorithm) - Renderer.ElkSharp, Renderer.ElkJs, Renderer.Msagl, Renderer.Svg - Signaling.Redis, Signaling.OracleAq - DataStore.MongoDB, DataStore.PostgreSQL, DataStore.Oracle WebService: ASP.NET Core Minimal API with 22 endpoints Tests (8 projects, 109 tests pass): - Engine.Tests (105 pass), WebService.Tests (4 E2E pass) - Renderer.Tests, DataStore.MongoDB/Oracle/PostgreSQL.Tests - Signaling.Redis.Tests, IntegrationTests.Shared Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
30
docs/workflow/tutorials/01-hello-world/README.md
Normal file
30
docs/workflow/tutorials/01-hello-world/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Tutorial 1: Hello World
|
||||
|
||||
The simplest possible workflow: initialize state from a start request, activate a single human task, and complete the workflow when the task is done.
|
||||
|
||||
## Concepts Introduced
|
||||
|
||||
- `IDeclarativeWorkflow<T>` — the contract every workflow implements
|
||||
- `WorkflowSpec.For<T>()` — the builder entry point
|
||||
- `.InitializeState()` — transforms the start request into workflow state
|
||||
- `.StartWith(task)` — sets the first task to activate
|
||||
- `WorkflowHumanTask.For<T>()` — defines a human task
|
||||
- `.OnComplete(flow => flow.Complete())` — terminal step
|
||||
|
||||
## What Happens at Runtime
|
||||
|
||||
1. Client calls `StartWorkflowAsync` with `WorkflowName = "Greeting"` and payload `{ "customerName": "John" }`
|
||||
2. State initializes to `{ "customerName": "John" }`
|
||||
3. Task "Greet Customer" is created with status "Pending"
|
||||
4. A user assigns the task to themselves, then completes it
|
||||
5. `OnComplete` executes `.Complete()` — the workflow finishes
|
||||
|
||||
## Variants
|
||||
|
||||
- [C# Fluent DSL](csharp/)
|
||||
- [Canonical JSON](json/)
|
||||
|
||||
## Next
|
||||
|
||||
[Tutorial 2: Service Tasks](../02-service-tasks/) — call external services before or after human tasks.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
|
||||
using WorkflowEngine.Abstractions;
|
||||
using WorkflowEngine.Contracts;
|
||||
|
||||
namespace WorkflowEngine.Tutorials;
|
||||
|
||||
// Start request — defines the input contract for the workflow.
|
||||
public sealed class GreetingRequest
|
||||
{
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// Workflow definition — implements IDeclarativeWorkflow<TStartRequest>.
|
||||
public sealed class GreetingWorkflow : IDeclarativeWorkflow<GreetingRequest>
|
||||
{
|
||||
// Identity: name + version uniquely identify the workflow definition.
|
||||
public string WorkflowName => "Greeting";
|
||||
public string WorkflowVersion => "1.0.0";
|
||||
public string DisplayName => "Customer Greeting";
|
||||
|
||||
// Roles: which user roles can see and interact with this workflow's tasks.
|
||||
public IReadOnlyCollection<string> WorkflowRoles => ["DBA", "UR_AGENT"];
|
||||
|
||||
// Spec: the workflow specification built via the fluent DSL.
|
||||
public WorkflowSpec<GreetingRequest> Spec { get; } = WorkflowSpec
|
||||
.For<GreetingRequest>()
|
||||
|
||||
// InitializeState: transform the start request into the workflow's mutable state.
|
||||
// State is a Dictionary<string, JsonElement> — all values are JSON-serialized.
|
||||
.InitializeState(request => new Dictionary<string, JsonElement>
|
||||
{
|
||||
["customerName"] = JsonSerializer.SerializeToElement(request.CustomerName),
|
||||
})
|
||||
|
||||
// StartWith: register and activate this task as the first step.
|
||||
.StartWith(greetTask)
|
||||
.Build();
|
||||
|
||||
// Tasks: expose task descriptors for the registration catalog.
|
||||
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
|
||||
|
||||
// Task definition: defines name, type (UI component), route (navigation), and behavior.
|
||||
private static readonly WorkflowHumanTaskDefinition<GreetingRequest> greetTask =
|
||||
WorkflowHumanTask.For<GreetingRequest>(
|
||||
taskName: "Greet Customer", // unique name within this workflow
|
||||
taskType: "GreetCustomerTask", // UI component identifier
|
||||
route: "customers/greet") // navigation route
|
||||
.WithPayload(context => new Dictionary<string, JsonElement>
|
||||
{
|
||||
// Pass state values to the task's UI payload.
|
||||
["customerName"] = context.StateValues
|
||||
.GetRequired<string>("customerName").AsJsonElement(),
|
||||
})
|
||||
// OnComplete: what happens after the user completes this task.
|
||||
.OnComplete(flow => flow.Complete()); // simply end the workflow
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"schemaVersion": "serdica.workflow.definition/v1",
|
||||
"workflowName": "Greeting",
|
||||
"workflowVersion": "1.0.0",
|
||||
"displayName": "Customer Greeting",
|
||||
|
||||
"startRequest": {
|
||||
"contractName": "GreetingRequest",
|
||||
"allowAdditionalProperties": true
|
||||
},
|
||||
|
||||
"workflowRoles": ["DBA", "UR_AGENT"],
|
||||
|
||||
"start": {
|
||||
"initializeStateExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{
|
||||
"name": "customerName",
|
||||
"expression": { "$type": "path", "path": "start.customerName" }
|
||||
}
|
||||
]
|
||||
},
|
||||
"sequence": {
|
||||
"steps": [
|
||||
{
|
||||
"$type": "activate-task",
|
||||
"taskName": "Greet Customer"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
"tasks": [
|
||||
{
|
||||
"taskName": "Greet Customer",
|
||||
"taskType": "GreetCustomerTask",
|
||||
"routeExpression": { "$type": "string", "value": "customers/greet" },
|
||||
"taskRoles": [],
|
||||
"payloadExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{
|
||||
"name": "customerName",
|
||||
"expression": { "$type": "path", "path": "state.customerName" }
|
||||
}
|
||||
]
|
||||
},
|
||||
"onCompleteSequence": {
|
||||
"steps": [
|
||||
{ "$type": "complete" }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
29
docs/workflow/tutorials/02-service-tasks/README.md
Normal file
29
docs/workflow/tutorials/02-service-tasks/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Tutorial 2: Service Tasks
|
||||
|
||||
Call external services (microservices, HTTP APIs, GraphQL, RabbitMQ) from within a workflow. Handle failures and timeouts gracefully.
|
||||
|
||||
## Concepts Introduced
|
||||
|
||||
- `.Call()` — invoke a transport with payload and optional response capture
|
||||
- Address types — `LegacyRabbit`, `Microservice`, `Http`, `Graphql`, `Rabbit`
|
||||
- `resultKey` — store the service response in workflow state
|
||||
- `whenFailure` / `whenTimeout` — recovery branches
|
||||
- `WorkflowHandledBranchAction.Complete` — shorthand for "complete on error"
|
||||
- `timeoutSeconds` — per-step timeout override (default: 1 hour)
|
||||
|
||||
## Key Points
|
||||
|
||||
- Each `Call` step executes synchronously within the workflow
|
||||
- The per-step timeout wraps the entire call including transport-level retries
|
||||
- Transport timeouts (30s default) control individual attempt duration
|
||||
- If no failure/timeout handler is defined, the error propagates and the signal pump retries
|
||||
|
||||
## Variants
|
||||
|
||||
- [C# Fluent DSL](csharp/)
|
||||
- [Canonical JSON](json/)
|
||||
|
||||
## Next
|
||||
|
||||
[Tutorial 3: Decisions](../03-decisions/) — branch workflow logic based on conditions.
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
|
||||
using WorkflowEngine.Abstractions;
|
||||
using WorkflowEngine.Contracts;
|
||||
|
||||
namespace WorkflowEngine.Tutorials;
|
||||
|
||||
public sealed class PolicyValidationRequest
|
||||
{
|
||||
public long PolicyId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PolicyValidationWorkflow : IDeclarativeWorkflow<PolicyValidationRequest>
|
||||
{
|
||||
public string WorkflowName => "PolicyValidation";
|
||||
public string WorkflowVersion => "1.0.0";
|
||||
public string DisplayName => "Policy Validation";
|
||||
public IReadOnlyCollection<string> WorkflowRoles => ["DBA"];
|
||||
|
||||
public WorkflowSpec<PolicyValidationRequest> Spec { get; } = WorkflowSpec
|
||||
.For<PolicyValidationRequest>()
|
||||
.InitializeState(WorkflowExpr.Object(
|
||||
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("start.policyId"))))
|
||||
.StartWith(BuildFlow)
|
||||
.Build();
|
||||
|
||||
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
|
||||
|
||||
private static void BuildFlow(WorkflowFlowBuilder<PolicyValidationRequest> flow)
|
||||
{
|
||||
flow
|
||||
// --- Example 1: Simple call with shorthand error handling ---
|
||||
.Call(
|
||||
"Validate Policy", // step name
|
||||
Address.LegacyRabbit("pas_policy_validate"), // transport address
|
||||
WorkflowExpr.Object( // payload (expression-based)
|
||||
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))),
|
||||
WorkflowHandledBranchAction.Complete, // on failure: complete workflow
|
||||
WorkflowHandledBranchAction.Complete) // on timeout: complete workflow
|
||||
|
||||
// --- Example 2: Call with typed response stored in state ---
|
||||
.Call<object>(
|
||||
"Load Policy Info",
|
||||
Address.LegacyRabbit("pas_get_policy_product_info"),
|
||||
WorkflowExpr.Object(
|
||||
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))),
|
||||
WorkflowHandledBranchAction.Complete,
|
||||
WorkflowHandledBranchAction.Complete,
|
||||
resultKey: "policyInfo") // store response as "policyInfo"
|
||||
|
||||
// Use the result to set state values
|
||||
.SetIfHasValue("productCode",
|
||||
WorkflowExpr.Func("upper", WorkflowExpr.Path("result.policyInfo.productCode")))
|
||||
.SetIfHasValue("lob",
|
||||
WorkflowExpr.Path("result.policyInfo.lob"))
|
||||
|
||||
// --- Example 3: Call with custom failure/timeout branches ---
|
||||
.Call(
|
||||
"Calculate Premium",
|
||||
Address.LegacyRabbit("pas_premium_calculate_for_object",
|
||||
SerdicaLegacyRabbitMode.MicroserviceConsumer),
|
||||
WorkflowExpr.Object(
|
||||
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))),
|
||||
whenFailure: fail => fail
|
||||
.Set("calculationFailed", WorkflowExpr.Bool(true))
|
||||
.Complete(),
|
||||
whenTimeout: timeout => timeout
|
||||
.Set("calculationTimedOut", WorkflowExpr.Bool(true))
|
||||
.Complete(),
|
||||
timeoutSeconds: 120) // per-step timeout: 2 minutes
|
||||
|
||||
// --- Example 4: HTTP transport ---
|
||||
// .Call("Notify External",
|
||||
// Address.Http("authority", "/api/v1/notifications", "POST"),
|
||||
// WorkflowExpr.Object(
|
||||
// WorkflowExpr.Prop("message", WorkflowExpr.String("Policy validated"))),
|
||||
// WorkflowHandledBranchAction.Complete,
|
||||
// WorkflowHandledBranchAction.Complete)
|
||||
|
||||
.Complete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"schemaVersion": "serdica.workflow.definition/v1",
|
||||
"workflowName": "PolicyValidation",
|
||||
"workflowVersion": "1.0.0",
|
||||
"displayName": "Policy Validation",
|
||||
"workflowRoles": ["DBA"],
|
||||
|
||||
"start": {
|
||||
"initializeStateExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "policyId", "expression": { "$type": "path", "path": "start.policyId" } }
|
||||
]
|
||||
},
|
||||
"sequence": {
|
||||
"steps": [
|
||||
{
|
||||
"$type": "call-transport",
|
||||
"stepName": "Validate Policy",
|
||||
"invocation": {
|
||||
"address": { "$type": "legacy-rabbit", "command": "pas_policy_validate", "mode": "Envelope" },
|
||||
"payloadExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } }
|
||||
]
|
||||
}
|
||||
},
|
||||
"whenFailure": { "steps": [{ "$type": "complete" }] },
|
||||
"whenTimeout": { "steps": [{ "$type": "complete" }] }
|
||||
},
|
||||
{
|
||||
"$type": "call-transport",
|
||||
"stepName": "Load Policy Info",
|
||||
"resultKey": "policyInfo",
|
||||
"invocation": {
|
||||
"address": { "$type": "legacy-rabbit", "command": "pas_get_policy_product_info", "mode": "Envelope" },
|
||||
"payloadExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } }
|
||||
]
|
||||
}
|
||||
},
|
||||
"whenFailure": { "steps": [{ "$type": "complete" }] },
|
||||
"whenTimeout": { "steps": [{ "$type": "complete" }] }
|
||||
},
|
||||
{
|
||||
"$type": "set-state",
|
||||
"stateKey": "productCode",
|
||||
"valueExpression": {
|
||||
"$type": "function",
|
||||
"functionName": "upper",
|
||||
"arguments": [{ "$type": "path", "path": "result.policyInfo.productCode" }]
|
||||
},
|
||||
"onlyIfPresent": true
|
||||
},
|
||||
{
|
||||
"$type": "call-transport",
|
||||
"stepName": "Calculate Premium",
|
||||
"timeoutSeconds": 120,
|
||||
"invocation": {
|
||||
"address": { "$type": "legacy-rabbit", "command": "pas_premium_calculate_for_object", "mode": "MicroserviceConsumer" },
|
||||
"payloadExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } }
|
||||
]
|
||||
}
|
||||
},
|
||||
"whenFailure": {
|
||||
"steps": [
|
||||
{ "$type": "set-state", "stateKey": "calculationFailed", "valueExpression": { "$type": "boolean", "value": true } },
|
||||
{ "$type": "complete" }
|
||||
]
|
||||
},
|
||||
"whenTimeout": {
|
||||
"steps": [
|
||||
{ "$type": "set-state", "stateKey": "calculationTimedOut", "valueExpression": { "$type": "boolean", "value": true } },
|
||||
{ "$type": "complete" }
|
||||
]
|
||||
}
|
||||
},
|
||||
{ "$type": "complete" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"tasks": []
|
||||
}
|
||||
28
docs/workflow/tutorials/03-decisions/README.md
Normal file
28
docs/workflow/tutorials/03-decisions/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Tutorial 3: Decisions
|
||||
|
||||
Branch workflow logic based on conditions — state values, payload answers, or complex expressions.
|
||||
|
||||
## Concepts Introduced
|
||||
|
||||
- `.WhenExpression()` — branch on any boolean expression
|
||||
- `.WhenStateFlag()` — shorthand for checking a boolean state value
|
||||
- `.WhenPayloadEquals()` — shorthand for checking a task completion payload value
|
||||
- Nested decisions — decisions inside decisions for complex routing
|
||||
|
||||
## Decision Types
|
||||
|
||||
| Method | Use When |
|
||||
|--------|----------|
|
||||
| `WhenExpression` | Complex conditions (comparisons, boolean logic, function calls) |
|
||||
| `WhenStateFlag` | Checking a boolean state key against true/false |
|
||||
| `WhenPayloadEquals` | Checking a task completion answer (inside OnComplete) |
|
||||
|
||||
## Variants
|
||||
|
||||
- [C# Fluent DSL](csharp/)
|
||||
- [Canonical JSON](json/)
|
||||
|
||||
## Next
|
||||
|
||||
[Tutorial 4: Human Tasks](../04-human-tasks/) — approve/reject patterns with OnComplete flows.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
using WorkflowEngine.Abstractions;
|
||||
using WorkflowEngine.Contracts;
|
||||
|
||||
namespace WorkflowEngine.Tutorials;
|
||||
|
||||
public sealed class PolicyRoutingRequest
|
||||
{
|
||||
public long PolicyId { get; set; }
|
||||
public string AnnexType { get; set; } = string.Empty;
|
||||
public bool PolicyExistsOnIPAL { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PolicyRoutingWorkflow : IDeclarativeWorkflow<PolicyRoutingRequest>
|
||||
{
|
||||
public string WorkflowName => "PolicyRouting";
|
||||
public string WorkflowVersion => "1.0.0";
|
||||
public string DisplayName => "Policy Routing Example";
|
||||
public IReadOnlyCollection<string> WorkflowRoles => ["DBA"];
|
||||
|
||||
public WorkflowSpec<PolicyRoutingRequest> Spec { get; } = WorkflowSpec
|
||||
.For<PolicyRoutingRequest>()
|
||||
.InitializeState(WorkflowExpr.Object(
|
||||
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("start.policyId")),
|
||||
WorkflowExpr.Prop("annexType", WorkflowExpr.Path("start.annexType")),
|
||||
WorkflowExpr.Prop("policyExistsOnIPAL",
|
||||
WorkflowExpr.Func("coalesce",
|
||||
WorkflowExpr.Path("start.policyExistsOnIPAL"),
|
||||
WorkflowExpr.Bool(true)))))
|
||||
.StartWith(BuildFlow)
|
||||
.Build();
|
||||
|
||||
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
|
||||
|
||||
private static void BuildFlow(WorkflowFlowBuilder<PolicyRoutingRequest> flow)
|
||||
{
|
||||
// --- Example 1: State flag decision (boolean shorthand) ---
|
||||
flow.WhenStateFlag(
|
||||
"policyExistsOnIPAL", // state key to check
|
||||
true, // expected value
|
||||
"Policy exists on IPAL?", // decision name (appears in diagram)
|
||||
whenTrue: ipal => ipal
|
||||
|
||||
// --- Example 2: Expression decision ---
|
||||
.WhenExpression(
|
||||
"Annex Type?",
|
||||
WorkflowExpr.Eq(
|
||||
WorkflowExpr.Func("upper", WorkflowExpr.Path("state.annexType")),
|
||||
WorkflowExpr.String("BENEF")),
|
||||
benefit => benefit
|
||||
.Set("route", WorkflowExpr.String("BENEFIT_PROCESSING"))
|
||||
.Complete(),
|
||||
|
||||
// --- Example 3: Nested decision ---
|
||||
other => other.WhenExpression(
|
||||
"Is Equipment?",
|
||||
WorkflowExpr.Eq(
|
||||
WorkflowExpr.Func("upper", WorkflowExpr.Path("state.annexType")),
|
||||
WorkflowExpr.String("ADDEQ")),
|
||||
equipment => equipment
|
||||
.Set("route", WorkflowExpr.String("EQUIPMENT_PROCESSING"))
|
||||
.Complete(),
|
||||
cover => cover
|
||||
.Set("route", WorkflowExpr.String("COVER_CHANGE"))
|
||||
.Complete())),
|
||||
|
||||
whenElse: notIpal => notIpal
|
||||
.Set("route", WorkflowExpr.String("INSIS_PROCESSING"))
|
||||
.Complete());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"schemaVersion": "serdica.workflow.definition/v1",
|
||||
"workflowName": "PolicyRouting",
|
||||
"workflowVersion": "1.0.0",
|
||||
"displayName": "Policy Routing Example",
|
||||
"workflowRoles": ["DBA"],
|
||||
"start": {
|
||||
"initializeStateExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "policyId", "expression": { "$type": "path", "path": "start.policyId" } },
|
||||
{ "name": "annexType", "expression": { "$type": "path", "path": "start.annexType" } },
|
||||
{ "name": "policyExistsOnIPAL", "expression": {
|
||||
"$type": "function", "functionName": "coalesce",
|
||||
"arguments": [
|
||||
{ "$type": "path", "path": "start.policyExistsOnIPAL" },
|
||||
{ "$type": "boolean", "value": true }
|
||||
]
|
||||
}}
|
||||
]
|
||||
},
|
||||
"sequence": {
|
||||
"steps": [
|
||||
{
|
||||
"$type": "decision",
|
||||
"decisionName": "Policy exists on IPAL?",
|
||||
"conditionExpression": { "$type": "path", "path": "state.policyExistsOnIPAL" },
|
||||
"whenTrue": {
|
||||
"steps": [
|
||||
{
|
||||
"$type": "decision",
|
||||
"decisionName": "Annex Type?",
|
||||
"conditionExpression": {
|
||||
"$type": "binary", "operator": "eq",
|
||||
"left": { "$type": "function", "functionName": "upper", "arguments": [{ "$type": "path", "path": "state.annexType" }] },
|
||||
"right": { "$type": "string", "value": "BENEF" }
|
||||
},
|
||||
"whenTrue": {
|
||||
"steps": [
|
||||
{ "$type": "set-state", "stateKey": "route", "valueExpression": { "$type": "string", "value": "BENEFIT_PROCESSING" } },
|
||||
{ "$type": "complete" }
|
||||
]
|
||||
},
|
||||
"whenElse": {
|
||||
"steps": [
|
||||
{
|
||||
"$type": "decision",
|
||||
"decisionName": "Is Equipment?",
|
||||
"conditionExpression": {
|
||||
"$type": "binary", "operator": "eq",
|
||||
"left": { "$type": "function", "functionName": "upper", "arguments": [{ "$type": "path", "path": "state.annexType" }] },
|
||||
"right": { "$type": "string", "value": "ADDEQ" }
|
||||
},
|
||||
"whenTrue": {
|
||||
"steps": [
|
||||
{ "$type": "set-state", "stateKey": "route", "valueExpression": { "$type": "string", "value": "EQUIPMENT_PROCESSING" } },
|
||||
{ "$type": "complete" }
|
||||
]
|
||||
},
|
||||
"whenElse": {
|
||||
"steps": [
|
||||
{ "$type": "set-state", "stateKey": "route", "valueExpression": { "$type": "string", "value": "COVER_CHANGE" } },
|
||||
{ "$type": "complete" }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"whenElse": {
|
||||
"steps": [
|
||||
{ "$type": "set-state", "stateKey": "route", "valueExpression": { "$type": "string", "value": "INSIS_PROCESSING" } },
|
||||
{ "$type": "complete" }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"tasks": []
|
||||
}
|
||||
34
docs/workflow/tutorials/04-human-tasks/README.md
Normal file
34
docs/workflow/tutorials/04-human-tasks/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Tutorial 4: Human Tasks with OnComplete Flows
|
||||
|
||||
The approve/reject pattern — the most common human task flow in insurance workflows.
|
||||
|
||||
## Concepts Introduced
|
||||
|
||||
- `WorkflowHumanTask.For<T>()` — define a task with name, type, route, and roles
|
||||
- `.WithPayload()` — data sent to the UI when the task is displayed
|
||||
- `.WithTimeout(seconds)` — optional deadline for the task
|
||||
- `.WithRoles()` — restrict which roles can interact with this task
|
||||
- `.OnComplete(flow => ...)` — sequence executed after user completes the task
|
||||
- `.ActivateTask()` — pause workflow and wait for user action
|
||||
- `.AddTask()` — register a task in the workflow spec (separate from activation)
|
||||
- Re-activation — send the user back to the same task on validation failure
|
||||
|
||||
## Approve/Reject Pattern
|
||||
|
||||
1. Workflow starts, runs some service tasks
|
||||
2. `.ActivateTask("Approve")` — workflow pauses
|
||||
3. User sees the task in their inbox, assigns it, submits an answer
|
||||
4. `.OnComplete` checks `payload.answer`:
|
||||
- `"approve"` — run confirmation operations, convert to policy
|
||||
- `"reject"` — cancel the application
|
||||
5. If operations fail, re-activate the same task for correction
|
||||
|
||||
## Variants
|
||||
|
||||
- [C# Fluent DSL](csharp/)
|
||||
- [Canonical JSON](json/)
|
||||
|
||||
## Next
|
||||
|
||||
[Tutorial 5: Sub-Workflows](../05-sub-workflows/) — inline vs fire-and-forget child workflows.
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
|
||||
using WorkflowEngine.Abstractions;
|
||||
using WorkflowEngine.Contracts;
|
||||
|
||||
namespace WorkflowEngine.Tutorials;
|
||||
|
||||
public sealed class ApprovalRequest
|
||||
{
|
||||
public long PolicyId { get; set; }
|
||||
public long AnnexId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ApprovalWorkflow : IDeclarativeWorkflow<ApprovalRequest>
|
||||
{
|
||||
public string WorkflowName => "ApprovalExample";
|
||||
public string WorkflowVersion => "1.0.0";
|
||||
public string DisplayName => "Approval Example";
|
||||
public IReadOnlyCollection<string> WorkflowRoles => ["DBA", "UR_UNDERWRITER"];
|
||||
|
||||
public WorkflowSpec<ApprovalRequest> Spec { get; } = WorkflowSpec
|
||||
.For<ApprovalRequest>()
|
||||
.InitializeState(WorkflowExpr.Object(
|
||||
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("start.policyId")),
|
||||
WorkflowExpr.Prop("annexId", WorkflowExpr.Path("start.annexId"))))
|
||||
|
||||
// Register the task definition (separate from activation).
|
||||
.AddTask(approveTask)
|
||||
|
||||
// Start flow: validate, then activate the approval task.
|
||||
.StartWith(flow => flow
|
||||
.Call("Validate",
|
||||
Address.LegacyRabbit("pas_policy_validate"),
|
||||
WorkflowExpr.Object(
|
||||
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))),
|
||||
WorkflowHandledBranchAction.Complete,
|
||||
WorkflowHandledBranchAction.Complete)
|
||||
.ActivateTask("Approve Policy")) // pauses here
|
||||
.Build();
|
||||
|
||||
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
|
||||
|
||||
// Define the human task with roles, payload, optional deadline, and OnComplete flow.
|
||||
private static readonly WorkflowHumanTaskDefinition<ApprovalRequest> approveTask =
|
||||
WorkflowHumanTask.For<ApprovalRequest>(
|
||||
"Approve Policy", // task name
|
||||
"PolicyApproval", // task type (UI component)
|
||||
"business/policies", // route
|
||||
taskRoles: ["UR_UNDERWRITER"]) // only underwriters
|
||||
.WithPayload(WorkflowExpr.Object(
|
||||
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId")),
|
||||
WorkflowExpr.Prop("annexId", WorkflowExpr.Path("state.annexId"))))
|
||||
.WithTimeout(86400) // 24-hour deadline (optional)
|
||||
.OnComplete(BuildApprovalFlow);
|
||||
|
||||
private static void BuildApprovalFlow(WorkflowFlowBuilder<ApprovalRequest> flow)
|
||||
{
|
||||
flow
|
||||
// Store the user's answer in state for auditability.
|
||||
.Set("answer", WorkflowExpr.Path("payload.answer"))
|
||||
|
||||
// Branch on the answer.
|
||||
.WhenPayloadEquals("answer", "reject", "Rejected?",
|
||||
rejected => rejected
|
||||
.Call("Cancel Application",
|
||||
Address.LegacyRabbit("pas_annexprocessing_cancelaplorqt"),
|
||||
WorkflowExpr.Object(
|
||||
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))),
|
||||
WorkflowHandledBranchAction.Complete,
|
||||
WorkflowHandledBranchAction.Complete)
|
||||
.Complete(),
|
||||
|
||||
approved => approved
|
||||
.Call<object>("Perform Operations",
|
||||
Address.LegacyRabbit("pas_operations_perform",
|
||||
SerdicaLegacyRabbitMode.MicroserviceConsumer),
|
||||
WorkflowExpr.Object(
|
||||
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId")),
|
||||
WorkflowExpr.Prop("stages", WorkflowExpr.Array(
|
||||
WorkflowExpr.String("UNDERWRITING"),
|
||||
WorkflowExpr.String("CONFIRMATION")))),
|
||||
WorkflowHandledBranchAction.Complete,
|
||||
WorkflowHandledBranchAction.Complete,
|
||||
resultKey: "operations")
|
||||
.Set("passed", WorkflowExpr.Path("result.operations.passed"))
|
||||
|
||||
.WhenStateFlag("passed", true, "Operations Passed?",
|
||||
passed => passed
|
||||
.Call("Convert To Policy",
|
||||
Address.LegacyRabbit("pas_polreg_convertapltopol"),
|
||||
WorkflowExpr.Object(
|
||||
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))),
|
||||
WorkflowHandledBranchAction.Complete,
|
||||
WorkflowHandledBranchAction.Complete)
|
||||
.Complete(),
|
||||
|
||||
// Operations failed: re-open the same task for the user to fix and retry.
|
||||
failed => failed.ActivateTask("Approve Policy")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"schemaVersion": "serdica.workflow.definition/v1",
|
||||
"workflowName": "ApprovalExample",
|
||||
"workflowVersion": "1.0.0",
|
||||
"displayName": "Approval Example",
|
||||
"workflowRoles": ["DBA", "UR_UNDERWRITER"],
|
||||
"start": {
|
||||
"initializeStateExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "policyId", "expression": { "$type": "path", "path": "start.policyId" } },
|
||||
{ "name": "annexId", "expression": { "$type": "path", "path": "start.annexId" } }
|
||||
]
|
||||
},
|
||||
"sequence": {
|
||||
"steps": [
|
||||
{
|
||||
"$type": "call-transport",
|
||||
"stepName": "Validate",
|
||||
"invocation": {
|
||||
"address": { "$type": "legacy-rabbit", "command": "pas_policy_validate", "mode": "Envelope" },
|
||||
"payloadExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } }
|
||||
]
|
||||
}
|
||||
},
|
||||
"whenFailure": { "steps": [{ "$type": "complete" }] },
|
||||
"whenTimeout": { "steps": [{ "$type": "complete" }] }
|
||||
},
|
||||
{
|
||||
"$type": "activate-task",
|
||||
"taskName": "Approve Policy",
|
||||
"timeoutSeconds": 86400
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"tasks": [
|
||||
{
|
||||
"taskName": "Approve Policy",
|
||||
"taskType": "PolicyApproval",
|
||||
"routeExpression": { "$type": "string", "value": "business/policies" },
|
||||
"taskRoles": ["UR_UNDERWRITER"],
|
||||
"payloadExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } },
|
||||
{ "name": "annexId", "expression": { "$type": "path", "path": "state.annexId" } }
|
||||
]
|
||||
},
|
||||
"onCompleteSequence": {
|
||||
"steps": [
|
||||
{ "$type": "set-state", "stateKey": "answer", "valueExpression": { "$type": "path", "path": "payload.answer" } },
|
||||
{
|
||||
"$type": "decision",
|
||||
"decisionName": "Rejected?",
|
||||
"conditionExpression": {
|
||||
"$type": "binary", "operator": "eq",
|
||||
"left": { "$type": "path", "path": "payload.answer" },
|
||||
"right": { "$type": "string", "value": "reject" }
|
||||
},
|
||||
"whenTrue": {
|
||||
"steps": [
|
||||
{
|
||||
"$type": "call-transport",
|
||||
"stepName": "Cancel Application",
|
||||
"invocation": {
|
||||
"address": { "$type": "legacy-rabbit", "command": "pas_annexprocessing_cancelaplorqt", "mode": "Envelope" },
|
||||
"payloadExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } }
|
||||
]
|
||||
}
|
||||
},
|
||||
"whenFailure": { "steps": [{ "$type": "complete" }] },
|
||||
"whenTimeout": { "steps": [{ "$type": "complete" }] }
|
||||
},
|
||||
{ "$type": "complete" }
|
||||
]
|
||||
},
|
||||
"whenElse": {
|
||||
"steps": [
|
||||
{
|
||||
"$type": "call-transport",
|
||||
"stepName": "Perform Operations",
|
||||
"resultKey": "operations",
|
||||
"invocation": {
|
||||
"address": { "$type": "legacy-rabbit", "command": "pas_operations_perform", "mode": "MicroserviceConsumer" },
|
||||
"payloadExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } },
|
||||
{ "name": "stages", "expression": { "$type": "array", "items": [
|
||||
{ "$type": "string", "value": "UNDERWRITING" },
|
||||
{ "$type": "string", "value": "CONFIRMATION" }
|
||||
]}}
|
||||
]
|
||||
}
|
||||
},
|
||||
"whenFailure": { "steps": [{ "$type": "complete" }] },
|
||||
"whenTimeout": { "steps": [{ "$type": "complete" }] }
|
||||
},
|
||||
{ "$type": "set-state", "stateKey": "passed", "valueExpression": { "$type": "path", "path": "result.operations.passed" } },
|
||||
{
|
||||
"$type": "decision",
|
||||
"decisionName": "Operations Passed?",
|
||||
"conditionExpression": { "$type": "path", "path": "state.passed" },
|
||||
"whenTrue": {
|
||||
"steps": [
|
||||
{
|
||||
"$type": "call-transport",
|
||||
"stepName": "Convert To Policy",
|
||||
"invocation": {
|
||||
"address": { "$type": "legacy-rabbit", "command": "pas_polreg_convertapltopol", "mode": "Envelope" },
|
||||
"payloadExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } }
|
||||
]
|
||||
}
|
||||
},
|
||||
"whenFailure": { "steps": [{ "$type": "complete" }] },
|
||||
"whenTimeout": { "steps": [{ "$type": "complete" }] }
|
||||
},
|
||||
{ "$type": "complete" }
|
||||
]
|
||||
},
|
||||
"whenElse": {
|
||||
"steps": [
|
||||
{ "$type": "activate-task", "taskName": "Approve Policy" }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
22
docs/workflow/tutorials/05-sub-workflows/README.md
Normal file
22
docs/workflow/tutorials/05-sub-workflows/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Tutorial 5: Sub-Workflows & Continuations
|
||||
|
||||
Compose workflows by invoking child workflows — either inline (SubWorkflow) or fire-and-forget (ContinueWith).
|
||||
|
||||
## SubWorkflow vs ContinueWith
|
||||
|
||||
| Feature | `.SubWorkflow()` | `.ContinueWith()` |
|
||||
|---------|-----------------|-------------------|
|
||||
| Parent waits | Yes — resumes after child completes | No — parent completes immediately |
|
||||
| State flows back | Yes — child state merges into parent | No — child is independent |
|
||||
| Same instance | Yes — tasks appear under parent instance | No — new workflow instance |
|
||||
| Use when | Steps must complete before parent continues | Fire-and-forget, scheduled work |
|
||||
|
||||
## Variants
|
||||
|
||||
- [C# Fluent DSL](csharp/)
|
||||
- [Canonical JSON](json/)
|
||||
|
||||
## Next
|
||||
|
||||
[Tutorial 6: Advanced Patterns](../06-advanced-patterns/) — Fork, Repeat, Timer, External Signal.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
using WorkflowEngine.Abstractions;
|
||||
using WorkflowEngine.Contracts;
|
||||
|
||||
namespace WorkflowEngine.Tutorials;
|
||||
|
||||
public sealed class ParentWorkflow : IDeclarativeWorkflow<PolicyChangeWorkflowRequest>
|
||||
{
|
||||
public string WorkflowName => "ParentWorkflow";
|
||||
public string WorkflowVersion => "1.0.0";
|
||||
public string DisplayName => "Parent Workflow Example";
|
||||
public IReadOnlyCollection<string> WorkflowRoles => [];
|
||||
|
||||
public WorkflowSpec<PolicyChangeWorkflowRequest> Spec { get; } = WorkflowSpec
|
||||
.For<PolicyChangeWorkflowRequest>()
|
||||
.InitializeState(WorkflowExpr.Object(
|
||||
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("start.policyId"))))
|
||||
.StartWith(BuildFlow)
|
||||
.Build();
|
||||
|
||||
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
|
||||
|
||||
private static void BuildFlow(WorkflowFlowBuilder<PolicyChangeWorkflowRequest> flow)
|
||||
{
|
||||
flow
|
||||
.Call("Open For Change",
|
||||
Address.LegacyRabbit("pas_annexprocessing_alterpolicy"),
|
||||
WorkflowExpr.Object(
|
||||
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))),
|
||||
WorkflowHandledBranchAction.Complete,
|
||||
WorkflowHandledBranchAction.Complete)
|
||||
|
||||
// --- SubWorkflow: inline execution, parent waits ---
|
||||
// The child workflow runs within this execution.
|
||||
// Its tasks appear under the parent instance.
|
||||
// State from the child merges back into the parent after completion.
|
||||
.SubWorkflow(
|
||||
"Review Policy Changes",
|
||||
new WorkflowWorkflowInvocationDeclaration
|
||||
{
|
||||
WorkflowName = "ReviewPolicyOpenForChange",
|
||||
PayloadExpression = WorkflowExpr.Object(
|
||||
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId")),
|
||||
WorkflowExpr.Prop("productCode", WorkflowExpr.Path("state.productCode"))),
|
||||
})
|
||||
// Execution resumes here after child completes.
|
||||
|
||||
// --- ContinueWith: fire-and-forget ---
|
||||
// The parent workflow completes immediately.
|
||||
// A new independent workflow instance is created via the signal bus.
|
||||
.ContinueWith(
|
||||
"Start Transfer Process",
|
||||
new WorkflowWorkflowInvocationDeclaration
|
||||
{
|
||||
WorkflowName = "TransferPolicy",
|
||||
PayloadExpression = WorkflowExpr.Path("state"),
|
||||
});
|
||||
// Parent is now complete. TransferPolicy runs independently.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"schemaVersion": "serdica.workflow.definition/v1",
|
||||
"workflowName": "ParentWorkflow",
|
||||
"workflowVersion": "1.0.0",
|
||||
"displayName": "Parent Workflow Example",
|
||||
"workflowRoles": [],
|
||||
"start": {
|
||||
"initializeStateExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "policyId", "expression": { "$type": "path", "path": "start.policyId" } }
|
||||
]
|
||||
},
|
||||
"sequence": {
|
||||
"steps": [
|
||||
{
|
||||
"$type": "call-transport",
|
||||
"stepName": "Open For Change",
|
||||
"invocation": {
|
||||
"address": { "$type": "legacy-rabbit", "command": "pas_annexprocessing_alterpolicy", "mode": "Envelope" },
|
||||
"payloadExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } }
|
||||
]
|
||||
}
|
||||
},
|
||||
"whenFailure": { "steps": [{ "$type": "complete" }] },
|
||||
"whenTimeout": { "steps": [{ "$type": "complete" }] }
|
||||
},
|
||||
{
|
||||
"$type": "sub-workflow",
|
||||
"stepName": "Review Policy Changes",
|
||||
"invocation": {
|
||||
"workflowName": "ReviewPolicyOpenForChange",
|
||||
"payloadExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } },
|
||||
{ "name": "productCode", "expression": { "$type": "path", "path": "state.productCode" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"$type": "continue-with-workflow",
|
||||
"stepName": "Start Transfer Process",
|
||||
"invocation": {
|
||||
"workflowName": "TransferPolicy",
|
||||
"payloadExpression": { "$type": "path", "path": "state" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"tasks": []
|
||||
}
|
||||
22
docs/workflow/tutorials/06-advanced-patterns/README.md
Normal file
22
docs/workflow/tutorials/06-advanced-patterns/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Tutorial 6: Advanced Patterns
|
||||
|
||||
Fork (parallel branches), Repeat (retry loops), Timer (delays), and External Signal (wait for events).
|
||||
|
||||
## Patterns
|
||||
|
||||
| Pattern | Use When |
|
||||
|---------|----------|
|
||||
| **Fork** | Multiple independent operations that should run concurrently |
|
||||
| **Repeat** | Retry a service call with backoff, poll until condition met |
|
||||
| **Timer** | Delay between steps (backoff, scheduled processing) |
|
||||
| **External Signal** | Wait for an external event (document upload, approval from another system) |
|
||||
|
||||
## Variants
|
||||
|
||||
- [C# Fluent DSL](csharp/)
|
||||
- [Canonical JSON](json/)
|
||||
|
||||
## Next
|
||||
|
||||
[Tutorial 7: Shared Helpers](../07-shared-helpers/) — organizing reusable workflow components.
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
using WorkflowEngine.Abstractions;
|
||||
using WorkflowEngine.Contracts;
|
||||
|
||||
namespace WorkflowEngine.Tutorials;
|
||||
|
||||
public sealed class AdvancedPatternsWorkflow : IDeclarativeWorkflow<PolicyChangeWorkflowRequest>
|
||||
{
|
||||
public string WorkflowName => "AdvancedPatterns";
|
||||
public string WorkflowVersion => "1.0.0";
|
||||
public string DisplayName => "Advanced Patterns Example";
|
||||
public IReadOnlyCollection<string> WorkflowRoles => [];
|
||||
|
||||
public WorkflowSpec<PolicyChangeWorkflowRequest> Spec { get; } = WorkflowSpec
|
||||
.For<PolicyChangeWorkflowRequest>()
|
||||
.InitializeState(WorkflowExpr.Object(
|
||||
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("start.policyId")),
|
||||
WorkflowExpr.Prop("retryAttempt", WorkflowExpr.Number(0)),
|
||||
WorkflowExpr.Prop("integrationFailed", WorkflowExpr.Bool(false))))
|
||||
.StartWith(BuildFlow)
|
||||
.Build();
|
||||
|
||||
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
|
||||
|
||||
private static void BuildFlow(WorkflowFlowBuilder<PolicyChangeWorkflowRequest> flow)
|
||||
{
|
||||
flow
|
||||
// ═══════════════════════════════════════════════
|
||||
// FORK: parallel branches
|
||||
// ═══════════════════════════════════════════════
|
||||
// Both branches run concurrently. Workflow resumes after all complete.
|
||||
.Fork("Process Documents and Notify",
|
||||
documents => documents
|
||||
.Call("Generate PDF",
|
||||
Address.LegacyRabbit("pas_pdf_generate"),
|
||||
WorkflowExpr.Object(
|
||||
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))),
|
||||
WorkflowHandledBranchAction.Complete,
|
||||
WorkflowHandledBranchAction.Complete),
|
||||
notification => notification
|
||||
.Call("Send Email",
|
||||
Address.LegacyRabbit("notifications_send_email",
|
||||
SerdicaLegacyRabbitMode.MicroserviceConsumer),
|
||||
WorkflowExpr.Object(
|
||||
WorkflowExpr.Prop("to", WorkflowExpr.String("agent@company.com")),
|
||||
WorkflowExpr.Prop("subject", WorkflowExpr.String("Policy processed"))),
|
||||
WorkflowHandledBranchAction.Complete,
|
||||
WorkflowHandledBranchAction.Complete))
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// REPEAT: retry loop with backoff
|
||||
// ═══════════════════════════════════════════════
|
||||
// Retries up to 3 times while integrationFailed is true.
|
||||
.Repeat(
|
||||
"Retry Integration",
|
||||
WorkflowExpr.Number(3), // max iterations
|
||||
"retryAttempt", // counter state key
|
||||
WorkflowExpr.Or( // continue while:
|
||||
WorkflowExpr.Eq( // first attempt (counter == 0)
|
||||
WorkflowExpr.Path("state.retryAttempt"),
|
||||
WorkflowExpr.Number(0)),
|
||||
WorkflowExpr.Path("state.integrationFailed")), // or previous attempt failed
|
||||
body => body
|
||||
.Set("integrationFailed", WorkflowExpr.Bool(false))
|
||||
.Call("Transfer Policy",
|
||||
Address.Http("integration", "/api/transfer", "POST"),
|
||||
WorkflowExpr.Object(
|
||||
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))),
|
||||
whenFailure: fail => fail
|
||||
.Set("integrationFailed", WorkflowExpr.Bool(true))
|
||||
// TIMER: wait before retrying
|
||||
.Wait("Backoff", WorkflowExpr.String("00:05:00")),
|
||||
whenTimeout: timeout => timeout
|
||||
.Set("integrationFailed", WorkflowExpr.Bool(true))))
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// EXTERNAL SIGNAL: wait for event
|
||||
// ═══════════════════════════════════════════════
|
||||
// Workflow pauses until an external system raises the named signal.
|
||||
.WaitForSignal(
|
||||
"Wait for Document Upload",
|
||||
signalName: "documents-ready",
|
||||
resultKey: "uploadedDocs")
|
||||
|
||||
// Use the signal payload in subsequent steps.
|
||||
.Set("documentCount",
|
||||
WorkflowExpr.Func("length",
|
||||
WorkflowExpr.Path("result.uploadedDocs.fileIds")))
|
||||
.Complete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"schemaVersion": "serdica.workflow.definition/v1",
|
||||
"workflowName": "AdvancedPatterns",
|
||||
"workflowVersion": "1.0.0",
|
||||
"displayName": "Advanced Patterns Example",
|
||||
"workflowRoles": [],
|
||||
"start": {
|
||||
"initializeStateExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "policyId", "expression": { "$type": "path", "path": "start.policyId" } },
|
||||
{ "name": "retryAttempt", "expression": { "$type": "number", "value": 0 } },
|
||||
{ "name": "integrationFailed", "expression": { "$type": "boolean", "value": false } }
|
||||
]
|
||||
},
|
||||
"sequence": {
|
||||
"steps": [
|
||||
{
|
||||
"$type": "fork",
|
||||
"stepName": "Process Documents and Notify",
|
||||
"branches": [
|
||||
{
|
||||
"steps": [
|
||||
{
|
||||
"$type": "call-transport",
|
||||
"stepName": "Generate PDF",
|
||||
"invocation": {
|
||||
"address": { "$type": "legacy-rabbit", "command": "pas_pdf_generate", "mode": "Envelope" },
|
||||
"payloadExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } }
|
||||
]
|
||||
}
|
||||
},
|
||||
"whenFailure": { "steps": [{ "$type": "complete" }] },
|
||||
"whenTimeout": { "steps": [{ "$type": "complete" }] }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"steps": [
|
||||
{
|
||||
"$type": "call-transport",
|
||||
"stepName": "Send Email",
|
||||
"invocation": {
|
||||
"address": { "$type": "legacy-rabbit", "command": "notifications_send_email", "mode": "MicroserviceConsumer" },
|
||||
"payloadExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "to", "expression": { "$type": "string", "value": "agent@company.com" } },
|
||||
{ "name": "subject", "expression": { "$type": "string", "value": "Policy processed" } }
|
||||
]
|
||||
}
|
||||
},
|
||||
"whenFailure": { "steps": [{ "$type": "complete" }] },
|
||||
"whenTimeout": { "steps": [{ "$type": "complete" }] }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"$type": "repeat",
|
||||
"stepName": "Retry Integration",
|
||||
"maxIterationsExpression": { "$type": "number", "value": 3 },
|
||||
"iterationStateKey": "retryAttempt",
|
||||
"continueWhileExpression": {
|
||||
"$type": "binary", "operator": "or",
|
||||
"left": {
|
||||
"$type": "binary", "operator": "eq",
|
||||
"left": { "$type": "path", "path": "state.retryAttempt" },
|
||||
"right": { "$type": "number", "value": 0 }
|
||||
},
|
||||
"right": { "$type": "path", "path": "state.integrationFailed" }
|
||||
},
|
||||
"body": {
|
||||
"steps": [
|
||||
{ "$type": "set-state", "stateKey": "integrationFailed", "valueExpression": { "$type": "boolean", "value": false } },
|
||||
{
|
||||
"$type": "call-transport",
|
||||
"stepName": "Transfer Policy",
|
||||
"invocation": {
|
||||
"address": { "$type": "http", "target": "integration", "path": "/api/transfer", "method": "POST" },
|
||||
"payloadExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } }
|
||||
]
|
||||
}
|
||||
},
|
||||
"whenFailure": {
|
||||
"steps": [
|
||||
{ "$type": "set-state", "stateKey": "integrationFailed", "valueExpression": { "$type": "boolean", "value": true } },
|
||||
{ "$type": "timer", "stepName": "Backoff", "delayExpression": { "$type": "string", "value": "00:05:00" } }
|
||||
]
|
||||
},
|
||||
"whenTimeout": {
|
||||
"steps": [
|
||||
{ "$type": "set-state", "stateKey": "integrationFailed", "valueExpression": { "$type": "boolean", "value": true } }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"$type": "external-signal",
|
||||
"stepName": "Wait for Document Upload",
|
||||
"signalNameExpression": { "$type": "string", "value": "documents-ready" },
|
||||
"resultKey": "uploadedDocs"
|
||||
},
|
||||
{
|
||||
"$type": "set-state",
|
||||
"stateKey": "documentCount",
|
||||
"valueExpression": {
|
||||
"$type": "function",
|
||||
"functionName": "length",
|
||||
"arguments": [{ "$type": "path", "path": "result.uploadedDocs.fileIds" }]
|
||||
}
|
||||
},
|
||||
{ "$type": "complete" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"tasks": []
|
||||
}
|
||||
24
docs/workflow/tutorials/07-shared-helpers/README.md
Normal file
24
docs/workflow/tutorials/07-shared-helpers/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Tutorial 7: Shared Support Helpers
|
||||
|
||||
When building many workflows for the same domain (e.g., 50+ policy change workflows), extract reusable components into a support helper class.
|
||||
|
||||
## What to Extract
|
||||
|
||||
| Component | Example |
|
||||
|-----------|---------|
|
||||
| **Address constants** | `LegacyRabbitAddress`, `HttpAddress` — centralized routing |
|
||||
| **Workflow references** | `WorkflowReference` — for SubWorkflow/ContinueWith targets |
|
||||
| **Payload builders** | Static methods returning `WorkflowExpressionDefinition` |
|
||||
| **State initializers** | Base state + override pattern |
|
||||
| **Flow extensions** | Extension methods on `WorkflowFlowBuilder<T>` for common sequences |
|
||||
|
||||
## C#-Only Tutorial
|
||||
|
||||
This tutorial has no JSON equivalent — it covers C# code organization patterns.
|
||||
|
||||
- [C# Example](csharp/)
|
||||
|
||||
## Next
|
||||
|
||||
[Tutorial 8: Expressions](../08-expressions/) — path navigation, functions, and operators.
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using WorkflowEngine.Abstractions;
|
||||
using WorkflowEngine.Contracts;
|
||||
|
||||
namespace WorkflowEngine.Tutorials;
|
||||
|
||||
/// <summary>
|
||||
/// Shared support helper for policy change workflows.
|
||||
/// Centralizes addresses, payload builders, and reusable flow patterns.
|
||||
/// </summary>
|
||||
internal static class PolicyWorkflowSupport
|
||||
{
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// ADDRESS REGISTRY
|
||||
// Centralize all service routing in one place.
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
internal static readonly LegacyRabbitAddress ValidatePolicyAddress =
|
||||
new("pas_policy_validate");
|
||||
|
||||
internal static readonly LegacyRabbitAddress AlterPolicyAddress =
|
||||
new("pas_annexprocessing_alterpolicy");
|
||||
|
||||
internal static readonly LegacyRabbitAddress CalculatePremiumAddress =
|
||||
new("pas_premium_calculate_for_object",
|
||||
SerdicaLegacyRabbitMode.MicroserviceConsumer);
|
||||
|
||||
internal static readonly LegacyRabbitAddress GetAnnexDescAddress =
|
||||
new("pas_polannexes_get");
|
||||
|
||||
internal static readonly LegacyRabbitAddress NotificationEmailAddress =
|
||||
new("notifications_send_email",
|
||||
SerdicaLegacyRabbitMode.MicroserviceConsumer);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// WORKFLOW REFERENCES
|
||||
// For SubWorkflow and ContinueWith invocations.
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
internal static readonly WorkflowReference ReviewPolicyReference =
|
||||
new("ReviewPolicyOpenForChange");
|
||||
|
||||
internal static readonly WorkflowReference TransferPolicyReference =
|
||||
new("TransferPolicy");
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// STATE INITIALIZATION
|
||||
// Base state + override pattern for workflow families.
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Builds a state initialization expression with common policy fields
|
||||
/// and optional per-workflow overrides.
|
||||
/// </summary>
|
||||
internal static WorkflowExpressionDefinition BuildInitializeState(
|
||||
params WorkflowNamedExpressionDefinition[] overrides)
|
||||
{
|
||||
var properties = new List<WorkflowNamedExpressionDefinition>
|
||||
{
|
||||
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("start.srPolicyId")),
|
||||
WorkflowExpr.Prop("srAnnexId", WorkflowExpr.Path("start.srAnnexId")),
|
||||
WorkflowExpr.Prop("srCustId", WorkflowExpr.Path("start.srCustId")),
|
||||
WorkflowExpr.Prop("annexType", WorkflowExpr.Path("start.annexType")),
|
||||
WorkflowExpr.Prop("beginDate", WorkflowExpr.Path("start.beginDate")),
|
||||
WorkflowExpr.Prop("endDate", WorkflowExpr.Path("start.endDate")),
|
||||
};
|
||||
|
||||
// Apply overrides: replace existing or add new properties.
|
||||
foreach (var o in overrides)
|
||||
{
|
||||
var existing = properties.FindIndex(
|
||||
p => string.Equals(p.Name, o.Name, StringComparison.OrdinalIgnoreCase));
|
||||
if (existing >= 0)
|
||||
{
|
||||
properties[existing] = o;
|
||||
}
|
||||
else
|
||||
{
|
||||
properties.Add(o);
|
||||
}
|
||||
}
|
||||
|
||||
return WorkflowExpr.Object(properties);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// PAYLOAD BUILDERS
|
||||
// Reusable expressions for common service call payloads.
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
internal static WorkflowExpressionDefinition BuildAlterPolicyPayload()
|
||||
{
|
||||
return WorkflowExpr.Object(
|
||||
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("state.srPolicyId")),
|
||||
WorkflowExpr.Prop("beginDate", WorkflowExpr.Path("state.beginDate")),
|
||||
WorkflowExpr.Prop("endDate", WorkflowExpr.Path("state.endDate")),
|
||||
WorkflowExpr.Prop("annexType", WorkflowExpr.Path("state.annexType")));
|
||||
}
|
||||
|
||||
internal static WorkflowExpressionDefinition BuildAnnexTypeEquals(string type)
|
||||
{
|
||||
return WorkflowExpr.Eq(
|
||||
WorkflowExpr.Func("upper", WorkflowExpr.Path("state.annexType")),
|
||||
WorkflowExpr.String(type));
|
||||
}
|
||||
|
||||
internal static WorkflowExpressionDefinition BuildPolicyIdPayload()
|
||||
{
|
||||
return WorkflowExpr.Object(
|
||||
WorkflowExpr.Prop("srPolicyId", WorkflowExpr.Path("state.srPolicyId")));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// WORKFLOW INVOCATION BUILDERS
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
internal static WorkflowWorkflowInvocationDeclaration BuildReviewInvocation()
|
||||
{
|
||||
return new WorkflowWorkflowInvocationDeclaration
|
||||
{
|
||||
WorkflowName = ReviewPolicyReference.WorkflowName,
|
||||
PayloadExpression = WorkflowExpr.Path("state"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for common flow patterns.
|
||||
/// Used across multiple workflows for DRY step sequences.
|
||||
/// </summary>
|
||||
internal static class PolicyWorkflowFlowExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies product info from a service call result into workflow state.
|
||||
/// </summary>
|
||||
internal static WorkflowFlowBuilder<T> ApplyProductInfo<T>(
|
||||
this WorkflowFlowBuilder<T> flow,
|
||||
string resultKey = "productInfo")
|
||||
where T : class
|
||||
{
|
||||
return flow
|
||||
.SetIfHasValue("productCode",
|
||||
WorkflowExpr.Func("upper",
|
||||
WorkflowExpr.Path($"result.{resultKey}.productCode")))
|
||||
.SetIfHasValue("lob",
|
||||
WorkflowExpr.Func("upper",
|
||||
WorkflowExpr.Path($"result.{resultKey}.lob")))
|
||||
.SetIfHasValue("contractType",
|
||||
WorkflowExpr.Path($"result.{resultKey}.contractType"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Standard "load product info and apply" pattern.
|
||||
/// </summary>
|
||||
internal static WorkflowFlowBuilder<T> LoadAndApplyProductInfo<T>(
|
||||
this WorkflowFlowBuilder<T> flow)
|
||||
where T : class
|
||||
{
|
||||
return flow
|
||||
.Call<object>("Load Product Info",
|
||||
Address.LegacyRabbit("pas_get_policy_product_info"),
|
||||
PolicyWorkflowSupport.BuildPolicyIdPayload(),
|
||||
WorkflowHandledBranchAction.Complete,
|
||||
WorkflowHandledBranchAction.Complete,
|
||||
resultKey: "productInfo")
|
||||
.ApplyProductInfo();
|
||||
}
|
||||
}
|
||||
36
docs/workflow/tutorials/08-expressions/README.md
Normal file
36
docs/workflow/tutorials/08-expressions/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Tutorial 8: Expressions
|
||||
|
||||
The expression system enables declarative logic that compiles to portable canonical JSON. All expressions are evaluable at runtime without recompilation.
|
||||
|
||||
## Path Navigation
|
||||
|
||||
| Prefix | Source | Example |
|
||||
|--------|--------|---------|
|
||||
| `start.*` | Start request fields | `start.policyId` |
|
||||
| `state.*` | Mutable workflow state | `state.customerName` |
|
||||
| `payload.*` | Task completion payload | `payload.answer` |
|
||||
| `result.*` | Step result (by resultKey) | `result.productInfo.lob` |
|
||||
|
||||
## Built-in Functions
|
||||
|
||||
| Function | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `coalesce` | First non-null | `coalesce(state.id, start.id, 0)` |
|
||||
| `concat` | String join | `concat("Policy #", state.policyNo)` |
|
||||
| `add` | Sum | `add(state.attempt, 1)` |
|
||||
| `if` | Conditional | `if(state.isVip, "VIP", "Standard")` |
|
||||
| `isNullOrWhiteSpace` | Null/empty check | `isNullOrWhiteSpace(state.name)` |
|
||||
| `length` | String/array length | `length(state.items)` |
|
||||
| `upper` | Uppercase | `upper(state.annexType)` |
|
||||
| `first` | First array element | `first(state.objects)` |
|
||||
| `mergeObjects` | Deep merge | `mergeObjects(state, payload)` |
|
||||
|
||||
## Variants
|
||||
|
||||
- [C# Expression Builder](csharp/)
|
||||
- [JSON Expression Format](json/)
|
||||
|
||||
## Next
|
||||
|
||||
[Tutorial 9: Testing](../09-testing/) — unit test setup with recording transports.
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
using WorkflowEngine.Abstractions;
|
||||
|
||||
namespace WorkflowEngine.Tutorials;
|
||||
|
||||
/// <summary>
|
||||
/// Expression builder examples showing all expression types.
|
||||
/// These examples are not a runnable workflow — they demonstrate the WorkflowExpr API.
|
||||
/// </summary>
|
||||
internal static class ExpressionExamples
|
||||
{
|
||||
// ═══════════════════════════════════════════════
|
||||
// LITERALS
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
static readonly WorkflowExpressionDefinition nullExpr = WorkflowExpr.Null();
|
||||
static readonly WorkflowExpressionDefinition stringExpr = WorkflowExpr.String("hello");
|
||||
static readonly WorkflowExpressionDefinition numberExpr = WorkflowExpr.Number(42);
|
||||
static readonly WorkflowExpressionDefinition boolExpr = WorkflowExpr.Bool(true);
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// PATH NAVIGATION
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
// Read from start request
|
||||
static readonly WorkflowExpressionDefinition fromStart = WorkflowExpr.Path("start.policyId");
|
||||
|
||||
// Read from workflow state
|
||||
static readonly WorkflowExpressionDefinition fromState = WorkflowExpr.Path("state.customerName");
|
||||
|
||||
// Read from task completion payload
|
||||
static readonly WorkflowExpressionDefinition fromPayload = WorkflowExpr.Path("payload.answer");
|
||||
|
||||
// Read from step result (requires resultKey on the Call step)
|
||||
static readonly WorkflowExpressionDefinition fromResult = WorkflowExpr.Path("result.productInfo.lob");
|
||||
|
||||
// Nested path navigation
|
||||
static readonly WorkflowExpressionDefinition nested = WorkflowExpr.Path("state.entityData.address.city");
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// OBJECT & ARRAY CONSTRUCTION
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
static readonly WorkflowExpressionDefinition obj = WorkflowExpr.Object(
|
||||
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId")),
|
||||
WorkflowExpr.Prop("status", WorkflowExpr.String("ACTIVE")),
|
||||
WorkflowExpr.Prop("tags", WorkflowExpr.Array(
|
||||
WorkflowExpr.String("motor"),
|
||||
WorkflowExpr.String("casco"))));
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// COMPARISONS
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
static readonly WorkflowExpressionDefinition eq = WorkflowExpr.Eq(
|
||||
WorkflowExpr.Path("state.status"), WorkflowExpr.String("APPROVED"));
|
||||
static readonly WorkflowExpressionDefinition ne = WorkflowExpr.Ne(
|
||||
WorkflowExpr.Path("state.status"), WorkflowExpr.String("REJECTED"));
|
||||
static readonly WorkflowExpressionDefinition gt = WorkflowExpr.Gt(
|
||||
WorkflowExpr.Path("state.premium"), WorkflowExpr.Number(1000));
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// BOOLEAN LOGIC
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
static readonly WorkflowExpressionDefinition not = WorkflowExpr.Not(
|
||||
WorkflowExpr.Path("state.isRejected"));
|
||||
static readonly WorkflowExpressionDefinition and = WorkflowExpr.And(
|
||||
WorkflowExpr.Path("state.isValid"),
|
||||
WorkflowExpr.Not(WorkflowExpr.Path("state.isRejected")));
|
||||
static readonly WorkflowExpressionDefinition or = WorkflowExpr.Or(
|
||||
WorkflowExpr.Eq(WorkflowExpr.Path("state.status"), WorkflowExpr.String("APPROVED")),
|
||||
WorkflowExpr.Eq(WorkflowExpr.Path("state.status"), WorkflowExpr.String("OVERRIDE")));
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// FUNCTION CALLS
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
// Coalesce: first non-null value
|
||||
static readonly WorkflowExpressionDefinition coalesce = WorkflowExpr.Func("coalesce",
|
||||
WorkflowExpr.Path("state.customerId"),
|
||||
WorkflowExpr.Path("start.customerId"),
|
||||
WorkflowExpr.Number(0));
|
||||
|
||||
// String concatenation
|
||||
static readonly WorkflowExpressionDefinition concat = WorkflowExpr.Func("concat",
|
||||
WorkflowExpr.String("Policy #"),
|
||||
WorkflowExpr.Path("state.policyNo"));
|
||||
|
||||
// Arithmetic
|
||||
static readonly WorkflowExpressionDefinition increment = WorkflowExpr.Func("add",
|
||||
WorkflowExpr.Func("coalesce",
|
||||
WorkflowExpr.Path("state.attempt"), WorkflowExpr.Number(0)),
|
||||
WorkflowExpr.Number(1));
|
||||
|
||||
// Conditional value
|
||||
static readonly WorkflowExpressionDefinition conditional = WorkflowExpr.Func("if",
|
||||
WorkflowExpr.Path("state.isVip"),
|
||||
WorkflowExpr.String("VIP"),
|
||||
WorkflowExpr.String("Standard"));
|
||||
|
||||
// Uppercase
|
||||
static readonly WorkflowExpressionDefinition upper = WorkflowExpr.Func("upper",
|
||||
WorkflowExpr.Path("state.annexType"));
|
||||
|
||||
// Null check
|
||||
static readonly WorkflowExpressionDefinition nullCheck = WorkflowExpr.Func("isNullOrWhiteSpace",
|
||||
WorkflowExpr.Path("state.integrationId"));
|
||||
|
||||
// Array length
|
||||
static readonly WorkflowExpressionDefinition length = WorkflowExpr.Func("length",
|
||||
WorkflowExpr.Path("state.documents"));
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// COMBINING EXPRESSIONS (real-world patterns)
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
// "Use integration customer ID if present, otherwise use lookup ID"
|
||||
static readonly WorkflowExpressionDefinition resolveCustomerId = WorkflowExpr.Func("if",
|
||||
WorkflowExpr.Not(
|
||||
WorkflowExpr.Func("isNullOrWhiteSpace",
|
||||
WorkflowExpr.Path("state.integrationCustomerId"))),
|
||||
WorkflowExpr.Path("state.integrationCustomerId"),
|
||||
WorkflowExpr.Path("state.lookupCustomerId"));
|
||||
|
||||
// "Should we retry? (first attempt or previous failed, and not timed out)"
|
||||
static readonly WorkflowExpressionDefinition shouldRetry = WorkflowExpr.Or(
|
||||
WorkflowExpr.Eq(WorkflowExpr.Path("state.retryAttempt"), WorkflowExpr.Number(0)),
|
||||
WorkflowExpr.And(
|
||||
WorkflowExpr.Not(WorkflowExpr.Path("state.timedOut")),
|
||||
WorkflowExpr.Path("state.integrationFailed")));
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
{
|
||||
"_comment": "Expression examples in canonical JSON format. Each key shows a different expression pattern.",
|
||||
|
||||
"literals": {
|
||||
"null": { "$type": "null" },
|
||||
"string": { "$type": "string", "value": "hello" },
|
||||
"number": { "$type": "number", "value": 42 },
|
||||
"boolean": { "$type": "boolean", "value": true }
|
||||
},
|
||||
|
||||
"paths": {
|
||||
"fromStartRequest": { "$type": "path", "path": "start.policyId" },
|
||||
"fromState": { "$type": "path", "path": "state.customerName" },
|
||||
"fromPayload": { "$type": "path", "path": "payload.answer" },
|
||||
"fromResult": { "$type": "path", "path": "result.productInfo.lob" },
|
||||
"nestedPath": { "$type": "path", "path": "state.entityData.address.city" }
|
||||
},
|
||||
|
||||
"objectConstruction": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } },
|
||||
{ "name": "status", "expression": { "$type": "string", "value": "ACTIVE" } },
|
||||
{ "name": "tags", "expression": {
|
||||
"$type": "array",
|
||||
"items": [
|
||||
{ "$type": "string", "value": "motor" },
|
||||
{ "$type": "string", "value": "casco" }
|
||||
]
|
||||
}}
|
||||
]
|
||||
},
|
||||
|
||||
"comparisons": {
|
||||
"equals": {
|
||||
"$type": "binary", "operator": "eq",
|
||||
"left": { "$type": "path", "path": "state.status" },
|
||||
"right": { "$type": "string", "value": "APPROVED" }
|
||||
},
|
||||
"notEquals": {
|
||||
"$type": "binary", "operator": "ne",
|
||||
"left": { "$type": "path", "path": "state.status" },
|
||||
"right": { "$type": "string", "value": "REJECTED" }
|
||||
},
|
||||
"greaterThan": {
|
||||
"$type": "binary", "operator": "gt",
|
||||
"left": { "$type": "path", "path": "state.premium" },
|
||||
"right": { "$type": "number", "value": 1000 }
|
||||
}
|
||||
},
|
||||
|
||||
"booleanLogic": {
|
||||
"not": {
|
||||
"$type": "unary", "operator": "not",
|
||||
"operand": { "$type": "path", "path": "state.isRejected" }
|
||||
},
|
||||
"and": {
|
||||
"$type": "binary", "operator": "and",
|
||||
"left": { "$type": "path", "path": "state.isValid" },
|
||||
"right": {
|
||||
"$type": "unary", "operator": "not",
|
||||
"operand": { "$type": "path", "path": "state.isRejected" }
|
||||
}
|
||||
},
|
||||
"or": {
|
||||
"$type": "binary", "operator": "or",
|
||||
"left": {
|
||||
"$type": "binary", "operator": "eq",
|
||||
"left": { "$type": "path", "path": "state.status" },
|
||||
"right": { "$type": "string", "value": "APPROVED" }
|
||||
},
|
||||
"right": {
|
||||
"$type": "binary", "operator": "eq",
|
||||
"left": { "$type": "path", "path": "state.status" },
|
||||
"right": { "$type": "string", "value": "OVERRIDE" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"functions": {
|
||||
"coalesce": {
|
||||
"$type": "function", "functionName": "coalesce",
|
||||
"arguments": [
|
||||
{ "$type": "path", "path": "state.customerId" },
|
||||
{ "$type": "path", "path": "start.customerId" },
|
||||
{ "$type": "number", "value": 0 }
|
||||
]
|
||||
},
|
||||
"concat": {
|
||||
"$type": "function", "functionName": "concat",
|
||||
"arguments": [
|
||||
{ "$type": "string", "value": "Policy #" },
|
||||
{ "$type": "path", "path": "state.policyNo" }
|
||||
]
|
||||
},
|
||||
"increment": {
|
||||
"$type": "function", "functionName": "add",
|
||||
"arguments": [
|
||||
{
|
||||
"$type": "function", "functionName": "coalesce",
|
||||
"arguments": [
|
||||
{ "$type": "path", "path": "state.attempt" },
|
||||
{ "$type": "number", "value": 0 }
|
||||
]
|
||||
},
|
||||
{ "$type": "number", "value": 1 }
|
||||
]
|
||||
},
|
||||
"conditional": {
|
||||
"$type": "function", "functionName": "if",
|
||||
"arguments": [
|
||||
{ "$type": "path", "path": "state.isVip" },
|
||||
{ "$type": "string", "value": "VIP" },
|
||||
{ "$type": "string", "value": "Standard" }
|
||||
]
|
||||
},
|
||||
"uppercase": {
|
||||
"$type": "function", "functionName": "upper",
|
||||
"arguments": [{ "$type": "path", "path": "state.annexType" }]
|
||||
},
|
||||
"nullCheck": {
|
||||
"$type": "function", "functionName": "isNullOrWhiteSpace",
|
||||
"arguments": [{ "$type": "path", "path": "state.integrationId" }]
|
||||
},
|
||||
"arrayLength": {
|
||||
"$type": "function", "functionName": "length",
|
||||
"arguments": [{ "$type": "path", "path": "state.documents" }]
|
||||
}
|
||||
},
|
||||
|
||||
"realWorldPatterns": {
|
||||
"resolveCustomerId_comment": "Use integration customer ID if present, otherwise use lookup ID",
|
||||
"resolveCustomerId": {
|
||||
"$type": "function", "functionName": "if",
|
||||
"arguments": [
|
||||
{
|
||||
"$type": "unary", "operator": "not",
|
||||
"operand": {
|
||||
"$type": "function", "functionName": "isNullOrWhiteSpace",
|
||||
"arguments": [{ "$type": "path", "path": "state.integrationCustomerId" }]
|
||||
}
|
||||
},
|
||||
{ "$type": "path", "path": "state.integrationCustomerId" },
|
||||
{ "$type": "path", "path": "state.lookupCustomerId" }
|
||||
]
|
||||
},
|
||||
|
||||
"shouldRetry_comment": "First attempt or previous failed and not timed out",
|
||||
"shouldRetry": {
|
||||
"$type": "binary", "operator": "or",
|
||||
"left": {
|
||||
"$type": "binary", "operator": "eq",
|
||||
"left": { "$type": "path", "path": "state.retryAttempt" },
|
||||
"right": { "$type": "number", "value": 0 }
|
||||
},
|
||||
"right": {
|
||||
"$type": "binary", "operator": "and",
|
||||
"left": {
|
||||
"$type": "unary", "operator": "not",
|
||||
"operand": { "$type": "path", "path": "state.timedOut" }
|
||||
},
|
||||
"right": { "$type": "path", "path": "state.integrationFailed" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
docs/workflow/tutorials/09-testing/README.md
Normal file
29
docs/workflow/tutorials/09-testing/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Tutorial 9: Testing Your Workflow
|
||||
|
||||
Write unit tests for workflows using `RecordingSerdicaLegacyRabbitTransport` and `TechnicalStyleWorkflowTestHelpers`.
|
||||
|
||||
## Test Setup Pattern
|
||||
|
||||
1. Create a recording transport with pre-configured responses
|
||||
2. Build a test service provider via `TechnicalStyleWorkflowTestHelpers.CreateServiceProvider`
|
||||
3. Resolve `WorkflowRuntimeService` from DI
|
||||
4. Call `StartWorkflowAsync` with test payload
|
||||
5. Assert: tasks created, transport calls made, state values correct
|
||||
6. Optionally complete tasks and verify downstream behavior
|
||||
|
||||
## What to Test
|
||||
|
||||
| Scenario | Approach |
|
||||
|----------|----------|
|
||||
| Workflow starts correctly | Assert single open task after start |
|
||||
| Service calls made in order | `transport.Invocations.Select(x => x.Command).Should().Equal(...)` |
|
||||
| Rejection flow | Complete task with `"answer": "reject"`, verify cancel call |
|
||||
| Approval flow | Complete with `"answer": "approve"`, verify conversion calls |
|
||||
| Operations failure re-opens task | Check same task re-appears after operations return `passed: false` |
|
||||
| Sub-workflow creates child tasks | Query tasks by child workflow name |
|
||||
| Business reference set | `startResponse.BusinessReference.Key.Should().Be(...)` |
|
||||
|
||||
## C#-Only Tutorial
|
||||
|
||||
- [C# Test Examples](csharp/)
|
||||
|
||||
196
docs/workflow/tutorials/09-testing/csharp/WorkflowTests.cs
Normal file
196
docs/workflow/tutorials/09-testing/csharp/WorkflowTests.cs
Normal file
@@ -0,0 +1,196 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using WorkflowEngine.Abstractions;
|
||||
using WorkflowEngine.Contracts;
|
||||
using WorkflowEngine.Services;
|
||||
|
||||
using FluentAssertions;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace WorkflowEngine.Tutorials.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class WorkflowTestExamples
|
||||
{
|
||||
// ═══════════════════════════════════════════════
|
||||
// BASIC: Start workflow and verify task created
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
[Test]
|
||||
public async Task Workflow_WhenStarted_ShouldCreateOpenTask()
|
||||
{
|
||||
// 1. Configure fake transport responses
|
||||
var transport = new RecordingSerdicaLegacyRabbitTransport()
|
||||
.Respond("pas_policy_validate", new { valid = true })
|
||||
.Respond("pas_get_policy_product_info", new
|
||||
{
|
||||
productCode = "4704",
|
||||
lob = "MOT",
|
||||
contractType = "STANDARD",
|
||||
});
|
||||
|
||||
// 2. Build test service provider
|
||||
using var provider = TechnicalStyleWorkflowTestHelpers.CreateServiceProvider(
|
||||
transport,
|
||||
WorkflowRuntimeProviderNames.SerdicaEngine);
|
||||
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
|
||||
|
||||
// 3. Start the workflow
|
||||
var start = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest
|
||||
{
|
||||
WorkflowName = "ApproveApplication",
|
||||
Payload = new Dictionary<string, object?>
|
||||
{
|
||||
["srPolicyId"] = 12345L,
|
||||
["srAnnexId"] = 67890L,
|
||||
["srCustId"] = 11111L,
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Assert task was created
|
||||
var tasks = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
|
||||
{
|
||||
WorkflowInstanceId = start.WorkflowInstanceId,
|
||||
Status = "Open",
|
||||
});
|
||||
tasks.Tasks.Should().ContainSingle();
|
||||
tasks.Tasks.Single().TaskName.Should().Be("Approve Application");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// VERIFY: Service calls made in order
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
[Test]
|
||||
public async Task Workflow_WhenStarted_ShouldCallServicesInOrder()
|
||||
{
|
||||
var transport = new RecordingSerdicaLegacyRabbitTransport()
|
||||
.Respond("pas_annexprocessing_alterpolicy", new
|
||||
{
|
||||
srPolicyId = 1L,
|
||||
srAnnexId = 2L,
|
||||
previouslyOpened = false,
|
||||
})
|
||||
.Respond("pas_polclmparticipants_create", new { ok = true })
|
||||
.Respond("pas_premium_calculate_for_object", new { ok = true },
|
||||
SerdicaLegacyRabbitMode.MicroserviceConsumer)
|
||||
.Respond("pas_polannexes_get", new
|
||||
{
|
||||
shortDescription = "Test annex",
|
||||
policyNo = "POL-001",
|
||||
});
|
||||
|
||||
using var provider = TechnicalStyleWorkflowTestHelpers.CreateServiceProvider(
|
||||
transport,
|
||||
WorkflowRuntimeProviderNames.SerdicaEngine);
|
||||
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
|
||||
|
||||
await runtimeService.StartWorkflowAsync(new StartWorkflowRequest
|
||||
{
|
||||
WorkflowName = "AssistantAddAnnex",
|
||||
Payload = new Dictionary<string, object?>
|
||||
{
|
||||
["srPolicyId"] = 1L,
|
||||
["srAnnexId"] = 2L,
|
||||
["policyExistsOnIPAL"] = true,
|
||||
["annexPreviouslyOpened"] = false,
|
||||
["annexType"] = "BENEF",
|
||||
["entityData"] = new { srCustId = 3L },
|
||||
},
|
||||
});
|
||||
|
||||
// Verify exact call sequence
|
||||
transport.Invocations.Select(x => x.Command)
|
||||
.Should().Equal(
|
||||
"pas_annexprocessing_alterpolicy",
|
||||
"pas_polclmparticipants_create",
|
||||
"pas_premium_calculate_for_object",
|
||||
"pas_polannexes_get");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// TASK COMPLETION: Approve/reject flows
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
[Test]
|
||||
public async Task Workflow_WhenTaskCompleted_ShouldExecuteOnCompleteFlow()
|
||||
{
|
||||
var transport = new RecordingSerdicaLegacyRabbitTransport()
|
||||
.Respond("pas_operations_perform", new { passed = true, nextStep = "CONTINUE" },
|
||||
SerdicaLegacyRabbitMode.MicroserviceConsumer)
|
||||
.Respond("pas_polreg_convertapltopol", new { ok = true })
|
||||
.Respond("pas_annexprocessing_generatepolicyno", new { ok = true })
|
||||
.Respond("pas_get_policy_product_info", new
|
||||
{
|
||||
productCode = "4704",
|
||||
lob = "MOT",
|
||||
contractType = "STANDARD",
|
||||
});
|
||||
|
||||
using var provider = TechnicalStyleWorkflowTestHelpers.CreateServiceProvider(
|
||||
transport,
|
||||
WorkflowRuntimeProviderNames.SerdicaEngine);
|
||||
var runtimeService = provider.GetRequiredService<WorkflowRuntimeService>();
|
||||
|
||||
// Start workflow
|
||||
var start = await runtimeService.StartWorkflowAsync(new StartWorkflowRequest
|
||||
{
|
||||
WorkflowName = "ApproveApplication",
|
||||
Payload = new Dictionary<string, object?>
|
||||
{
|
||||
["srPolicyId"] = 1L,
|
||||
["srAnnexId"] = 2L,
|
||||
["srCustId"] = 3L,
|
||||
},
|
||||
});
|
||||
|
||||
// Get the open task
|
||||
var task = (await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
|
||||
{
|
||||
WorkflowInstanceId = start.WorkflowInstanceId,
|
||||
Status = "Open",
|
||||
})).Tasks.Single();
|
||||
|
||||
// Complete with "approve"
|
||||
await runtimeService.CompleteTaskAsync(new WorkflowTaskCompleteRequest
|
||||
{
|
||||
WorkflowTaskId = task.WorkflowTaskId,
|
||||
ActorId = "test-user",
|
||||
ActorRoles = ["DBA"],
|
||||
Payload = new Dictionary<string, object?> { ["answer"] = "approve" },
|
||||
});
|
||||
|
||||
// Verify operations and conversion were called
|
||||
transport.Invocations.Should().Contain(x => x.Command == "pas_operations_perform");
|
||||
transport.Invocations.Should().Contain(x => x.Command == "pas_polreg_convertapltopol");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// RECORDING TRANSPORT: multiple responses
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
[Test]
|
||||
public void RecordingTransport_CanConfigureMultipleResponses()
|
||||
{
|
||||
var transport = new RecordingSerdicaLegacyRabbitTransport()
|
||||
// Default mode (Envelope)
|
||||
.Respond("command_a", new { result = "first" })
|
||||
// Specific mode
|
||||
.Respond("command_b", new { result = "second" },
|
||||
SerdicaLegacyRabbitMode.MicroserviceConsumer)
|
||||
// Same command, different responses (returned in order)
|
||||
.Respond("command_c", new { attempt = 1 })
|
||||
.Respond("command_c", new { attempt = 2 });
|
||||
|
||||
// After workflow execution, inspect:
|
||||
// transport.Invocations — list of all calls made
|
||||
// transport.Invocations[0].Command — command name
|
||||
// transport.Invocations[0].Payload — request payload
|
||||
transport.Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
32
docs/workflow/tutorials/README.md
Normal file
32
docs/workflow/tutorials/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Workflow Declaration Tutorials
|
||||
|
||||
Step-by-step tutorials for building workflows with the Serdica Workflow Engine. Each tutorial is available in both **C# fluent DSL** and **canonical JSON** variants.
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
- [Engine Reference Manual](../ENGINE.md) - Architecture, configuration, service surface, timeout model, signal system
|
||||
- [Fluent DSL Syntax Guide](../workflow-fluent-syntax-guide.md) - Complete DSL method reference
|
||||
|
||||
## Tutorials
|
||||
|
||||
| # | Tutorial | C# | JSON | Topics |
|
||||
|---|---------|-----|------|--------|
|
||||
| 01 | [Hello World](01-hello-world/) | [C#](01-hello-world/csharp/) | [JSON](01-hello-world/json/) | Minimal workflow, single task, state init |
|
||||
| 02 | [Service Tasks](02-service-tasks/) | [C#](02-service-tasks/csharp/) | [JSON](02-service-tasks/json/) | Transport calls, addresses, failure/timeout handling |
|
||||
| 03 | [Decisions](03-decisions/) | [C#](03-decisions/csharp/) | [JSON](03-decisions/json/) | WhenExpression, WhenStateFlag, nested branching |
|
||||
| 04 | [Human Tasks](04-human-tasks/) | [C#](04-human-tasks/csharp/) | [JSON](04-human-tasks/json/) | Approve/reject, OnComplete, re-activation, deadlines |
|
||||
| 05 | [Sub-Workflows](05-sub-workflows/) | [C#](05-sub-workflows/csharp/) | [JSON](05-sub-workflows/json/) | SubWorkflow vs ContinueWith, state flow |
|
||||
| 06 | [Advanced Patterns](06-advanced-patterns/) | [C#](06-advanced-patterns/csharp/) | [JSON](06-advanced-patterns/json/) | Fork, Repeat, Timer, External Signal |
|
||||
| 07 | [Shared Helpers](07-shared-helpers/) | [C#](07-shared-helpers/csharp/) | - | Address registries, payload builders, extensions |
|
||||
| 08 | [Expressions](08-expressions/) | [C#](08-expressions/csharp/) | [JSON](08-expressions/json/) | Path navigation, functions, operators |
|
||||
| 09 | [Testing](09-testing/) | [C#](09-testing/csharp/) | - | Recording transports, task completion, assertions |
|
||||
|
||||
## How to Read
|
||||
|
||||
Each tutorial folder contains:
|
||||
- **`README.md`** - Explanation, concepts, and what to expect
|
||||
- **`csharp/`** - C# fluent DSL examples
|
||||
- **`json/`** - Equivalent canonical JSON definitions (where applicable)
|
||||
|
||||
Start with Tutorial 01 and progress sequentially. Tutorials 07 (Shared Helpers) and 09 (Testing) are C#-only since they cover code organization and test infrastructure.
|
||||
|
||||
Reference in New Issue
Block a user