diff --git a/src/Core/Configuration/CPluginConfigurationBase.cs b/src/Core/Configuration/CPluginConfigurationBase.cs index e570d75..8724731 100644 --- a/src/Core/Configuration/CPluginConfigurationBase.cs +++ b/src/Core/Configuration/CPluginConfigurationBase.cs @@ -27,6 +27,24 @@ public abstract class CPluginConfigurationBase /// public abstract IEnumerable GetPluginFiles(); + /// + /// Gets the full path to each plugin file from a configuration source. + /// + /// + /// A collection of plugin files that also contains the paths; + /// or + /// Returns an empty enumerable when the plugin files could not be obtained. + /// This method never returns null. + /// + /// + /// Plugin files must be in the plugins directory of the current directory + /// where the host application is running. + /// Each plugin file must have a .dll extension and must be in its own directory. + /// Example: + /// /HostApp/bin/Debug/net7.0/plugins/MyPlugin1/MyPlugin1.dll + /// + public abstract IEnumerable GetPluginConfigFiles(); + /// /// Gets the full path of a plugin file. /// diff --git a/src/Core/Configuration/CPluginEnvConfiguration.cs b/src/Core/Configuration/CPluginEnvConfiguration.cs index 6c4f675..5ee0e3a 100644 --- a/src/Core/Configuration/CPluginEnvConfiguration.cs +++ b/src/Core/Configuration/CPluginEnvConfiguration.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace CPlugin.Net; +namespace CPlugin.Net; /// /// Represents a configuration to get the plugin files from an environment variable. @@ -11,6 +7,14 @@ namespace CPlugin.Net; /// The variable must be called PLUGINS and its value must be a string separated by spaces or new lines. /// Example: /// PLUGINS=MyPlugin1.dll MyPlugin2.dll +/// if you have plugins with dependencies, you can do this: +/// +/// PLUGINS=" +/// MyPlugin1.dll->MyPlugin2.dll,MyPlugin3.dll +/// MyPlugin2.dll +/// MyPlugin3.dll +///" +/// /// public class CPluginEnvConfiguration : CPluginConfigurationBase { @@ -35,4 +39,28 @@ public override IEnumerable GetPluginFiles() return pluginFiles; } + + public override IEnumerable 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] + }; + }); + } } diff --git a/src/Core/Configuration/CPluginJsonConfiguration.cs b/src/Core/Configuration/CPluginJsonConfiguration.cs index c22dc84..cdecf8f 100644 --- a/src/Core/Configuration/CPluginJsonConfiguration.cs +++ b/src/Core/Configuration/CPluginJsonConfiguration.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration; namespace CPlugin.Net; @@ -14,6 +11,23 @@ namespace CPlugin.Net; /// /// { "Plugins": [ "MyPlugin1.dll", "MyPlugin2.dll" ] } /// +/// if you have plugins with dependencies, you can do this: +/// +/// { +/// "Plugins": [ +/// { +/// "Name": "TestProject.JsonPlugin", +/// "DependsOn": [ +/// "TestProject.OldJsonPlugin" +/// ] +/// }, +/// { +/// "Name": "TestProject.OldJsonPlugin", +/// "DependsOn": [] +/// } +/// ] +/// } +/// /// public class CPluginJsonConfiguration : CPluginConfigurationBase { @@ -34,6 +48,19 @@ public CPluginJsonConfiguration(IConfiguration configuration) _configuration = configuration; } + public override IEnumerable GetPluginConfigFiles() + { + var values = _configuration + .GetSection("Plugins") + .Get(); + + return values is null ? [] : values.Select(p => new PluginConfig + { + Name = GetPluginPath(p.Name), + DependsOn = p.DependsOn + }); + } + /// public override IEnumerable GetPluginFiles() { diff --git a/src/Core/Configuration/PluginConfig.cs b/src/Core/Configuration/PluginConfig.cs new file mode 100644 index 0000000..e5fd074 --- /dev/null +++ b/src/Core/Configuration/PluginConfig.cs @@ -0,0 +1,6 @@ +namespace CPlugin.Net; +public class PluginConfig +{ + public string Name { get; set; } = string.Empty; + public List DependsOn { get; set; } = []; +} diff --git a/src/Core/Exceptions/PluginNotFoundException.cs b/src/Core/Exceptions/PluginNotFoundException.cs new file mode 100644 index 0000000..34361a4 --- /dev/null +++ b/src/Core/Exceptions/PluginNotFoundException.cs @@ -0,0 +1,10 @@ +namespace CPlugin.Net.Exceptions; +/// +/// Represents an exception that is thrown when a plugin is not found. +/// +/// The missing plugin. +/// The dependent plugin. +public class PluginNotFoundException(string missingPlugin, string dependentPlugin) + : Exception($"The plugin '{dependentPlugin}' depends on '{missingPlugin}', but '{missingPlugin}' was not found.") +{ +} diff --git a/src/Core/PluginLoader.cs b/src/Core/PluginLoader.cs index c5cc223..1b1d4be 100644 --- a/src/Core/PluginLoader.cs +++ b/src/Core/PluginLoader.cs @@ -1,4 +1,5 @@ -using System; +using CPlugin.Net.Exceptions; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; @@ -42,6 +43,50 @@ public static void Load(CPluginConfigurationBase configuration) } } + /// + /// 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. + /// + /// + /// A configuration source that provides the list of plugin files and their dependencies. + /// + /// + /// 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 is thrown. + /// + /// + /// Thrown when is null. + /// + /// + /// Thrown when a required plugin dependency is missing. + /// + 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); diff --git a/tests/CPlugin.Net/Core/CPluginEnvConfigurationTests.cs b/tests/CPlugin.Net/Core/CPluginEnvConfigurationTests.cs index c50186a..d8881f7 100644 --- a/tests/CPlugin.Net/Core/CPluginEnvConfigurationTests.cs +++ b/tests/CPlugin.Net/Core/CPluginEnvConfigurationTests.cs @@ -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() { diff --git a/tests/CPlugin.Net/Core/CPluginJsonConfigurationTests.cs b/tests/CPlugin.Net/Core/CPluginJsonConfigurationTests.cs index c122afd..b29a6d7 100644 --- a/tests/CPlugin.Net/Core/CPluginJsonConfigurationTests.cs +++ b/tests/CPlugin.Net/Core/CPluginJsonConfigurationTests.cs @@ -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() { diff --git a/tests/CPlugin.Net/Core/PluginLoaderTests.cs b/tests/CPlugin.Net/Core/PluginLoaderTests.cs index b7309f2..b15049b 100644 --- a/tests/CPlugin.Net/Core/PluginLoaderTests.cs +++ b/tests/CPlugin.Net/Core/PluginLoaderTests.cs @@ -1,4 +1,6 @@ -namespace CPlugin.Net.Tests.Core; +using CPlugin.Net.Exceptions; + +namespace CPlugin.Net.Tests.Core; public class PluginLoaderTests { @@ -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 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() + .WithMessage($"The plugin '{dependentPlugin}' depends on '{missingPlugin}', but '{missingPlugin}' was not found."); + } } diff --git a/tests/CPlugin.Net/Resources/settingsWithDependencies.json b/tests/CPlugin.Net/Resources/settingsWithDependencies.json new file mode 100644 index 0000000..25e10f7 --- /dev/null +++ b/tests/CPlugin.Net/Resources/settingsWithDependencies.json @@ -0,0 +1,14 @@ +{ + "Plugins": [ + { + "Name": "TestProject.JsonPlugin", + "DependsOn": [ + "TestProject.OldJsonPlugin" + ] + }, + { + "Name": "TestProject.OldJsonPlugin", + "DependsOn": [] + } + ] +} \ No newline at end of file diff --git a/tests/CPlugin.Net/Resources/testwithdependencies.env b/tests/CPlugin.Net/Resources/testwithdependencies.env new file mode 100644 index 0000000..0b78664 --- /dev/null +++ b/tests/CPlugin.Net/Resources/testwithdependencies.env @@ -0,0 +1,4 @@ +PLUGINS=" + TestProject.JsonPlugin.dll->TestProject.OldJsonPlugin.dll + TestProject.OldJsonPlugin.dll +" \ No newline at end of file