Skip to content

Commit 33066cc

Browse files
authored
Merge pull request #2077 from thomhurst/feature/plugin-system
feat: Add plugin system for extensibility
2 parents 112c7f9 + 2ebc843 commit 33066cc

8 files changed

Lines changed: 574 additions & 1 deletion

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
namespace ModularPipelines.Exceptions;
2+
3+
/// <summary>
4+
/// Exception thrown when a plugin fails to initialize during pipeline setup.
5+
/// </summary>
6+
/// <remarks>
7+
/// <para>
8+
/// This exception is thrown when a plugin's <c>ConfigureServices</c> or <c>ConfigurePipeline</c>
9+
/// method throws an exception. Plugin initialization failures are fatal and stop the pipeline
10+
/// from executing.
11+
/// </para>
12+
/// <para><b>Example:</b></para>
13+
/// <code>
14+
/// try
15+
/// {
16+
/// await pipelineBuilder.ExecutePipelineAsync();
17+
/// }
18+
/// catch (PluginInitializationException ex)
19+
/// {
20+
/// Console.WriteLine($"Plugin '{ex.PluginName}' failed: {ex.Message}");
21+
/// }
22+
/// </code>
23+
/// </remarks>
24+
public class PluginInitializationException : PipelineException
25+
{
26+
/// <summary>
27+
/// Gets the name of the plugin that failed to initialize.
28+
/// </summary>
29+
public string PluginName { get; }
30+
31+
/// <summary>
32+
/// Initializes a new instance of the <see cref="PluginInitializationException"/> class.
33+
/// </summary>
34+
/// <param name="message">The message that describes the error.</param>
35+
/// <param name="pluginName">The name of the plugin that failed.</param>
36+
public PluginInitializationException(string message, string pluginName)
37+
: base(message)
38+
{
39+
PluginName = pluginName;
40+
}
41+
42+
/// <summary>
43+
/// Initializes a new instance of the <see cref="PluginInitializationException"/> class.
44+
/// </summary>
45+
/// <param name="message">The message that describes the error.</param>
46+
/// <param name="pluginName">The name of the plugin that failed.</param>
47+
/// <param name="innerException">The exception that is the cause of the current exception.</param>
48+
public PluginInitializationException(string message, string pluginName, Exception innerException)
49+
: base(message, innerException)
50+
{
51+
PluginName = pluginName;
52+
}
53+
}

src/ModularPipelines/PipelineBuilder.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using ModularPipelines.Engine;
99
using ModularPipelines.Exceptions;
1010
using ModularPipelines.Options;
11+
using ModularPipelines.Plugins;
1112
using ModularPipelines.Validation;
1213
using Vertical.SpectreLogger.Options;
1314

@@ -243,17 +244,23 @@ private async Task<IPipeline> BuildPipelineAsync()
243244
{
244245
LoadModularPipelineAssembliesIfNotLoadedYet();
245246

247+
// Apply plugin configuration to the builder (modules, hooks, options)
248+
PluginIntegration.ApplyPluginConfiguration(this);
249+
246250
// Configure the host with our collected configuration
247251
_hostBuilder.ConfigureAppConfiguration((_, config) =>
248252
{
249253
config.AddConfiguration(_configuration);
250254
});
251255

252-
// Configure services: first the core services, then user services
256+
// Configure services: first the core services, then plugins, then user services
253257
_hostBuilder.ConfigureServices((_, services) =>
254258
{
255259
DependencyInjectionSetup.Initialize(services);
256260

261+
// Apply plugin services after core services
262+
PluginIntegration.ApplyPluginServices(services);
263+
257264
// Configure pipeline options
258265
services.Configure<PipelineOptions>(opts =>
259266
{
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
3+
namespace ModularPipelines.Plugins;
4+
5+
/// <summary>
6+
/// Defines a plugin that can extend ModularPipelines functionality.
7+
/// Plugins self-register via [ModuleInitializer] methods calling <see cref="PluginRegistry.Register"/>.
8+
/// </summary>
9+
public interface IModularPipelinesPlugin
10+
{
11+
/// <summary>
12+
/// Gets the unique name identifying this plugin.
13+
/// </summary>
14+
string Name { get; }
15+
16+
/// <summary>
17+
/// Gets the execution priority. Lower values run first. Default is 0.
18+
/// </summary>
19+
int Priority => 0;
20+
21+
/// <summary>
22+
/// Configure services in the DI container.
23+
/// Called during host setup, before the pipeline is built.
24+
/// </summary>
25+
/// <param name="services">The service collection to configure.</param>
26+
void ConfigureServices(IServiceCollection services);
27+
28+
/// <summary>
29+
/// Configure the pipeline builder.
30+
/// Register hooks, modules, and other pipeline components.
31+
/// Called after services are configured, before execution.
32+
/// </summary>
33+
/// <param name="pipelineBuilder">The pipeline builder to configure.</param>
34+
void ConfigurePipeline(PipelineBuilder pipelineBuilder);
35+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using ModularPipelines.Exceptions;
3+
4+
namespace ModularPipelines.Plugins;
5+
6+
/// <summary>
7+
/// Handles integration of registered plugins into the pipeline setup process.
8+
/// </summary>
9+
internal static class PluginIntegration
10+
{
11+
/// <summary>
12+
/// Applies all registered plugins' service configuration.
13+
/// Called during DI setup, before the host is built.
14+
/// </summary>
15+
/// <param name="services">The service collection to configure.</param>
16+
/// <exception cref="PluginInitializationException">Thrown if a plugin fails to configure services.</exception>
17+
public static void ApplyPluginServices(IServiceCollection services)
18+
{
19+
foreach (var plugin in PluginRegistry.Plugins)
20+
{
21+
try
22+
{
23+
plugin.ConfigureServices(services);
24+
}
25+
catch (Exception ex)
26+
{
27+
throw new PluginInitializationException(
28+
$"Plugin '{plugin.Name}' failed during ConfigureServices: {ex.Message}",
29+
plugin.Name,
30+
ex);
31+
}
32+
}
33+
}
34+
35+
/// <summary>
36+
/// Applies all registered plugins' pipeline configuration.
37+
/// Called during pipeline building, after services are configured.
38+
/// </summary>
39+
/// <param name="pipelineBuilder">The pipeline builder to configure.</param>
40+
/// <exception cref="PluginInitializationException">Thrown if a plugin fails to configure the pipeline.</exception>
41+
public static void ApplyPluginConfiguration(PipelineBuilder pipelineBuilder)
42+
{
43+
foreach (var plugin in PluginRegistry.Plugins)
44+
{
45+
try
46+
{
47+
plugin.ConfigurePipeline(pipelineBuilder);
48+
}
49+
catch (Exception ex)
50+
{
51+
throw new PluginInitializationException(
52+
$"Plugin '{plugin.Name}' failed during ConfigurePipeline: {ex.Message}",
53+
plugin.Name,
54+
ex);
55+
}
56+
}
57+
}
58+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
namespace ModularPipelines.Plugins;
2+
3+
/// <summary>
4+
/// Static registry for ModularPipelines plugins.
5+
/// Plugins register themselves via [ModuleInitializer] methods during assembly load.
6+
/// </summary>
7+
public static class PluginRegistry
8+
{
9+
private static readonly List<IModularPipelinesPlugin> Registered = [];
10+
11+
/// <summary>
12+
/// Gets all registered plugins, ordered by priority (ascending).
13+
/// </summary>
14+
public static IReadOnlyList<IModularPipelinesPlugin> Plugins =>
15+
Registered.OrderBy(p => p.Priority).ToList();
16+
17+
/// <summary>
18+
/// Registers a plugin. Call this from a [ModuleInitializer] method.
19+
/// </summary>
20+
/// <param name="plugin">The plugin to register.</param>
21+
/// <exception cref="InvalidOperationException">Thrown if a plugin with the same name is already registered.</exception>
22+
public static void Register(IModularPipelinesPlugin plugin)
23+
{
24+
ArgumentNullException.ThrowIfNull(plugin);
25+
26+
if (Registered.Any(p => p.Name == plugin.Name))
27+
{
28+
throw new InvalidOperationException($"Plugin '{plugin.Name}' is already registered.");
29+
}
30+
31+
Registered.Add(plugin);
32+
}
33+
34+
/// <summary>
35+
/// Clears all registered plugins. For testing purposes only.
36+
/// </summary>
37+
internal static void Clear() => Registered.Clear();
38+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
namespace ModularPipelines.Plugins;
2+
3+
/// <summary>
4+
/// Helper class for testing plugins in isolation.
5+
/// </summary>
6+
public static class PluginTestHelper
7+
{
8+
/// <summary>
9+
/// Creates an isolated plugin registry scope for testing.
10+
/// Clears all registered plugins and restores them when disposed.
11+
/// </summary>
12+
/// <returns>A disposable scope that restores the original plugins when disposed.</returns>
13+
/// <example>
14+
/// <code>
15+
/// [Test]
16+
/// public async Task MyPlugin_Should_Register_Services()
17+
/// {
18+
/// using var _ = PluginTestHelper.IsolatedRegistry();
19+
/// PluginRegistry.Register(new MyTestPlugin());
20+
///
21+
/// // Test plugin behavior in isolation...
22+
/// }
23+
/// </code>
24+
/// </example>
25+
public static IDisposable IsolatedRegistry()
26+
{
27+
var snapshot = PluginRegistry.Plugins.ToList();
28+
PluginRegistry.Clear();
29+
return new RegistryRestorer(snapshot);
30+
}
31+
32+
private sealed class RegistryRestorer : IDisposable
33+
{
34+
private readonly List<IModularPipelinesPlugin> _snapshot;
35+
36+
public RegistryRestorer(List<IModularPipelinesPlugin> snapshot)
37+
{
38+
_snapshot = snapshot;
39+
}
40+
41+
public void Dispose()
42+
{
43+
PluginRegistry.Clear();
44+
foreach (var plugin in _snapshot)
45+
{
46+
PluginRegistry.Register(plugin);
47+
}
48+
}
49+
}
50+
}

0 commit comments

Comments
 (0)