diff --git a/pkgs/sdk/server/src/Components.cs b/pkgs/sdk/server/src/Components.cs index d001fc8f..9ce264b2 100644 --- a/pkgs/sdk/server/src/Components.cs +++ b/pkgs/sdk/server/src/Components.cs @@ -6,6 +6,7 @@ using LaunchDarkly.Sdk.Server.Integrations; using LaunchDarkly.Sdk.Server.Interfaces; using LaunchDarkly.Sdk.Server.Internal; +using LaunchDarkly.Sdk.Server.Plugins; using LaunchDarkly.Sdk.Server.Subsystems; namespace LaunchDarkly.Sdk.Server @@ -218,6 +219,27 @@ public static LoggingConfigurationBuilder Logging(ILogAdapter adapter) => /// a configuration builder public static HookConfigurationBuilder Hooks(IEnumerable hooks) => new HookConfigurationBuilder(hooks); + /// + /// Returns a configuration builder for the SDK's plugin configuration. + /// + /// + /// + /// var config = Configuration.Builder(sdkKey) + /// .Add(new MyPlugin(...)) + /// .Add(new MyOtherPlugin(...)) + /// ).Build(); + /// + /// + /// a configuration builder + public static PluginConfigurationBuilder Plugins() => new PluginConfigurationBuilder(); + + /// + /// Returns a configuration builder for the SDK's plugin configuration, with an initial set of plugins. + /// + /// a collection of plugins + /// a configuration builder + public static PluginConfigurationBuilder Plugins(IEnumerable plugins) => new PluginConfigurationBuilder(plugins); + /// /// Returns a configuration object that disables analytics events. /// diff --git a/pkgs/sdk/server/src/Configuration.cs b/pkgs/sdk/server/src/Configuration.cs index d11dd4c7..092217ed 100644 --- a/pkgs/sdk/server/src/Configuration.cs +++ b/pkgs/sdk/server/src/Configuration.cs @@ -112,6 +112,11 @@ public class Configuration /// public HookConfigurationBuilder Hooks { get; } + /// + /// Contains methods for configuring the SDK's 'plugins' to extend or customize SDK behavior. + /// + public PluginConfigurationBuilder Plugins { get; } + #endregion #region Public methods @@ -189,6 +194,7 @@ internal Configuration(ConfigurationBuilder builder) ApplicationInfo = builder._applicationInfo; WrapperInfo = builder._wrapperInfo; Hooks = builder._hooks; + Plugins = builder._plugins; } #endregion diff --git a/pkgs/sdk/server/src/ConfigurationBuilder.cs b/pkgs/sdk/server/src/ConfigurationBuilder.cs index 82af4a2f..1e45d98a 100644 --- a/pkgs/sdk/server/src/ConfigurationBuilder.cs +++ b/pkgs/sdk/server/src/ConfigurationBuilder.cs @@ -39,6 +39,7 @@ public sealed class ConfigurationBuilder internal bool _diagnosticOptOut = false; internal IComponentConfigurer _events = null; internal HookConfigurationBuilder _hooks = null; + internal PluginConfigurationBuilder _plugins = null; internal IComponentConfigurer _http = null; internal IComponentConfigurer _logging = null; internal bool _offline = false; @@ -65,6 +66,7 @@ internal ConfigurationBuilder(Configuration copyFrom) _diagnosticOptOut = copyFrom.DiagnosticOptOut; _events = copyFrom.Events; _hooks = copyFrom.Hooks; + _plugins = copyFrom.Plugins; _http = copyFrom.Http; _logging = copyFrom.Logging; _offline = copyFrom.Offline; @@ -291,6 +293,17 @@ public ConfigurationBuilder Hooks(HookConfigurationBuilder hooksConfig) return this; } + /// + /// Configures the SDK's plugins. + /// + /// the plugin configuration + /// the same builder + public ConfigurationBuilder Plugins(PluginConfigurationBuilder pluginsConfig) + { + _plugins = pluginsConfig; + return this; + } + /// /// Sets whether or not this client is offline. If true, no calls to Launchdarkly will be made. /// diff --git a/pkgs/sdk/server/src/Integrations/PluginConfigurationBuilder.cs b/pkgs/sdk/server/src/Integrations/PluginConfigurationBuilder.cs new file mode 100644 index 00000000..de38acac --- /dev/null +++ b/pkgs/sdk/server/src/Integrations/PluginConfigurationBuilder.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Linq; +using LaunchDarkly.Sdk.Server.Plugins; +using LaunchDarkly.Sdk.Server.Subsystems; + +namespace LaunchDarkly.Sdk.Server.Integrations +{ + /// + /// PluginConfigurationBuilder is a builder for the SDK's plugin configuration. + /// + public sealed class PluginConfigurationBuilder + { + private readonly List _plugins; + + /// + /// Constructs a configuration from an existing collection of plugins. + /// + public PluginConfigurationBuilder(IEnumerable plugins = null) + { + _plugins = plugins is null ? new List() : plugins.ToList(); + } + + /// + /// Adds a plugin to the configuration. + /// + /// the builder + public PluginConfigurationBuilder Add(Plugin plugin) + { + _plugins.Add(plugin); + return this; + } + + /// + /// Builds the configuration. + /// + /// the built configuration + public PluginConfiguration Build() + { + return new PluginConfiguration(_plugins.ToList()); + } + } +} diff --git a/pkgs/sdk/server/src/LaunchDarkly.ServerSdk.csproj b/pkgs/sdk/server/src/LaunchDarkly.ServerSdk.csproj index e356cfd9..43d97988 100644 --- a/pkgs/sdk/server/src/LaunchDarkly.ServerSdk.csproj +++ b/pkgs/sdk/server/src/LaunchDarkly.ServerSdk.csproj @@ -39,9 +39,9 @@ - + - + @@ -51,7 +51,6 @@ - ../../../../LaunchDarkly.snk true diff --git a/pkgs/sdk/server/src/LdClient.cs b/pkgs/sdk/server/src/LdClient.cs index 46f783df..49bd37dc 100644 --- a/pkgs/sdk/server/src/LdClient.cs +++ b/pkgs/sdk/server/src/LdClient.cs @@ -7,6 +7,7 @@ using LaunchDarkly.Sdk.Server.Hooks; using LaunchDarkly.Sdk.Server.Interfaces; using LaunchDarkly.Sdk.Server.Internal; +using LaunchDarkly.Sdk.Integrations.Plugins; using LaunchDarkly.Sdk.Server.Internal.BigSegments; using LaunchDarkly.Sdk.Server.Internal.DataSources; using LaunchDarkly.Sdk.Server.Internal.DataStores; @@ -130,7 +131,7 @@ public LdClient(Configuration config) { _configuration = config; - var logConfig = (config.Logging ?? Components.Logging()).Build(new LdClientContext(config.SdkKey)); + var logConfig = (_configuration.Logging ?? Components.Logging()).Build(new LdClientContext(_configuration.SdkKey)); _log = logConfig.LogAdapter.Logger(logConfig.BaseLoggerName ?? LogNames.DefaultBase); _log.Info("Starting LaunchDarkly client {0}", @@ -140,24 +141,24 @@ public LdClient(Configuration config) var taskExecutor = new TaskExecutor(this, _log); var clientContext = new LdClientContext( - config.SdkKey, + _configuration.SdkKey, null, null, null, _log, - config.Offline, - config.ServiceEndpoints, + _configuration.Offline, + _configuration.ServiceEndpoints, null, taskExecutor, - config.ApplicationInfo?.Build() ?? new ApplicationInfo(), - config.WrapperInfo?.Build() + _configuration.ApplicationInfo?.Build() ?? new ApplicationInfo(), + _configuration.WrapperInfo?.Build() ); - var httpConfig = (config.Http ?? Components.HttpConfiguration()).Build(clientContext); + var httpConfig = (_configuration.Http ?? Components.HttpConfiguration()).Build(clientContext); clientContext = clientContext.WithHttp(httpConfig); var diagnosticStore = _configuration.DiagnosticOptOut ? null : - new ServerDiagnosticStore(config, clientContext); + new ServerDiagnosticStore(_configuration, clientContext); clientContext = clientContext.WithDiagnosticStore(diagnosticStore); var dataStoreUpdates = new DataStoreUpdatesImpl(taskExecutor, _log.SubLogger(LogNames.DataStoreSubLog)); @@ -185,25 +186,33 @@ public LdClient(Configuration config) ); var eventProcessorFactory = - config.Offline ? Components.NoEvents : - (_configuration.Events?? Components.SendEvents()); + _configuration.Offline ? Components.NoEvents : + (_configuration.Events ?? Components.SendEvents()); _eventProcessor = eventProcessorFactory.Build(clientContext); var dataSourceUpdates = new DataSourceUpdatesImpl(_dataStore, _dataStoreStatusProvider, taskExecutor, _log, logConfig.LogDataSourceOutageAsErrorAfter); IComponentConfigurer dataSourceFactory = - config.Offline ? Components.ExternalUpdatesOnly : + _configuration.Offline ? Components.ExternalUpdatesOnly : (_configuration.DataSource ?? Components.StreamingDataSource()); _dataSource = dataSourceFactory.Build(clientContext.WithDataSourceUpdates(dataSourceUpdates)); _dataSourceStatusProvider = new DataSourceStatusProviderImpl(dataSourceUpdates); _flagTracker = new FlagTrackerImpl(dataSourceUpdates, (string key, Context context) => JsonVariation(key, context, LdValue.Null)); - var hookConfig = (config.Hooks ?? Components.Hooks()).Build(); - _hookExecutor = hookConfig.Hooks.Any() ? - (IHookExecutor) new Executor(_log.SubLogger(LogNames.HooksSubLog), hookConfig.Hooks) + var allHooks = new List(); + + var hookConfig = (_configuration.Hooks ?? Components.Hooks()).Build(); + allHooks.AddRange(hookConfig.Hooks); + + var pluginConfig = (_configuration.Plugins ?? Components.Plugins()).Build(); + EnvironmentMetadata environmentMetadata = CreateEnvironmentMetadata(clientContext); + allHooks.AddRange(this.GetPluginHooks(pluginConfig.Plugins, environmentMetadata, _log)); + _hookExecutor = allHooks.Any() ? + (IHookExecutor)new Executor(_log.SubLogger(LogNames.HooksSubLog), allHooks) : new NoopExecutor(); + this.RegisterPlugins(pluginConfig.Plugins, environmentMetadata, _log); var initTask = _dataSource.Start(); @@ -323,7 +332,7 @@ public MigrationVariation MigrationVariation(string key, Context context, Migrat var (detail, flag) = EvaluateWithHooks(Method.MigrationVariation, key, context, LdValue.Of(defaultStage.ToDataModelString()), LdValue.Convert.String, true, EventFactory.Default); - var nullableStage = MigrationStageExtensions.FromDataModelString(detail.Value); + var nullableStage = MigrationStageExtensions.FromDataModelString(detail.Value); var stage = nullableStage ?? defaultStage; if (nullableStage == null) { @@ -648,24 +657,34 @@ public Logger GetLogger() #region Private methods - private FeatureFlag GetFlag(string key) + /// + /// Creates metadata about the environment, including SDK and application information. + /// + /// The client context containing application and wrapper information. + /// An instance containing the environment metadata. + /// + /// This method constructs the environment metadata using the SDK key, application ID, and version, + /// along with any wrapper information if available. It is used to provide context for plugins and + /// hooks that may need to interact with the environment. + /// + private EnvironmentMetadata CreateEnvironmentMetadata(LdClientContext clientContext) { - var maybeItem = _dataStore.Get(DataModel.Features, key); - if (maybeItem.HasValue && maybeItem.Value.Item != null && maybeItem.Value.Item is FeatureFlag f) - { - return f; - } - return null; - } - - private Segment GetSegment(string key) - { - var maybeItem = _dataStore.Get(DataModel.Segments, key); - if (maybeItem.HasValue && maybeItem.Value.Item != null && maybeItem.Value.Item is Segment s) - { - return s; - } - return null; + var applicationInfo = clientContext.ApplicationInfo; + var wrapperInfo = _configuration.WrapperInfo?.Build(); + + var sdkMetadata = new SdkMetadata( + "dotnet-server-sdk", + AssemblyVersions.GetAssemblyVersionStringForType(typeof(LdClient)), + wrapperInfo?.Name, + wrapperInfo?.Version + ); + + var applicationMetadata = new ApplicationMetadata( + applicationInfo.ApplicationId, + applicationInfo.ApplicationVersion + ); + + return new EnvironmentMetadata(sdkMetadata, _configuration.SdkKey, CredentialType.SdkKey, applicationMetadata); } private void Dispose(bool disposing) @@ -694,6 +713,26 @@ private string GetEnvironmentId() return null; } + private FeatureFlag GetFlag(string key) + { + var maybeItem = _dataStore.Get(DataModel.Features, key); + if (maybeItem.HasValue && maybeItem.Value.Item != null && maybeItem.Value.Item is FeatureFlag f) + { + return f; + } + return null; + } + + private Segment GetSegment(string key) + { + var maybeItem = _dataStore.Get(DataModel.Segments, key); + if (maybeItem.HasValue && maybeItem.Value.Item != null && maybeItem.Value.Item is Segment s) + { + return s; + } + return null; + } + #endregion } } diff --git a/pkgs/sdk/server/src/Plugins/Plugin.cs b/pkgs/sdk/server/src/Plugins/Plugin.cs new file mode 100644 index 00000000..5c8f2b50 --- /dev/null +++ b/pkgs/sdk/server/src/Plugins/Plugin.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using LaunchDarkly.Sdk.Server.Hooks; +using LaunchDarkly.Sdk.Server.Interfaces; +using LaunchDarkly.Sdk.Integrations.Plugins; + +namespace LaunchDarkly.Sdk.Server.Plugins +{ + /// + /// Abstract base class for extending SDK functionality via plugins in the server-side SDK. + /// All provided server-side plugin implementations MUST inherit from this class. + /// + public abstract class Plugin : PluginBase + { + /// + /// Initializes a new instance of the class with the specified name. + /// + /// The name of the plugin. + protected Plugin(string name) + : base(name) + { + } + } +} diff --git a/pkgs/sdk/server/src/Subsystems/PluginConfiguration.cs b/pkgs/sdk/server/src/Subsystems/PluginConfiguration.cs new file mode 100644 index 00000000..66855c5e --- /dev/null +++ b/pkgs/sdk/server/src/Subsystems/PluginConfiguration.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using LaunchDarkly.Sdk.Server.Plugins; + +namespace LaunchDarkly.Sdk.Server.Subsystems +{ + /// + /// Configuration containing plugins for the SDK. + /// + public sealed class PluginConfiguration + { + /// + /// The collection of plugins. + /// + public IEnumerable Plugins { get; } + + /// + /// Initializes a new instance of the class with the specified plugins. + /// + /// The plugins to include in this configuration. + public PluginConfiguration(IEnumerable plugins) + { + Plugins = plugins; + } + } +} diff --git a/pkgs/sdk/server/test/Integrations/PluginConfigurationBuilderTest.cs b/pkgs/sdk/server/test/Integrations/PluginConfigurationBuilderTest.cs new file mode 100644 index 00000000..77a33bc4 --- /dev/null +++ b/pkgs/sdk/server/test/Integrations/PluginConfigurationBuilderTest.cs @@ -0,0 +1,134 @@ +using System.Collections.Generic; +using System.Linq; +using LaunchDarkly.Sdk.Integrations.Plugins; +using LaunchDarkly.Sdk.Server.Hooks; +using LaunchDarkly.Sdk.Server.Interfaces; +using LaunchDarkly.Sdk.Server.Plugins; +using LaunchDarkly.Sdk.Server.Subsystems; +using Xunit; + +namespace LaunchDarkly.Sdk.Server.Integrations +{ + // Mock plugin for testing + public class TestPlugin : Plugin + { + public TestPlugin(string name = "test-plugin") + :base(name) { } + + public override void Register(ILdClient client, EnvironmentMetadata metadata) + { + // No-op for testing + } + } + + public class PluginConfigurationBuilderTest + { + [Fact] + public void Constructor_WithNoParameters_CreatesEmptyConfiguration() + { + var builder = new PluginConfigurationBuilder(); + var config = builder.Build(); + + Assert.NotNull(config.Plugins); + Assert.Empty(config.Plugins); + } + + [Fact] + public void Constructor_WithPluginCollection_CreatesConfigurationWithPlugins() + { + var plugin1 = new TestPlugin("plugin1"); + var plugin2 = new TestPlugin("plugin2"); + var plugins = new List { plugin1, plugin2 }; + + var builder = new PluginConfigurationBuilder(plugins); + var config = builder.Build(); + + Assert.Equal(2, config.Plugins.Count()); + Assert.Contains(plugin1, config.Plugins); + Assert.Contains(plugin2, config.Plugins); + } + + [Fact] + public void Add_MultiplePlugins_AddsAllPluginsToConfiguration() + { + var builder = new PluginConfigurationBuilder(); + var plugin1 = new TestPlugin("plugin1"); + var plugin2 = new TestPlugin("plugin2"); + var plugin3 = new TestPlugin("plugin3"); + + builder.Add(plugin1).Add(plugin2).Add(plugin3); + var config = builder.Build(); + + Assert.Equal(3, config.Plugins.Count()); + Assert.Contains(plugin1, config.Plugins); + Assert.Contains(plugin2, config.Plugins); + Assert.Contains(plugin3, config.Plugins); + } + + [Fact] + public void Add_ReturnsBuilderInstance_AllowsFluentInterface() + { + var builder = new PluginConfigurationBuilder(); + var plugin = new TestPlugin("test-plugin"); + + var returnedBuilder = builder.Add(plugin); + + Assert.Same(builder, returnedBuilder); + } + + [Fact] + public void Build_CanBeCalledMultipleTimes() + { + var builder = new PluginConfigurationBuilder(); + var plugin = new TestPlugin("test-plugin"); + builder.Add(plugin); + + var config1 = builder.Build(); + var config2 = builder.Build(); + + Assert.Single(config1.Plugins); + Assert.Single(config2.Plugins); + Assert.Contains(plugin, config1.Plugins); + Assert.Contains(plugin, config2.Plugins); + } + + [Fact] + public void Build_ModifyingBuilderAfterBuild_DoesNotAffectPreviousConfiguration() + { + var builder = new PluginConfigurationBuilder(); + var plugin1 = new TestPlugin("plugin1"); + var plugin2 = new TestPlugin("plugin2"); + + builder.Add(plugin1); + var config1 = builder.Build(); + + builder.Add(plugin2); + var config2 = builder.Build(); + + Assert.Single(config1.Plugins); + Assert.Equal(2, config2.Plugins.Count()); + Assert.Contains(plugin1, config1.Plugins); + Assert.DoesNotContain(plugin2, config1.Plugins); + } + + [Fact] + public void Constructor_WithEmptyPluginCollection_CreatesEmptyConfiguration() + { + var builder = new PluginConfigurationBuilder(new List()); + var config = builder.Build(); + + Assert.NotNull(config.Plugins); + Assert.Empty(config.Plugins); + } + + [Fact] + public void Constructor_WithNullPluginCollection_CreatesEmptyConfiguration() + { + var builder = new PluginConfigurationBuilder(null); + var config = builder.Build(); + + Assert.NotNull(config.Plugins); + Assert.Empty(config.Plugins); + } + } +}