Skip to content
Open
18 changes: 18 additions & 0 deletions src/Core/Configuration/CPluginConfigurationBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,24 @@ public abstract class CPluginConfigurationBase
/// </remarks>
public abstract IEnumerable<string> GetPluginFiles();

/// <summary>
/// Gets the full path to each plugin file from a configuration source.
/// </summary>
/// <returns>
/// A collection of plugin files that also contains the paths;
/// <para>or</para>
/// Returns an empty enumerable when the plugin files could not be obtained.
/// <para>This method never returns <c>null</c>.</para>
/// </returns>
/// <remarks>
/// Plugin files must be in the <c>plugins</c> directory of the current directory
/// where the host application is running.
/// <para>Each plugin file must have a <c>.dll</c> extension and must be in its own directory.</para>
/// <para>Example:</para>
/// <c>/HostApp/bin/Debug/net7.0/plugins/MyPlugin1/MyPlugin1.dll</c>
/// </remarks>
public abstract IEnumerable<PluginConfig> GetPluginConfigFiles();

/// <summary>
/// Gets the full path of a plugin file.
/// </summary>
Expand Down
38 changes: 33 additions & 5 deletions src/Core/Configuration/CPluginEnvConfiguration.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;

namespace CPlugin.Net;
namespace CPlugin.Net;

/// <summary>
/// Represents a configuration to get the plugin files from an environment variable.
Expand All @@ -11,6 +7,14 @@ namespace CPlugin.Net;
/// The variable must be called <c>PLUGINS</c> and its value must be a string separated by spaces or new lines.
/// <para>Example:</para>
/// <c>PLUGINS=MyPlugin1.dll MyPlugin2.dll</c>
/// <para>if you have plugins with dependencies, you can do this:</para>
/// <c>
/// PLUGINS="
/// MyPlugin1.dll->MyPlugin2.dll,MyPlugin3.dll
/// MyPlugin2.dll
/// MyPlugin3.dll
///"
/// </c>
/// </remarks>
public class CPluginEnvConfiguration : CPluginConfigurationBase
{
Expand All @@ -35,4 +39,28 @@ public override IEnumerable<string> GetPluginFiles()

return pluginFiles;
}

public override IEnumerable<PluginConfig> GetPluginConfigFiles()
{
var retrievedValue = Environment.GetEnvironmentVariable("PLUGINS");
if (retrievedValue is null)
return [];

var pluginFiles = retrievedValue
.Split(s_separator, StringSplitOptions.None)
.Where(pluginFile => !string.IsNullOrWhiteSpace(pluginFile))
.ToList();

return pluginFiles.Select(p =>
{
var str = p.Split("->");
var dependsOn = str.Length == 1 ? [] : str[1].Split(",");

return new PluginConfig
{
Name = GetPluginPath(str[0]),
DependsOn = [.. dependsOn]
};
});
}
}
35 changes: 31 additions & 4 deletions src/Core/Configuration/CPluginJsonConfiguration.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration;

namespace CPlugin.Net;

Expand All @@ -14,6 +11,23 @@ namespace CPlugin.Net;
/// <c>
/// { "Plugins": [ "MyPlugin1.dll", "MyPlugin2.dll" ] }
/// </c>
/// <para>if you have plugins with dependencies, you can do this:</para>
/// <c>
/// {
/// "Plugins": [
/// {
/// "Name": "TestProject.JsonPlugin",
/// "DependsOn": [
/// "TestProject.OldJsonPlugin"
/// ]
/// },
/// {
/// "Name": "TestProject.OldJsonPlugin",
/// "DependsOn": []
/// }
/// ]
/// }
/// </c>
/// </remarks>
public class CPluginJsonConfiguration : CPluginConfigurationBase
{
Expand All @@ -34,6 +48,19 @@ public CPluginJsonConfiguration(IConfiguration configuration)
_configuration = configuration;
}

public override IEnumerable<PluginConfig> GetPluginConfigFiles()
{
var values = _configuration
.GetSection("Plugins")
.Get<PluginConfig[]>();

return values is null ? [] : values.Select(p => new PluginConfig
{
Name = GetPluginPath(p.Name),
DependsOn = p.DependsOn
});
}

/// <inheritdoc />
public override IEnumerable<string> GetPluginFiles()
{
Expand Down
6 changes: 6 additions & 0 deletions src/Core/Configuration/PluginConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace CPlugin.Net;
public class PluginConfig
{
public string Name { get; set; } = string.Empty;
public List<string> DependsOn { get; set; } = [];
}
10 changes: 10 additions & 0 deletions src/Core/Exceptions/PluginNotFoundException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace CPlugin.Net.Exceptions;
/// <summary>
/// Represents an exception that is thrown when a plugin is not found.
/// </summary>
/// <param name="missingPlugin"> The missing plugin. </param>
/// <param name="dependentPlugin"> The dependent plugin. </param>
public class PluginNotFoundException(string missingPlugin, string dependentPlugin)
: Exception($"The plugin '{dependentPlugin}' depends on '{missingPlugin}', but '{missingPlugin}' was not found.")
{
}
47 changes: 46 additions & 1 deletion src/Core/PluginLoader.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using CPlugin.Net.Exceptions;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
Expand Down Expand Up @@ -42,6 +43,50 @@ public static void Load(CPluginConfigurationBase configuration)
}
}

/// <summary>
/// Loads plugins and their dependencies from a specified configuration source.
/// The plugin list can be retrieved from a JSON file, an environment variable (.env), or another configuration source.
/// This method ensures that all required dependencies are resolved before loading a plugin.
/// </summary>
/// <param name="configuration">
/// A configuration source that provides the list of plugin files and their dependencies.
/// </param>
/// <remarks>
/// This method is idempotent, meaning that if it is called multiple times,
/// it will not reload assemblies that have already been loaded.
/// If a plugin depends on another plugin that is missing, a <see cref="PluginNotFoundException"/> is thrown.
/// </remarks>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="configuration"/> is <c>null</c>.
/// </exception>
/// <exception cref="PluginNotFoundException">
/// Thrown when a required plugin dependency is missing.
/// </exception>
public static void LoadPluginsWithDependencies(CPluginConfigurationBase configuration)

{
ArgumentNullException.ThrowIfNull(configuration);
var pluginConfigs = configuration.GetPluginConfigFiles();
foreach (var pluginConfig in pluginConfigs)
{
if (pluginConfig.DependsOn?.Count > 0)
{
foreach (var dependency in pluginConfig.DependsOn)
{
if (!pluginConfigs.Any(pc => pc.Name.Contains(dependency)))
{
string pluginName = Path.GetFileName(pluginConfig.Name);
throw new PluginNotFoundException(dependency, pluginName);
}
}
}

Assembly currentAssembly = FindAssembly(pluginConfig.Name);
if (currentAssembly is null)
LoadAssembly(pluginConfig.Name);
}
}

private static void LoadAssembly(string assemblyFile)
{
var loadContext = new PluginLoadContext(assemblyFile);
Expand Down
32 changes: 32 additions & 0 deletions tests/CPlugin.Net/Core/CPluginEnvConfigurationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,38 @@ public void GetPluginFiles_WhenPluginFilesAreObtainedFromEnvFile_ShouldReturnsFu
actual.Should().BeEquivalentTo(expectedPaths);
}

[Test]
public void GetPluginConfigFiles_WhenPluginFilesAreObtainedFromEnvFile_ShouldReturnsFullPaths()
{
// Arrange
new EnvLoader()
.AllowOverwriteExistingVars()
.EnableFileNotFoundException()
.AddEnvFile("./Resources/testwithdependencies.env")
.Load();
var envConfiguration = new CPluginEnvConfiguration();
var basePath = AppContext.BaseDirectory;
PluginConfig[] expectedPaths =
[
new PluginConfig
{
Name = Path.Combine(basePath, "plugins", "TestProject.OldJsonPlugin", "TestProject.OldJsonPlugin.dll"),
DependsOn = []
},
new PluginConfig
{
Name = Path.Combine(basePath, "plugins", "TestProject.JsonPlugin", "TestProject.JsonPlugin.dll"),
DependsOn = ["TestProject.OldJsonPlugin.dll"]
},
];

// Act
var actual = envConfiguration.GetPluginConfigFiles().ToList();

// Assert
actual.Should().BeEquivalentTo(expectedPaths);
}

[Test]
public void GetPluginFiles_WhenPluginFilesAreNotPresent_ShouldReturnsEmptyEnumerable()
{
Expand Down
28 changes: 28 additions & 0 deletions tests/CPlugin.Net/Core/CPluginJsonConfigurationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,34 @@ public void GetPluginFiles_WhenPluginFileDoesNotHaveDllExtension_ShouldBeAddedBy
actual.Should().BeEquivalentTo(expectedPaths);
}

[Test]
public void GetPluginConfigFiles_WhenPluginFilesArePresent_ShouldReturnsFullPaths()
{
// Arrange
var configurationRoot = new ConfigurationBuilder()
.AddJsonFile("./Resources/settingsWithDependencies.json")
.Build();
var jsonConfiguration = new CPluginJsonConfiguration(configurationRoot);
var basePath = AppContext.BaseDirectory;
PluginConfig[] expectedPaths =
[
new PluginConfig
{
Name = Path.Combine(basePath, "plugins", "TestProject.OldJsonPlugin", "TestProject.OldJsonPlugin.dll"),
DependsOn = []
},
new PluginConfig
{
Name = Path.Combine(basePath, "plugins", "TestProject.JsonPlugin", "TestProject.JsonPlugin.dll"),
DependsOn = ["TestProject.OldJsonPlugin"]
},
];
// Act
var actual = jsonConfiguration.GetPluginConfigFiles().ToList();
// Assert
actual.Should().BeEquivalentTo(expectedPaths);
}

[Test]
public void Constructor_WhenArgumentIsNull_ShouldThrowArgumentNullException()
{
Expand Down
109 changes: 108 additions & 1 deletion tests/CPlugin.Net/Core/PluginLoaderTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace CPlugin.Net.Tests.Core;
using CPlugin.Net.Exceptions;

namespace CPlugin.Net.Tests.Core;

public class PluginLoaderTests
{
Expand Down Expand Up @@ -79,4 +81,109 @@ public void Load_WhenMethodIsCalledMultipleTimes_ShouldNotLoadSamePluginsIntoMem
.Should()
.Be(expectedAssemblies);
}

[Test]
public void LoadPluginsWithDependencies_WhenPluginsAreFound_ShouldBeLoadedIntoMemory()
{
// Arrange
var value =
"""
TestProject.OldJsonPlugin.dll->TestProject.JsonPlugin.dll
TestProject.JsonPlugin.dll
""";
Environment.SetEnvironmentVariable("PLUGINS", value);
var envConfiguration = new CPluginEnvConfiguration();
int expectedAssemblies = 2;

// Act
PluginLoader.LoadPluginsWithDependencies(envConfiguration);

AppDomain
.CurrentDomain
.GetAssemblies()
.Where(assembly => assembly.GetName().Name == "TestProject.OldJsonPlugin"
|| assembly.GetName().Name == "TestProject.JsonPlugin")
.Count()
.Should()
.Be(expectedAssemblies);
}

[Test]
public void LoadPluginsWithDependencies_WhenPluginsAreIndependent_ShouldBeLoadedIntoMemory()
{
// Arrange
var value =
"""
TestProject.OldJsonPlugin.dll
TestProject.JsonPlugin.dll
""";
Environment.SetEnvironmentVariable("PLUGINS", value);
var envConfiguration = new CPluginEnvConfiguration();
int expectedAssemblies = 2;

// Act
PluginLoader.LoadPluginsWithDependencies(envConfiguration);

AppDomain
.CurrentDomain
.GetAssemblies()
.Where(assembly => assembly.GetName().Name == "TestProject.OldJsonPlugin"
|| assembly.GetName().Name == "TestProject.JsonPlugin")
.Count()
.Should()
.Be(expectedAssemblies);
}

[Test]
public void LoadPluginsWithDependencies_WhenPluginsHaveMultipleDependencies_ShouldBeLoaded()
{
// Arrange
List<string> plugins =
[
"TestProject.OldJsonPlugin",
"TestProject.JsonPlugin",
"TestProject.HelloPlugin"
];

var value =
"""
TestProject.OldJsonPlugin.dll
TestProject.JsonPlugin.dll->TestProject.OldJsonPlugin.dll,TestProject.HelloPlugin.dll
TestProject.HelloPlugin.dll
""";
Environment.SetEnvironmentVariable("PLUGINS", value);
var envConfiguration = new CPluginEnvConfiguration();
int expectedAssemblies = 3;

// Act
PluginLoader.LoadPluginsWithDependencies(envConfiguration);

// Assert
AppDomain
.CurrentDomain
.GetAssemblies()
.Where(assembly => plugins.Contains(assembly.GetName().Name))
.Count()
.Should()
.Be(expectedAssemblies);
}

[Test]
public void LoadPluginsWithDependencies_WhenDependencyIsNotFound_ShouldThrowPluginNotFoundException()
{
// Arrange
var dependentPlugin = "TestProject.JsonPlugin.dll";
var missingPlugin = "TestProject.OldJsonPlugin.dll";
var value = $"{dependentPlugin}->{missingPlugin}";
Environment.SetEnvironmentVariable("PLUGINS", value);
var envConfiguration = new CPluginEnvConfiguration();

// Act
Action act = () => PluginLoader.LoadPluginsWithDependencies(envConfiguration);

// Assert
act.Should()
.Throw<PluginNotFoundException>()
.WithMessage($"The plugin '{dependentPlugin}' depends on '{missingPlugin}', but '{missingPlugin}' was not found.");
}
}
Loading