diff --git a/pkgs/shared/common/src/CredentialType.cs b/pkgs/shared/common/src/CredentialType.cs new file mode 100644 index 00000000..14d99684 --- /dev/null +++ b/pkgs/shared/common/src/CredentialType.cs @@ -0,0 +1,17 @@ +namespace LaunchDarkly.Sdk +{ + /// + /// The type of credential used for the environment. + /// + public enum CredentialType + { + /// + /// A mobile key credential. + /// + MobileKey, + /// + /// An SDK key credential. + /// + SdkKey + } +} diff --git a/pkgs/shared/common/src/Integrations/Plugins/ApplicationMetadata.cs b/pkgs/shared/common/src/Integrations/Plugins/ApplicationMetadata.cs new file mode 100644 index 00000000..4a40c866 --- /dev/null +++ b/pkgs/shared/common/src/Integrations/Plugins/ApplicationMetadata.cs @@ -0,0 +1,43 @@ +namespace LaunchDarkly.Sdk.Integrations.Plugins +{ + /// + /// Metadata about the application where the SDK is running. + /// + public sealed class ApplicationMetadata + { + /// + /// Gets the application identifier. + /// + public string Id { get; } + + /// + /// Gets the application version. + /// + public string Version { get; } + + /// + /// Gets the application name. + /// + public string Name { get; } + + /// + /// Gets the application version name. + /// + public string VersionName { get; } + + /// + /// Initializes a new instance of the class with the specified application ID, version, name, and version name. + /// + /// The application identifier. + /// The application version. + /// The application name. + /// The application version name. + public ApplicationMetadata(string id = null, string version = null, string name = null, string versionName = null) + { + Id = id; + Version = version; + Name = name; + VersionName = versionName; + } + } +} diff --git a/pkgs/shared/common/src/Integrations/Plugins/EnvironmentMetadata.cs b/pkgs/shared/common/src/Integrations/Plugins/EnvironmentMetadata.cs new file mode 100644 index 00000000..ae1b0237 --- /dev/null +++ b/pkgs/shared/common/src/Integrations/Plugins/EnvironmentMetadata.cs @@ -0,0 +1,43 @@ +namespace LaunchDarkly.Sdk.Integrations.Plugins +{ + /// + /// Metadata about the environment where the SDK is running. + /// + public sealed class EnvironmentMetadata + { + /// + /// Gets the SDK metadata. + /// + public SdkMetadata Sdk { get; } + + /// + /// Gets the SDK key. + /// + public string Credential { get; } + + /// + /// Gets the type of credential used (e.g., Mobile Key or SDK Key). + /// + public CredentialType CredentialType { get; } + + /// + /// Gets the application metadata. + /// + public ApplicationMetadata Application { get; } + + /// + /// Initializes a new instance of the class. + /// + /// the SDK metadata + /// the SDK Key or Mobile Key + /// the type of credential + /// the application metadata + public EnvironmentMetadata(SdkMetadata sdkMetadata, string credential, CredentialType credentialType, ApplicationMetadata applicationMetadata) + { + Sdk = sdkMetadata; + Credential = credential; + CredentialType = credentialType; + Application = applicationMetadata; + } + } +} diff --git a/pkgs/shared/common/src/Integrations/Plugins/PluginBase.cs b/pkgs/shared/common/src/Integrations/Plugins/PluginBase.cs new file mode 100644 index 00000000..1a205f0b --- /dev/null +++ b/pkgs/shared/common/src/Integrations/Plugins/PluginBase.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; + +namespace LaunchDarkly.Sdk.Integrations.Plugins +{ + /// + /// Abstract base class for extending SDK functionality via plugins. + /// Consumers should provide specific implementations of this class with the appropariate + /// client and hook types for their use case. + /// This class includes default implementations for optional methods, allowing + /// LaunchDarkly to expand the list of plugin methods without breaking customer integrations. + /// Plugins provide an interface which allows for initialization, access to credentials, + /// and hook registration in a single interface. + /// + /// The type of the LaunchDarkly client (e.g., ILdClient) + /// The type of hooks used by this plugin (e.g., Hook) + public abstract class PluginBase + { + /// + /// Get metadata about the plugin implementation. + /// + /// Metadata describing this plugin + public PluginMetadata Metadata { get; private set; } + + /// + /// Initializes a new instance of the class with the specified name. + /// + /// The name of the plugin. + public PluginBase(string name) + { + Metadata = new PluginMetadata(name); + } + + /// + /// Registers the plugin with the specified LaunchDarkly client and environment metadata. + /// + /// An instance of the LaunchDarkly client to register the plugin with. + /// Metadata about the environment. + public abstract void Register(TClient client, EnvironmentMetadata metadata); + + /// + /// Returns a list of hooks to be registered for the plugin, based on the provided environment metadata. + /// + /// Metadata about the environment. + /// A list of hook instances to be registered. + public virtual IList GetHooks(EnvironmentMetadata metadata) + { + return new List(); + } + } +} diff --git a/pkgs/shared/common/src/Integrations/Plugins/PluginExtensions.cs b/pkgs/shared/common/src/Integrations/Plugins/PluginExtensions.cs new file mode 100644 index 00000000..2b79381e --- /dev/null +++ b/pkgs/shared/common/src/Integrations/Plugins/PluginExtensions.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using LaunchDarkly.Logging; + +namespace LaunchDarkly.Sdk.Integrations.Plugins +{ + /// + /// Extension methods for plugin registration and hook collection. + /// + public static class PluginExtensions + { + /// + /// Registers all plugins with the client and environment metadata. + /// + /// The client type (e.g., ILdClient) + /// The hook type (e.g., Hook) + /// The client instance to register plugins with + /// The collection of plugins to register + /// Metadata about the environment + /// Logger for error reporting + /// + /// This method iterates through each plugin in the collection and calls its `Register` method + /// to initialize it with the client and environment metadata. It logs any exceptions that occur during + /// the registration process, allowing the client to continue functioning even if some plugins fail to register. + /// + public static void RegisterPlugins( + this TClient client, + IEnumerable> plugins, + EnvironmentMetadata environmentMetadata, + Logger logger) + { + foreach (var plugin in plugins) + { + try + { + plugin.Register(client, environmentMetadata); + } + catch (Exception ex) + { + logger.Error("Error registering plugin {0}: {1}", + plugin.Metadata.Name ?? "unknown", ex); + } + } + } + + /// + /// Retrieves all hooks from the specified plugins. + /// + /// The client type + /// The hook type + /// The client instance to register plugins with + /// The collection of plugins + /// Metadata about the environment + /// Logger for error reporting + /// A list of hooks from all plugins + /// + /// This method iterates through each plugin in the collection and calls its `GetHooks` method + /// to retrieve any hooks the plugin provides. It logs any exceptions that occur during + /// the hook retrieval process and continues processing remaining plugins. + /// + public static List GetPluginHooks( + this TClient client, + IEnumerable> plugins, + EnvironmentMetadata environmentMetadata, + Logger logger) + { + var allHooks = new List(); + foreach (var plugin in plugins) + { + try + { + var pluginHooks = plugin.GetHooks(environmentMetadata); + if (pluginHooks != null) + { + allHooks.AddRange(pluginHooks); + } + } + catch (Exception ex) + { + logger.Error("Error getting hooks from plugin {0}: {1}", + plugin.Metadata.Name ?? "unknown", ex); + } + } + return allHooks; + } + } +} diff --git a/pkgs/shared/common/src/Integrations/Plugins/PluginMetadata.cs b/pkgs/shared/common/src/Integrations/Plugins/PluginMetadata.cs new file mode 100644 index 00000000..6226d8fd --- /dev/null +++ b/pkgs/shared/common/src/Integrations/Plugins/PluginMetadata.cs @@ -0,0 +1,24 @@ +using System; + +namespace LaunchDarkly.Sdk.Integrations.Plugins +{ + /// + /// Metadata about a plugin implementation. + /// + public sealed class PluginMetadata + { + /// + /// The name of the plugin. + /// + public string Name { get; } + + /// + /// Initializes a new instance of the class with the specified name. + /// + /// The name of the plugin. + public PluginMetadata(string name) + { + Name = name; + } + } +} diff --git a/pkgs/shared/common/src/Integrations/Plugins/SdkMetadata.cs b/pkgs/shared/common/src/Integrations/Plugins/SdkMetadata.cs new file mode 100644 index 00000000..7aa4e133 --- /dev/null +++ b/pkgs/shared/common/src/Integrations/Plugins/SdkMetadata.cs @@ -0,0 +1,44 @@ +namespace LaunchDarkly.Sdk.Integrations.Plugins +{ + /// + /// Metadata about the SDK itself. + /// + public sealed class SdkMetadata + { + /// + /// Gets the id of the SDK. This should match the identifier in the SDK. + /// This field should be either the x-launchdarkly-user-agent or the user-agent. + /// + public string Name { get; } + + /// + /// Gets the version of the SDK. + /// + public string Version { get; } + + /// + /// Gets the wrapper name if this SDK is a wrapper. + /// + public string WrapperName { get; } + + /// + /// Gets the wrapper version if this SDK is a wrapper. + /// + public string WrapperVersion { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The id of the SDK. This should match the identifier in the SDK. It should be either the x-launchdarkly-user-agent or the user-agent. + /// The version of the SDK. + /// If this SDK is a wrapper, then this should be the wrapper name. + /// If this SDK is a wrapper, then this should be the wrapper version. + public SdkMetadata(string name, string version, string wrapperName = null, string wrapperVersion = null) + { + Name = name; + Version = version; + WrapperName = wrapperName; + WrapperVersion = wrapperVersion; + } + } +} diff --git a/pkgs/shared/common/test/Integrations/Plugins/ApplicationMetadataTest.cs b/pkgs/shared/common/test/Integrations/Plugins/ApplicationMetadataTest.cs new file mode 100644 index 00000000..b7e184e6 --- /dev/null +++ b/pkgs/shared/common/test/Integrations/Plugins/ApplicationMetadataTest.cs @@ -0,0 +1,29 @@ +using Xunit; + +namespace LaunchDarkly.Sdk.Integrations.Plugins +{ + public class ApplicationMetadataTest + { + [Fact] + public void CanConstructWithAllParameters() + { + var appMetadata = new ApplicationMetadata("app-id", "1.0.0", "My App", "v1.0.0"); + + Assert.Equal("app-id", appMetadata.Id); + Assert.Equal("1.0.0", appMetadata.Version); + Assert.Equal("My App", appMetadata.Name); + Assert.Equal("v1.0.0", appMetadata.VersionName); + } + + [Fact] + public void CanConstructWithDefaultParameters() + { + var appMetadata = new ApplicationMetadata(); + + Assert.Null(appMetadata.Id); + Assert.Null(appMetadata.Version); + Assert.Null(appMetadata.Name); + Assert.Null(appMetadata.VersionName); + } + } +} diff --git a/pkgs/shared/common/test/Integrations/Plugins/EnvironmentMetadataTest.cs b/pkgs/shared/common/test/Integrations/Plugins/EnvironmentMetadataTest.cs new file mode 100644 index 00000000..4e1d6c96 --- /dev/null +++ b/pkgs/shared/common/test/Integrations/Plugins/EnvironmentMetadataTest.cs @@ -0,0 +1,20 @@ +using Xunit; + +namespace LaunchDarkly.Sdk.Integrations.Plugins +{ + public class EnvironmentMetadataTest + { + [Fact] + public void CanConstructWithAllParameters() + { + var sdkMetadata = new SdkMetadata("dotnet-server-sdk", "6.0.0"); + var appMetadata = new ApplicationMetadata("app-id", "1.0.0"); + var envMetadata = new EnvironmentMetadata(sdkMetadata, "test-sdk-key", CredentialType.SdkKey, appMetadata); + + Assert.Equal(sdkMetadata, envMetadata.Sdk); + Assert.Equal("test-sdk-key", envMetadata.Credential); + Assert.Equal(CredentialType.SdkKey, envMetadata.CredentialType); + Assert.Equal(appMetadata, envMetadata.Application); + } + } +} diff --git a/pkgs/shared/common/test/Integrations/Plugins/PluginBaseTest.cs b/pkgs/shared/common/test/Integrations/Plugins/PluginBaseTest.cs new file mode 100644 index 00000000..a0a44454 --- /dev/null +++ b/pkgs/shared/common/test/Integrations/Plugins/PluginBaseTest.cs @@ -0,0 +1,101 @@ +using System.Collections.Generic; +using Xunit; + +namespace LaunchDarkly.Sdk.Integrations.Plugins +{ + public class PluginBaseTest + { + // Test implementation of PluginBase that uses default GetHooks + public class TestPluginWithDefaultHooks : PluginBase + { + public TestPluginWithDefaultHooks() + : base("test-plugin") { } + + public override void Register(string client, EnvironmentMetadata metadata) + { + // No-op for testing + } + + // Uses default GetHooks implementation + } + + // Test implementation of PluginBase that overrides GetHooks + public class TestPluginWithCustomHooks : PluginBase + { + public TestPluginWithCustomHooks() + : base("test-plugin-with-custom-hooks") { } + public List CustomHooks { get; set; } = new List(); + + public override void Register(string client, EnvironmentMetadata metadata) + { + // No-op for testing + } + + public override IList GetHooks(EnvironmentMetadata metadata) + { + return CustomHooks; + } + } + + [Fact] + public void DefaultGetHooks_ReturnsEmptyList() + { + var plugin = new TestPluginWithDefaultHooks(); + var metadata = new EnvironmentMetadata( + new SdkMetadata("test-sdk", "1.0.0"), + "test-key", + CredentialType.SdkKey, + new ApplicationMetadata("test-app", "1.0.0") + ); + + var hooks = plugin.GetHooks(metadata); + + Assert.NotNull(hooks); + Assert.Empty(hooks); + } + + [Fact] + public void CustomGetHooks_ReturnsProvidedHooks() + { + var plugin = new TestPluginWithCustomHooks(); + plugin.CustomHooks.Add("hook1"); + plugin.CustomHooks.Add("hook2"); + + var metadata = new EnvironmentMetadata( + new SdkMetadata("test-sdk", "1.0.0"), + "test-key", + CredentialType.SdkKey, + new ApplicationMetadata("test-app", "1.0.0") + ); + + var hooks = plugin.GetHooks(metadata); + + Assert.Equal(2, hooks.Count); + Assert.Contains("hook1", hooks); + Assert.Contains("hook2", hooks); + } + + [Fact] + public void GetMetadata_ReturnsExpectedMetadata() + { + var plugin = new TestPluginWithDefaultHooks(); + + Assert.Equal("test-plugin", plugin.Metadata.Name); + } + + [Fact] + public void Register_CanBeCalledWithoutException() + { + var plugin = new TestPluginWithDefaultHooks(); + var metadata = new EnvironmentMetadata( + new SdkMetadata("test-sdk", "1.0.0"), + "test-key", + CredentialType.SdkKey, + new ApplicationMetadata("test-app", "1.0.0") + ); + + // Should not throw exception + plugin.Register("test-client", metadata); + } + } +} diff --git a/pkgs/shared/common/test/Integrations/Plugins/PluginExtensionsTest.cs b/pkgs/shared/common/test/Integrations/Plugins/PluginExtensionsTest.cs new file mode 100644 index 00000000..d76b1805 --- /dev/null +++ b/pkgs/shared/common/test/Integrations/Plugins/PluginExtensionsTest.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using LaunchDarkly.Logging; +using Xunit; +using Xunit.Abstractions; + +namespace LaunchDarkly.Sdk.Integrations.Plugins +{ + // Mock client for testing + public class MockClient + { + public List RegisteredPlugins { get; } = new List(); + } + + // Mock hook for testing + public class MockHook + { + public string Name { get; set; } + } + + // Mock plugin implementation for testing + public class MockPlugin : PluginBase + { + public MockPlugin(string name) + : base(name) { } + public List Hooks { get; set; } = new List(); + public bool ThrowOnRegister { get; set; } = false; + public bool ThrowOnGetHooks { get; set; } = false; + public bool ReturnNullHooks { get; set; } = false; + + public override void Register(MockClient client, EnvironmentMetadata metadata) + { + if (ThrowOnRegister) + throw new InvalidOperationException("Test exception"); + + client.RegisteredPlugins.Add(Metadata.Name); + } + + public override IList GetHooks(EnvironmentMetadata metadata) + { + if (ThrowOnGetHooks) + throw new InvalidOperationException("Test exception"); + + if (ReturnNullHooks) + return null; + + return Hooks; + } + } + + public class PluginExtensionsTest + { + private readonly ITestOutputHelper _testOutputHelper; + private readonly Logger _logger; + private readonly MockClient _client; + private readonly EnvironmentMetadata _environmentMetadata; + + public PluginExtensionsTest(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + _logger = TestLogging.TestLogger(testOutputHelper); + _client = new MockClient(); + _environmentMetadata = new EnvironmentMetadata( + new SdkMetadata("test-sdk", "1.0.0"), + "test-key", + CredentialType.SdkKey, + new ApplicationMetadata("test-app", "1.0.0") + ); + } + + [Fact] + public void RegisterPlugins_SuccessfullyRegistersAllPlugins() + { + var plugin1 = new MockPlugin("plugin1"); + var plugin2 = new MockPlugin("plugin2"); + var plugins = new List { plugin1, plugin2 }; + + _client.RegisterPlugins(plugins, _environmentMetadata, _logger); + + Assert.Contains("plugin1", _client.RegisteredPlugins); + Assert.Contains("plugin2", _client.RegisteredPlugins); + Assert.Equal(2, _client.RegisteredPlugins.Count); + } + + [Fact] + public void RegisterPlugins_ContinuesAfterExceptionInOnePlugin() + { + var plugin1 = new MockPlugin("plugin1") { ThrowOnRegister = true }; + var plugin2 = new MockPlugin("plugin2"); + var plugins = new List { plugin1, plugin2 }; + + _client.RegisterPlugins(plugins, _environmentMetadata, _logger); + + Assert.DoesNotContain("plugin1", _client.RegisteredPlugins); + Assert.Contains("plugin2", _client.RegisteredPlugins); + Assert.Single(_client.RegisteredPlugins); + } + + [Fact] + public void RegisterPlugins_HandlesEmptyPluginsList() + { + var plugins = new List(); + + _client.RegisterPlugins(plugins, _environmentMetadata, _logger); + + Assert.Empty(_client.RegisteredPlugins); + } + + [Fact] + public void GetPluginHooks_ReturnsAllHooksFromAllPlugins() + { + var plugin1 = new MockPlugin("plugin1") + { + Hooks = new List { new MockHook { Name = "hook1" } } + }; + var plugin2 = new MockPlugin("plugin2") + { + Hooks = new List + { + new MockHook { Name = "hook2" }, + new MockHook { Name = "hook3" } + } + }; + var plugins = new List { plugin1, plugin2 }; + + var hooks = _client.GetPluginHooks(plugins, _environmentMetadata, _logger); + + Assert.Equal(3, hooks.Count); + Assert.Contains(hooks, h => h.Name == "hook1"); + Assert.Contains(hooks, h => h.Name == "hook2"); + Assert.Contains(hooks, h => h.Name == "hook3"); + } + + [Fact] + public void GetPluginHooks_ContinuesAfterExceptionInOnePlugin() + { + var plugin1 = new MockPlugin("plugin1") + { + Hooks = new List { new MockHook { Name = "hook1" } }, + ThrowOnGetHooks = true + }; + var plugin2 = new MockPlugin("plugin2") + { + Hooks = new List { new MockHook { Name = "hook2" } } + }; + var plugins = new List { plugin1, plugin2 }; + + var hooks = _client.GetPluginHooks(plugins, _environmentMetadata, _logger); + + Assert.Single(hooks); + Assert.Contains(hooks, h => h.Name == "hook2"); + } + + [Fact] + public void GetPluginHooks_HandlesNullHooksFromPlugin() + { + var plugin1 = new MockPlugin("plugin1") + { + ReturnNullHooks = true + }; + var plugin2 = new MockPlugin("plugin2") + { + Hooks = new List { new MockHook { Name = "hook2" } } + }; + var plugins = new List { plugin1, plugin2 }; + + var hooks = _client.GetPluginHooks(plugins, _environmentMetadata, _logger); + + Assert.Single(hooks); + Assert.Contains(hooks, h => h.Name == "hook2"); + } + + [Fact] + public void GetPluginHooks_HandlesEmptyPluginsList() + { + var plugins = new List(); + + var hooks = _client.GetPluginHooks(plugins, _environmentMetadata, _logger); + + Assert.Empty(hooks); + } + + [Fact] + public void GetPluginHooks_HandlesPluginWithNoHooks() + { + var plugin = new MockPlugin("plugin1"); // No hooks added + var plugins = new List { plugin }; + + var hooks = _client.GetPluginHooks(plugins, _environmentMetadata, _logger); + + Assert.Empty(hooks); + } + } + + // Helper class for test logging + public static class TestLogging + { + public static Logger TestLogger(ITestOutputHelper testOutputHelper) => + Logs.ToMethod(line => + { + try + { + testOutputHelper.WriteLine("LOG OUTPUT >> " + line); + } + catch { } + }).Logger(""); + } +} diff --git a/pkgs/shared/common/test/Integrations/Plugins/PluginMetadataTest.cs b/pkgs/shared/common/test/Integrations/Plugins/PluginMetadataTest.cs new file mode 100644 index 00000000..790c633c --- /dev/null +++ b/pkgs/shared/common/test/Integrations/Plugins/PluginMetadataTest.cs @@ -0,0 +1,23 @@ +using System; +using Xunit; + +namespace LaunchDarkly.Sdk.Integrations.Plugins +{ + public class PluginMetadataTest + { + [Fact] + public void CanConstructWithValidName() + { + var pluginMetadata = new PluginMetadata("test-plugin"); + + Assert.Equal("test-plugin", pluginMetadata.Name); + } + + [Fact] + public void NoExceptionForNullName() + { + var pluginMetadata = new PluginMetadata(null); + Assert.Null(pluginMetadata.Name); + } + } +} diff --git a/pkgs/shared/common/test/Integrations/Plugins/SdkMetadataTest.cs b/pkgs/shared/common/test/Integrations/Plugins/SdkMetadataTest.cs new file mode 100644 index 00000000..46b4201f --- /dev/null +++ b/pkgs/shared/common/test/Integrations/Plugins/SdkMetadataTest.cs @@ -0,0 +1,30 @@ +using Xunit; + +namespace LaunchDarkly.Sdk.Integrations.Plugins +{ + public class SdkMetadataTest + { + + [Fact] + public void CanConstructWithAllParameters() + { + var sdkMetadata = new SdkMetadata("dotnet-server-sdk", "6.0.0", "my-wrapper", "2.0.0"); + + Assert.Equal("dotnet-server-sdk", sdkMetadata.Name); + Assert.Equal("6.0.0", sdkMetadata.Version); + Assert.Equal("my-wrapper", sdkMetadata.WrapperName); + Assert.Equal("2.0.0", sdkMetadata.WrapperVersion); + } + + [Fact] + public void CanHandleNullValues() + { + var sdkMetadata = new SdkMetadata(null, null); + + Assert.Null(sdkMetadata.Name); + Assert.Null(sdkMetadata.Version); + Assert.Null(sdkMetadata.WrapperName); + Assert.Null(sdkMetadata.WrapperVersion); + } + } +}