Merge pull request #3 from ditkrg/dev

Improvements
This commit is contained in:
Shkar T. Noori 2022-06-18 13:40:01 +03:00 committed by GitHub
commit 3add480296
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 403 additions and 101 deletions

18
.config/dotnet-tools.json Normal file
View File

@ -0,0 +1,18 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-sonarscanner": {
"version": "5.6.0",
"commands": [
"dotnet-sonarscanner"
]
},
"dotnet-coverage": {
"version": "17.3.2",
"commands": [
"dotnet-coverage"
]
}
}
}

78
.github/workflows/tests-base.yaml vendored Normal file
View File

@ -0,0 +1,78 @@
name: Run Tests
on:
push:
paths-ignore:
- "**.md"
- ".github/**"
- "!.github/workflows/tests-base.yaml"
workflow_call:
inputs:
sonarqube:
type: boolean
required: false
default: fals
secrets:
SONARQUBE_HOST:
required: false
SONARQUBE_TOKEN:
required: false
jobs:
run-tests:
name: Run Tests
timeout-minutes: 10
runs-on: ubuntu-latest
env:
PROJECT_KEY: ditkrg_DIT.Workflower_AYF14rjSb80e2b0bns3t
SONARQUBE_HOST: ${{ secrets.SONARQUBE_HOST }}
SONARQUBE_TOKEN: ${{ secrets.SONARQUBE_TOKEN }}
ASPNETCORE_ENVIRONMENT: Testing
steps:
- uses: actions/setup-dotnet@v2
with:
dotnet-version: "6.0.x"
- uses: actions/checkout@v3
if: ${{ !inputs.sonarqube }}
- name: Run tests
if: ${{ !inputs.sonarqube }}
run: dotnet test
###############################
########## SONARQUBE ##########
###############################
- name: Set up JDK 11
uses: actions/setup-java@v2
if: ${{ inputs.sonarqube }}
with:
distribution: "zulu"
java-version: "11"
- uses: actions/checkout@v3
if: ${{ inputs.sonarqube }}
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Restore tools
if: ${{ inputs.sonarqube }}
run: dotnet tool restore
- name: Run tests (SonarQube)
if: ${{ inputs.sonarqube }}
run: |
dotnet tool run dotnet-sonarscanner begin -k:"$PROJECT_KEY" \
-d:sonar.login="$SONARQUBE_TOKEN" \
-d:sonar.host.url="$SONARQUBE_HOST" \
-d:sonar.cs.vscoveragexml.reportsPaths=coverage.xml
dotnet build --no-incremental
dotnet dotnet-coverage collect "dotnet test" -f xml -o "coverage.xml"
dotnet tool run dotnet-sonarscanner end -d:sonar.login="$SONARQUBE_TOKEN"

3
.gitignore vendored
View File

@ -10,6 +10,8 @@
*.userosscache
*.sln.docstates
.sonarqube
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
@ -147,6 +149,7 @@ _TeamCity*
!.axoCover/settings.json
# Visual Studio code coverage results
coverage.xml
*.coverage
*.coveragexml
**/coverage

102
README.md
View File

@ -1 +1,101 @@
# 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
```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
In the `Program.cs`, register the workflow factory, defining one or more workflow definitions.
```csharp
public record PhoneCall(bool Active);
services.AddWorkflowDefinition<PhoneState, PhoneCommand, PhoneCall>(id: "constant-id", 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(id: "constant-id", 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)
}
}
```
Note: If you do not specify an id for the workflow, the default id is:
```csharp
$"{typeof(TState).Name}_{typeof(TCommand).Name}_{typeof(TContext).Name}";
```

View File

@ -4,8 +4,12 @@ public interface IWorkflow<TState, TCommand, TContext>
where TState : struct
where TCommand : struct
{
string Id { get; }
int Version { get; }
string Reference => $"{Id}.v{Version}";
/// <summary>
/// Gets a list of allowed transitions without any condition checks.
/// </summary>

View File

@ -4,9 +4,8 @@ public interface IWorkflowFactory<TState, TCommand, TContext>
where TState : struct
where TCommand : struct
{
public IWorkflow<TState, TCommand, TContext> CreateWorkflow(int version = 1);
public IWorkflow<TState, TCommand, TContext> CreateWorkflow();
public IWorkflow<TState, TCommand, TContext> CreateWorkflow(int version);
public IWorkflow<TState, TCommand, TContext> CreateWorkflow(string id, int version = 1);
}

View File

@ -1,5 +1,4 @@

namespace DIT.Workflower.DependencyInjection;
namespace DIT.Workflower.DependencyInjection;
public class DefaultWorkflowFactory<TState, TCommand, TContext> : IWorkflowFactory<TState, TCommand, TContext>
where TState : struct
@ -13,16 +12,20 @@ public class DefaultWorkflowFactory<TState, TCommand, TContext> : IWorkflowFacto
_serviceProvider = sp;
}
public IWorkflow<TState, TCommand, TContext> CreateWorkflow()
=> CreateWorkflow(version: 1);
public IWorkflow<TState, TCommand, TContext> CreateWorkflow(int version)
public IWorkflow<TState, TCommand, TContext> CreateWorkflow(int version = 1)
{
var id = WorkflowDefinitionWrapper<TState, TCommand, TContext>.GetDefaultId();
return CreateWorkflow(id, version);
}
public IWorkflow<TState, TCommand, TContext> CreateWorkflow(string id, int version = 1)
{
var reference = $"{id}.v{version}";
var service = _serviceProvider.GetServices<IWorkflow<TState, TCommand, TContext>>()
.FirstOrDefault(x => x.Version == version);
.FirstOrDefault(x => x.Reference == reference);
if (service is null)
throw new ArgumentOutOfRangeException(nameof(version), $"Version {version} of workflow does not exist");
throw new KeyNotFoundException($"Workflow reference {id}.v{version} does not exist");
return service;
}

View File

@ -5,25 +5,33 @@ namespace DIT.Workflower.DependencyInjection.Extensions;
public static class IServiceCollectionExtensions
{
public static WorkflowDefinitionBuilder<TState, TCommand, TContext> AddWorkflowDefinition<TState, TCommand, TContext>(this IServiceCollection services)
public static ITransitionStart<TState, TCommand, TContext> AddWorkflowDefinition<TState, TCommand, TContext>(this IServiceCollection services, in int version = 1)
where TState : struct
where TCommand : struct
{
return AddWorkflowDefinition<TState, TCommand, TContext>(services, version: 1);
var id = WorkflowDefinitionWrapper<TState, TCommand, TContext>.GetDefaultId();
return AddWorkflowDefinition<TState, TCommand, TContext>(services, id, version);
}
public static WorkflowDefinitionBuilder<TState, TCommand, TContext> AddWorkflowDefinition<TState, TCommand, TContext>(this IServiceCollection services, int version)
public static ITransitionStart<TState, TCommand, TContext> AddWorkflowDefinition<TState, TCommand, TContext>(this IServiceCollection services, in string id)
where TState : struct
where TCommand : struct
{
var builder = new WorkflowDefinitionBuilder<TState, TCommand, TContext>();
return AddWorkflowDefinition<TState, TCommand, TContext>(services, id, version: 1);
}
public static ITransitionStart<TState, TCommand, TContext> AddWorkflowDefinition<TState, TCommand, TContext>(this IServiceCollection services, string id, int version)
where TState : struct
where TCommand : struct
{
var builder = WorkflowDefinitionBuilder<TState, TCommand, TContext>.Create();
services.TryAddSingleton<IWorkflowFactory<TState, TCommand, TContext>, DefaultWorkflowFactory<TState, TCommand, TContext>>();
services.AddSingleton<IWorkflow<TState, TCommand, TContext>, WorkflowDefinitionWrapper<TState, TCommand, TContext>>(sp =>
{
var workflow = builder.Build();
var wrapper = new WorkflowDefinitionWrapper<TState, TCommand, TContext>(builder, version);
var definition = (WorkflowDefinitionBuilder<TState, TCommand, TContext>)builder;
var wrapper = new WorkflowDefinitionWrapper<TState, TCommand, TContext>(definition, id, version);
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

@ -5,12 +5,20 @@ public record WorkflowDefinitionWrapper<TState, TCommand, TContext> : WorkflowDe
where TCommand : struct
{
public string Id { get; }
public int Version { get; }
public WorkflowDefinitionWrapper(WorkflowDefinitionBuilder<TState, TCommand, TContext> builder, int version)
public WorkflowDefinitionWrapper(WorkflowDefinitionBuilder<TState, TCommand, TContext> builder, string id, int version)
: base(builder.Transitions)
{
Id = id;
Version = version;
}
public static string GetDefaultId()
{
return $"{typeof(TState).Name}_{typeof(TCommand).Name}_{typeof(TContext).Name}";
}
}

View File

@ -0,0 +1,42 @@
namespace DIT.Workflower.Abstractions;
public interface ITransitionStart<TState, TCommand, TContext>
where TState : struct
where TCommand : struct
{
ITransitionOn<TState, TCommand, TContext> From(in TState state);
}
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 state);
}
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>
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 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; }
}

View File

@ -1,59 +1,65 @@
namespace DIT.Workflower;
using DIT.Workflower.Abstractions;
namespace DIT.Workflower;
public sealed class WorkflowDefinitionBuilder<TState, TCommand, TContext> :
ITransitionStart<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 TCommand : struct
{
private TransitionDefinition<TState, TCommand, TContext>? _current;
private TransitionDefinition<TState, TCommand, TContext> _current;
public List<TransitionDefinition<TState, TCommand, TContext>> Transitions { get; } = new();
#region Constructor
public WorkflowDefinitionBuilder<TState, TCommand, TContext> From(in TState state)
private WorkflowDefinitionBuilder()
=> _current = new();
public static ITransitionStart<TState, TCommand, TContext> Create()
=> new WorkflowDefinitionBuilder<TState, TCommand, TContext>();
#endregion
#region Methods
public ITransitionOn<TState, TCommand, TContext> From(in TState state)
{
if (_current != null)
Transitions.Add(_current);
_current = new() { From = state };
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 };
Transitions.Add(_current);
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 };
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;
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)
_current.Conditions = new();
@ -64,12 +70,11 @@ public class WorkflowDefinitionBuilder<TState, TCommand, TContext>
public WorkflowDefinition<TState, TCommand, TContext> Build()
{
if (_current != null)
Transitions.Add(_current);
if (!Transitions.Any())
throw new InvalidOperationException("No transitions are added");
return new(Transitions);
}
#endregion
}

View File

@ -10,9 +10,9 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>

View File

@ -1,9 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using DIT.Workflower.DependencyInjection.Abstractions;
using DIT.Workflower.DependencyInjection.Abstractions;
using DIT.Workflower.DependencyInjection.Extensions;
using Microsoft.Extensions.DependencyInjection;
@ -17,9 +12,9 @@ public class DependencyInjectionTests
{
var sc = new ServiceCollection();
var id = "test";
sc.AddWorkflowDefinition<PhoneState, PhoneCommand, PhoneCall>(version: 1)
sc.AddWorkflowDefinition<PhoneState, PhoneCommand, PhoneCall>(id, version: 1)
.From(PhoneState.Idle)
.On(PhoneCommand.IncomingCall)
.To(PhoneState.Ringing)
@ -29,8 +24,7 @@ public class DependencyInjectionTests
.To(PhoneState.Connected)
;
sc.AddWorkflowDefinition<PhoneState, PhoneCommand, PhoneCall>(version: 2)
sc.AddWorkflowDefinition<PhoneState, PhoneCommand, PhoneCall>(id, version: 2)
.From(PhoneState.Idle)
.On(PhoneCommand.IncomingCall)
.To(PhoneState.Ringing)
@ -46,9 +40,11 @@ public class DependencyInjectionTests
var sp = sc.BuildServiceProvider();
var workflowFactory = sp.GetService<IWorkflowFactory<PhoneState, PhoneCommand, PhoneCall>>();
var v1 = workflowFactory?.CreateWorkflow();
var v2 = workflowFactory?.CreateWorkflow(version: 2);
var workflowFactory = sp.GetRequiredService<IWorkflowFactory<PhoneState, PhoneCommand, PhoneCall>>();
var v1 = workflowFactory.CreateWorkflow(id);
var v2 = workflowFactory.CreateWorkflow(id, version: 2);
Assert.NotNull(workflowFactory);
Assert.NotNull(v1);
@ -62,4 +58,36 @@ public class DependencyInjectionTests
Assert.Equal(2, v2.GetAllowedTransitions(PhoneState.Ringing).Count);
}
[Fact]
public void IdGenerationTest()
{
var sc = new ServiceCollection();
sc.AddWorkflowDefinition<PhoneState, PhoneCommand, PhoneCall>(version: 1)
.From(PhoneState.Idle)
.On(PhoneCommand.IncomingCall)
.To(PhoneState.Ringing);
var sp = sc.BuildServiceProvider();
var workflowFactory = sp.GetRequiredService<IWorkflowFactory<PhoneState, PhoneCommand, PhoneCall>>();
var workflow = workflowFactory.CreateWorkflow();
Assert.Equal("PhoneState_PhoneCommand_PhoneCall", workflow.Id);
}
[Fact]
public void UnknownWorkflowReferenceThrows()
{
var sc = new ServiceCollection();
sc.AddWorkflowDefinition<PhoneState, PhoneCommand, PhoneCall>(version: 1)
.From(PhoneState.Idle)
.On(PhoneCommand.IncomingCall)
.To(PhoneState.Ringing);
var sp = sc.BuildServiceProvider();
var workflowFactory = sp.GetRequiredService<IWorkflowFactory<PhoneState, PhoneCommand, PhoneCall>>();
Assert.Throws<KeyNotFoundException>(() => workflowFactory.CreateWorkflow("unknown"));
}
}

View File

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

View File

@ -2,53 +2,71 @@ namespace DIT.Workflower.Tests;
public class WorkflowConditionTests
{
private WorkflowDefinitionBuilder<PhoneState, PhoneCommand, PhoneCall> GetDefaultBuilder()
private static ITransitionStart<PhoneState, PhoneCommand, PhoneCall> GetDefaultBuilder()
{
return new();
return WorkflowDefinitionBuilder<PhoneState, PhoneCommand, PhoneCall>.Create();
}
[Fact]
public void SingleConditionTests()
{
var phone = new PhoneCall(Active: false);
var builder1 = GetDefaultBuilder();
var builder2 = GetDefaultBuilder();
var meta = "String";
var a = "b";
builder1
.From(PhoneState.Idle)
.When((res) => a == "n");
var builder1 = GetDefaultBuilder()
.From(PhoneState.Ringing)
.On(PhoneCommand.Decline)
.When((res) => a == "n")
.To(PhoneState.Declined);
builder2
.From(PhoneState.Idle)
.When((res) => a == "b" && res.Active is false);
var builder2 = GetDefaultBuilder()
.From(PhoneState.Ringing)
.On(PhoneCommand.Decline)
.When((res) => a == "b" && res.Active is false)
.WithMeta(meta)
.To(PhoneState.OnHold);
Assert.Empty(builder1.Build().GetAllowedTransitions(phone, PhoneState.Idle));
Assert.Single(builder2.Build().GetAllowedTransitions(phone, PhoneState.Idle));
Assert.Empty(builder1.Build().GetAllowedTransitions(phone, PhoneState.Ringing));
Assert.Single(builder2.Build().GetAllowedTransitions(phone, PhoneState.Ringing));
// Check meta
Assert.Equal(meta, builder2.Build().GetAllowedTransitions(phone, PhoneState.Ringing).First().Meta);
}
[Fact]
public void MultiConditionTests()
{
var phone = new PhoneCall();
var builder1 = GetDefaultBuilder();
var builder2 = GetDefaultBuilder();
var a = "b";
var other = a;
builder1
.From(PhoneState.Idle)
var builder1 = GetDefaultBuilder()
.From(PhoneState.OnHold)
.On(PhoneCommand.Resume)
.When((res) => a == "c")
.When((res) => other == a);
.When((res) => other == a)
.To(PhoneState.Connected);
builder2
.From(PhoneState.Idle)
var builder2 = GetDefaultBuilder()
.From(PhoneState.OnHold)
.On(PhoneCommand.Resume)
.When((res) => a == "b")
.When((res) => other == a);
.When((res) => other == a)
.To(PhoneState.Connected);
Assert.Empty(builder1.Build().GetAllowedTransitions(phone, PhoneState.Idle));
Assert.Single(builder2.Build().GetAllowedTransitions(phone, PhoneState.Idle));
Assert.Empty(builder1.Build().GetAllowedTransitions(phone, from: PhoneState.OnHold));
Assert.Single(builder2.Build().GetAllowedTransitions(phone, from: PhoneState.OnHold));
}
[Fact]
public void EmptyBuildThrowsError()
{
var builder1 = (WorkflowDefinitionBuilder<PhoneState, PhoneCommand, PhoneCall>)GetDefaultBuilder();
Assert.Throws<InvalidOperationException>(() => builder1.Build());
}
}

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);
}
}