diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Components.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Components.java index 05adbbf..d996d3b 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -10,6 +10,7 @@ import com.launchdarkly.sdk.server.ComponentsImpl.LoggingConfigurationBuilderImpl; import com.launchdarkly.sdk.server.ComponentsImpl.NullDataSourceFactory; import com.launchdarkly.sdk.server.ComponentsImpl.PersistentDataStoreBuilderImpl; +import com.launchdarkly.sdk.server.ComponentsImpl.PluginsConfigurationBuilderImpl; import com.launchdarkly.sdk.server.ComponentsImpl.PollingDataSourceBuilderImpl; import com.launchdarkly.sdk.server.ComponentsImpl.ServiceEndpointsBuilderImpl; import com.launchdarkly.sdk.server.ComponentsImpl.StreamingDataSourceBuilderImpl; @@ -21,6 +22,7 @@ import com.launchdarkly.sdk.server.integrations.HttpConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.LoggingConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; +import com.launchdarkly.sdk.server.integrations.PluginsConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; import com.launchdarkly.sdk.server.integrations.ServiceEndpointsBuilder; import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; @@ -445,6 +447,26 @@ public static HooksConfigurationBuilder hooks() { return new HooksConfigurationBuilderImpl(); } + /** + * Returns a builder for configuring plugins. + * + * Passing this to {@link LDConfig.Builder#plugins(com.launchdarkly.sdk.server.integrations.PluginsConfigurationBuilder)}, + * after setting any desired plugins on the builder, applies this configuration to the SDK. + *

+   *     List plugins = myCreatePluginsFunc();
+   *     LDConfig config = new LDConfig.Builder()
+   *         .plugins(
+   *             Components.plugins()
+   *                 .setPlugins(plugins)
+   *         )
+   *         .build();
+   * 
+ * @return a {@link PluginsConfigurationBuilder} that can be used for customization + */ + public static PluginsConfigurationBuilder plugins() { + return new PluginsConfigurationBuilderImpl(); + } + /** * Returns a wrapper information builder. *

diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java index ea40f41..cf9351b 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java @@ -17,6 +17,7 @@ import com.launchdarkly.sdk.server.integrations.HttpConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.LoggingConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; +import com.launchdarkly.sdk.server.integrations.PluginsConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; import com.launchdarkly.sdk.server.integrations.ServiceEndpointsBuilder; import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; @@ -35,6 +36,8 @@ import com.launchdarkly.sdk.server.subsystems.HttpConfiguration; import com.launchdarkly.sdk.server.subsystems.LoggingConfiguration; import com.launchdarkly.sdk.server.subsystems.PersistentDataStore; +import com.launchdarkly.sdk.server.subsystems.PluginsConfiguration; + import okhttp3.Credentials; import java.io.IOException; @@ -478,6 +481,19 @@ public HookConfiguration build() { } } + static final class PluginsConfigurationBuilderImpl extends PluginsConfigurationBuilder { + public static PluginsConfigurationBuilderImpl fromPluginsConfiguration(PluginsConfiguration pluginsConfiguration) { + PluginsConfigurationBuilderImpl builder = new PluginsConfigurationBuilderImpl(); + builder.setPlugins(pluginsConfiguration.getPlugins()); + return builder; + } + + @Override + public PluginsConfiguration build() { + return new PluginsConfiguration(plugins); + } + } + static final class WrapperInfoBuilderImpl extends WrapperInfoBuilder { public WrapperInfoBuilderImpl() { this(null, null); diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 4e6bda3..9b29053 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -9,6 +9,10 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.LDValueType; import com.launchdarkly.sdk.internal.http.HttpHelpers; +import com.launchdarkly.sdk.server.integrations.EnvironmentMetadata; +import com.launchdarkly.sdk.server.integrations.Hook; +import com.launchdarkly.sdk.server.integrations.Plugin; +import com.launchdarkly.sdk.server.integrations.SdkMetadata; import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.BigSegmentsConfiguration; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; @@ -30,6 +34,9 @@ import java.io.UnsupportedEncodingException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; @@ -206,13 +213,33 @@ public LDClient(String sdkKey, LDConfig config) { EvaluatorInterface evaluator = new InputValidatingEvaluator(dataStore, bigSegmentStoreWrapper, eventProcessor, evaluationLogger); + // build environment metadata for plugins + SdkMetadata sdkMetadata; + if (config.wrapperInfo == null) { + sdkMetadata = new SdkMetadata("JavaClient", Version.SDK_VERSION); + } else { + sdkMetadata = new SdkMetadata("JavaClient", Version.SDK_VERSION, config.wrapperInfo.getWrapperName(), config.wrapperInfo.getWrapperVersion()); + } + EnvironmentMetadata environmentMetadata = new EnvironmentMetadata(config.applicationInfo, sdkMetadata, sdkKey); + + // add plugin hooks + List allHooks = new ArrayList<>(config.hooks.getHooks()); + for (Plugin plugin : config.plugins.getPlugins()) { + try { + allHooks.addAll(plugin.getHooks(environmentMetadata)); + } catch (Exception e) { + baseLogger.error("Exception thrown getting hooks for plugin " + plugin.getMetadata().getName() + ". Unable to get hooks, plugin will not be registered."); + } + } + allHooks = Collections.unmodifiableList(allHooks); + // decorate evaluator with hooks if hooks were provided - if (config.hooks.getHooks().isEmpty()) { + if (allHooks.isEmpty()) { this.evaluator = evaluator; this.migrationEvaluator = new MigrationStageEnforcingEvaluator(evaluator, evaluationLogger); } else { - this.evaluator = new EvaluatorWithHooks(evaluator, config.hooks.getHooks(), this.baseLogger.subLogger(Loggers.HOOKS_LOGGER_NAME)); - this.migrationEvaluator = new EvaluatorWithHooks(new MigrationStageEnforcingEvaluator(evaluator, evaluationLogger), config.hooks.getHooks(), this.baseLogger.subLogger(Loggers.HOOKS_LOGGER_NAME)); + this.evaluator = new EvaluatorWithHooks(evaluator, allHooks, this.baseLogger.subLogger(Loggers.HOOKS_LOGGER_NAME)); + this.migrationEvaluator = new EvaluatorWithHooks(new MigrationStageEnforcingEvaluator(evaluator, evaluationLogger), allHooks, this.baseLogger.subLogger(Loggers.HOOKS_LOGGER_NAME)); } this.flagChangeBroadcaster = EventBroadcasterImpl.forFlagChangeEvents(sharedExecutor, baseLogger); @@ -236,6 +263,15 @@ public LDClient(String sdkKey, LDConfig config) { this.dataSource = config.dataSource.build(context.withDataSourceUpdateSink(dataSourceUpdates)); this.dataSourceStatusProvider = new DataSourceStatusProviderImpl(dataSourceStatusNotifier, dataSourceUpdates); + // register plugins as soon as possible after client is valid + for (Plugin plugin : config.plugins.getPlugins()) { + try { + plugin.register(this, environmentMetadata); + } catch (Exception e) { + baseLogger.error("Exception thrown registering plugin " + plugin.getMetadata().getName() + ". Plugin will not be registered."); + } + } + Future startFuture = dataSource.start(); if (!config.startWait.isZero() && !config.startWait.isNegative()) { if (!(dataSource instanceof ComponentsImpl.NullDataSource)) { diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/LDConfig.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/LDConfig.java index f0fac29..c840a50 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/LDConfig.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/LDConfig.java @@ -4,6 +4,7 @@ import com.launchdarkly.sdk.EvaluationReason.BigSegmentsStatus; import com.launchdarkly.sdk.server.integrations.ApplicationInfoBuilder; import com.launchdarkly.sdk.server.integrations.HooksConfigurationBuilder; +import com.launchdarkly.sdk.server.integrations.PluginsConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.ServiceEndpointsBuilder; import com.launchdarkly.sdk.server.integrations.WrapperInfoBuilder; import com.launchdarkly.sdk.server.interfaces.ApplicationInfo; @@ -17,6 +18,7 @@ import com.launchdarkly.sdk.server.subsystems.HookConfiguration; import com.launchdarkly.sdk.server.subsystems.HttpConfiguration; import com.launchdarkly.sdk.server.subsystems.LoggingConfiguration; +import com.launchdarkly.sdk.server.subsystems.PluginsConfiguration; import java.time.Duration; @@ -38,6 +40,7 @@ public final class LDConfig { final boolean diagnosticOptOut; final ComponentConfigurer events; final HookConfiguration hooks; + final PluginsConfiguration plugins; final ComponentConfigurer http; final ComponentConfigurer logging; final ServiceEndpoints serviceEndpoints; @@ -61,6 +64,7 @@ protected LDConfig(Builder builder) { this.dataStore = builder.dataStore == null ? Components.inMemoryDataStore() : builder.dataStore; this.diagnosticOptOut = builder.diagnosticOptOut; this.hooks = (builder.hooksConfigurationBuilder == null ? Components.hooks() : builder.hooksConfigurationBuilder).build(); + this.plugins = (builder.pluginsConfigurationBuilder == null ? Components.plugins() : builder.pluginsConfigurationBuilder).build(); this.http = builder.http == null ? Components.httpConfiguration() : builder.http; this.logging = builder.logging == null ? Components.logging() : builder.logging; this.offline = builder.offline; @@ -91,6 +95,7 @@ public static class Builder { private boolean diagnosticOptOut = false; private ComponentConfigurer events = null; private HooksConfigurationBuilder hooksConfigurationBuilder = null; + private PluginsConfigurationBuilder pluginsConfigurationBuilder = null; private ComponentConfigurer http = null; private ComponentConfigurer logging = null; private ServiceEndpointsBuilder serviceEndpointsBuilder = null; @@ -120,6 +125,7 @@ public static Builder fromConfig(LDConfig config) { newBuilder.diagnosticOptOut = config.diagnosticOptOut; newBuilder.events = config.events; newBuilder.hooksConfigurationBuilder = ComponentsImpl.HooksConfigurationBuilderImpl.fromHooksConfiguration(config.hooks); + newBuilder.pluginsConfigurationBuilder = ComponentsImpl.PluginsConfigurationBuilderImpl.fromPluginsConfiguration(config.plugins); newBuilder.http = config.http; newBuilder.logging = config.logging; @@ -270,6 +276,22 @@ public Builder hooks(HooksConfigurationBuilder hooksConfiguration) { return this; } + /** + * Sets the SDK's plugins configuration, using a builder. This is normally a obtained from + * {@link Components#plugins()} ()}, which has methods for setting individual other plugin + * related properties. + *

+ * Plugin support is currently experimental and subject to change. + * + * @param pluginsConfiguration the plugins configuration builder + * @return the main configuration builder + * @see Components#plugins() + */ + public Builder plugins(PluginsConfigurationBuilder pluginsConfiguration) { + this.pluginsConfigurationBuilder = pluginsConfiguration; + return this; + } + /** * Sets the SDK's networking configuration, using a configuration builder. This builder is * obtained from {@link Components#httpConfiguration()}, and has methods for setting individual diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/EnvironmentMetadata.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/EnvironmentMetadata.java new file mode 100644 index 0000000..5672735 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/EnvironmentMetadata.java @@ -0,0 +1,44 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.server.interfaces.ApplicationInfo; + +/** + * Metadata about the environment that flag evaluations or other functionalities are being performed in. + */ +public final class EnvironmentMetadata { + private final ApplicationInfo applicationInfo; + private final SdkMetadata sdkMetadata; + private final String sdkKey; + + /** + * @param applicationInfo for the application this SDK is used in + * @param sdkMetadata for the LaunchDarkly SDK + * @param sdkKey for the key used to initialize the SDK client + */ + public EnvironmentMetadata(ApplicationInfo applicationInfo, SdkMetadata sdkMetadata, String sdkKey) { + this.applicationInfo = applicationInfo; + this.sdkMetadata = sdkMetadata; + this.sdkKey = sdkKey; + } + + /** + * @return the {@link ApplicationInfo} for the application this SDK is used in. + */ + public ApplicationInfo getApplicationInfo() { + return applicationInfo; + } + + /** + * @return the {@link SdkMetadata} for the LaunchDarkly SDK. + */ + public SdkMetadata getSdkMetadata() { + return sdkMetadata; + } + + /** + * @return the key used to initialize the SDK client + */ + public String getSdkKey() { + return sdkKey; + } +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/Plugin.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/Plugin.java new file mode 100644 index 0000000..4504e9f --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/Plugin.java @@ -0,0 +1,38 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.server.LDClient; + +import java.util.Collections; +import java.util.List; + +/** + * Abstract class that you can extend to create a plugin to the LaunchDarkly SDK. + */ +public abstract class Plugin { + /** + * @return the {@link PluginMetadata} that gives details about the plugin. + */ + public abstract PluginMetadata getMetadata(); + + /** + * Registers the plugin with the SDK. Called once during SDK initialization. + * The SDK initialization will typically not have been completed at this point, so the plugin should take appropriate + * actions to ensure the SDK is ready before sending track events or evaluating flags. + * + * @param client for the plugin to use + * @param metadata metadata about the environment where the plugin is running. + */ + public abstract void register(LDClient client, EnvironmentMetadata metadata); + + /** + * Gets a list of hooks that the plugin wants to register. + * This method will be called once during SDK initialization before the register method is called. + * If the plugin does not need to register any hooks, this method doesn't need to be implemented. + * + * @param metadata metadata about the environment where the plugin is running. + * @return a list of hooks that the plugin wants to register. + */ + public List getHooks(EnvironmentMetadata metadata) { + return Collections.emptyList(); + } +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/PluginMetadata.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/PluginMetadata.java new file mode 100644 index 0000000..de8ad61 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/PluginMetadata.java @@ -0,0 +1,11 @@ +package com.launchdarkly.sdk.server.integrations; + +/** + * PluginMetadata contains information about a specific plugin implementation + */ +public abstract class PluginMetadata { + /** + * @return the name of the plugin implementation + */ + public abstract String getName(); +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/PluginsConfigurationBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/PluginsConfigurationBuilder.java new file mode 100644 index 0000000..a38b380 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/PluginsConfigurationBuilder.java @@ -0,0 +1,51 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.subsystems.PluginsConfiguration; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Contains methods for configuring the SDK's 'plugins'. + *

+ * If you want to add plugins, use {@link Components#plugins()}, configure accordingly, and pass it + * to {@link com.launchdarkly.sdk.server.LDConfig.Builder#plugins(PluginsConfigurationBuilder)}. + * + *


+ *     List plugins = getPluginsFunc();
+ *     LDConfig config = new LDConfig.Builder()
+ *         .plugins(
+ *             Components.plugins()
+ *                 .setPlugins(plugins)
+ *         )
+ *         .build();
+ * 
+ *

+ * Note that this class is abstract; the actual implementation is created by calling {@link Components#plugins()}. + */ +public abstract class PluginsConfigurationBuilder { + /** + * The current set of plugins the builder has. + */ + protected List plugins = Collections.emptyList(); + + /** + * Sets the provided list of plugins on the configuration. Note that the order of plugins is important and controls + * the order in which they will be registered. See {@link Plugin} for more details. + * + * @param plugins to be set on the configuration + * @return the builder + */ + public PluginsConfigurationBuilder setPlugins(List plugins) { + // copy to avoid list manipulations impacting the SDK + this.plugins = Collections.unmodifiableList(new ArrayList<>(plugins)); + return this; + } + + /** + * @return the plugins configuration + */ + public abstract PluginsConfiguration build(); +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/SdkMetadata.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/SdkMetadata.java new file mode 100644 index 0000000..6623d74 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/SdkMetadata.java @@ -0,0 +1,50 @@ +package com.launchdarkly.sdk.server.integrations; + +/** + * Metadata about the LaunchDarkly SDK. + */ +public final class SdkMetadata { + private final String name; + private final String version; + private final String wrapperName; + private final String wrapperVersion; + + public SdkMetadata(String name, String version) { + this(name, version, null, null); + } + + public SdkMetadata(String name, String version, String wrapperName, String wrapperVersion) { + this.name = name; + this.version = version; + this.wrapperName = wrapperName; + this.wrapperVersion = wrapperVersion; + } + + /** + * @return name of the SDK for informational purposes such as logging + */ + public String getName() { + return name; + } + + /** + * @return version of the SDK for informational purposes such as logging + */ + public String getVersion() { + return version; + } + + /** + * @return name of the wrapper if this is a wrapper SDK + */ + public String getWrapperName() { + return wrapperName; + } + + /** + * @return version of the wrapper if this is a wrapper SDK + */ + public String getWrapperVersion() { + return wrapperVersion; + } +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/PluginsConfiguration.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/PluginsConfiguration.java new file mode 100644 index 0000000..8bd3585 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/PluginsConfiguration.java @@ -0,0 +1,30 @@ +package com.launchdarkly.sdk.server.subsystems; + +import com.launchdarkly.sdk.server.integrations.Plugin; +import com.launchdarkly.sdk.server.integrations.PluginsConfigurationBuilder; + +import java.util.Collections; +import java.util.List; + +/** + * Encapsulates the SDK's 'plugins' configuration. + *

+ * Use {@link PluginsConfigurationBuilder} to construct an instance. + */ +public class PluginsConfiguration { + private final List plugins; + + /** + * @param plugins the list of {@link Plugin} that will be registered. + */ + public PluginsConfiguration(List plugins) { + this.plugins = Collections.unmodifiableList(plugins); + } + + /** + * @return immutable list of plugins + */ + public List getPlugins() { + return plugins; + } +} diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientPluginsTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientPluginsTest.java new file mode 100644 index 0000000..c2626a7 --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientPluginsTest.java @@ -0,0 +1,234 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.server.integrations.EnvironmentMetadata; +import com.launchdarkly.sdk.server.integrations.Hook; +import com.launchdarkly.sdk.server.integrations.Plugin; +import com.launchdarkly.sdk.server.integrations.PluginMetadata; +import com.launchdarkly.sdk.server.integrations.SdkMetadata; +import com.launchdarkly.sdk.server.interfaces.ApplicationInfo; + +import org.junit.Test; +import org.easymock.IArgumentMatcher; + +import static org.easymock.EasyMock.isA; +import static org.easymock.EasyMock.anyObject; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.expectLastCall; +import static org.easymock.EasyMock.mock; +import static org.easymock.EasyMock.replay; +import static org.easymock.EasyMock.reportMatcher; +import static org.easymock.EasyMock.verify; +import static org.junit.Assert.assertTrue; + +import java.util.Collections; +import java.util.Objects; + +@SuppressWarnings("javadoc") +public class LDClientPluginsTest extends BaseTest { + private static EnvironmentMetadata envMetaEquals(EnvironmentMetadata metadata) { + reportMatcher(new IArgumentMatcher() { + @Override + public boolean matches(Object argument) { + if (metadata == argument) { + return true; + } + if (metadata == null || argument == null || !(argument instanceof EnvironmentMetadata)) { + return false; + } + EnvironmentMetadata target = (EnvironmentMetadata)argument; + return ( + Objects.equals(metadata.getSdkKey(), target.getSdkKey()) && + Objects.equals(metadata.getSdkMetadata().getName(), target.getSdkMetadata().getName()) && + Objects.equals(metadata.getSdkMetadata().getVersion(), target.getSdkMetadata().getVersion()) && + Objects.equals(metadata.getSdkMetadata().getWrapperName(), target.getSdkMetadata().getWrapperName()) && + Objects.equals(metadata.getSdkMetadata().getWrapperVersion(), target.getSdkMetadata().getWrapperVersion()) && + Objects.equals(metadata.getApplicationInfo().getApplicationId(), target.getApplicationInfo().getApplicationId()) && + Objects.equals(metadata.getApplicationInfo().getApplicationVersion(), target.getApplicationInfo().getApplicationVersion()) + ); + } + @Override + public void appendTo(StringBuffer buffer) { + buffer.append("envMetaEquals()"); + } + }); + return null; + } + + private final static String SDK_KEY = "SDK_KEY"; + + @Test + public void pluginsAreProvidedEnvironmentMetadata() throws Exception { + EnvironmentMetadata expected = new EnvironmentMetadata( + new ApplicationInfo(null, null), + new SdkMetadata("JavaClient", Version.SDK_VERSION), + SDK_KEY + ); + + Plugin mockPlugin = mock(Plugin.class); + expect(mockPlugin.getHooks(envMetaEquals(expected))).andReturn(Collections.emptyList()); + mockPlugin.register(isA(LDClient.class), envMetaEquals(expected)); + expectLastCall(); + replay(mockPlugin); + + LDConfig config = baseConfig() + .plugins(Components.plugins().setPlugins(Collections.singletonList(mockPlugin))) + .build(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + verify(mockPlugin); + } + } + + @Test + public void pluginsAreProvidedEnvironmentMetadataWithApplicationInfo() throws Exception { + EnvironmentMetadata expected = new EnvironmentMetadata( + new ApplicationInfo("app-id", "app-version"), + new SdkMetadata("JavaClient", Version.SDK_VERSION), + SDK_KEY + ); + + Plugin mockPlugin = mock(Plugin.class); + expect(mockPlugin.getHooks(envMetaEquals(expected))).andReturn(Collections.emptyList()); + mockPlugin.register(isA(LDClient.class), envMetaEquals(expected)); + expectLastCall(); + replay(mockPlugin); + + LDConfig config = baseConfig() + .plugins(Components.plugins().setPlugins(Collections.singletonList(mockPlugin))) + .applicationInfo(Components.applicationInfo().applicationId("app-id").applicationVersion("app-version")) + .build(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + verify(mockPlugin); + } + } + + @Test + public void pluginsAreProvidedEnvironmentMetadataWithWrapper() throws Exception { + EnvironmentMetadata expected = new EnvironmentMetadata( + new ApplicationInfo(null, null), + new SdkMetadata("JavaClient", Version.SDK_VERSION, "wrapper-name", "wrapper-version"), + SDK_KEY + ); + + Plugin mockPlugin = mock(Plugin.class); + expect(mockPlugin.getHooks(envMetaEquals(expected))).andReturn(Collections.emptyList()); + mockPlugin.register(isA(LDClient.class), envMetaEquals(expected)); + expectLastCall(); + replay(mockPlugin); + + LDConfig config = baseConfig() + .plugins(Components.plugins().setPlugins(Collections.singletonList(mockPlugin))) + .wrapper(Components.wrapperInfo().wrapperName("wrapper-name").wrapperVersion("wrapper-version")) + .build(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + verify(mockPlugin); + } + } + + @Test + public void pluginHooksAreRegistered() throws Exception { + Hook mockHook = mock(Hook.class); + expect(mockHook.beforeEvaluation(anyObject(), anyObject())).andReturn(Collections.emptyMap()); + expect(mockHook.afterEvaluation(anyObject(), anyObject(), anyObject())).andReturn(Collections.emptyMap()); + + Plugin mockPlugin = mock(Plugin.class); + expect(mockPlugin.getHooks(anyObject())).andReturn(Collections.singletonList(mockHook)); + mockPlugin.register(anyObject(), anyObject()); + expectLastCall(); + + replay(mockHook, mockPlugin); + + LDConfig config = baseConfig() + .plugins(Components.plugins().setPlugins(Collections.singletonList(mockPlugin))) + .build(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + client.boolVariation(SDK_KEY, LDContext.create("test-context"), false); + verify(mockHook, mockPlugin); + } + } + + @Test + public void handlesExceptionInGetHooks() throws Exception { + Plugin mockPlugin = mock(Plugin.class); + expect(mockPlugin.getHooks(anyObject())).andThrow(new RuntimeException("test error")); + expect(mockPlugin.getMetadata()).andReturn(new PluginMetadata() { + @Override + public String getName() { + return "TestPlugin"; + } + }); + mockPlugin.register(anyObject(), anyObject()); + expectLastCall(); + + replay(mockPlugin); + + LDConfig config = baseConfig() + .plugins(Components.plugins().setPlugins(Collections.singletonList(mockPlugin))) + .build(); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertTrue( + logCapture.getMessageStrings().contains( + "ERROR:Exception thrown getting hooks for plugin TestPlugin. Unable to get hooks, plugin will not be registered." + ) + ); + verify(mockPlugin); + } + } + + @Test + public void pluginHooksAreRegisteredWithExistingHooks() throws Exception { + Hook mockExistingHook = mock(Hook.class); + expect(mockExistingHook.beforeEvaluation(anyObject(), anyObject())).andReturn(Collections.emptyMap()); + expect(mockExistingHook.afterEvaluation(anyObject(), anyObject(), anyObject())).andReturn(Collections.emptyMap()); + + Hook mockPluginHook = mock(Hook.class); + expect(mockPluginHook.beforeEvaluation(anyObject(), anyObject())).andReturn(Collections.emptyMap()); + expect(mockPluginHook.afterEvaluation(anyObject(), anyObject(), anyObject())).andReturn(Collections.emptyMap()); + + Plugin mockPlugin = mock(Plugin.class); + expect(mockPlugin.getHooks(anyObject())).andReturn(Collections.singletonList(mockPluginHook)); + mockPlugin.register(anyObject(), anyObject()); + expectLastCall(); + + replay(mockExistingHook, mockPluginHook, mockPlugin); + + LDConfig config = baseConfig() + .hooks(Components.hooks().setHooks(Collections.singletonList(mockExistingHook))) + .plugins(Components.plugins().setPlugins(Collections.singletonList(mockPlugin))) + .build(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + client.boolVariation(SDK_KEY, LDContext.create("test-context"), false); + verify(mockExistingHook, mockPluginHook, mockPlugin); + } + } + + @Test + public void handlesExceptionInRegister() throws Exception { + Plugin mockPlugin = mock(Plugin.class); + expect(mockPlugin.getHooks(anyObject())).andReturn(Collections.emptyList()); + expect(mockPlugin.getMetadata()).andReturn(new PluginMetadata() { + @Override + public String getName() { + return "TestPlugin"; + } + }); + mockPlugin.register(anyObject(), anyObject()); + expectLastCall().andThrow(new RuntimeException("test error")); + + replay(mockPlugin); + + LDConfig config = baseConfig() + .plugins(Components.plugins().setPlugins(Collections.singletonList(mockPlugin))) + .build(); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertTrue( + logCapture.getMessageStrings().contains( + "ERROR:Exception thrown registering plugin TestPlugin. Plugin will not be registered." + ) + ); + verify(mockPlugin); + } + } +} diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java index af894e8..a5540ac 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java @@ -3,6 +3,7 @@ import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.integrations.MockPersistentDataStore; +import com.launchdarkly.sdk.server.integrations.Plugin; import com.launchdarkly.sdk.server.integrations.Hook; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.LDClientInterface; @@ -31,9 +32,12 @@ import static com.launchdarkly.sdk.server.TestComponents.initedDataStore; import static com.launchdarkly.sdk.server.TestComponents.specificComponent; import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; +import static org.easymock.EasyMock.anyObject; import static org.easymock.EasyMock.capture; import static org.easymock.EasyMock.expect; import static org.easymock.EasyMock.mock; +import static org.easymock.EasyMock.niceMock; +import static org.easymock.EasyMock.replay; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.not; @@ -229,6 +233,17 @@ public void canSetHooks() throws Exception { try (LDClient client = new LDClient(SDK_KEY, config2)) { assertEquals(EvaluatorWithHooks.class, client.evaluator.getClass()); } + + Plugin mockPlugin = niceMock(Plugin.class); + expect(mockPlugin.getHooks(anyObject())).andReturn(Collections.singletonList(mock(Hook.class))); + replay(mockPlugin); + + LDConfig config3 = new LDConfig.Builder() + .plugins(Components.plugins().setPlugins(Collections.singletonList(mockPlugin))) + .build(); + try (LDClient client = new LDClient(SDK_KEY, config3)) { + assertEquals(EvaluatorWithHooks.class, client.evaluator.getClass()); + } } @Test diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java index 100cac1..5a062fb 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java @@ -6,6 +6,8 @@ import com.launchdarkly.sdk.server.integrations.HooksConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.HttpConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.LoggingConfigurationBuilder; +import com.launchdarkly.sdk.server.integrations.Plugin; +import com.launchdarkly.sdk.server.integrations.PluginsConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.ServiceEndpointsBuilder; import com.launchdarkly.sdk.server.integrations.WrapperInfoBuilder; import com.launchdarkly.sdk.server.integrations.Hook; @@ -128,6 +130,15 @@ public void hooks() { assertEquals(mockHook, config.hooks.getHooks().get(0)); } + @Test + public void plugins() { + Plugin mockPlugin = mock(Plugin.class); + PluginsConfigurationBuilder b = Components.plugins().setPlugins(Collections.singletonList(mockPlugin)); + LDConfig config = new LDConfig.Builder().plugins(b).build(); + assertEquals(1, config.plugins.getPlugins().size()); + assertEquals(mockPlugin, config.plugins.getPlugins().get(0)); + } + @Test public void http() { HttpConfigurationBuilder b = Components.httpConfiguration().connectTimeout(Duration.ofSeconds(9)); @@ -197,6 +208,8 @@ public void fromConfig() { ComponentConfigurer eventProcessor = specificComponent(null); Hook mockHook = mock(Hook.class); HooksConfigurationBuilder hooksBuilder = Components.hooks().setHooks(Collections.singletonList(mockHook)); + Plugin mockPlugin = mock(Plugin.class); + PluginsConfigurationBuilder pluginsBuilder = Components.plugins().setPlugins(Collections.singletonList(mockPlugin)); HttpConfigurationBuilder http = Components.httpConfiguration().connectTimeout(Duration.ofSeconds(9)); WrapperInfoBuilder wrapperInfo = Components.wrapperInfo().wrapperName("the-name").wrapperVersion("the-version"); ApplicationInfoBuilder applicationInfo = Components.applicationInfo().applicationId("test").applicationVersion("version"); @@ -211,6 +224,7 @@ public void fromConfig() { .diagnosticOptOut(true) .offline(false) // To keep the data source from being removed in the build. .hooks(hooksBuilder) + .plugins(pluginsBuilder) .http(http) .serviceEndpoints(serviceEndpoints) .wrapper(wrapperInfo).build(); @@ -233,6 +247,7 @@ public void fromConfig() { assertEquals(URI.create("events"), config2.serviceEndpoints.getEventsBaseUri()); assertEquals(mockHook, config2.hooks.getHooks().get(0)); + assertEquals(mockPlugin, config2.plugins.getPlugins().get(0)); } @Test @@ -252,6 +267,9 @@ public void fromConfigDefault() { assertNotNull(config.hooks.getHooks()); assertEquals(0, config.hooks.getHooks().size()); + assertNotNull(config.plugins.getPlugins()); + assertEquals(0, config.plugins.getPlugins().size()); + assertNotNull(config.http); HttpConfiguration httpConfig = config.http.build(BASIC_CONTEXT); assertEquals(HttpConfigurationBuilder.DEFAULT_CONNECT_TIMEOUT, httpConfig.getConnectTimeout()); diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/PluginsConfigurationBuilderTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/PluginsConfigurationBuilderTest.java new file mode 100644 index 0000000..b9fb864 --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/integrations/PluginsConfigurationBuilderTest.java @@ -0,0 +1,29 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.subsystems.PluginsConfiguration; +import org.junit.Test; + +import java.util.Arrays; + +import static org.easymock.EasyMock.mock; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; + +public class PluginsConfigurationBuilderTest { + @Test + public void emptyPluginsAsDefault() { + PluginsConfiguration configuration = Components.plugins().build(); + assertEquals(0, configuration.getPlugins().size()); + } + + @Test + public void canSetPlugins() { + Plugin pluginA = mock(Plugin.class); + Plugin pluginB = mock(Plugin.class); + PluginsConfiguration configuration = Components.plugins().setPlugins(Arrays.asList(pluginA, pluginB)).build(); + assertEquals(2, configuration.getPlugins().size()); + assertSame(pluginA, configuration.getPlugins().get(0)); + assertSame(pluginB, configuration.getPlugins().get(1)); + } +}