From b1dd7134616c528598aa19552e44298c09ddf49e Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 4 Nov 2026 18:03:44 -0800 Subject: [PATCH] feat: add payloadFilter option to request a filtered environment Co-authored-by: Cursor --- .../launchdarkly/sdktest/Representations.java | 2 + .../launchdarkly/sdktest/SdkClientEntity.java | 6 ++ .../com/launchdarkly/sdktest/TestService.java | 4 +- .../DataSourceBuildInputsInternal.java | 13 ++- .../sdk/android/DataSystemComponents.java | 9 +- .../sdk/android/FDv2DataSourceBuilder.java | 3 +- .../android/FDv2StreamingSynchronizer.java | 6 +- .../sdk/android/HttpFeatureFlagFetcher.java | 7 ++ .../launchdarkly/sdk/android/LDConfig.java | 49 +++++++++++ .../com/launchdarkly/sdk/android/LDUtil.java | 12 +++ .../sdk/android/StreamingDataSource.java | 4 +- .../DataSourceBuildInputsInternalTest.java | 2 +- .../sdk/android/DataSystemComponentsTest.java | 80 +++++++++++++++++- .../FDv2StreamingSynchronizerTest.java | 40 ++++++++- .../sdk/android/LDConfigTest.java | 43 ++++++++++ .../sdk/android/StreamingDataSourceTest.java | 83 ++++++++++++++++++- 16 files changed, 350 insertions(+), 13 deletions(-) diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java index 5c94b5806..f53488fcc 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java @@ -45,11 +45,13 @@ public static class SdkConfigParams { public static class SdkConfigStreamParams { String baseUri; Long initialRetryDelayMs; + String filter; } public static class SdkConfigPollParams { String baseUri; Long pollIntervalMs; + String filter; } public static class SdkConfigEventParams { diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java index 2056d47e2..52e6ce2f9 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java @@ -327,6 +327,12 @@ private LDConfig buildSdkConfig(SdkConfigParams params, LDLogAdapter logAdapter, builder.events(eventsBuilder); } + if (params.streaming != null && params.streaming.filter != null) { + builder.payloadFilter(params.streaming.filter); + } else if (params.polling != null && params.polling.filter != null) { + builder.payloadFilter(params.polling.filter); + } + builder.evaluationReasons(params.clientSide.evaluationReasons); builder.http( Components.httpConfiguration() diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java index 246388bc7..cfbda370c 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java @@ -46,7 +46,9 @@ public class TestService extends NanoHTTPD { "client-event-source-http-errors", "retry-conformance-fdv1-streaming", "retry-conformance-fdv1-polling", - "client-use-post" + "client-use-post", + "filtering", + "filtering-strict" }; private static final String MIME_JSON = "application/json"; static final Gson gson = new GsonBuilder() diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternal.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternal.java index 1299859b5..7dfb5fd69 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternal.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternal.java @@ -30,6 +30,9 @@ final class DataSourceBuildInputsInternal extends DataSourceBuildInputs { @Nullable private final PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData perEnvironmentData; + @Nullable + private final String payloadFilter; + DataSourceBuildInputsInternal( LDContext evaluationContext, ServiceEndpoints serviceEndpoints, @@ -39,11 +42,13 @@ final class DataSourceBuildInputsInternal extends DataSourceBuildInputs { ScheduledExecutorService sharedExecutor, @NonNull File cacheDir, LDLogger baseLogger, - @Nullable PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData perEnvironmentData + @Nullable PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData perEnvironmentData, + @Nullable String payloadFilter ) { super(evaluationContext, serviceEndpoints, http, evaluationReasons, selectorSource, sharedExecutor, cacheDir, baseLogger); this.perEnvironmentData = perEnvironmentData; + this.payloadFilter = payloadFilter; } /** @@ -64,6 +69,7 @@ static DataSourceBuildInputsInternal get(DataSourceBuildInputs inputs) { inputs.getSharedExecutor(), inputs.getCacheDir(), inputs.getBaseLogger(), + null, null ); } @@ -72,4 +78,9 @@ static DataSourceBuildInputsInternal get(DataSourceBuildInputs inputs) { PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData getPerEnvironmentDataIfAvailable() { return perEnvironmentData; } + + @Nullable + String getPayloadFilter() { + return payloadFilter; + } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java index 7e6c5ec1d..2c98ea945 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java @@ -141,7 +141,9 @@ public Synchronizer build(DataSourceBuildInputs inputs) { StandardEndpoints.FDV2_STREAMING_REQUEST_BASE_PATH, requestor, initialReconnectDelayMillis, - inputs.isEvaluationReasons(), inputs.getHttp().shouldUsePost(), + inputs.isEvaluationReasons(), + DataSourceBuildInputsInternal.get(inputs).getPayloadFilter(), + inputs.getHttp().shouldUsePost(), httpProps, inputs.getSharedExecutor(), inputs.getBaseLogger(), null); } @@ -165,6 +167,7 @@ public Synchronizer build(DataSourceBuildInputs inputs) { FeatureFetcher fetcher = new HttpFeatureFlagFetcher( inputs.getServiceEndpoints().getPollingBaseUri(), inputs.isEvaluationReasons(), + DataSourceBuildInputsInternal.get(inputs).getPayloadFilter(), inputs.getHttp().shouldUsePost(), LDUtil.makeHttpProperties(inputs.getHttp()), inputs.getCacheDir(), @@ -366,6 +369,8 @@ private static FDv2Requestor makePollingRequestor(DataSourceBuildInputs inputs, StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, StandardEndpoints.FDV2_POLLING_REQUEST_POST_BASE_PATH, httpProps, inputs.getHttp().shouldUsePost(), - inputs.isEvaluationReasons(), null, inputs.getBaseLogger()); + inputs.isEvaluationReasons(), + DataSourceBuildInputsInternal.get(inputs).getPayloadFilter(), + inputs.getBaseLogger()); } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java index 2fdfc1037..a1c2ead46 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java @@ -174,7 +174,8 @@ private DataSourceBuildInputsInternal makeInputs(ClientContext clientContext) { sharedExecutor, impl.getPlatformState().getCacheDir(), clientContext.getBaseLogger(), - impl.getPerEnvironmentDataIfAvailable() + impl.getPerEnvironmentDataIfAvailable(), + LDUtil.payloadFilter(clientContext) ); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizer.java index c44e0f71d..237443d8d 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizer.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizer.java @@ -67,6 +67,7 @@ final class FDv2StreamingSynchronizer implements Synchronizer { @Nullable private final FDv2Requestor requestor; private final boolean evaluationReasons; + private final String payloadFilter; private final int initialReconnectDelayMillis; @Nullable private final DiagnosticStore diagnosticStore; @@ -92,6 +93,7 @@ final class FDv2StreamingSynchronizer implements Synchronizer { * @param requestor optional requestor for handling ping events via poll; may be null * @param initialReconnectDelayMillis delay before reconnecting after an error, in milliseconds * @param evaluationReasons true to request evaluation reasons in the stream + * @param payloadFilter optional payload filter key; sent as {@code filter} query param * @param usePost true to send the context in the request body via HTTP POST * @param httpProperties HTTP configuration for the stream request * @param executor executor used to run the streaming loop on a background @@ -107,6 +109,7 @@ final class FDv2StreamingSynchronizer implements Synchronizer { @Nullable FDv2Requestor requestor, int initialReconnectDelayMillis, boolean evaluationReasons, + @Nullable String payloadFilter, boolean usePost, @NonNull HttpProperties httpProperties, @NonNull Executor executor, @@ -120,6 +123,7 @@ final class FDv2StreamingSynchronizer implements Synchronizer { this.requestor = requestor; this.initialReconnectDelayMillis = initialReconnectDelayMillis; this.evaluationReasons = evaluationReasons; + this.payloadFilter = payloadFilter; this.usePost = usePost; this.httpProperties = httpProperties; this.executor = executor; @@ -250,7 +254,7 @@ private URI getStreamUri() { if (evaluationReasons) { uri = HttpHelpers.addQueryParam(uri, "withReasons", "true"); } - return uri; + return LDUtil.withPayloadFilter(uri, payloadFilter); } private void recordStreamInit(boolean failed) { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HttpFeatureFlagFetcher.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HttpFeatureFlagFetcher.java index 782807dc1..c4607dc10 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HttpFeatureFlagFetcher.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HttpFeatureFlagFetcher.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.android; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.LDContext; @@ -32,6 +33,7 @@ class HttpFeatureFlagFetcher implements FeatureFetcher { private final URI pollUri; private final boolean evaluationReasons; + private final String payloadFilter; private final boolean useReport; private final HttpProperties httpProperties; private final OkHttpClient client; @@ -43,6 +45,7 @@ class HttpFeatureFlagFetcher implements FeatureFetcher { this( clientContext.getServiceEndpoints().getPollingBaseUri(), clientContext.isEvaluationReasons(), + LDUtil.payloadFilter(clientContext), clientContext.getHttp().isUseReport(), LDUtil.makeHttpProperties(clientContext), ClientContextImpl.get(clientContext).getPlatformState().getCacheDir(), @@ -53,6 +56,7 @@ class HttpFeatureFlagFetcher implements FeatureFetcher { HttpFeatureFlagFetcher( @NonNull URI pollUri, boolean evaluationReasons, + @Nullable String payloadFilter, boolean useReport, @NonNull HttpProperties httpProperties, @NonNull File platformCacheDir, @@ -60,6 +64,7 @@ class HttpFeatureFlagFetcher implements FeatureFetcher { ) { this.pollUri = pollUri; this.evaluationReasons = evaluationReasons; + this.payloadFilter = payloadFilter; this.useReport = useReport; this.httpProperties = httpProperties; this.logger = logger; @@ -159,6 +164,7 @@ private Request getDefaultRequest(LDContext ldContext) throws IOException { if (evaluationReasons) { uri = URI.create(uri.toString() + "?withReasons=true"); } + uri = LDUtil.withPayloadFilter(uri, payloadFilter); logger.debug("Attempting to fetch Feature flags using uri: {}", uri); return new Request.Builder().url(uri.toURL()) .headers(httpProperties.toHeadersBuilder().build()) @@ -170,6 +176,7 @@ private Request getReportRequest(LDContext ldContext) throws IOException { if (evaluationReasons) { uri = URI.create(uri.toString() + "?withReasons=true"); } + uri = LDUtil.withPayloadFilter(uri, payloadFilter); logger.debug("Attempting to report user using uri: {}", uri); String contextJson = JsonSerialization.serialize(ldContext); RequestBody reportBody = RequestBody.create(contextJson, JSON); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java index 9056f9dcc..b2f4d5ac8 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java @@ -4,6 +4,7 @@ import com.launchdarkly.logging.LDLogAdapter; import com.launchdarkly.logging.LDLogLevel; +import com.launchdarkly.logging.LDLogger; import com.launchdarkly.logging.Logs; import com.launchdarkly.sdk.ContextKind; import com.launchdarkly.sdk.LDContext; @@ -28,6 +29,7 @@ import java.util.HashSet; import java.util.Map; import java.util.Set; +import java.util.regex.Pattern; import okhttp3.MediaType; @@ -64,6 +66,7 @@ public class LDConfig { static final int DEFAULT_MAX_CACHED_CONTEXTS = 5; static final int DEFAULT_CONNECTION_TIMEOUT_MILLIS = 10_000; // 10 seconds + private static final Pattern PAYLOAD_FILTER_PATTERN = Pattern.compile("^[a-zA-Z0-9][._\\-a-zA-Z0-9]*$"); private final Map mobileKeys; @@ -88,6 +91,7 @@ public class LDConfig { private final int maxCachedContexts; private final boolean offline; private final long connectionModeStateDebounceMs; + private final String payloadFilter; private final PersistentDataStore persistentDataStore; // configurable for testing only LDConfig(Map mobileKeys, @@ -107,6 +111,7 @@ public class LDConfig { boolean generateAnonymousKeys, boolean autoEnvAttributes, long connectionModeStateDebounceMs, + String payloadFilter, PersistentDataStore persistentDataStore, LDLogAdapter logAdapter, String loggerName) { @@ -127,6 +132,7 @@ public class LDConfig { this.generateAnonymousKeys = generateAnonymousKeys; this.autoEnvAttributes = autoEnvAttributes; this.connectionModeStateDebounceMs = connectionModeStateDebounceMs; + this.payloadFilter = payloadFilter; this.persistentDataStore = persistentDataStore; this.logAdapter = logAdapter; this.loggerName = loggerName; @@ -181,6 +187,14 @@ public boolean isEvaluationReasons() { return evaluationReasons; } + /** + * @return the payload filter key, or null if no valid filter was configured + * @see Builder#payloadFilter(String) + */ + public String getPayloadFilter() { + return payloadFilter; + } + /** * @return true if opting out of diagnostics, false otherwise */ @@ -278,6 +292,8 @@ public enum AutoEnvAttributes { private long connectionModeStateDebounceMs = StateDebounceManager.DEFAULT_DEBOUNCE_MS; + private String payloadFilter = null; + private LDLogAdapter logAdapter = defaultLogAdapter(); private String loggerName = LDPackageConsts.DEFAULT_LOGGER_NAME; private LDLogLevel logLevel = null; @@ -594,6 +610,25 @@ public Builder evaluationReasons(boolean evaluationReasons) { return this; } + /** + * Sets the key of a payload filter to apply to flag data requests. + *

+ * Payload filters are defined per project in LaunchDarkly. When a filter key is set, the SDK + * adds it as the {@code filter} query parameter to its streaming and polling requests, and + * LaunchDarkly limits the flags it sends to the ones selected by that filter. + *

+ * The key must contain only alphanumeric characters, periods, underscores, and hyphens, and + * must start with an alphanumeric character. If the key is invalid, the SDK logs an error and + * requests the full environment instead. + * + * @param payloadFilter the payload filter key, or null for no filter + * @return the builder + */ + public Builder payloadFilter(String payloadFilter) { + this.payloadFilter = payloadFilter; + return this; + } + /** * Set to true to opt out of sending diagnostics data. * @@ -774,6 +809,19 @@ private static LDLogAdapter defaultLogAdapter() { return LDTimberLogging.adapter(); } + private String validPayloadFilterOrNull(LDLogAdapter actualLogAdapter) { + if (payloadFilter == null || payloadFilter.isEmpty()) { + return null; + } + if (!PAYLOAD_FILTER_PATTERN.matcher(payloadFilter).matches()) { + LDLogger.withAdapter(actualLogAdapter, loggerName).error( + "Payload filter \"{}\" is invalid; the full environment will be requested instead", + payloadFilter); + return null; + } + return payloadFilter; + } + /** * Returns the configured {@link LDConfig} object. * @return the configuration @@ -840,6 +888,7 @@ public LDConfig build() { generateAnonymousKeys, autoEnvAttributes, connectionModeStateDebounceMs, + validPayloadFilterOrNull(actualLogAdapter), persistentDataStore, actualLogAdapter, loggerName); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDUtil.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDUtil.java index 4a77849b2..1ff0fe6f2 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDUtil.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDUtil.java @@ -13,9 +13,11 @@ import com.launchdarkly.sdk.android.subsystems.ClientContext; import com.launchdarkly.sdk.android.subsystems.HttpConfiguration; import com.launchdarkly.sdk.internal.http.HeadersTransformer; +import com.launchdarkly.sdk.internal.http.HttpHelpers; import com.launchdarkly.sdk.internal.http.HttpProperties; import com.launchdarkly.sdk.json.JsonSerialization; +import java.net.URI; import java.nio.charset.Charset; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -179,6 +181,16 @@ static String urlSafeBase64(LDContext context) { Base64.URL_SAFE + Base64.NO_WRAP); } + @Nullable + static String payloadFilter(ClientContext clientContext) { + LDConfig config = clientContext.getConfig(); + return config == null ? null : config.getPayloadFilter(); + } + + static URI withPayloadFilter(URI uri, @Nullable String payloadFilter) { + return payloadFilter == null ? uri : HttpHelpers.addQueryParam(uri, "filter", payloadFilter); + } + static HttpProperties makeHttpProperties(ClientContext clientContext) { return makeHttpProperties(clientContext.getHttp()); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java index 3bf6ab92b..fc67c6e95 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java @@ -62,6 +62,7 @@ final class StreamingDataSource implements DataSource { private final LDContext context; private final HttpProperties httpProperties; private final boolean evaluationReasons; + private final String payloadFilter; final int initialReconnectDelayMillis; // visible for testing private final RetryDelayStrategy normalRetryDelay; private final RetryDelayStrategy extendedRetryDelay; @@ -106,6 +107,7 @@ final class StreamingDataSource implements DataSource { this.streamUri = clientContext.getServiceEndpoints().getStreamingBaseUri(); this.httpProperties = LDUtil.makeHttpProperties(clientContext); this.evaluationReasons = clientContext.isEvaluationReasons(); + this.payloadFilter = LDUtil.payloadFilter(clientContext); this.useReport = clientContext.getHttp().isUseReport(); this.initialReconnectDelayMillis = initialReconnectDelayMillis; this.normalRetryDelay = RetryDelayStrategy.defaultStrategy() @@ -230,7 +232,7 @@ private URI getUri(@Nullable LDContext context) { uri = URI.create(uri.toString() + "?withReasons=true"); } - return uri; + return LDUtil.withPayloadFilter(uri, payloadFilter); } @VisibleForTesting diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternalTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternalTest.java index 6ea149573..b11c40d3c 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternalTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternalTest.java @@ -34,7 +34,7 @@ private static DataSourceBuildInputsInternal makeInternalInputs( return new DataSourceBuildInputsInternal( CONTEXT, null, null, false, () -> Selector.EMPTY, null, CACHE_DIR, - LDLogger.none(), envData + LDLogger.none(), envData, null ); } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSystemComponentsTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSystemComponentsTest.java index ed30edac1..55e2b356b 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSystemComponentsTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSystemComponentsTest.java @@ -65,11 +65,16 @@ private static HttpConfiguration httpConfig(boolean useReport, boolean usePost) } private DataSourceBuildInputsInternal makeInputs(HttpServer server, HttpConfiguration http) { + return makeInputs(server, http, null); + } + + private DataSourceBuildInputsInternal makeInputs(HttpServer server, HttpConfiguration http, + String payloadFilter) { ServiceEndpoints endpoints = new ServiceEndpoints( server.getUri(), server.getUri(), server.getUri()); return new DataSourceBuildInputsInternal( CONTEXT, endpoints, http, false, - () -> Selector.EMPTY, executor, cacheDir.getRoot(), LOGGER, null); + () -> Selector.EMPTY, executor, cacheDir.getRoot(), LOGGER, null, payloadFilter); } @Test @@ -178,4 +183,77 @@ public void fdv1FallbackSynchronizerSendsGetByDefault() throws Exception { } } } + + @Test + public void pollingSynchronizerSendsPayloadFilter() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.bodyJson(VALID_POLL_RESPONSE_JSON))) { + Synchronizer sync = new DataSystemComponents.PollingSynchronizerBuilderImpl() + .build(makeInputs(server, httpConfig(false, false), "mobile-flags")); + try { + sync.next().get(5, TimeUnit.SECONDS); + + RequestInfo req = server.getRecorder().requireRequest(); + assertEquals("?filter=mobile-flags", req.getQuery()); + } finally { + sync.close(); + } + } + } + + @Test + public void streamingSynchronizerSendsPayloadFilter() throws Exception { + String serverIntent = "event: server-intent\ndata: {\"payloads\":[{\"id\":\"p1\",\"target\":100,\"intentCode\":\"xfer-full\",\"reason\":\"\"}]}"; + String payloadTransferred = "event: payload-transferred\ndata: {\"state\":\"(p:p1:100)\",\"version\":100}"; + + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(serverIntent), + Handlers.SSE.event(payloadTransferred), + Handlers.SSE.leaveOpen()))) { + Synchronizer sync = new DataSystemComponents.StreamingSynchronizerBuilderImpl() + .build(makeInputs(server, httpConfig(false, false), "mobile-flags")); + try { + sync.next().get(5, TimeUnit.SECONDS); + + RequestInfo req = server.getRecorder().requireRequest(); + assertEquals("?filter=mobile-flags", req.getQuery()); + } finally { + sync.close(); + } + } + } + + @Test + public void fdv1FallbackSynchronizerSendsPayloadFilterWithGet() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.bodyJson(VALID_FDV1_JSON))) { + Synchronizer sync = new DataSystemComponents.FDv1PollingSynchronizerBuilderImpl() + .build(makeInputs(server, httpConfig(false, false), "mobile-flags")); + try { + sync.next().get(5, TimeUnit.SECONDS); + + RequestInfo req = server.getRecorder().requireRequest(); + assertEquals("GET", req.getMethod()); + assertEquals("?filter=mobile-flags", req.getQuery()); + } finally { + sync.close(); + } + } + } + + @Test + public void fdv1FallbackSynchronizerSendsPayloadFilterWithReport() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.bodyJson(VALID_FDV1_JSON))) { + Synchronizer sync = new DataSystemComponents.FDv1PollingSynchronizerBuilderImpl() + .build(makeInputs(server, httpConfig(false, true), "mobile-flags")); + try { + sync.next().get(5, TimeUnit.SECONDS); + + RequestInfo req = server.getRecorder().requireRequest(); + assertEquals("REPORT", req.getMethod()); + assertEquals("?filter=mobile-flags", req.getQuery()); + } finally { + sync.close(); + } + } + } } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizerTest.java index 17937c138..7646033ab 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizerTest.java @@ -83,6 +83,7 @@ private FDv2StreamingSynchronizer makeSynchronizer( null, 100, false, + null, false, httpProperties(), executor, @@ -93,9 +94,15 @@ private FDv2StreamingSynchronizer makeSynchronizer( private FDv2StreamingSynchronizer makeSynchronizer( URI streamBaseUri, boolean evaluationReasons, boolean usePost) { + return makeSynchronizer(streamBaseUri, evaluationReasons, usePost, null); + } + + private FDv2StreamingSynchronizer makeSynchronizer( + URI streamBaseUri, + boolean evaluationReasons, boolean usePost, String payloadFilter) { return new FDv2StreamingSynchronizer( CONTEXT, EMPTY_SELECTOR_SOURCE, streamBaseUri, STREAM_PATH, - null, 100, evaluationReasons, usePost, + null, 100, evaluationReasons, payloadFilter, usePost, httpProperties(), executor, LOGGER, null); } @@ -1050,6 +1057,33 @@ public void evaluationReasonsAddedToStreamUrl() throws Exception { } } + @Test + public void payloadFilterAddedToStreamUrl() throws Exception { + String serverIntent = makeEvent("server-intent", "{\"payloads\":[{\"id\":\"payload-1\",\"target\":100,\"intentCode\":\"xfer-full\",\"reason\":\"payload-missing\"}]}"); + String payloadTransferred = makeEvent("payload-transferred", "{\"state\":\"(p:payload-1:100)\",\"version\":100}"); + + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(serverIntent), + Handlers.SSE.event(payloadTransferred), + Handlers.SSE.leaveOpen()))) { + + FDv2StreamingSynchronizer sync = makeSynchronizer(server.getUri(), + true /* evaluationReasons */, false /* usePost */, "mobile-flags"); + + sync.next().get(5, TimeUnit.SECONDS); + + RequestInfo request = server.getRecorder().requireRequest(); + assertNotNull(request.getQuery()); + assertTrue("query should contain filter=mobile-flags", + request.getQuery().contains("filter=mobile-flags")); + assertTrue("query should still contain withReasons=true", + request.getQuery().contains("withReasons=true")); + + sync.close(); + } + } + @Test public void usePostMethodForStreamRequest() throws Exception { String serverIntent = makeEvent("server-intent", "{\"payloads\":[{\"id\":\"payload-1\",\"target\":100,\"intentCode\":\"xfer-full\",\"reason\":\"payload-missing\"}]}"); @@ -1131,7 +1165,7 @@ public void pingEventWithRequestorTriggersPoll() throws Exception { FDv2StreamingSynchronizer sync = new FDv2StreamingSynchronizer( CONTEXT, EMPTY_SELECTOR_SOURCE, streamServer.getUri(), STREAM_PATH, - requestor, 100, false, false, + requestor, 100, false, null, false, httpProperties(), executor, LOGGER, null); try { @@ -1171,7 +1205,7 @@ public void close() { FDv2StreamingSynchronizer sync = new FDv2StreamingSynchronizer( CONTEXT, EMPTY_SELECTOR_SOURCE, URI.create("http://localhost:9999/"), STREAM_PATH, - trackingRequestor, 100, false, false, + trackingRequestor, 100, false, null, false, httpProperties(), executor, LOGGER, null); assertFalse(requestorClosed.get()); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java index 5db264615..c44d9206b 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java @@ -49,6 +49,49 @@ public void testBuilderEvaluationReasons() { assertTrue(config.isEvaluationReasons()); } + @Test + public void testBuilderPayloadFilterDefaultsToNull() { + LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled).build(); + + assertNull(config.getPayloadFilter()); + } + + @Test + public void testBuilderPayloadFilterValid() { + LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled) + .logAdapter(logging.logAdapter) + .payloadFilter("mobile.flags_v2-a") + .build(); + + assertEquals("mobile.flags_v2-a", config.getPayloadFilter()); + logging.assertNoErrorsLogged(); + } + + @Test + public void testBuilderPayloadFilterEmptyIsIgnored() { + LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled) + .logAdapter(logging.logAdapter) + .payloadFilter("") + .build(); + + assertNull(config.getPayloadFilter()); + logging.assertNoErrorsLogged(); + } + + @Test + public void testBuilderPayloadFilterInvalidIsDroppedWithError() { + String[] invalidKeys = {"-starts-with-hyphen", ".starts-with-period", "has space", "has/slash", "has&"}; + for (String key : invalidKeys) { + LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled) + .logAdapter(logging.logAdapter) + .payloadFilter(key) + .build(); + + assertNull(key, config.getPayloadFilter()); + logging.assertErrorLogged("Payload filter \"" + key + "\" is invalid"); + } + } + @Test public void testBuilderDiagnosticOptOut() { LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled).diagnosticOptOut(true).build(); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java index c0be925de..d68aa0e99 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java @@ -159,9 +159,18 @@ private ClientContext makeDataSourceClientContext( URI streamBaseUri, MockComponents.MockDataSourceUpdateSink sink, boolean evaluationReasons, boolean useReport) { + return makeDataSourceClientContext(streamBaseUri, sink, evaluationReasons, useReport, null); + } + + private ClientContext makeDataSourceClientContext( + URI streamBaseUri, + MockComponents.MockDataSourceUpdateSink sink, + boolean evaluationReasons, boolean useReport, String payloadFilter) { LDConfig.Builder configBuilder = new LDConfig.Builder(AutoEnvAttributes.Disabled) + .logAdapter(logging.logAdapter) .serviceEndpoints(Components.serviceEndpoints().streaming(streamBaseUri)) - .evaluationReasons(evaluationReasons); + .evaluationReasons(evaluationReasons) + .payloadFilter(payloadFilter); if (useReport) { configBuilder.http(Components.httpConfiguration().useReport(true)); } @@ -592,6 +601,78 @@ public void startSendsRequestWithReasonsWhenEnabled() throws Exception { } } + @Test + public void startSendsPayloadFilterWithGet() throws Exception { + String putEvent = makeSseEvent("put", VALID_PUT_JSON); + + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(putEvent), + Handlers.SSE.leaveOpen()))) { + + ClientContext clientContext = makeDataSourceClientContext( + server.getUri(), dataSourceUpdateSink, true, false, "mobile-flags"); + StreamingDataSource sds = (StreamingDataSource) Components.streamingDataSource() + .build(clientContext); + TrackingCallback callback = new TrackingCallback(); + startDataSource(sds, callback); + + assertNotNull(callback.awaitSuccess()); + + RequestInfo request = server.getRecorder().requireRequest(); + assertEquals("GET", request.getMethod()); + assertEquals("?withReasons=true&filter=mobile-flags", request.getQuery()); + } + } + + @Test + public void startSendsPayloadFilterWithReport() throws Exception { + String putEvent = makeSseEvent("put", VALID_PUT_JSON); + + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(putEvent), + Handlers.SSE.leaveOpen()))) { + + ClientContext clientContext = makeDataSourceClientContext( + server.getUri(), dataSourceUpdateSink, false, true, "mobile-flags"); + StreamingDataSource sds = (StreamingDataSource) Components.streamingDataSource() + .build(clientContext); + TrackingCallback callback = new TrackingCallback(); + startDataSource(sds, callback); + + assertNotNull(callback.awaitSuccess()); + + RequestInfo request = server.getRecorder().requireRequest(); + assertEquals("REPORT", request.getMethod()); + assertEquals("?filter=mobile-flags", request.getQuery()); + } + } + + @Test + public void startOmitsInvalidPayloadFilter() throws Exception { + String putEvent = makeSseEvent("put", VALID_PUT_JSON); + + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(putEvent), + Handlers.SSE.leaveOpen()))) { + + ClientContext clientContext = makeDataSourceClientContext( + server.getUri(), dataSourceUpdateSink, false, false, "not valid"); + StreamingDataSource sds = (StreamingDataSource) Components.streamingDataSource() + .build(clientContext); + TrackingCallback callback = new TrackingCallback(); + startDataSource(sds, callback); + + assertNotNull(callback.awaitSuccess()); + + RequestInfo request = server.getRecorder().requireRequest(); + assertTrue("query should not contain a filter", + request.getQuery() == null || !request.getQuery().contains("filter=")); + } + } + @Test public void startSendsRequestWithoutReasonsWhenDisabled() throws Exception { String putEvent = makeSseEvent("put", VALID_PUT_JSON);