Add project files.

This commit is contained in:
2025-08-18 17:11:43 +03:30
parent 807dc263be
commit e5eb10d604
3943 changed files with 223719 additions and 0 deletions
@@ -0,0 +1,72 @@
using FastEndpoints;
namespace Elsa.Abstractions;
/// <summary>
/// An endpoint that maps a request to a response.
/// </summary>
public abstract class ElsaEndpointWithMapper<TRequest, TMapper> : EndpointWithMapper<TRequest, TMapper> where TMapper : class, IRequestMapper where TRequest : notnull
{
protected void ConfigurePermissions(params string[] permissions)
{
if (!EndpointSecurityOptions.SecurityIsEnabled)
AllowAnonymous();
else
Permissions(new[] { PermissionNames.All }.Concat(permissions).ToArray());
}
}
public abstract class ElsaEndpointWithoutRequest : EndpointWithoutRequest
{
protected void ConfigurePermissions(params string[] permissions)
{
if (!EndpointSecurityOptions.SecurityIsEnabled)
AllowAnonymous();
else
Permissions(new[] { PermissionNames.All }.Concat(permissions).ToArray());
}
}
public abstract class ElsaEndpointWithoutRequest<TResponse> : EndpointWithoutRequest<TResponse> where TResponse : notnull
{
protected void ConfigurePermissions(params string[] permissions)
{
if (!EndpointSecurityOptions.SecurityIsEnabled)
AllowAnonymous();
else
Permissions(new[] { PermissionNames.All }.Concat(permissions).ToArray());
}
}
public class ElsaEndpoint<TRequest, TResponse> : Endpoint<TRequest, TResponse> where TRequest : notnull, new() where TResponse : notnull
{
protected void ConfigurePermissions(params string[] permissions)
{
if (!EndpointSecurityOptions.SecurityIsEnabled)
AllowAnonymous();
else
Permissions(new[] { PermissionNames.All }.Concat(permissions).ToArray());
}
}
public class ElsaEndpoint<TRequest, TResponse, TMapper> : Endpoint<TRequest, TResponse, TMapper> where TRequest : notnull, new() where TResponse : notnull where TMapper : class, IMapper, new()
{
protected void ConfigurePermissions(params string[] permissions)
{
if (!EndpointSecurityOptions.SecurityIsEnabled)
AllowAnonymous();
else
Permissions(new[] { PermissionNames.All }.Concat(permissions).ToArray());
}
}
public class ElsaEndpoint<TRequest> : Endpoint<TRequest> where TRequest : notnull, new()
{
protected void ConfigurePermissions(params string[] permissions)
{
if (!EndpointSecurityOptions.SecurityIsEnabled)
AllowAnonymous();
else
Permissions(new[] { PermissionNames.All }.Concat(permissions).ToArray());
}
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>
Provides common features to modules that expose API endpoints.
</Description>
<RootNamespace>Elsa</RootNamespace>
<PackageTags>elsa api common</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FastEndpoints" />
<PackageReference Include="FastEndpoints.Security" />
<PackageReference Include="FastEndpoints.Swagger" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\modules\Elsa.Workflows.Core\Elsa.Workflows.Core.csproj" />
<ProjectReference Include="..\Elsa.Features\Elsa.Features.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,11 @@
namespace Elsa;
public static class EndpointSecurityOptions
{
public static string AdminRoleName = "Admin";
public static string ReaderRoleName = "Reader";
public static string WriteRoleName = "Writer";
public static bool SecurityIsEnabled = true;
public static void DisableSecurity() => SecurityIsEnabled = false;
}
@@ -0,0 +1,25 @@
using System.Net;
using Microsoft.AspNetCore.Http;
namespace Elsa.Extensions;
/// <summary>
/// Extension methods for <see cref="HttpRequest"/>.
/// </summary>
public static class HttpRequestExtensions
{
/// <summary>
/// Returns a value indicating whether the specified request is a local request.
/// </summary>
/// <param name="request"></param>
/// <returns>True if the request is local, otherwise false.</returns>
public static bool IsLocal(this HttpRequest request)
{
var connection = request.HttpContext.Connection;
return connection.RemoteIpAddress != null
? connection.LocalIpAddress != null
? connection.RemoteIpAddress.Equals(connection.LocalIpAddress)
: IPAddress.IsLoopback(connection.RemoteIpAddress)
: connection.RemoteIpAddress == null && connection.LocalIpAddress == null;
}
}
@@ -0,0 +1,55 @@
using System.Reflection;
using Elsa.Features.Services;
using FastEndpoints;
// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;
/// <summary>
/// Provides extensions to <see cref="IModule"/>.
/// </summary>
public static class ModuleExtensions
{
private static readonly object FastEndpointsAssembliesKey = new();
/// <summary>
/// Registers the specified assembly for FastEndpoint assembly discovery.
/// </summary>
public static IModule AddFastEndpointsAssembly(this IModule module, Assembly assembly)
{
var assemblies = module.Properties.GetOrAdd(FastEndpointsAssembliesKey, () => new HashSet<Assembly>());
assemblies.Add(assembly);
return module;
}
/// <summary>
/// Registers the assembly for FastEndpoint assembly discovery using the specified marker type.
/// </summary>
public static IModule AddFastEndpointsAssembly<T>(this IModule module) => module.AddFastEndpointsAssembly(typeof(T));
/// <summary>
/// Registers the assembly for FastEndpoint assembly discovery using the specified marker type.
/// </summary>
public static IModule AddFastEndpointsAssembly(this IModule module, Type markerType) => module.AddFastEndpointsAssembly(markerType.Assembly);
/// <summary>
/// Returns all collected assemblies for discovery of endpoints.
/// </summary>
public static IEnumerable<Assembly> GetFastEndpointsAssembliesFromModule(this IModule module) => module.Properties.GetOrAdd(FastEndpointsAssembliesKey, () => new HashSet<Assembly>());
/// <summary>
/// Adds FastEndpoints to the service container and registers all collected assemblies for discovery of endpoints.
/// </summary>
public static IModule AddFastEndpointsFromModule(this IModule module)
{
var assemblies = module.GetFastEndpointsAssembliesFromModule().ToList();
module.Services.AddFastEndpoints(options =>
{
options.DisableAutoDiscovery = true;
options.Assemblies = assemblies;
});
return module;
}
}
@@ -0,0 +1,15 @@
// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;
static class ObjectDictionaryExtensions
{
public static T GetOrAdd<T>(this IDictionary<object, object> dictionary, object key, Func<T> valueFactory)
{
if (dictionary.TryGetValue(key, out var value)) return (T)value;
value = valueFactory();
dictionary.Add(key, value!);
return (T)value!;
}
public static T Get<T>(this IDictionary<object, object> dictionary, object key) => (T)dictionary[key];
}
@@ -0,0 +1,50 @@
using Elsa.Features.Services;
using FastEndpoints.Swagger;
using Microsoft.AspNetCore.Builder;
namespace Elsa.Extensions
{
/// <summary>
/// Extensions to enable the generation of Swagger API Documentation and associated Swagger UI.
/// </summary>
public static class SwaggerExtensions
{
/// <summary>
/// Registers Swagger document generator.
/// </summary>
public static IModule AddSwagger(this IModule module)
{
Version ver = new(3, 0);
// Swagger API documentation
module.Services.SwaggerDocument(o =>
{
o.EnableJWTBearerAuth = true;
o.DocumentSettings = s =>
{
s.DocumentName = $"v{ver.Major}";
s.Title = "Elsa API";
s.Version = $"v{ver.Major}.{ver.Minor}";
s.AddAuth("ApiKey", new()
{
Name = "Authorization",
In = NSwag.OpenApiSecurityApiKeyLocation.Header,
Type = NSwag.OpenApiSecuritySchemeType.ApiKey,
Description = "Enter: ApiKey [your API key]"
});
};
});
return module;
}
/// <summary>
/// Adds middleware to enable the Swagger UI at '/swagger'
/// </summary>
public static IApplicationBuilder UseSwaggerUI(this IApplicationBuilder app)
{
return app.UseSwaggerGen();
}
}
}
@@ -0,0 +1,75 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using Elsa.Workflows;
using FastEndpoints;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;
/// <summary>
/// Provides extension methods to add the FastEndpoints middleware configured for use with Elsa API endpoints.
/// </summary>
[PublicAPI]
public static class WebApplicationExtensions
{
/// <summary>
/// Register the FastEndpoints middleware configured for use with with Elsa API endpoints.
/// </summary>
/// <param name="app"></param>
/// <param name="routePrefix">The route prefix to apply to Elsa API endpoints.</param>
/// <example>E.g. "elsa/api" will expose endpoints like this: "/elsa/api/workflow-definitions"</example>
public static IApplicationBuilder UseWorkflowsApi(this IApplicationBuilder app, string routePrefix = "elsa/api")
{
return app.UseFastEndpoints(config =>
{
config.Endpoints.RoutePrefix = routePrefix;
config.Serializer.RequestDeserializer = DeserializeRequestAsync;
config.Serializer.ResponseSerializer = SerializeRequestAsync;
config.Binding.ValueParserFor<DateTimeOffset>(s =>
new(DateTimeOffset.TryParse(s.ToString(), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var result), result));
});
}
/// <summary>
/// Register the FastEndpoints middleware configured for use with with Elsa API endpoints.
/// </summary>
/// <param name="routes">The <see cref="IEndpointRouteBuilder"/> to register the endpoints with.</param>
/// <param name="routePrefix">The route prefix to apply to Elsa API endpoints.</param>
/// <example>E.g. "elsa/api" will expose endpoints like this: "/elsa/api/workflow-definitions"</example>
public static IEndpointRouteBuilder MapWorkflowsApi(this IEndpointRouteBuilder routes, string routePrefix = "elsa/api") =>
routes.MapFastEndpoints(config =>
{
config.Endpoints.RoutePrefix = routePrefix;
config.Serializer.RequestDeserializer = DeserializeRequestAsync;
config.Serializer.ResponseSerializer = SerializeRequestAsync;
});
private static ValueTask<object?> DeserializeRequestAsync(HttpRequest httpRequest, Type modelType, JsonSerializerContext? serializerContext, CancellationToken cancellationToken)
{
var serializer = httpRequest.HttpContext.RequestServices.GetRequiredService<IApiSerializer>();
var options = serializer.GetOptions();
return serializerContext == null
? JsonSerializer.DeserializeAsync(httpRequest.Body, modelType, options, cancellationToken)
: JsonSerializer.DeserializeAsync(httpRequest.Body, modelType, serializerContext, cancellationToken);
}
private static Task SerializeRequestAsync(HttpResponse httpResponse, object? dto, string contentType, JsonSerializerContext? serializerContext, CancellationToken cancellationToken)
{
var serializer = httpResponse.HttpContext.RequestServices.GetRequiredService<IApiSerializer>();
var options = serializer.GetOptions();
httpResponse.ContentType = contentType;
return serializerContext == null
? JsonSerializer.SerializeAsync(httpResponse.Body, dto, dto?.GetType() ?? typeof(object), options, cancellationToken)
: JsonSerializer.SerializeAsync(httpResponse.Body, dto, dto?.GetType() ?? typeof(object), serializerContext, cancellationToken);
}
}
@@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<ConfigureAwait />
</Weavers>
@@ -0,0 +1,7 @@
namespace Elsa.Models;
/// <summary>
/// Represents a count response that offers a unified format for returning count of things from API endpoints.
/// </summary>
/// <param name="Count">The number of items found for a given query.</param>
public record CountResponse(long Count);
@@ -0,0 +1,8 @@
namespace Elsa.Models;
public record LinkedResource(Link[] Links)
{
public LinkedResource() : this([]) { }
}
public record Link(string Href, string Rel, string Method);
@@ -0,0 +1,33 @@
namespace Elsa.Models;
/// <summary>
/// Represents a generic list response that offers a unified format for returning list of things from API endpoints.
/// </summary>
public class ListResponse<T>
{
/// <summary>
/// Default constructor.
/// </summary>
public ListResponse()
{
}
/// <summary>
/// Constructor accepting a list of items.
/// </summary>
public ListResponse(ICollection<T> items)
{
Items = items;
Count = items.Count;
}
/// <summary>
/// A list of items.
/// </summary>
public ICollection<T> Items { get; set; } = default!;
/// <summary>
/// The number of items in the list.
/// </summary>
public long Count { get; set; }
}
@@ -0,0 +1,21 @@
using Elsa.Common.Models;
namespace Elsa.Models;
public record PagedListResponse<T>: LinkedResource
{
public PagedListResponse()
{
}
public PagedListResponse(Page<T> page)
{
Items = page.Items;
TotalCount = page.TotalCount;
}
public ICollection<T> Items { get; set; } = default!;
public long TotalCount { get; set; }
public static PagedListResponse<T> From(Page<T> page) => new(page);
}
@@ -0,0 +1,7 @@
namespace Elsa.Models;
public record ValidateCredentialsResult(bool IsValid, object? Context = null)
{
public static ValidateCredentialsResult Invalid() => new(false);
public static ValidateCredentialsResult Valid(object? context = null) => new(true, context);
}
@@ -0,0 +1,6 @@
namespace Elsa;
public static class PermissionNames
{
public const string All = "*";
}
@@ -0,0 +1,49 @@
using System.Security.Claims;
using Elsa.Extensions;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
namespace Elsa.Requirements;
/// <summary>
/// Adda the "create:application" permission to the current user if the request is local.
/// </summary>
public class LocalHostPermissionRequirement : IAuthorizationRequirement
{
}
/// <inheritdoc />
[PublicAPI]
public class LocalHostPermissionRequirementHandler : AuthorizationHandler<LocalHostPermissionRequirement>
{
private readonly IHttpContextAccessor _httpContextAccessor;
/// <inheritdoc />
public LocalHostPermissionRequirementHandler(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
/// <inheritdoc />
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocalHostPermissionRequirement requirement)
{
if (_httpContextAccessor.HttpContext?.Request.IsLocal() == false)
return Task.CompletedTask;
var currentIdentity = context.User.Identity;
if (currentIdentity?.IsAuthenticated == false)
{
var identity = new ClaimsIdentity(JwtBearerDefaults.AuthenticationScheme);
identity.AddClaim(new Claim("permissions", "create:application"));
identity.AddClaim(new Claim("permissions", "create:user"));
identity.AddClaim(new Claim("permissions", "create:role"));
context.User.AddIdentity(identity);
}
context.Succeed(requirement);
return Task.CompletedTask;
}
}
@@ -0,0 +1,36 @@
using Elsa.Extensions;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
namespace Elsa.Requirements;
/// <summary>
/// Represents an authorization requirement for localhost requests, meaning that if a request comes from the localhost, the requirement is met.
/// </summary>
public class LocalHostRequirement : IAuthorizationRequirement
{
}
/// <inheritdoc />
[PublicAPI]
public class LocalHostRequirementHandler : AuthorizationHandler<LocalHostRequirement>
{
private readonly IHttpContextAccessor _httpContextAccessor;
/// <inheritdoc />
public LocalHostRequirementHandler(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
/// <inheritdoc />
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocalHostRequirement requirement)
{
if (_httpContextAccessor.HttpContext?.Request.IsLocal() == false)
context.Fail(new AuthorizationFailureReason(this, "Only requests from localhost are allowed"));
context.Succeed(requirement);
return Task.CompletedTask;
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>
Reference this package when developing drop-ins for Elsa Workflows.
</Description>
<PackageTags>elsa drop-ins common</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Elsa.Features\Elsa.Features.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<ConfigureAwait />
</Weavers>
+25
View File
@@ -0,0 +1,25 @@
using Elsa.Features.Services;
namespace Elsa.DropIns.Core;
/// <summary>
/// Implement this in your drop-in module to configure elsa and register services.
/// </summary>
public interface IDropIn
{
/// <summary>
/// Called when the drop-in is installed.
/// </summary>
/// <param name="module">The Elsa module.</param>
void Install(IModule module);
/// <summary>
/// Called when the drop-in is being configured.
/// </summary>
ValueTask ConfigureAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken);
/// <summary>
/// Unconfigure the drop-in when it's deleted.
/// </summary>
void Unconfigure(IServiceProvider serviceProvider);
}
@@ -0,0 +1,29 @@
using System.Reflection;
using Elsa.DropIns.Contracts;
using Elsa.DropIns.Models;
namespace Elsa.DropIns.Catalogs;
public class AssembliesDropInCatalog : IDropInCatalog
{
private readonly IEnumerable<Assembly> _assemblies;
public AssembliesDropInCatalog(IEnumerable<Assembly> assemblies)
{
_assemblies = assemblies;
}
public IEnumerable<DropInDescriptor> List()
{
var allDropIns = new List<DropInDescriptor>();
foreach (var assembly in _assemblies)
{
var assemblyCatalog = new AssemblyDropInCatalog(assembly);
var dropIns = assemblyCatalog.List();
allDropIns.AddRange(dropIns);
}
return allDropIns;
}
}
@@ -0,0 +1,26 @@
using System.Reflection;
using Elsa.DropIns.Contracts;
using Elsa.DropIns.Core;
using Elsa.DropIns.Helpers;
using Elsa.DropIns.Models;
namespace Elsa.DropIns.Catalogs;
public class AssemblyDropInCatalog : IDropInCatalog
{
private readonly Assembly _assembly;
public AssemblyDropInCatalog(Assembly assembly)
{
_assembly = assembly;
}
public IEnumerable<DropInDescriptor> List()
{
var dropIns = TypeFinder.FindImplementationsOf<IDropIn>(_assembly)
.Select(type => new DropInDescriptor(type))
.ToList();
return dropIns;
}
}
@@ -0,0 +1,62 @@
using System.Reflection;
using Elsa.DropIns.Contracts;
using Elsa.DropIns.Helpers;
using Elsa.DropIns.Models;
namespace Elsa.DropIns.Catalogs;
/// <summary>
/// A catalog that lists drop-ins from a directory.
/// </summary>
public class DirectoryDropInCatalog : IDropInCatalog
{
private readonly string _directoryPath;
/// <summary>
/// Initializes a new instance of the <see cref="DirectoryDropInCatalog"/> class.
/// </summary>
/// <param name="directoryPath">The directory path to list drop-ins from.</param>
public DirectoryDropInCatalog(string directoryPath)
{
_directoryPath = directoryPath;
}
/// <inheritdoc />
public IEnumerable<DropInDescriptor> List()
{
if (!Directory.Exists(_directoryPath))
return Enumerable.Empty<DropInDescriptor>();
var assemblies = ListAssemblies();
var packages = ListPackages();
var assembliesCatalog = new AssembliesDropInCatalog(assemblies);
var packagesCatalog = new NuGetPackagesCatalog(packages);
return assembliesCatalog.List().Concat(packagesCatalog.List());
}
private IEnumerable<Assembly> ListAssemblies()
{
var assemblyPaths = Directory.GetFiles(_directoryPath, "*.dll", SearchOption.AllDirectories);
foreach (var assemblyPath in assemblyPaths)
{
var assembly = LoadDropInAssembly(assemblyPath);
if (assembly == null)
continue;
yield return assembly;
}
}
private string[] ListPackages()
{
return Directory.GetFiles(_directoryPath, "*.nupkg", SearchOption.AllDirectories);
}
private static Assembly? LoadDropInAssembly(string path)
{
return !File.Exists(path) ? null : AssemblyLoader.LoadPath(path);
}
}
@@ -0,0 +1,24 @@
using Elsa.DropIns.Contexts;
using Elsa.DropIns.Contracts;
using Elsa.DropIns.Models;
namespace Elsa.DropIns.Catalogs;
public class NuGetPackageCatalog : IDropInCatalog
{
private readonly string _packagePath;
public NuGetPackageCatalog(string packagePath)
{
_packagePath = packagePath;
}
public IEnumerable<DropInDescriptor> List()
{
var assemblyLoadContext = new NuGetPackageAssemblyLoadContext(_packagePath);
var assemblies = assemblyLoadContext.Assemblies;
var assembliesCatalog = new AssembliesDropInCatalog(assemblies);
return assembliesCatalog.List();
}
}
@@ -0,0 +1,19 @@
using Elsa.DropIns.Contracts;
using Elsa.DropIns.Models;
namespace Elsa.DropIns.Catalogs;
public class NuGetPackagesCatalog : IDropInCatalog
{
private readonly IEnumerable<string> _packagePaths;
public NuGetPackagesCatalog(IEnumerable<string> packagePaths)
{
_packagePaths = packagePaths;
}
public IEnumerable<DropInDescriptor> List()
{
return _packagePaths.Select(packagePath => new NuGetPackageCatalog(packagePath)).SelectMany(catalog => catalog.List());
}
}
@@ -0,0 +1,26 @@
using System.Reflection;
using System.Runtime.Loader;
namespace Elsa.DropIns.Contexts;
internal sealed class DirectoryAssemblyLoadContext : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver _resolver;
public DirectoryAssemblyLoadContext(string dropInPath)
{
_resolver = new AssemblyDependencyResolver(dropInPath);
}
protected override Assembly? Load(AssemblyName assemblyName)
{
var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
return assemblyPath != null ? LoadFromAssemblyPath(assemblyPath) : null;
}
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
var libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
return libraryPath != null ? LoadUnmanagedDllFromPath(libraryPath) : IntPtr.Zero;
}
}
@@ -0,0 +1,33 @@
using System.Reflection;
using System.Runtime.Loader;
using NuGet.Packaging;
namespace Elsa.DropIns.Contexts;
internal sealed class NuGetPackageAssemblyLoadContext : AssemblyLoadContext
{
private readonly Dictionary<string, Assembly> _loadedAssemblies = new Dictionary<string, Assembly>();
public NuGetPackageAssemblyLoadContext(string nugetPackagePath)
{
var packageReader = new PackageArchiveReader(nugetPackagePath);
foreach (var dllFile in packageReader.GetFiles().Where(fileName => fileName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)))
{
using var dllStream = packageReader.GetStream(dllFile);
using var memoryStream = new MemoryStream();
dllStream.CopyTo(memoryStream);
memoryStream.Seek(0, SeekOrigin.Begin);
var assembly = LoadFromStream(memoryStream);
_loadedAssemblies[assembly.FullName!] = assembly;
}
//Closes the package reader, closing the .nupkg file
packageReader.Dispose();
}
protected override Assembly? Load(AssemblyName assemblyName)
{
return _loadedAssemblies.GetValueOrDefault(assemblyName.FullName);
}
}
@@ -0,0 +1,8 @@
using Elsa.DropIns.Models;
namespace Elsa.DropIns.Contracts;
public interface IDropInCatalog
{
IEnumerable<DropInDescriptor> List();
}
@@ -0,0 +1,8 @@
using Elsa.Features.Services;
namespace Elsa.DropIns.Contracts;
public interface IDropInInstaller
{
void Install(IModule module);
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>
Provides drop-in features for Elsa workflows.
</Description>
<PackageTags>elsa drop-ins common</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="NuGet.Packaging" />
<PackageReference Include="NuGet.Protocol" />
<PackageReference Include="System.Formats.Asn1" />
<PackageReference Include="ThrottleDebounce" />
</ItemGroup>
<!--Overridden
for vulnerability reasons with dependencies referencing older versions.-->
<ItemGroup>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Elsa.DropIns.Core\Elsa.DropIns.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,21 @@
using Elsa.DropIns.Contracts;
using Elsa.DropIns.Options;
using Elsa.Features.Services;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.DropIns.Extensions;
public static class ModuleExtensions
{
public static IModule InstallDropIns(this IModule elsa, Action<DropInOptions>? configureOptions = default)
{
// Install drop-ins.
var serviceProvider = new ServiceCollection().AddDropInInstaller(configureOptions).BuildServiceProvider();
serviceProvider.GetRequiredService<IDropInInstaller>().Install(elsa);
// Register drop-in monitor.
elsa.Services.AddDropInMonitor(configureOptions);
return elsa;
}
}
@@ -0,0 +1,33 @@
using Elsa.DropIns.Contracts;
using Elsa.DropIns.HostedServices;
using Elsa.DropIns.Options;
using Elsa.DropIns.Services;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.DropIns.Extensions;
internal static class ServiceCollectionExtensions
{
public static IServiceCollection AddDropInMonitor(this IServiceCollection services, Action<DropInOptions>? configureOptions = default)
{
services.AddDropInCore(configureOptions);
services.AddHostedService<DropInDirectoryMonitorHostedService>();
return services;
}
public static IServiceCollection AddDropInInstaller(this IServiceCollection services, Action<DropInOptions>? configureOptions = default)
{
services.AddDropInCore(configureOptions);
services.AddSingleton<IDropInInstaller, DropInInstaller>();
return services;
}
public static IServiceCollection AddDropInCore(this IServiceCollection services, Action<DropInOptions>? configureOptions = default)
{
services.Configure(configureOptions ?? (_ => { }));
return services;
}
}
+3
View File
@@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<ConfigureAwait />
</Weavers>
@@ -0,0 +1,24 @@
using System.Reflection;
using System.Runtime.Loader;
using Elsa.DropIns.Contexts;
namespace Elsa.DropIns.Helpers;
public static class AssemblyLoader
{
public static Assembly LoadPath(string path)
{
var loadContext = new DirectoryAssemblyLoadContext(path);
var assemblyName = AssemblyLoadContext.GetAssemblyName(path);
// Copy drop in to memory stream to avoid file locking
using var fileStream = File.OpenRead(path);
using var memoryStream = new MemoryStream();
fileStream.CopyTo(memoryStream);
memoryStream.Seek(0, SeekOrigin.Begin);
fileStream.Close();
var assembly = loadContext.LoadFromStream(memoryStream);
return assembly;
}
}
@@ -0,0 +1,18 @@
using System.Reflection;
namespace Elsa.DropIns.Helpers;
public static class TypeFinder
{
public static IEnumerable<Type> FindImplementationsOf<T>(IEnumerable<Assembly> assemblies)
{
// Find all types that implement IDropInStartup:
return assemblies.SelectMany(FindImplementationsOf<T>);
}
public static IEnumerable<Type> FindImplementationsOf<T>(Assembly assembly)
{
// Find all types that implement T:
return assembly.GetExportedTypes().Where(t => typeof(T).IsAssignableFrom(t) && t is { IsAbstract: false, IsInterface: false });
}
}
@@ -0,0 +1,123 @@
using Elsa.DropIns.Catalogs;
using Elsa.DropIns.Core;
using Elsa.DropIns.Options;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ThrottleDebounce;
namespace Elsa.DropIns.HostedServices;
/// <summary>
/// Monitors the drop-in directory for changes and loads any new drop-ins.
/// </summary>
public class DropInDirectoryMonitorHostedService : BackgroundService
{
private readonly IOptions<DropInOptions> _options;
private readonly ILogger<DropInDirectoryMonitorHostedService> _logger;
private readonly IServiceProvider _serviceProvider;
private readonly RateLimitedFunc<string, Task> _debouncedLoader;
private readonly RateLimitedFunc<string, Task> _debouncedUnloader;
private readonly FileSystemWatcher _watcher;
private readonly Dictionary<string, List<IDropIn>> _installedDropIns;
/// <inheritdoc />
public DropInDirectoryMonitorHostedService(
IOptions<DropInOptions> options,
IServiceProvider serviceProvider,
ILogger<DropInDirectoryMonitorHostedService> logger
)
{
_options = options;
_logger = logger;
_serviceProvider = serviceProvider;
_debouncedLoader = Debouncer.Debounce<string, Task>(LoadDropInAssemblyAsync, TimeSpan.FromSeconds(2));
_debouncedUnloader = Debouncer.Debounce<string, Task>(UnloadDropInAssemblyAsync, TimeSpan.FromSeconds(2));
_installedDropIns = [];
var rootDirectoryPath = _options.Value.DropInRootDirectory;
if (!Directory.Exists(rootDirectoryPath))
Directory.CreateDirectory(rootDirectoryPath);
_watcher = new FileSystemWatcher(rootDirectoryPath)
{
EnableRaisingEvents = true,
IncludeSubdirectories = true,
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName
};
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await LoadDropInAssemblyAsync(_options.Value.DropInRootDirectory);
_watcher.Changed += OnChanged;
_watcher.Deleted += OnDeleted;
}
private async void OnChanged(object sender, FileSystemEventArgs e)
{
var task = _debouncedLoader.Invoke(e.FullPath);
if (task == null)
return;
await task;
}
private async void OnDeleted(object sender, FileSystemEventArgs e)
{
var task = _debouncedUnloader.Invoke(e.FullPath);
if (task == null)
return;
await task;
}
private async Task LoadDropInAssemblyAsync(string fullPath)
{
var directory = Path.GetDirectoryName(fullPath)!;
var directoryCatalog = new DirectoryDropInCatalog(directory);
var dropInDescriptors = directoryCatalog.List();
foreach (var dropInDescriptor in dropInDescriptors)
{
var dropIn = (IDropIn)Activator.CreateInstance(dropInDescriptor.Type)!;
if (_installedDropIns.TryGetValue(fullPath, out var installedDropIns))
{
installedDropIns.Add(dropIn);
}
else
{
_installedDropIns[fullPath] = [dropIn];
}
await dropIn.ConfigureAsync(_serviceProvider, CancellationToken.None);
}
}
private Task UnloadDropInAssemblyAsync(string fullPath)
{
if (_installedDropIns.TryGetValue(fullPath, out var installedDropIn))
{
installedDropIn.ForEach(dropIn =>
{
try
{
dropIn.Unconfigure(_serviceProvider);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error unconfiguring drop-in {DropIn}", dropIn.GetType().Name);
}
});
_installedDropIns.Remove(fullPath);
}
return Task.CompletedTask;
}
}
@@ -0,0 +1,13 @@
using System.Diagnostics.CodeAnalysis;
namespace Elsa.DropIns.Models;
public class DropInDescriptor([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type type)
{
/// <summary>
/// Gets or sets the type of the drop-in.
/// The DynamicallyAccessedMembers attribute ensures that the IL2072 warning is suppressed.
/// </summary>
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)]
public Type Type { get; set; } = type;
}
@@ -0,0 +1,6 @@
namespace Elsa.DropIns.Options;
public class DropInOptions
{
public string DropInRootDirectory { get; set; } = default!;
}
@@ -0,0 +1,36 @@
using Elsa.DropIns.Catalogs;
using Elsa.DropIns.Contracts;
using Elsa.DropIns.Core;
using Elsa.DropIns.Options;
using Elsa.Features.Services;
using Microsoft.Extensions.Options;
namespace Elsa.DropIns.Services;
/// <inheritdoc />
public class DropInInstaller : IDropInInstaller
{
private readonly IOptions<DropInOptions> _options;
/// <summary>
/// Initializes a new instance of the <see cref="DropInInstaller"/> class.
/// </summary>
public DropInInstaller(IOptions<DropInOptions> options)
{
_options = options;
}
/// <inheritdoc />
public void Install(IModule module)
{
var dropInRootDirectory = _options.Value.DropInRootDirectory;
var directoryCatalog = new DirectoryDropInCatalog(dropInRootDirectory);
var dropInDescriptors = directoryCatalog.List();
foreach (var dropInDescriptor in dropInDescriptors)
{
var dropIn = (IDropIn)Activator.CreateInstance(dropInDescriptor.Type)!;
dropIn.Install(module);
}
}
}
@@ -0,0 +1,60 @@
using Elsa.Features.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Elsa.Features.Abstractions;
/// <summary>
/// Base type for classes that represent a feature.
/// </summary>
public abstract class FeatureBase : IFeature
{
/// <summary>
/// Constructor.
/// </summary>
protected FeatureBase(IModule module)
{
Module = module;
}
/// <summary>
/// The module this feature is a part of.
/// </summary>
public IModule Module { get; }
/// <summary>
/// A reference to the <see cref="IServiceCollection"/> to which services can be added.
/// </summary>
public IServiceCollection Services => Module.Services;
/// <summary>
/// Override this method to configure your feature.
/// </summary>
public virtual void Configure()
{
}
/// <summary>
/// Override this method to register any hosted services provided by your feature.
/// </summary>
public virtual void ConfigureHostedServices()
{
}
/// <summary>
/// Override this to register services with <see cref="Services"/>.
/// </summary>
public virtual void Apply()
{
}
/// <summary>
/// Configures the specified hosted service using an optional priority to control in which order it will be registered with the service container.
/// </summary>
/// <param name="priority">The priority.</param>
/// <typeparam name="T">The type of hosted service to configure.</typeparam>
protected void ConfigureHostedService<T>(int priority = 0) where T : class, IHostedService
{
Module.ConfigureHostedService<T>(priority);
}
}
@@ -0,0 +1,19 @@
namespace Elsa.Features.Attributes;
/// <summary>
/// Specifies that the feature is enabled automatically when the specified feature is enabled.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public class DependencyOfAttribute : Attribute
{
/// <inheritdoc />
public DependencyOfAttribute(Type type)
{
Type = type;
}
/// <summary>
/// The type of the feature this feature is a dependency of.
/// </summary>
public Type Type { get; set; }
}
@@ -0,0 +1,22 @@
namespace Elsa.Features.Attributes;
/// <summary>
/// Specifies that the feature depends on another feature.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public class DependsOnAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="DependsOnAttribute"/> class.
/// </summary>
/// <param name="type">The type of the feature this feature depends on.</param>
public DependsOnAttribute(Type type)
{
Type = type;
}
/// <summary>
/// The type of the feature this feature depends on.
/// </summary>
public Type Type { get; set; }
}
@@ -0,0 +1,28 @@
using Elsa.Features.Models;
namespace Elsa.Features.Contracts;
/// <summary>
/// Represents a registry of installed features.
/// </summary>
public interface IInstalledFeatureRegistry
{
/// <summary>
/// Adds a feature descriptor to the registry.
/// </summary>
/// <param name="descriptor">The feature descriptor.</param>
void Add(FeatureDescriptor descriptor);
/// <summary>
/// Gets all installed features.
/// </summary>
/// <returns>All installed features.</returns>
IEnumerable<FeatureDescriptor> List();
/// <summary>
/// Finds a feature descriptor by its full name.
/// </summary>
/// <param name="fullName">The full name of the feature.</param>
/// <returns>The feature descriptor or null if not found.</returns>
FeatureDescriptor? Find(string fullName);
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>
Provides utilities to turn your class library into a module that provides features.
</Description>
<PackageTags>elsa common</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
</ItemGroup>
</Project>
@@ -0,0 +1,26 @@
using Elsa.Features.Implementations;
using Elsa.Features.Services;
using Microsoft.Extensions.DependencyInjection;
// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;
/// <summary>
/// Adds extension methods to <see cref="IServiceCollection"/> that creates and configures modules.
/// </summary>
public static class DependencyInjectionExtensions
{
/// <summary>
/// Returns a new instance of an <see cref="IModule"/> implementation.
/// </summary>
public static IModule CreateModule(this IServiceCollection services) => new Module(services);
/// <summary>
/// Installs and configures the specified feature. If the feature was already installed, it is not added twice, which means it is safe to call this method multiple times.
/// </summary>
public static IModule Use<T>(this IModule module, Action<T>? configure = null) where T: class, IFeature
{
module.Configure(configure);
return module;
}
}
@@ -0,0 +1,39 @@
// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;
/// <summary>
/// Adds extension methods to sort collections by their dependencies, also known as a topological sort.
/// </summary>
public static class EnumerableTopologicalSortExtensions
{
/// <summary>
/// Returns a topologically sorted copy of the specified list.
/// </summary>
// ReSharper disable once InconsistentNaming
public static IEnumerable<T> TSort<T>(this IEnumerable<T> source, Func<T, IEnumerable<T>> dependencies, bool throwOnCycle = false)
{
var sorted = new List<T>();
var visited = new HashSet<T>();
foreach (var item in source)
Visit(item, visited, sorted, dependencies, throwOnCycle);
return sorted;
}
private static void Visit<T>(T item, ISet<T> visited, ICollection<T> sorted, Func<T, IEnumerable<T>> dependencies, bool throwOnCycle)
{
if (visited.Add(item))
{
foreach (var dep in dependencies(item))
Visit(dep, visited, sorted, dependencies, throwOnCycle);
sorted.Add(item);
}
else
{
if (throwOnCycle && !sorted.Contains(item))
throw new Exception("Cyclic dependency found");
}
}
}
+3
View File
@@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<ConfigureAwait />
</Weavers>
@@ -0,0 +1,172 @@
using System.ComponentModel;
using System.Reflection;
using Elsa.Extensions;
using Elsa.Features.Attributes;
using Elsa.Features.Contracts;
using Elsa.Features.Models;
using Elsa.Features.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
namespace Elsa.Features.Implementations;
/// <inheritdoc />
public class Module : IModule
{
private sealed record HostedServiceDescriptor(int Order, Type Type);
private Dictionary<Type, IFeature> _features = new();
private readonly HashSet<IFeature> _configuredFeatures = new();
private readonly List<HostedServiceDescriptor> _hostedServiceDescriptors = new();
/// <summary>
/// Constructor.
/// </summary>
public Module(IServiceCollection services)
{
Services = services;
}
/// <inheritdoc />
public IServiceCollection Services { get; }
/// <inheritdoc />
public IDictionary<object, object> Properties { get; } = new Dictionary<object, object>();
/// <inheritdoc />
public bool HasFeature<T>() where T : class, IFeature
{
return HasFeature(typeof(T));
}
/// <inheritdoc />
public bool HasFeature(Type featureType)
{
return _features.ContainsKey(featureType);
}
/// <inheritdoc />
public T Configure<T>(Action<T>? configure = null) where T : class, IFeature
{
return Configure(module => (T)Activator.CreateInstance(typeof(T), module)!, configure);
}
/// <inheritdoc />
public T Configure<T>(Func<IModule, T> factory, Action<T>? configure = null) where T : class, IFeature
{
if (!_features.TryGetValue(typeof(T), out var feature))
{
feature = factory(this);
_features[typeof(T)] = feature;
}
configure?.Invoke((T)feature);
if (!_isApplying)
return (T)feature;
var dependencies = GetDependencyTypes(feature.GetType()).ToHashSet();
foreach (var dependency in dependencies.Select(GetOrCreateFeature))
ConfigureFeature(dependency);
ConfigureFeature(feature);
return (T)feature;
}
/// <inheritdoc />
public IModule ConfigureHostedService<T>(int priority = 0) where T : class, IHostedService
{
return ConfigureHostedService(typeof(T), priority);
}
/// <inheritdoc />
public IModule ConfigureHostedService(Type hostedServiceType, int priority = 0)
{
_hostedServiceDescriptors.Add(new(priority, hostedServiceType));
return this;
}
private bool _isApplying;
/// <inheritdoc />
public void Apply()
{
_isApplying = true;
var featureTypes = GetFeatureTypes();
_features = featureTypes.ToDictionary(featureType => featureType, featureType => _features.TryGetValue(featureType, out var existingFeature) ? existingFeature : (IFeature)Activator.CreateInstance(featureType, this)!);
// Iterate over a copy of the features to avoid concurrent modification exceptions.
foreach (var feature in _features.Values.ToList())
{
// This will cause additional features to be added to _features.
ConfigureFeature(feature);
}
// Filter out features that depend on other features that are not installed.
_features = ExcludeFeaturesWithMissingDependencies(_features.Values).ToDictionary(x => x.GetType(), x => x);
// Add hosted services in order of priority.
foreach (var hostedServiceDescriptor in _hostedServiceDescriptors.OrderBy(x => x.Order))
Services.TryAddEnumerable(ServiceDescriptor.Singleton(typeof(IHostedService), hostedServiceDescriptor.Type));
// Make sure to use the complete list of features when applying them.
foreach (var feature in _features.Values)
feature.Apply();
// Add a registry of enabled features to the service collection for client applications to reflect on what features are installed.
var registry = new InstalledFeatureRegistry();
foreach (var feature in _features.Values)
{
var type = feature.GetType();
var name = type.Name.Replace("Feature", string.Empty);
var ns = "Elsa";
var displayName = type.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName ?? name;
var description = type.GetCustomAttribute<DescriptionAttribute>()?.Description;
registry.Add(new(name, ns, displayName, description));
}
Services.AddSingleton<IInstalledFeatureRegistry>(registry);
}
private IEnumerable<IFeature> ExcludeFeaturesWithMissingDependencies(IEnumerable<IFeature> features)
{
return
from feature in features
let featureType = feature.GetType()
let dependencyOfAttributes = featureType.GetCustomAttributes<DependencyOfAttribute>(true).ToList()
let missingDependencies = dependencyOfAttributes.Where(x => !_features.ContainsKey(x.Type)).ToList()
where missingDependencies.Count == 0
select feature;
}
private void ConfigureFeature(IFeature feature)
{
if (_configuredFeatures.Contains(feature))
return;
feature.Configure();
feature.ConfigureHostedServices();
_features[feature.GetType()] = feature;
_configuredFeatures.Add(feature);
}
private IFeature GetOrCreateFeature(Type featureType)
{
return _features.TryGetValue(featureType, out var existingFeature) ? existingFeature : (IFeature)Activator.CreateInstance(featureType, this)!;
}
private HashSet<Type> GetFeatureTypes()
{
var featureTypes = _features.Keys.ToHashSet();
var featureTypesWithDependencies = featureTypes.Concat(featureTypes.SelectMany(GetDependencyTypes)).ToHashSet();
return featureTypesWithDependencies.TSort(x => x.GetCustomAttributes<DependsOnAttribute>(true).Select(dependsOn => dependsOn.Type)).ToHashSet();
}
// Recursively get dependency types.
private IEnumerable<Type> GetDependencyTypes(Type type)
{
var dependencies = type.GetCustomAttributes<DependsOnAttribute>(true).Select(dependsOn => dependsOn.Type).ToList();
return dependencies.Concat(dependencies.SelectMany(GetDependencyTypes));
}
}
@@ -0,0 +1,57 @@
using JetBrains.Annotations;
namespace Elsa.Features.Models;
/// <summary>
/// Represents a feature descriptor.
/// </summary>
[PublicAPI]
public class FeatureDescriptor
{
/// <summary>
/// Initializes a new instance of the <see cref="FeatureDescriptor"/> class.
/// </summary>
public FeatureDescriptor()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="FeatureDescriptor"/> class.
/// </summary>
/// <param name="name">The name of the feature.</param>
/// <param name="ns">The namespace of the feature.</param>
/// <param name="displayName">The display name for the feature.</param>
/// <param name="description">The description of the feature.</param>
public FeatureDescriptor(string name, string ns, string displayName, string? description = default)
{
Name = name;
Namespace = ns;
DisplayName = displayName;
Description = description ?? "";
}
/// <summary>
/// Gets or sets the name of the feature.
/// </summary>
public string Name { get; set; } = default!;
/// <summary>
/// Gets or sets the namespace of the feature.
/// </summary>
public string Namespace { get; set; } = default!;
/// <summary>
/// Gets the full name of the feature.
/// </summary>
public string FullName => $"{Namespace}.{Name}";
/// <summary>
/// The display name for the feature.
/// </summary>
public string DisplayName { get; set; } = default!;
/// <summary>
/// The description of the feature.
/// </summary>
public string Description { get; set; } = "";
}
@@ -0,0 +1,31 @@
using JetBrains.Annotations;
namespace Elsa.Features.Services;
/// <summary>
/// Represents a feature.
/// </summary>
[UsedImplicitly(ImplicitUseTargetFlags.WithInheritors | ImplicitUseTargetFlags.Members)]
public interface IFeature
{
/// <summary>
/// Gets the module that this feature belongs to.
/// </summary>
IModule Module { get; }
/// <summary>
/// Configures the feature.
/// </summary>
void Configure();
/// <summary>
/// Configures the hosted services.
/// </summary>
void ConfigureHostedServices();
/// <summary>
/// Applies the feature by adding services to the service collection.
/// </summary>
void Apply();
}
@@ -0,0 +1,55 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Elsa.Features.Services;
/// <summary>
/// A thin abstraction on top of <see cref="IServiceCollection"/> to help organize features and dependencies.
/// </summary>
public interface IModule
{
/// <summary>
/// The service collection being populated.
/// </summary>
IServiceCollection Services { get; }
/// <summary>
/// A dictionary into which features can stash away values for later use.
/// </summary>
IDictionary<object, object> Properties { get; }
/// <summary>
/// Returns true if a feature of the specified type has been configured.
/// </summary>
bool HasFeature<T>() where T : class, IFeature;
/// <summary>
/// Returns true if a feature of the specified type has been configured.
/// </summary>
bool HasFeature(Type featureType);
/// <summary>
/// Creates and configures a feature of the specified type.
/// </summary>
T Configure<T>(Action<T>? configure = null) where T : class, IFeature;
/// <summary>
/// Creates and configures a feature of the specified type.
/// </summary>
T Configure<T>(Func<IModule, T> factory, Action<T>? configure = null) where T : class, IFeature;
/// <summary>
/// Configures a <see cref="IHostedService"/> using an optional priority to control in which order it will be registered with the service container.
/// </summary>
IModule ConfigureHostedService<T>(int priority = 0) where T : class, IHostedService;
/// <summary>
/// Configures a <see cref="IHostedService"/> using an optional priority to control in which order it will be registered with the service container.
/// </summary>
IModule ConfigureHostedService(Type hostedServiceType, int priority = 0);
/// <summary>
/// Will apply all configured features, causing the <see cref="Services"/> collection to be populated.
/// </summary>
void Apply();
}
@@ -0,0 +1,19 @@
using Elsa.Features.Contracts;
using Elsa.Features.Models;
namespace Elsa.Features.Services;
/// <inheritdoc />
public class InstalledFeatureRegistry : IInstalledFeatureRegistry
{
private readonly Dictionary<string, FeatureDescriptor> _descriptors = new();
/// <inheritdoc />
public void Add(FeatureDescriptor descriptor) => _descriptors[descriptor.FullName] = descriptor;
/// <inheritdoc />
public IEnumerable<FeatureDescriptor> List() => _descriptors.Values;
/// <inheritdoc />
public FeatureDescriptor? Find(string fullName) => _descriptors.GetValueOrDefault(fullName);
}
@@ -0,0 +1,30 @@
using System.Threading.Channels;
namespace Elsa.Mediator.Abstractions;
/// <summary>
/// A base class for channels.
/// </summary>
/// <typeparam name="T">The type of the items in the channel.</typeparam>
public abstract class ChannelBase<T>
{
private readonly Channel<T> _channel;
/// <summary>
/// Initializes a new instance of the <see cref="ChannelBase{T}"/> class.
/// </summary>
protected ChannelBase()
{
_channel = Channel.CreateUnbounded<T>();
}
/// <summary>
/// Gets the writer for the channel.
/// </summary>
public ChannelWriter<T> Writer => _channel.Writer;
/// <summary>
/// Gets the reader for the channel.
/// </summary>
public ChannelReader<T> Reader => _channel.Reader;
}
@@ -0,0 +1,10 @@
using Elsa.Mediator.Abstractions;
using Elsa.Mediator.Contracts;
using Elsa.Mediator.Middleware.Command;
namespace Elsa.Mediator.Channels;
/// <inheritdoc cref="Elsa.Mediator.Contracts.ICommandsChannel" />
public class CommandsChannel : ChannelBase<CommandContext>, ICommandsChannel
{
}
@@ -0,0 +1,10 @@
using Elsa.Mediator.Abstractions;
using Elsa.Mediator.Contracts;
using Elsa.Mediator.Models;
namespace Elsa.Mediator.Channels;
/// <inheritdoc cref="Elsa.Mediator.Contracts.IJobsChannel" />
public class JobsChannel : ChannelBase<EnqueuedJob>, IJobsChannel
{
}
@@ -0,0 +1,10 @@
using Elsa.Mediator.Abstractions;
using Elsa.Mediator.Contracts;
using Elsa.Mediator.Middleware.Notification;
namespace Elsa.Mediator.Channels;
/// <inheritdoc cref="Elsa.Mediator.Contracts.INotificationsChannel" />
public class NotificationsChannel : ChannelBase<NotificationContext>, INotificationsChannel
{
}
@@ -0,0 +1,19 @@
using Elsa.Mediator.Contexts;
using Elsa.Mediator.Contracts;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Mediator.CommandStrategies;
/// <summary>
/// Invokes command handlers in the background and does not wait for the result.
/// </summary>
public class BackgroundStrategy : ICommandStrategy
{
/// <inheritdoc />
public async Task<TResult> ExecuteAsync<TResult>(CommandStrategyContext context)
{
var commandsChannel = context.ServiceProvider.GetRequiredService<ICommandsChannel>();
await commandsChannel.Writer.WriteAsync(context.CommandContext, context.CancellationToken);
return default!;
}
}
@@ -0,0 +1,23 @@
using Elsa.Mediator.Contexts;
using Elsa.Mediator.Contracts;
using Elsa.Mediator.Extensions;
namespace Elsa.Mediator.CommandStrategies;
/// <summary>
/// Invokes command handlers using the default strategy.
/// </summary>
public class DefaultStrategy : ICommandStrategy
{
/// <inheritdoc />
public async Task<TResult> ExecuteAsync<TResult>(CommandStrategyContext context)
{
var commandContext = context.CommandContext;
var command = commandContext.Command;
var commandType = command.GetType();
var handleMethod = commandType.GetCommandHandlerMethod();
var handler = context.Handler;
return await handler.InvokeAsync<TResult>(handleMethod, commandContext);
}
}
@@ -0,0 +1,19 @@
using Elsa.Mediator.CommandStrategies;
namespace Elsa.Mediator;
/// <summary>
/// Provides a set of strategies for publishing events.
/// </summary>
public static class CommandStrategy
{
/// <summary>
/// Invokes command handlers in parallel and waits for the result.
/// </summary>
public static readonly DefaultStrategy Default = new();
/// <summary>
/// Invokes command handlers in the background and does not wait for the result.
/// </summary>
public static readonly BackgroundStrategy Background = new();
}
@@ -0,0 +1,13 @@
using Elsa.Mediator.Contracts;
using Elsa.Mediator.Middleware.Command;
namespace Elsa.Mediator.Contexts;
/// <summary>
/// Represents a context for executing a command.
/// </summary>
/// <param name="CommandContext">The command context to execute.</param>
/// <param name="Handler">The command handler.</param>
/// <param name="ServiceProvider">The service provider to resolve services from.</param>
/// <param name="CancellationToken">The cancellation token.</param>
public record CommandStrategyContext(CommandContext CommandContext, ICommandHandler Handler, IServiceProvider ServiceProvider, CancellationToken CancellationToken = default);
@@ -0,0 +1,15 @@
using Elsa.Mediator.Contracts;
using Elsa.Mediator.Middleware.Notification;
using Microsoft.Extensions.Logging;
namespace Elsa.Mediator.Contexts;
/// <summary>
/// Represents a context for publishing events.
/// </summary>
/// <param name="NotificationContext">The notification to publish.</param>
/// <param name="Handlers">The handlers to publish the notification to.</param>
/// <param name="Logger">The logger.</param>
/// <param name="ServiceProvider">The service provider to resolve services from.</param>
/// <param name="CancellationToken">The cancellation token.</param>
public record NotificationStrategyContext(NotificationContext NotificationContext, INotificationHandler[] Handlers, ILogger Logger, IServiceProvider ServiceProvider, CancellationToken CancellationToken = default);
@@ -0,0 +1,20 @@
using JetBrains.Annotations;
namespace Elsa.Mediator.Contracts;
/// <summary>
/// Represents a command.
/// </summary>
[PublicAPI]
public interface ICommand
{
}
/// <summary>
/// Represents a command.
/// </summary>
/// <typeparam name="T">The type of the result.</typeparam>
[PublicAPI]
public interface ICommand<T> : ICommand
{
}
@@ -0,0 +1,34 @@
using Elsa.Mediator.Models;
namespace Elsa.Mediator.Contracts;
/// <summary>
/// Represents a command handler.
/// </summary>
public interface ICommandHandler
{
}
/// <summary>
/// Represents a command handler.
/// </summary>
/// <typeparam name="TCommand">The type of the command to handle.</typeparam>
/// <typeparam name="TResult">The type of the result.</typeparam>
public interface ICommandHandler<in TCommand, TResult> : ICommandHandler where TCommand : ICommand
{
/// <summary>
/// Handles the command.
/// </summary>
/// <param name="command">The command to handle.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The result.</returns>
Task<TResult> HandleAsync(TCommand command, CancellationToken cancellationToken);
}
/// <summary>
/// Represents a command handler.
/// </summary>
/// <typeparam name="TCommand">The type of the command to handle.</typeparam>
public interface ICommandHandler<in TCommand> : ICommandHandler<TCommand, Unit> where TCommand : ICommand
{
}
@@ -0,0 +1,67 @@
namespace Elsa.Mediator.Contracts;
/// <summary>
/// Sends a command.
/// </summary>
public interface ICommandSender
{
/// <summary>
/// Sends a command using the default strategy.
/// </summary>
Task<T> SendAsync<T>(ICommand<T> command, CancellationToken cancellationToken = default);
/// <summary>
/// Sends a command using the default strategy.
/// </summary>
/// <param name="command">The command to send.</param>
/// <param name="headers">Any headers to pass along.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <typeparam name="T">The type of the result.</typeparam>
/// <returns>The result.</returns>
Task<T> SendAsync<T>(ICommand<T> command, IDictionary<object, object> headers, CancellationToken cancellationToken = default);
/// <summary>
/// Sends a command using the specified strategy.
/// </summary>
/// <param name="command">The command to send.</param>
/// <param name="strategy">The command strategy to use.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <typeparam name="T">The type of the result.</typeparam>
/// <returns>The result.</returns>
Task<T> SendAsync<T>(ICommand<T> command, ICommandStrategy strategy, CancellationToken cancellationToken = default);
/// <summary>
/// Sends a command using the specified strategy.
/// </summary>
/// <param name="command">The command to send.</param>
/// <param name="strategy">The command strategy to use.</param>
/// <param name="headers">Any headers to pass along.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <typeparam name="T">The type of the result.</typeparam>
/// <returns>The result.</returns>
Task<T> SendAsync<T>(ICommand<T> command, ICommandStrategy strategy, IDictionary<object, object> headers, CancellationToken cancellationToken = default);
/// <summary>
/// Sends a command using the default strategy.
/// </summary>
/// <param name="command">The command to send.</param>
/// <param name="cancellationToken">The cancellation token.</param>
Task SendAsync(ICommand command, CancellationToken cancellationToken = default);
/// <summary>
/// Sends a command using the specified strategy.
/// </summary>
/// <param name="command">The command to send.</param>
/// <param name="strategy">The command strategy to use.</param>
/// <param name="cancellationToken">The cancellation token.</param>
Task SendAsync(ICommand command, ICommandStrategy strategy, CancellationToken cancellationToken = default);
/// <summary>
/// Sends a command using the specified strategy.
/// </summary>
/// <param name="command">The command to send.</param>
/// <param name="strategy">The command strategy to use.</param>
/// <param name="headers">Any headers to pass along.</param>
/// <param name="cancellationToken">The cancellation token.</param>
Task SendAsync(ICommand command, ICommandStrategy strategy, IDictionary<object, object> headers, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,15 @@
using Elsa.Mediator.Contexts;
namespace Elsa.Mediator.Contracts;
/// <summary>
/// Represents a strategy for executing commands.
/// </summary>
public interface ICommandStrategy
{
/// <summary>
/// Executes a command.
/// </summary>
/// <param name="context">The context for executing the command.</param>
Task<TResult> ExecuteAsync<TResult>(CommandStrategyContext context);
}
@@ -0,0 +1,20 @@
using System.Threading.Channels;
using Elsa.Mediator.Middleware.Command;
namespace Elsa.Mediator.Contracts;
/// <summary>
/// A channel that can be used to enqueue commands.
/// </summary>
public interface ICommandsChannel
{
/// <summary>
/// Gets the writer for the commands queue.
/// </summary>
ChannelWriter<CommandContext> Writer { get; }
/// <summary>
/// Gets the reader for the commands queue.
/// </summary>
ChannelReader<CommandContext> Reader { get; }
}
@@ -0,0 +1,15 @@
namespace Elsa.Mediator.Contracts;
/// <summary>
/// Represents a consumer of a given message type.
/// </summary>
/// <typeparam name="T">The type of the message to consume.</typeparam>
public interface IConsumer<in T>
{
/// <summary>
/// Consumes the given message.
/// </summary>
/// <param name="message">The message to consume.</param>
/// <param name="cancellationToken">The cancellation token.</param>
ValueTask ConsumeAsync(T message, CancellationToken cancellationToken);
}
@@ -0,0 +1,15 @@
using Elsa.Mediator.Contexts;
namespace Elsa.Mediator.Contracts;
/// <summary>
/// Represents a strategy for publishing events.
/// </summary>
public interface IEventPublishingStrategy
{
/// <summary>
/// Publishes an event.
/// </summary>
/// <param name="context">The context for publishing the event.</param>
Task PublishAsync(NotificationStrategyContext context);
}
@@ -0,0 +1,39 @@
namespace Elsa.Mediator.Contracts;
/// <summary>
/// Manages jobs.
/// </summary>
public interface IJobQueue
{
/// <summary>
/// Creates a pending job. The caller must use the returned ID to schedule the job for execution.
/// </summary>
string Create(Func<CancellationToken, Task> job);
/// <summary>
/// Enqueues a job for execution.
/// </summary>
/// <param name="jobId">The ID of the job to enqueue.</param>
void Enqueue(string jobId);
/// <summary>
/// Enqueues a job for execution.
/// </summary>
/// <param name="job">The job to enqueue.</param>
/// <returns>The ID of the job.</returns>
string Enqueue(Func<CancellationToken, Task> job);
/// <summary>
/// Dequeues a job.
/// </summary>
/// <param name="jobId">The ID of the job to dequeue.</param>
/// <returns><c>true</c> if the job was dequeued; otherwise, <c>false</c>.</returns>
bool Dequeue(string jobId);
/// <summary>
/// Cancels a job.
/// </summary>
/// <param name="jobId">The ID of the job to cancel.</param>
/// <returns><c>true</c> if the job was cancelled; otherwise, <c>false</c>.</returns>
bool Cancel(string jobId);
}
@@ -0,0 +1,20 @@
using System.Threading.Channels;
using Elsa.Mediator.Models;
namespace Elsa.Mediator.Contracts;
/// <summary>
/// A channel that can be used to enqueue jobs.
/// </summary>
public interface IJobsChannel
{
/// <summary>
/// Gets the writer for the job queue.
/// </summary>
ChannelWriter<EnqueuedJob> Writer { get; }
/// <summary>
/// Gets the reader for the job queue.
/// </summary>
ChannelReader<EnqueuedJob> Reader { get; }
}
@@ -0,0 +1,8 @@
namespace Elsa.Mediator.Contracts;
/// <summary>
/// Represents a mediator that can send requests, commands and publish events.
/// </summary>
public interface IMediator : IRequestSender, ICommandSender, INotificationSender
{
}
@@ -0,0 +1,8 @@
namespace Elsa.Mediator.Contracts;
/// <summary>
/// Represents a notification.
/// </summary>
public interface INotification
{
}
@@ -0,0 +1,22 @@
namespace Elsa.Mediator.Contracts;
/// <summary>
/// Represents a notification handler.
/// </summary>
public interface INotificationHandler
{
}
/// <summary>
/// Represents a notification handler.
/// </summary>
/// <typeparam name="T">The type of the notification.</typeparam>
public interface INotificationHandler<in T> : INotificationHandler where T : INotification
{
/// <summary>
/// Handles the given notification.
/// </summary>
/// <param name="notification">The notification to handle.</param>
/// <param name="cancellationToken">The cancellation token.</param>
Task HandleAsync(T notification, CancellationToken cancellationToken);
}
@@ -0,0 +1,24 @@
using Elsa.Mediator.PublishingStrategies;
namespace Elsa.Mediator.Contracts;
/// <summary>
/// Publishes notifications.
/// </summary>
public interface INotificationSender
{
/// <summary>
/// Publishes the given notification.
/// </summary>
/// <param name="notification">The notification to publish.</param>
/// <param name="cancellationToken">The cancellation token.</param>
Task SendAsync(INotification notification, CancellationToken cancellationToken = default);
/// <summary>
/// Publishes the given notification.
/// </summary>
/// <param name="notification">The notification to publish.</param>
/// <param name="strategy"><see cref="SequentialProcessingStrategy"/><see cref="ParallelProcessingStrategy"/></param>
/// <param name="cancellationToken">The cancellation token.</param>
Task SendAsync(INotification notification, IEventPublishingStrategy? strategy, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,20 @@
using System.Threading.Channels;
using Elsa.Mediator.Middleware.Notification;
namespace Elsa.Mediator.Contracts;
/// <summary>
/// A channel that can be used to enqueue notifications.
/// </summary>
public interface INotificationsChannel
{
/// <summary>
/// Gets the writer for the notification queue.
/// </summary>
ChannelWriter<NotificationContext> Writer { get; }
/// <summary>
/// Gets the reader for the notification queue.
/// </summary>
ChannelReader<NotificationContext> Reader { get; }
}
@@ -0,0 +1,16 @@
namespace Elsa.Mediator.Contracts;
/// <summary>
/// Represents a request.
/// </summary>
public interface IRequest
{
}
/// <summary>
/// Represents a request.
/// </summary>
/// <typeparam name="T">The type of the response.</typeparam>
public interface IRequest<T> : IRequest
{
}
@@ -0,0 +1,24 @@
namespace Elsa.Mediator.Contracts;
/// <summary>
/// Represents a request handler.
/// </summary>
public interface IRequestHandler
{
}
/// <summary>
/// Represents a request handler.
/// </summary>
/// <typeparam name="TRequest">The type of the request.</typeparam>
/// <typeparam name="TResponse">The type of the response.</typeparam>
public interface IRequestHandler<in TRequest, TResponse> : IRequestHandler where TRequest : IRequest<TResponse>?
{
/// <summary>
/// Handles the given request.
/// </summary>
/// <param name="request">The request to handle.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The response.</returns>
Task<TResponse> HandleAsync(TRequest request, CancellationToken cancellationToken);
}
@@ -0,0 +1,16 @@
namespace Elsa.Mediator.Contracts;
/// <summary>
/// Represents a request sender.
/// </summary>
public interface IRequestSender
{
/// <summary>
/// Sends a request.
/// </summary>
/// <param name="request">The request to send.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <typeparam name="T">The type of the response.</typeparam>
/// <returns>The response.</returns>
Task<T> SendAsync<T>(IRequest<T> request, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>
Provides a mediator service inspired by MediatR.
</Description>
<PackageTags>elsa module common mediator</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
</ItemGroup>
</Project>
@@ -0,0 +1,228 @@
using System.Reflection;
using System.Threading.Channels;
using Elsa.Mediator.Channels;
using Elsa.Mediator.Contracts;
using Elsa.Mediator.HostedServices;
using Elsa.Mediator.Middleware.Command;
using Elsa.Mediator.Middleware.Command.Contracts;
using Elsa.Mediator.Middleware.Notification;
using Elsa.Mediator.Middleware.Notification.Contracts;
using Elsa.Mediator.Middleware.Request;
using Elsa.Mediator.Middleware.Request.Contracts;
using Elsa.Mediator.Models;
using Elsa.Mediator.Options;
using Elsa.Mediator.Services;
using JetBrains.Annotations;
using Microsoft.Extensions.Logging;
// ReSharper disable once CheckNamespace
namespace Microsoft.Extensions.DependencyInjection;
/// <summary>
/// Adds mediator services to the <see cref="IServiceCollection"/>.
/// </summary>
[PublicAPI]
public static class DependencyInjectionExtensions
{
/// <summary>
/// Adds mediator services to the <see cref="IServiceCollection"/>.
/// </summary>
public static IServiceCollection AddMediator(this IServiceCollection services, Action<MediatorOptions>? configure = default)
{
services.Configure(configure ?? (_ => { }));
return services
.AddScoped<IMediator, DefaultMediator>()
.AddScoped<IRequestSender>(sp => sp.GetRequiredService<IMediator>())
.AddScoped<ICommandSender>(sp => sp.GetRequiredService<IMediator>())
.AddScoped<INotificationSender>(sp => sp.GetRequiredService<IMediator>())
.AddSingleton<IRequestPipeline, RequestPipeline>()
.AddSingleton<ICommandPipeline, CommandPipeline>()
.AddSingleton<INotificationPipeline, NotificationPipeline>()
;
}
/// <summary>
/// Adds mediator hosted services to the <see cref="IServiceCollection"/>.
/// </summary>
/// <param name="services"></param>
/// <returns></returns>
public static IServiceCollection AddMediatorHostedServices(this IServiceCollection services)
{
return services
.AddSingleton<INotificationsChannel, NotificationsChannel>()
.AddSingleton<ICommandsChannel, CommandsChannel>()
.AddSingleton<IJobsChannel, JobsChannel>()
.AddSingleton<IJobQueue, JobQueue>()
.AddHostedService<JobRunnerHostedService>()
.AddHostedService<BackgroundCommandSenderHostedService>()
.AddHostedService<BackgroundEventPublisherHostedService>();
}
/// <summary>
/// Adds a <see cref="Channel{T}"/> to the <see cref="IServiceCollection"/> and a hosted service that continuously reads from the channel and executes each received message.
/// </summary>
public static IServiceCollection AddMessageChannel<T>(this IServiceCollection services, int workerCount = 1) where T : notnull
{
services.AddSingleton<Channel<T>>(_ => CreateChannel<T>());
services.AddHostedService(sp =>
{
var channel = sp.GetRequiredService<Channel<T>>();
var consumers = sp.GetServices<IConsumer<T>>();
var logger = sp.GetRequiredService<ILogger<MessageProcessorHostedService<T>>>();
return new MessageProcessorHostedService<T>(workerCount, channel, consumers, logger);
});
return services;
}
/// <summary>
/// Adds a channel consumer.
/// </summary>
public static IServiceCollection AddMessageConsumer<T, TConsumer>(this IServiceCollection services) where TConsumer : class, IConsumer<T> where T : notnull
{
return services.AddScoped<IConsumer<T>, TConsumer>();
}
/// <summary>
/// Registers a <see cref="ICommandHandler{T}"/> with the service container.
/// </summary>
public static IServiceCollection AddCommandHandler<THandler>(this IServiceCollection services) where THandler : class, ICommandHandler =>
services.AddScoped<ICommandHandler, THandler>();
/// <summary>
/// Registers a <see cref="ICommandHandler{T}"/> with the service container.
/// </summary>
public static IServiceCollection AddCommandHandler<THandler, TCommand>(this IServiceCollection services)
where THandler : class, ICommandHandler<TCommand> where TCommand : ICommand<Unit> =>
services.AddCommandHandler<THandler, TCommand, Unit>();
/// <summary>
/// Registers a <see cref="ICommandHandler{T}"/> with the service container.
/// </summary>
public static IServiceCollection AddCommandHandler<THandler, TCommand, TResult>(this IServiceCollection services)
where THandler : class, ICommandHandler<TCommand, TResult>
where TCommand : ICommand<TResult>
{
return services.AddScoped<ICommandHandler, THandler>();
}
/// <summary>
/// Registers a <see cref="ICommandHandler{T}"/> with the service container.
/// </summary>
public static IServiceCollection AddNotificationHandler<THandler>(this IServiceCollection services)
where THandler : class, INotificationHandler
{
return services.AddScoped<INotificationHandler, THandler>();
}
/// <summary>
/// Registers a <see cref="INotificationHandler{T}"/> with the service container.
/// </summary>
public static IServiceCollection AddNotificationHandler<THandler, TNotification>(this IServiceCollection services)
where THandler : class, INotificationHandler<TNotification>
where TNotification : INotification =>
services.AddScoped<INotificationHandler, THandler>();
/// <summary>
/// Registers a <see cref="INotificationHandler{T}"/> with the service container.
/// </summary>
public static IServiceCollection AddNotificationHandler<THandler, TNotification>(this IServiceCollection services, Func<IServiceProvider, THandler> factory)
where THandler : class, INotificationHandler<TNotification>
where TNotification : INotification =>
services.AddScoped<INotificationHandler, THandler>(factory);
/// <summary>
/// Registers a <see cref="IRequestHandler{TRequest,TResponse}"/> with the service container.
/// </summary>
public static IServiceCollection AddRequestHandler<THandler, TRequest, TResponse>(this IServiceCollection services)
where THandler : class, IRequestHandler<TRequest, TResponse>
where TRequest : IRequest<TResponse>?
{
return services.AddScoped<IRequestHandler, THandler>();
}
/// <summary>
/// Registers all handlers from the assembly of the specified <c>TMarker</c> type with the service container.
/// </summary>
public static IServiceCollection AddHandlersFrom<TMarker>(this IServiceCollection services) => services.AddHandlersFrom(typeof(TMarker));
/// <summary>
/// Registers all handlers from the assembly of the specified marker type with the service container.
/// </summary>
public static IServiceCollection AddHandlersFrom(this IServiceCollection services, Type markerType) => services.AddHandlersFrom(markerType.Assembly);
/// <summary>
/// Registers all handlers from the specified assembly with the service container.
/// </summary>
public static IServiceCollection AddHandlersFrom(this IServiceCollection services, Assembly assembly)
{
return services
.AddNotificationHandlersFrom(assembly)
.AddRequestHandlersFrom(assembly)
.AddCommandHandlersFrom(assembly);
}
/// <summary>
/// Registers all handlers from the assembly of the specified <c>TMarker</c> type with the service container.
/// </summary>
public static IServiceCollection AddNotificationHandlersFrom<TMarker>(this IServiceCollection services) => services.AddHandlersFromInternal<INotificationHandler>(typeof(TMarker));
/// <summary>
/// Registers all handlers from the assembly of the specified marker type with the service container.
/// </summary>
public static IServiceCollection AddNotificationHandlersFrom(this IServiceCollection services, Type markerType) => services.AddHandlersFromInternal<INotificationHandler>(markerType);
/// <summary>
/// Registers all handlers from the specified assembly with the service container.
/// </summary>
public static IServiceCollection AddNotificationHandlersFrom(this IServiceCollection services, Assembly assembly) => services.AddHandlersFromInternal<INotificationHandler>(assembly);
/// <summary>
/// Registers all handlers from the assembly of the specified <c>TMarker</c> type with the service container.
/// </summary>
public static IServiceCollection AddRequestHandlersFrom<TMarker>(this IServiceCollection services) => services.AddHandlersFromInternal<IRequestHandler>(typeof(TMarker));
/// <summary>
/// Registers all handlers from the assembly of the specified marker type with the service container.
/// </summary>
public static IServiceCollection AddRequestHandlersFrom(this IServiceCollection services, Type markerType) => services.AddHandlersFromInternal<IRequestHandler>(markerType);
/// <summary>
/// Registers all handlers from the specified assembly with the service container.
/// </summary>
public static IServiceCollection AddRequestHandlersFrom(this IServiceCollection services, Assembly assembly) => services.AddHandlersFromInternal<IRequestHandler>(assembly);
/// <summary>
/// Registers all handlers from the assembly of the specified <c>TMarker</c> type with the service container.
/// </summary>
public static IServiceCollection AddCommandHandlersFrom<TMarker>(this IServiceCollection services) => services.AddHandlersFromInternal<ICommandHandler>(typeof(TMarker));
/// <summary>
/// Registers all handlers from the assembly of the specified marker type with the service container.
/// </summary>
public static IServiceCollection AddCommandHandlersFrom(this IServiceCollection services, Type markerType) => services.AddHandlersFromInternal<ICommandHandler>(markerType);
/// <summary>
/// Registers all handlers from the specified assembly with the service container.
/// </summary>
public static IServiceCollection AddCommandHandlersFrom(this IServiceCollection services, Assembly assembly) => services.AddHandlersFromInternal<ICommandHandler>(assembly);
private static IServiceCollection AddHandlersFromInternal<TService, TMarker>(this IServiceCollection services) => services.AddHandlersFromInternal<TService>(typeof(TMarker));
private static IServiceCollection AddHandlersFromInternal<TService>(this IServiceCollection services, Type assemblyMarkerType) => services.AddHandlersFromInternal<TService>(assemblyMarkerType.Assembly);
private static IServiceCollection AddHandlersFromInternal<TService>(this IServiceCollection services, Assembly assembly)
{
var serviceType = typeof(TService);
var types = assembly.DefinedTypes.Where(x => serviceType.IsAssignableFrom(x));
foreach (var type in types)
services.AddScoped(serviceType, type);
return services;
}
private static Channel<T> CreateChannel<T>() => Channel.CreateUnbounded<T>(new UnboundedChannelOptions());
private static ChannelReader<T> CreateChannelReader<T>(IServiceProvider serviceProvider) => serviceProvider.GetRequiredService<Channel<T>>().Reader;
private static ChannelWriter<T> CreateChannelWriter<T>(IServiceProvider serviceProvider) => serviceProvider.GetRequiredService<Channel<T>>().Writer;
}
@@ -0,0 +1,67 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using Elsa.Mediator.Contracts;
using Elsa.Mediator.Middleware.Command;
using Elsa.Mediator.Middleware.Notification;
using Elsa.Mediator.Models;
namespace Elsa.Mediator.Extensions;
/// <summary>
/// Contains helper methods for invoking notification handlers.
/// </summary>
public static class HandlerExtensions
{
/// <summary>
/// Gets the handle method for a notification handler of the given notification type.
/// </summary>
/// <param name="notificationType">The notification type.</param>
/// <returns>The handle method.</returns>
public static MethodInfo GetNotificationHandlerMethod(this Type notificationType)
{
var handlerType = typeof(INotificationHandler<>).MakeGenericType(notificationType);
return handlerType.GetMethod("HandleAsync")!;
}
/// <summary>
/// Gets the handle method for a command handler of the given command type.
/// </summary>
/// <param name="commandType">The command type.</param>
/// <returns>The handle method.</returns>
public static MethodInfo GetCommandHandlerMethod([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] this Type commandType)
{
var commandTypeInterfaces = commandType.GetInterfaces();
var commandTypeInterface = commandTypeInterfaces.FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(ICommand<>));
var resultType = commandTypeInterface?.GetGenericArguments()[0] ?? typeof(Unit);
var handlerType = typeof(ICommandHandler<,>).MakeGenericType(commandType, resultType);
return handlerType.GetMethod("HandleAsync")!;
}
/// <summary>
/// Invokes the given handler for the given notification.
/// </summary>
/// <param name="handler">The handler to invoke.</param>
/// <param name="handleMethod">The handle method.</param>
/// <param name="notificationContext">The notification to handle.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public static Task InvokeAsync(this INotificationHandler handler, MethodBase handleMethod, NotificationContext notificationContext)
{
var notification = notificationContext.Notification;
var cancellationToken = notificationContext.CancellationToken;
return (Task)handleMethod.Invoke(handler, [notification, cancellationToken])!;
}
/// <summary>
/// Invokes the given handler for the given command.
/// </summary>
/// <param name="handler">The handler to invoke.</param>
/// <param name="handleMethod">The handle method.</param>
/// <param name="commandContext">The command to handle.</param>
public static Task<TResult> InvokeAsync<TResult>(this ICommandHandler handler, MethodBase handleMethod, CommandContext commandContext)
{
var command = commandContext.Command;
var cancellationToken = commandContext.CancellationToken;
var task = (Task<TResult>)handleMethod.Invoke(handler, [command, cancellationToken])!;
return task;
}
}
@@ -0,0 +1,55 @@
using Elsa.Mediator.Contracts;
using Elsa.Mediator.Options;
// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;
/// <summary>
/// Provides a set of extension methods for <see cref="IMediator"/>.
/// </summary>
public static class MediatorExtensions
{
/// <summary>
/// Sends a notification using the default notification strategy configured via <see cref="MediatorOptions"/>.
/// </summary>
/// <param name="mediator">The mediator.</param>
/// <param name="notification">The notification to send.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public static async Task SendAsync(this IMediator mediator, INotification notification, CancellationToken cancellationToken = default)
{
await mediator.SendAsync(notification, default, cancellationToken);
}
/// <summary>
/// Sends a notification using the default notification strategy configured via <see cref="MediatorOptions"/>.
/// </summary>
/// <param name="publisher">The publisher.</param>
/// <param name="notification">The notification to send.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public static async Task SendAsync(this INotificationSender publisher, INotification notification, CancellationToken cancellationToken = default)
{
await publisher.SendAsync(notification, default, cancellationToken);
}
/// <summary>
/// Sends a command using the default command strategy configured via <see cref="MediatorOptions"/>.
/// </summary>
/// <param name="mediator">The mediator.</param>
/// <param name="command">The command to send.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public static async Task SendAsync(this IMediator mediator, ICommand command, CancellationToken cancellationToken = default)
{
await mediator.SendAsync(command, default, cancellationToken);
}
/// <summary>
/// Sends a command using the default command strategy configured via <see cref="MediatorOptions"/>.
/// </summary>
/// <param name="commandSender">The command sender.</param>
/// <param name="command">The command to send.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public static async Task SendAsync(this ICommandSender commandSender, ICommand command, CancellationToken cancellationToken = default)
{
await commandSender.SendAsync(command, default, cancellationToken);
}
}
+3
View File
@@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<ConfigureAwait />
</Weavers>
@@ -0,0 +1,91 @@
using System.Threading.Channels;
using Elsa.Mediator.Contracts;
using Elsa.Mediator.Middleware.Command;
using Elsa.Mediator.Options;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Elsa.Mediator.HostedServices;
/// <summary>
/// Continuously reads from a channel to which commands can be sent, executing each received command.
/// </summary>
public class BackgroundCommandSenderHostedService : BackgroundService
{
private readonly int _workerCount;
private readonly ICommandsChannel _commandsChannel;
private readonly IServiceScopeFactory _scopeFactory;
private readonly List<Channel<CommandContext>> _outputs;
private readonly ILogger _logger;
/// <inheritdoc />
public BackgroundCommandSenderHostedService(IOptions<MediatorOptions> options, ICommandsChannel commandsChannel, IServiceScopeFactory scopeFactory, ILogger<BackgroundCommandSenderHostedService> logger)
{
_workerCount = options.Value.CommandWorkerCount;
_commandsChannel = commandsChannel; // The shared input channel for all commands
_scopeFactory = scopeFactory;
_logger = logger;
_outputs = new(_workerCount); // Prepare a list to hold worker-specific channels
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
var index = 0; // Used for round-robin distribution of work
// Set up worker channels and start background tasks for each worker
for (var i = 0; i < _workerCount; i++)
{
var output = Channel.CreateUnbounded<CommandContext>();
_outputs.Add(output);
// Start a background task that processes commands from this worker's channel
_ = ReadOutputAsync(output, cancellationToken);
}
// Main dispatcher loop: read from the input channel and distribute to worker channels
await foreach (var commandContext in _commandsChannel.Reader.ReadAllAsync(cancellationToken))
{
var output = _outputs[index];
await output.Writer.WriteAsync(commandContext, cancellationToken);
// Round-robin distribution - move to next worker
index = (index + 1) % _workerCount;
}
// If the input channel is completed, complete all worker channels
foreach (var output in _outputs)
output.Writer.Complete();
}
private async Task ReadOutputAsync(Channel<CommandContext> output, CancellationToken cancellationToken)
{
// Worker task: process commands from the worker's channel
await foreach (var commandContext in output.Reader.ReadAllAsync(cancellationToken))
{
try
{
// Create a fresh scope for each command to ensure proper service lifetime
using var scope = _scopeFactory.CreateScope();
var commandSender = scope.ServiceProvider.GetRequiredService<ICommandSender>();
// Link the service cancellation token with the command's token to ensure proper cancellation
using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken,
commandContext.CancellationToken);
// Process the command using the command sender service with the linked token
await commandSender.SendAsync(
commandContext.Command,
CommandStrategy.Default,
commandContext.Headers,
linkedTokenSource.Token);
}
catch (Exception e)
{
// Log errors but continue processing other commands
_logger.LogError(e, "An unhandled exception occurred while processing the queue");
}
}
}
}
@@ -0,0 +1,97 @@
using System.Threading.Channels;
using Elsa.Mediator.Contracts;
using Elsa.Mediator.Middleware.Notification;
using Elsa.Mediator.Options;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Elsa.Mediator.HostedServices;
/// <summary>
/// Continuously reads from a channel to which notifications can be sent, publishing each received notification.
/// </summary>
public class BackgroundEventPublisherHostedService : BackgroundService
{
private readonly int _workerCount;
private readonly INotificationsChannel _notificationsChannel;
private readonly IServiceScopeFactory _scopeFactory;
private readonly List<Channel<NotificationContext>> _outputs;
private readonly ILogger _logger;
/// <inheritdoc />
public BackgroundEventPublisherHostedService(IOptions<MediatorOptions> options, INotificationsChannel notificationsChannel, IServiceScopeFactory scopeFactory, ILogger<BackgroundEventPublisherHostedService> logger)
{
_workerCount = options.Value.NotificationWorkerCount;
_notificationsChannel = notificationsChannel;
_scopeFactory = scopeFactory;
_logger = logger;
_outputs = new(_workerCount);
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
// Index to round-robin distribute notifications across worker channels
var index = 0;
using var scope = _scopeFactory.CreateScope();
var notificationSender = scope.ServiceProvider.GetRequiredService<INotificationSender>();
// Create multiple output channels and start worker tasks for parallel processing
for (var i = 0; i < _workerCount; i++)
{
var output = Channel.CreateUnbounded<NotificationContext>();
_outputs.Add(output);
// Start a background task to process notifications from this output channel
_ = ReadOutputAsync(output, notificationSender, cancellationToken);
}
var channelReader = _notificationsChannel.Reader;
// Continuously read notifications from the input channel and distribute them to worker channels
// using round-robin distribution for load balancing
await foreach (var notification in channelReader.ReadAllAsync(cancellationToken))
{
var output = _outputs[index];
await output.Writer.WriteAsync(notification, cancellationToken);
// Move to the next worker in a circular fashion
index = (index + 1) % _workerCount;
}
// When the input channel is completed, complete all output channels
foreach (var output in _outputs)
{
output.Writer.Complete();
}
}
/// <summary>
/// Processes notifications from an output channel asynchronously.
/// </summary>
/// <param name="output">The channel to read notifications from</param>
/// <param name="notificationSender">The service used to send notifications</param>
/// <param name="cancellationToken">Cancellation token from the hosted service</param>
private async Task ReadOutputAsync(Channel<NotificationContext> output, INotificationSender notificationSender, CancellationToken cancellationToken)
{
await foreach (var notificationContext in output.Reader.ReadAllAsync(cancellationToken))
{
try
{
var notification = notificationContext.Notification;
// Link the cancellation tokens so that cancellation can happen from either source
using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, notificationContext.CancellationToken);
await notificationSender.SendAsync(notification, NotificationStrategy.Sequential, linkedTokenSource.Token);
}
catch (OperationCanceledException e)
{
_logger.LogDebug(e, "An operation was cancelled while processing the queue");
}
catch (Exception e)
{
_logger.LogError(e, "An unhandled exception occured while processing the queue");
}
}
}
}
@@ -0,0 +1,71 @@
using Elsa.Mediator.Contracts;
using Elsa.Mediator.Options;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Elsa.Mediator.HostedServices;
/// <summary>
/// A hosted service that runs jobs.
/// </summary>
public class JobRunnerHostedService : BackgroundService
{
private readonly int _workerCount;
private readonly IJobsChannel _jobsChannel;
private readonly ILogger<JobRunnerHostedService> _logger;
/// <inheritdoc />
public JobRunnerHostedService(IOptions<MediatorOptions> options, IJobsChannel jobsChannel, ILogger<JobRunnerHostedService> logger)
{
_workerCount = options.Value.JobWorkerCount;
_jobsChannel = jobsChannel;
_logger = logger;
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Create an array of worker tasks to process jobs in parallel
var workers = new Task[_workerCount];
// Start multiple workers (tasks) to process jobs concurrently
for (var i = 0; i < _workerCount; i++)
workers[i] = ProcessJobsAsync(stoppingToken);
// Wait for all worker tasks to complete (typically when the application is shutting down)
await Task.WhenAll(workers);
}
/// <summary>
/// Continuously processes jobs from the job channel until cancellation is requested.
/// </summary>
/// <param name="stoppingToken">Cancellation token from the hosted service</param>
private async Task ProcessJobsAsync(CancellationToken stoppingToken)
{
// Process all jobs from the channel until it's completed or cancellation is requested
await foreach (var jobItem in _jobsChannel.Reader.ReadAllAsync(stoppingToken))
{
try
{
// Link the cancellation tokens so that cancellation can happen from either source
using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, jobItem.CancellationTokenSource.Token);
await jobItem.Action(linkedTokenSource.Token);
_logger.LogInformation("Worker {CurrentTaskId} processed job {JobId}", Task.CurrentId, jobItem.JobId);
}
catch (OperationCanceledException)
{
_logger.LogInformation("Job {JobId} was canceled", jobItem.JobId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Job {JobId} failed", jobItem.JobId);
}
finally
{
// Notify that the job has completed (whether successfully or not)
jobItem.OnJobCompleted(jobItem.JobId);
}
}
}
}
@@ -0,0 +1,119 @@
using System.Threading.Channels;
using Elsa.Mediator.Contracts;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Elsa.Mediator.HostedServices;
/// <summary>
/// Continuously reads from a channel to which commands can be sent, executing each received command.
/// </summary>
public class MessageProcessorHostedService<T> : BackgroundService where T : notnull
{
private readonly int _workerCount;
private readonly Channel<T> _channel;
private readonly IEnumerable<IConsumer<T>> _consumers;
private readonly ILogger _logger;
private readonly List<MessageWorker<T>> _workers;
/// <inheritdoc />
// ReSharper disable once ContextualLoggerProblem
public MessageProcessorHostedService(int workerCount, Channel<T> channel, IEnumerable<IConsumer<T>> consumers, ILogger<MessageProcessorHostedService<T>> logger)
{
_workerCount = workerCount;
_channel = channel;
_consumers = consumers;
_logger = logger;
_workers = new List<MessageWorker<T>>(workerCount);
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
var index = 0;
for (var i = 0; i < _workerCount; i++)
{
var worker = new MessageWorker<T>(Channel.CreateUnbounded<T>(), _consumers, _logger);
_workers.Add(worker);
_ = worker.StartAsync(cancellationToken);
}
await foreach (var message in _channel.Reader.ReadAllAsync(cancellationToken))
{
var worker = _workers[index];
await worker.DeliverMessageAsync(message, cancellationToken);
index = (index + 1) % _workerCount;
}
foreach (var worker in _workers)
worker.Complete();
_workers.Clear();
}
}
/// <summary>
/// Represents a worker that continuously reads from a channel and processes each received message.
/// </summary>
/// <typeparam name="T">The type of message to process.</typeparam>
public class MessageWorker<T> where T : notnull
{
private readonly Channel<T> _channel;
private readonly IEnumerable<IConsumer<T>> _consumers;
private readonly ILogger _logger;
/// <summary>
/// Initializes a new instance of the <see cref="MessageWorker{T}"/> class.
/// </summary>
/// <param name="channel">The channel to read from.</param>
/// <param name="consumers">The consumers that will process each received message.</param>
/// <param name="logger">The logger.</param>
public MessageWorker(Channel<T> channel, IEnumerable<IConsumer<T>> consumers, ILogger logger)
{
_channel = channel;
_consumers = consumers;
_logger = logger;
}
/// <summary>
/// Continuously reads from the channel and processes each received message.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
public async Task StartAsync(CancellationToken cancellationToken)
{
await foreach (var message in _channel.Reader.ReadAllAsync(cancellationToken))
foreach (var consumer in _consumers)
await InvokeConsumerAsync(consumer, message, cancellationToken);
}
/// <summary>
/// Delivers a message to the channel.
/// </summary>
/// <param name="message">The message to deliver.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public async Task DeliverMessageAsync(T message, CancellationToken cancellationToken)
{
await _channel.Writer.WriteAsync(message, cancellationToken);
}
/// <summary>
/// Completes the channel.
/// </summary>
public void Complete()
{
_channel.Writer.Complete();
}
private async Task InvokeConsumerAsync(IConsumer<T> consumer, T message, CancellationToken cancellationToken)
{
try
{
await consumer.ConsumeAsync(message, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "An error occurred while invoking consumer {ConsumerType} for message {MessageType}", consumer.GetType(), message.GetType());
}
}
}
@@ -0,0 +1,57 @@
using Elsa.Mediator.Contracts;
namespace Elsa.Mediator.Middleware.Command;
/// <summary>
/// Provides context for a command.
/// </summary>
public class CommandContext
{
/// <summary>
/// Initializes a new instance of the <see cref="CommandContext"/> class.
/// </summary>
public CommandContext(ICommand command, ICommandStrategy commandStrategy, Type resultType, IDictionary<object, object> headers, IServiceProvider serviceProvider, CancellationToken cancellationToken)
{
Command = command;
CommandStrategy = commandStrategy;
ResultType = resultType;
Headers = headers;
ServiceProvider = serviceProvider;
CancellationToken = cancellationToken;
}
/// <summary>
/// Gets the command.
/// </summary>
public ICommand Command { get; init; }
/// <summary>
/// Gets the command strategy.
/// </summary>
public ICommandStrategy CommandStrategy { get; }
/// <summary>
/// Gets or sets the headers associated with the command context.
/// </summary>
public IDictionary<object, object> Headers { get; }
/// <summary>
/// Gets the service provider used to resolve services for the command context.
/// </summary>
public IServiceProvider ServiceProvider { get; }
/// <summary>
/// Gets the result type.
/// </summary>
public Type ResultType { get; init; }
/// <summary>
/// Gets the cancellation token.
/// </summary>
public CancellationToken CancellationToken { get; init; }
/// <summary>
/// Gets or sets the result.
/// </summary>
public object? Result { get; set; }
}
@@ -0,0 +1,6 @@
namespace Elsa.Mediator.Middleware.Command;
/// <summary>
/// Represents a command middleware delegate.
/// </summary>
public delegate ValueTask CommandMiddlewareDelegate(CommandContext context);
@@ -0,0 +1,33 @@
using Elsa.Mediator.Middleware.Command.Contracts;
namespace Elsa.Mediator.Middleware.Command;
/// <inheritdoc />
public class CommandPipeline : ICommandPipeline
{
private readonly CommandPipelineBuilder _builder;
private CommandMiddlewareDelegate _pipeline = null!;
/// <summary>
/// Constructor.
/// </summary>
public CommandPipeline(IServiceProvider serviceProvider)
{
_builder = new(serviceProvider);
Setup(x => x.UseCommandInvoker().UseCommandLogging());
}
/// <inheritdoc />
public CommandMiddlewareDelegate Pipeline => _pipeline;
/// <inheritdoc />
public CommandMiddlewareDelegate Setup(Action<ICommandPipelineBuilder>? setup = null)
{
setup?.Invoke(_builder);
_pipeline = _builder.Build();
return _pipeline;
}
/// <inheritdoc />
public async Task InvokeAsync(CommandContext context) => await Pipeline(context);
}
@@ -0,0 +1,77 @@
using Elsa.Mediator.Middleware.Command.Contracts;
namespace Elsa.Mediator.Middleware.Command;
/// <inheritdoc />
public class CommandPipelineBuilder : ICommandPipelineBuilder
{
private const string ServicesKey = "mediator.Services";
private readonly List<Func<CommandMiddlewareDelegate, CommandMiddlewareDelegate>> _components = new();
/// <summary>
/// Initializes a new instance of the <see cref="CommandPipelineBuilder"/> class.
/// </summary>
public CommandPipelineBuilder(IServiceProvider serviceProvider)
{
ApplicationServices = serviceProvider;
}
/// <inheritdoc />
public IDictionary<string, object?> Properties { get; } = new Dictionary<string, object?>();
/// <inheritdoc />
public IServiceProvider ApplicationServices
{
get => GetProperty<IServiceProvider>(ServicesKey)!;
set => SetProperty(ServicesKey, value);
}
/// <inheritdoc />
public ICommandPipelineBuilder Use(Func<CommandMiddlewareDelegate, CommandMiddlewareDelegate> middleware)
{
_components.Add(middleware);
return this;
}
/// <inheritdoc />
public ICommandPipelineBuilder Use(int index, Func<CommandMiddlewareDelegate, CommandMiddlewareDelegate> middleware)
{
_components.Insert(index, middleware);
return this;
}
/// <inheritdoc />
public ICommandPipelineBuilder Remove(Func<CommandMiddlewareDelegate, CommandMiddlewareDelegate> middleware)
{
_components.Remove(middleware);
return this;
}
/// <inheritdoc />
public ICommandPipelineBuilder RemoveAt(int index)
{
_components.RemoveAt(index);
return this;
}
/// <inheritdoc />
public ICommandPipelineBuilder Clear()
{
_components.Clear();
return this;
}
/// <inheritdoc />
public CommandMiddlewareDelegate Build()
{
CommandMiddlewareDelegate pipeline = _ => new();
for (var i = _components.Count - 1; i >= 0; i--)
pipeline = _components[i](pipeline);
return pipeline;
}
private T? GetProperty<T>(string key) => Properties.TryGetValue(key, out var value) ? (T?)value : default;
private void SetProperty<T>(string key, T value) => Properties[key] = value;
}
@@ -0,0 +1,24 @@
using Elsa.Mediator.Middleware.Command.Contracts;
using Elsa.Mediator.Middleware.Command.Components;
namespace Elsa.Mediator.Middleware.Command;
/// <summary>
/// Provides extension methods for <see cref="ICommandPipelineBuilder"/>.
/// </summary>
public static class CommandPipelineBuilderExtensions
{
/// <summary>
/// Adds the <see cref="CommandHandlerInvokerMiddleware"/> to the pipeline.
/// </summary>
/// <param name="builder">The pipeline builder.</param>
/// <returns>The pipeline builder.</returns>
public static ICommandPipelineBuilder UseCommandInvoker(this ICommandPipelineBuilder builder) => builder.UseMiddleware<CommandHandlerInvokerMiddleware>();
/// <summary>
/// Adds the <see cref="CommandLoggingMiddleware"/> to the pipeline.
/// </summary>
/// <param name="builder">The pipeline builder.</param>
/// <returns>The pipeline builder.</returns>
public static ICommandPipelineBuilder UseCommandLogging(this ICommandPipelineBuilder builder) => builder.UseMiddleware<CommandLoggingMiddleware>();
}
@@ -0,0 +1,50 @@
using Elsa.Mediator.Contexts;
using Elsa.Mediator.Contracts;
using Elsa.Mediator.Middleware.Command.Contracts;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Mediator.Middleware.Command.Components;
/// <summary>
/// A command middleware that invokes the command.
/// </summary>
[UsedImplicitly]
public class CommandHandlerInvokerMiddleware(CommandMiddlewareDelegate next) : ICommandMiddleware
{
/// <inheritdoc />
public async ValueTask InvokeAsync(CommandContext context)
{
// Find all handlers for the specified command.
var command = context.Command;
var commandType = command.GetType();
var resultType = context.ResultType;
var handlerType = typeof(ICommandHandler<,>).MakeGenericType(commandType, resultType);
var serviceProvider = context.ServiceProvider;
var commandHandlers = serviceProvider.GetServices<ICommandHandler>();
var handlers = commandHandlers.DistinctBy(x => x.GetType()).Where(x => handlerType.IsInstanceOfType(x)).ToArray();
if (handlers.Length == 0)
throw new InvalidOperationException($"There is no handler to handle the {commandType.FullName} command");
if (handlers.Length > 1)
throw new InvalidOperationException($"Multiple handlers were found to handle the {commandType.FullName} command");
var handler = handlers.First();
var strategyContext = new CommandStrategyContext(context, handler, serviceProvider, context.CancellationToken);
var strategy = context.CommandStrategy;
var executeMethod = strategy.GetType().GetMethod(nameof(ICommandStrategy.ExecuteAsync))!;
var executeMethodWithReturnType = executeMethod.MakeGenericMethod(resultType);
// Execute command.
var task = executeMethodWithReturnType.Invoke(strategy, [strategyContext]);
// Get the result of the task.
var taskWithReturnType = typeof(Task<>).MakeGenericType(resultType);
var resultProperty = taskWithReturnType.GetProperty(nameof(Task<object>.Result))!;
context.Result = resultProperty.GetValue(task);
// Invoke next middleware.
await next(context);
}
}
@@ -0,0 +1,27 @@
using Elsa.Mediator.Middleware.Command.Contracts;
using Elsa.Mediator.Models;
using JetBrains.Annotations;
using Microsoft.Extensions.Logging;
namespace Elsa.Mediator.Middleware.Command.Components;
/// <summary>
/// A command middleware that logs the command being invoked.
/// </summary>
[UsedImplicitly]
public class CommandLoggingMiddleware(CommandMiddlewareDelegate next, ILogger<CommandLoggingMiddleware> logger) : ICommandMiddleware
{
/// <inheritdoc />
public async ValueTask InvokeAsync(CommandContext context)
{
var commandType = context.Command.GetType();
logger.LogInformation("Invoking {CommandName}", commandType.Name);
await next(context);
if (context.Result is null or Unit)
logger.LogInformation("{CommandName} completed with no result", commandType.Name);
else
logger.LogInformation("{CommandName} completed with result {CommandResult}", commandType.Name, context.Result);
}
}
@@ -0,0 +1,13 @@
namespace Elsa.Mediator.Middleware.Command.Contracts;
/// <summary>
/// Represents a command middleware.
/// </summary>
public interface ICommandMiddleware
{
/// <summary>
/// Invokes the middleware.
/// </summary>
/// <param name="context">The command context.</param>
ValueTask InvokeAsync(CommandContext context);
}
@@ -0,0 +1,25 @@
namespace Elsa.Mediator.Middleware.Command.Contracts;
/// <summary>
/// Represents a pipeline for processing commands. The pipeline is responsible for orchestrating the execution of registered middleware in sequence.
/// </summary>
public interface ICommandPipeline
{
/// <summary>
/// Configures the pipeline.
/// </summary>
/// <param name="setup">A delegate that configures the pipeline.</param>
/// <returns>The pipeline.</returns>
CommandMiddlewareDelegate Setup(Action<ICommandPipelineBuilder> setup);
/// <summary>
/// Gets the pipeline.
/// </summary>
CommandMiddlewareDelegate Pipeline { get; }
/// <summary>
/// Invokes the pipeline.
/// </summary>
/// <param name="context">The command context.</param>
Task InvokeAsync(CommandContext context);
}
@@ -0,0 +1,48 @@
namespace Elsa.Mediator.Middleware.Command.Contracts;
/// <summary>
/// Represents a command pipeline builder.
/// </summary>
public interface ICommandPipelineBuilder
{
/// <summary>
/// Gets a property bag that can be used to share data between middleware components.
/// </summary>
public IDictionary<string, object?> Properties { get; }
/// <summary>
/// Gets the service provider.
/// </summary>
IServiceProvider ApplicationServices { get; }
/// <summary>
/// Appends a middleware component to the pipeline.
/// </summary>
ICommandPipelineBuilder Use(Func<CommandMiddlewareDelegate, CommandMiddlewareDelegate> middleware);
/// <summary>
/// Adds a middleware component at the specified index.
/// </summary>
ICommandPipelineBuilder Use(int index, Func<CommandMiddlewareDelegate, CommandMiddlewareDelegate> middleware);
/// <summary>
/// Removes a middleware component from the pipeline.
/// </summary>
ICommandPipelineBuilder Remove(Func<CommandMiddlewareDelegate, CommandMiddlewareDelegate> middleware);
/// <summary>
/// Removes a middleware component at the specified index from the pipeline.
/// </summary>
ICommandPipelineBuilder RemoveAt(int index);
/// <summary>
/// Clears the pipeline.
/// </summary>
ICommandPipelineBuilder Clear();
/// <summary>
/// Builds the pipeline.
/// </summary>
/// <returns>The pipeline.</returns>
public CommandMiddlewareDelegate Build();
}
@@ -0,0 +1,45 @@
using Elsa.Mediator.Middleware.Command.Contracts;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Mediator.Middleware.Command;
/// <summary>
/// Provides extension methods for <see cref="ICommandPipelineBuilder"/>.
/// </summary>
public static class MiddlewareExtensions
{
/// <summary>
/// Adds middleware to the pipeline.
/// </summary>
public static ICommandPipelineBuilder UseMiddleware<TMiddleware>(this ICommandPipelineBuilder builder, params object[] args) where TMiddleware : ICommandMiddleware
{
return builder.Use(next => BuildMiddlewareDelegate<TMiddleware>(builder, next, args));
}
/// <summary>
/// Inserts middleware at a specific index in the pipeline.
/// </summary>
public static ICommandPipelineBuilder UseMiddleware<TMiddleware>(this ICommandPipelineBuilder builder, int index, params object[] args) where TMiddleware : ICommandMiddleware
{
return builder.Use(index, next => BuildMiddlewareDelegate<TMiddleware>(builder, next, args));
}
/// <summary>
/// Builds a delegate for the middleware type.
/// </summary>
private static CommandMiddlewareDelegate BuildMiddlewareDelegate<TMiddleware>(
ICommandPipelineBuilder builder,
CommandMiddlewareDelegate next,
object[] args
) where TMiddleware : ICommandMiddleware
{
var middleware = typeof(TMiddleware);
var invokeMethod = MiddlewareHelpers.GetInvokeMethod(middleware);
var ctorParams = new[]
{
next
}.Concat(args).Select(x => x!).ToArray();
var instance = ActivatorUtilities.CreateInstance(builder.ApplicationServices, middleware, ctorParams);
return (CommandMiddlewareDelegate)invokeMethod.CreateDelegate(typeof(CommandMiddlewareDelegate), instance);
}
}
@@ -0,0 +1,38 @@
using System.Reflection;
namespace Elsa.Mediator.Middleware;
/// <summary>
/// Provides a set of static methods for working with middleware.
/// </summary>
public static class MiddlewareHelpers
{
/// <summary>
/// Gets the Invoke or InvokeAsync method from the middleware type.
/// </summary>
/// <param name="middleware">The middleware type.</param>
/// <returns>The Invoke or InvokeAsync method.</returns>
/// <exception cref="InvalidOperationException">Thrown when the Invoke or InvokeAsync method cannot be found or the return type is not Task or ValueTask.</exception>
public static MethodInfo GetInvokeMethod(Type middleware)
{
const string invokeMethodName = "Invoke";
const string invokeAsyncMethodName = "InvokeAsync";
var methods = middleware.GetMethods(BindingFlags.Instance | BindingFlags.Public);
var invokeMethods = methods.Where(m => string.Equals(m.Name, invokeMethodName, StringComparison.Ordinal) || string.Equals(m.Name, invokeAsyncMethodName, StringComparison.Ordinal)).ToArray();
switch (invokeMethods.Length)
{
case > 1:
throw new InvalidOperationException("Multiple Invoke methods were found. Use either Invoke or InvokeAsync.");
case 0:
throw new InvalidOperationException("No Invoke methods were found. Use either Invoke or InvokeAsync");
}
var methodInfo = invokeMethods[0];
if (!typeof(Task).IsAssignableFrom(methodInfo.ReturnType) && !typeof(ValueTask).IsAssignableFrom(methodInfo.ReturnType))
throw new InvalidOperationException($"The {methodInfo.Name} method must return Task or ValueTask");
return methodInfo;
}
}
@@ -0,0 +1,34 @@
using Elsa.Mediator.Contexts;
using Elsa.Mediator.Contracts;
using Elsa.Mediator.Middleware.Notification.Contracts;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Elsa.Mediator.Middleware.Notification.Components;
/// <inheritdoc />
[UsedImplicitly]
public class NotificationHandlerInvokerMiddleware(
NotificationMiddlewareDelegate next,
ILogger<NotificationHandlerInvokerMiddleware> logger)
: INotificationMiddleware
{
/// <inheritdoc />
public async ValueTask InvokeAsync(NotificationContext context)
{
// Find all handlers for the specified notification.
var notification = context.Notification;
var notificationType = notification.GetType();
var handlerType = typeof(INotificationHandler<>).MakeGenericType(notificationType);
var serviceProvider = context.ServiceProvider;
var notificationHandlers = serviceProvider.GetServices<INotificationHandler>();
var handlers = notificationHandlers.Where(x => handlerType.IsInstanceOfType(x)).DistinctBy(x => x.GetType()).ToArray();
var strategyContext = new NotificationStrategyContext(context, handlers, logger, serviceProvider, context.CancellationToken);
await context.NotificationStrategy.PublishAsync(strategyContext);
// Invoke next middleware.
await next(context);
}
}

Some files were not shown because too many files have changed in this diff Show More