This commit is contained in:
Shkar T. Noori 2022-05-22 09:21:44 +03:00
parent e66733b46a
commit 1942bcfe84
No known key found for this signature in database
GPG Key ID: BDCEE57BA14A37DD
10 changed files with 194 additions and 75 deletions

View File

@ -1 +1,95 @@
# DIT.Workflower # DIT.Workflower
Workflower is a library based on .NET Standard, To handle Finite State Machines (FSM) and workflow management.
## Getting Started
### Nuget
Install the latest nuget package into your ASP.NET Core application.
```sh
dotnet add package DIT.Workflower
```
You can also download support for Dependency Injection:
```sh
dotnet add package DIT.Workflower.DependencyInjection
```
### Workflow Builder
In the `Program.cs`, register the Swagger generator, defining one or more Swagger documents.
```csharp
using DIT.Workflower;
```
```csharp
var builder = new WorkflowDefinitionBuilder<TState, TCommand, TContext>()
.From(TState.State_1)
.On(TCommand.GoNext)
.To(TState.State_2)
.From(TState.State_2)
.On(TCommand.GoNext)
.To(TState.State_3)
.AndOn(TCommand.GoBack)
.To(TState.State_1)
var workflow = builder.Build();
var allowedTransitions = workflow.GetAllowedTransitions(from: TState.State_2);
// allowedTransitions will be 2 transitions
// State_2 -> State_3 (GoNext)
// State_2 -> State_1 (GoBack)
```
### Dependency Injection
```csharp
public record PhoneCall(bool Active);
services.AddWorkflowDefinition<PhoneState, PhoneCommand, PhoneCall>(version: 1)
// Idle -> Ringing (Incoming)
.From(PhoneState.Idle)
.On(PhoneCommand.IncomingCall)
.To(PhoneState.Ringing)
// Ringing -> Connected (Accept & ctx.Active)
.From(PhoneState.Ringing)
.On(PhoneCommand.Accept)
.To(PhoneState.Connected)
.When(ctx => ctx.Active) // An instance of PhoneCall will become the ctx.
// Ringing -> Declined (Decline)
.From(PhoneState.Ringing)
.On(PhoneCommand.Decline)
.To(PhoneState.Declined)
```
```csharp
[Route("[controller]")]
public class SampleController : JsonApiControllerBase
{
private readonly IWorkflow<PhoneState, PhoneCommand, PhoneCall> _workflow;
public SampleController(IWorkflowFactory<PhoneState, PhoneCommand, PhoneCall> factory)
{
_workflow = factory.CreateWorkflow(version: 1); // Optional version param to support versioning on workflows.
}
[HttpGet("{state}")]
[ProducesResponseType(typeof(JsonApiSingleDataResponse<InitiateRequest>), StatusCodes.Status200OK)]
public ActionResult GetRequest(PhoneState state, bool isActive)
{
var phoneCall = new PhoneCall(isActive)
var transitions = _workflow.GetAllowedTransitions(context: phoneCall, state);
return Ok(transitions)
}
}
```

View File

@ -5,25 +5,25 @@ namespace DIT.Workflower.DependencyInjection.Extensions;
public static class IServiceCollectionExtensions public static class IServiceCollectionExtensions
{ {
public static WorkflowDefinitionBuilder<TState, TCommand, TContext> AddWorkflowDefinition<TState, TCommand, TContext>(this IServiceCollection services) public static ITransitionOn<TState, TCommand, TContext> AddWorkflowDefinition<TState, TCommand, TContext>(this IServiceCollection services, TState initial)
where TState : struct where TState : struct
where TCommand : struct where TCommand : struct
{ {
return AddWorkflowDefinition<TState, TCommand, TContext>(services, version: 1); return AddWorkflowDefinition<TState, TCommand, TContext>(services, initial, version: 1);
} }
public static WorkflowDefinitionBuilder<TState, TCommand, TContext> AddWorkflowDefinition<TState, TCommand, TContext>(this IServiceCollection services, int version) public static ITransitionOn<TState, TCommand, TContext> AddWorkflowDefinition<TState, TCommand, TContext>(this IServiceCollection services, TState initial, int version)
where TState : struct where TState : struct
where TCommand : struct where TCommand : struct
{ {
var builder = new WorkflowDefinitionBuilder<TState, TCommand, TContext>(); var builder = WorkflowDefinitionBuilder<TState, TCommand, TContext>.Initial(initial);
services.TryAddSingleton<IWorkflowFactory<TState, TCommand, TContext>, DefaultWorkflowFactory<TState, TCommand, TContext>>(); services.TryAddSingleton<IWorkflowFactory<TState, TCommand, TContext>, DefaultWorkflowFactory<TState, TCommand, TContext>>();
services.AddSingleton<IWorkflow<TState, TCommand, TContext>, WorkflowDefinitionWrapper<TState, TCommand, TContext>>(sp => services.AddSingleton<IWorkflow<TState, TCommand, TContext>, WorkflowDefinitionWrapper<TState, TCommand, TContext>>(sp =>
{ {
var workflow = builder.Build(); var definition = ((WorkflowDefinitionBuilder<TState, TCommand, TContext>)builder);
var wrapper = new WorkflowDefinitionWrapper<TState, TCommand, TContext>(builder, version); var wrapper = new WorkflowDefinitionWrapper<TState, TCommand, TContext>(definition, version);
return wrapper; return wrapper;
}); });

View File

@ -1,2 +1,3 @@
global using DIT.Workflower.DependencyInjection.Abstractions; global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.DependencyInjection; global using DIT.Workflower.Abstractions;
global using DIT.Workflower.DependencyInjection.Abstractions;

View File

@ -0,0 +1,35 @@
namespace DIT.Workflower.Abstractions;
public interface ITransitionOn<TState, TCommand, TContext>
where TState : struct
where TCommand : struct
{
ITransitionCondition<TState, TCommand, TContext> On(in TCommand command);
}
public interface ITransitionExit<TState, TCommand, TContext>
where TState : struct
where TCommand : struct
{
ITransitionDone<TState, TCommand, TContext> To(in TState command);
}
public interface ITransitionCondition<TState, TCommand, TContext> : ITransitionExit<TState, TCommand, TContext>
where TState : struct
where TCommand : struct
{
ITransitionCondition<TState, TCommand, TContext> WithMeta(in object meta);
ITransitionCondition<TState, TCommand, TContext> When(Func<TContext, bool> condition);
}
public interface ITransitionDone<TState, TCommand, TContext> : ITransitionOn<TState, TCommand, TContext>
where TState : struct
where TCommand : struct
{
ITransitionOn<TState, TCommand, TContext> From(in TState state);
WorkflowDefinition<TState, TCommand, TContext> Build();
}

View File

@ -4,11 +4,11 @@ public record Transition<TState, TCommand>
where TState : struct where TState : struct
where TCommand : struct where TCommand : struct
{ {
public TState From { get; set; } public TState From { get; set; } = default!;
public TState To { get; set; } public TState To { get; set; } = default!;
public TCommand Command { get; set; } public TCommand Command { get; set; } = default!;
public object? Meta { get; set; } public object? Meta { get; set; }
} }

View File

@ -1,59 +1,64 @@
namespace DIT.Workflower; using DIT.Workflower.Abstractions;
namespace DIT.Workflower;
public sealed class WorkflowDefinitionBuilder<TState, TCommand, TContext> :
ITransitionOn<TState, TCommand, TContext>,
ITransitionExit<TState, TCommand, TContext>,
ITransitionCondition<TState, TCommand, TContext>,
ITransitionDone<TState, TCommand, TContext>
public class WorkflowDefinitionBuilder<TState, TCommand, TContext>
where TState : struct where TState : struct
where TCommand : struct where TCommand : struct
{ {
private TransitionDefinition<TState, TCommand, TContext>? _current; private TransitionDefinition<TState, TCommand, TContext> _current;
public List<TransitionDefinition<TState, TCommand, TContext>> Transitions { get; } = new(); public List<TransitionDefinition<TState, TCommand, TContext>> Transitions { get; } = new();
#region Constructor
public WorkflowDefinitionBuilder<TState, TCommand, TContext> From(in TState state) private WorkflowDefinitionBuilder(TState initialState)
=> _current = new() { From = initialState };
public static ITransitionOn<TState, TCommand, TContext> Initial(TState initialState)
=> new WorkflowDefinitionBuilder<TState, TCommand, TContext>(initialState);
#endregion
#region Methods
public ITransitionOn<TState, TCommand, TContext> From(in TState state)
{ {
if (_current != null)
Transitions.Add(_current);
_current = new() { From = state }; _current = new() { From = state };
return this; return this;
} }
public WorkflowDefinitionBuilder<TState, TCommand, TContext> To(in TState state) public ITransitionDone<TState, TCommand, TContext> To(in TState state)
{ {
if (_current == null)
throw new InvalidOperationException($"From needs to be called first");
_current = _current with { To = state }; _current = _current with { To = state };
Transitions.Add(_current);
return this; return this;
} }
public WorkflowDefinitionBuilder<TState, TCommand, TContext> On(in TCommand command) public ITransitionCondition<TState, TCommand, TContext> On(in TCommand command)
{ {
if (_current == null)
throw new InvalidOperationException($"From needs to be called first");
_current = _current with { Command = command }; _current = _current with { Command = command };
return this; return this;
} }
public WorkflowDefinitionBuilder<TState, TCommand, TContext> WithMeta(in object meta) public ITransitionCondition<TState, TCommand, TContext> WithMeta(in object meta)
{ {
if (_current == null)
throw new InvalidOperationException($"From needs to be called first");
_current.Meta = meta; _current.Meta = meta;
return this; return this;
} }
public WorkflowDefinitionBuilder<TState, TCommand, TContext> When(Func<TContext, bool> condition) public ITransitionCondition<TState, TCommand, TContext> When(Func<TContext, bool> condition)
{ {
if (_current == null)
throw new InvalidOperationException($"From needs to be called first");
if (_current.Conditions is null) if (_current.Conditions is null)
_current.Conditions = new(); _current.Conditions = new();
@ -64,7 +69,6 @@ public class WorkflowDefinitionBuilder<TState, TCommand, TContext>
public WorkflowDefinition<TState, TCommand, TContext> Build() public WorkflowDefinition<TState, TCommand, TContext> Build()
{ {
if (_current != null)
Transitions.Add(_current); Transitions.Add(_current);
if (!Transitions.Any()) if (!Transitions.Any())
@ -72,4 +76,6 @@ public class WorkflowDefinitionBuilder<TState, TCommand, TContext>
return new(Transitions); return new(Transitions);
} }
#endregion
} }

View File

@ -18,9 +18,7 @@ public class DependencyInjectionTests
var sc = new ServiceCollection(); var sc = new ServiceCollection();
sc.AddWorkflowDefinition<PhoneState, PhoneCommand, PhoneCall>(version: 1) sc.AddWorkflowDefinition<PhoneState, PhoneCommand, PhoneCall>(initial: PhoneState.Idle, version: 1)
.From(PhoneState.Idle)
.On(PhoneCommand.IncomingCall) .On(PhoneCommand.IncomingCall)
.To(PhoneState.Ringing) .To(PhoneState.Ringing)
@ -29,9 +27,7 @@ public class DependencyInjectionTests
.To(PhoneState.Connected) .To(PhoneState.Connected)
; ;
sc.AddWorkflowDefinition<PhoneState, PhoneCommand, PhoneCall>(version: 2) sc.AddWorkflowDefinition<PhoneState, PhoneCommand, PhoneCall>(initial: PhoneState.Idle, version: 2)
.From(PhoneState.Idle)
.On(PhoneCommand.IncomingCall) .On(PhoneCommand.IncomingCall)
.To(PhoneState.Ringing) .To(PhoneState.Ringing)

View File

@ -1,2 +1,3 @@
global using DIT.Workflower; global using DIT.Workflower;
global using DIT.Workflower.Abstractions;
global using Xunit; global using Xunit;

View File

@ -2,27 +2,27 @@ namespace DIT.Workflower.Tests;
public class WorkflowConditionTests public class WorkflowConditionTests
{ {
private WorkflowDefinitionBuilder<PhoneState, PhoneCommand, PhoneCall> GetDefaultBuilder() private ITransitionOn<PhoneState, PhoneCommand, PhoneCall> GetDefaultBuilder()
{ {
return new(); return WorkflowDefinitionBuilder<PhoneState, PhoneCommand, PhoneCall>.Initial(PhoneState.Idle);
} }
[Fact] [Fact]
public void SingleConditionTests() public void SingleConditionTests()
{ {
var phone = new PhoneCall(Active: false); var phone = new PhoneCall(Active: false);
var builder1 = GetDefaultBuilder();
var builder2 = GetDefaultBuilder();
var a = "b"; var a = "b";
builder1 var builder1 = GetDefaultBuilder()
.From(PhoneState.Idle) .On(PhoneCommand.Decline)
.When((res) => a == "n"); .When((res) => a == "n")
.To(PhoneState.Declined);
builder2 var builder2 = GetDefaultBuilder()
.From(PhoneState.Idle) .On(PhoneCommand.Decline)
.When((res) => a == "b" && res.Active is false); .When((res) => a == "b" && res.Active is false)
.To(PhoneState.OnHold);
Assert.Empty(builder1.Build().GetAllowedTransitions(phone, PhoneState.Idle)); Assert.Empty(builder1.Build().GetAllowedTransitions(phone, PhoneState.Idle));
Assert.Single(builder2.Build().GetAllowedTransitions(phone, PhoneState.Idle)); Assert.Single(builder2.Build().GetAllowedTransitions(phone, PhoneState.Idle));
@ -32,21 +32,21 @@ public class WorkflowConditionTests
public void MultiConditionTests() public void MultiConditionTests()
{ {
var phone = new PhoneCall(); var phone = new PhoneCall();
var builder1 = GetDefaultBuilder();
var builder2 = GetDefaultBuilder();
var a = "b"; var a = "b";
var other = a; var other = a;
builder1 var builder1 = GetDefaultBuilder()
.From(PhoneState.Idle) .On(PhoneCommand.Resume)
.When((res) => a == "c") .When((res) => a == "c")
.When((res) => other == a); .When((res) => other == a)
.To(PhoneState.Connected);
builder2 var builder2 = GetDefaultBuilder()
.From(PhoneState.Idle) .On(PhoneCommand.Resume)
.When((res) => a == "b") .When((res) => a == "b")
.When((res) => other == a); .When((res) => other == a)
.To(PhoneState.Connected);
Assert.Empty(builder1.Build().GetAllowedTransitions(phone, PhoneState.Idle)); Assert.Empty(builder1.Build().GetAllowedTransitions(phone, PhoneState.Idle));
Assert.Single(builder2.Build().GetAllowedTransitions(phone, PhoneState.Idle)); Assert.Single(builder2.Build().GetAllowedTransitions(phone, PhoneState.Idle));

View File

@ -1,14 +0,0 @@
namespace DIT.Workflower.Tests;
public class WorkflowMetaTests
{
[Fact]
public void WorkflowNeedsAtLeastOneTransition()
{
var builder = new WorkflowDefinitionBuilder<PhoneState, PhoneCommand, PhoneCall>();
Assert.Throws<InvalidOperationException>(builder.Build);
}
}