diff --git a/Makefile b/Makefile index ed9965b7..05f23b0a 100644 --- a/Makefile +++ b/Makefile @@ -21,8 +21,8 @@ run-contract-tests: | VERSION=v2 PARAMS="-url http://localhost:8001 -host 10.0.2.2 -debug -skip-from $(SUPPRESSION_FILE) $(TEST_HARNESS_PARAMS_V2)" sh @echo "Running SDK contract test v3..." @curl $${GITHUB_TOKEN:+ -H "Authorization: Token $${GITHUB_TOKEN}"} \ - -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/v3.1.0-alpha.6/downloader/run.sh \ - | VERSION=v3.1.0-alpha.6 PARAMS="-url http://localhost:8001 -host 10.0.2.2 -debug -stop-service-at-end -skip-from $(SUPPRESSION_FILE_FDV2) $(TEST_HARNESS_PARAMS_V3)" sh + -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/v3.2.0-alpha.10/downloader/run.sh \ + | VERSION=v3.2.0-alpha.10 PARAMS="-url http://localhost:8001 -host 10.0.2.2 -debug -stop-service-at-end -skip-from $(SUPPRESSION_FILE_FDV2) $(TEST_HARNESS_PARAMS_V3)" sh contract-tests: build-contract-tests start-emulator start-contract-test-service run-contract-tests 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 4b7aa650..5c94b580 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java @@ -78,6 +78,7 @@ public static class SdkConfigClientSideParams { LDContext initialContext; boolean evaluationReasons; boolean useReport; + boolean usePost; boolean includeEnvironmentAttributes; } 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 f9627704..1e35a31d 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java @@ -284,7 +284,7 @@ private LDConfig buildSdkConfig(SdkConfigParams params, LDLogAdapter logAdapter, ConfigHelper.configureIsolatedInMemoryPersistence(builder); if (params.dataSystem != null) { - configureDataSystem(builder, params.dataSystem); + configureDataSystem(builder, params.dataSystem, params.clientSide.usePost); } else { if (params.polling != null && params.polling.baseUri != null) { endpoints.polling(params.polling.baseUri); @@ -391,15 +391,16 @@ private LDConfig buildSdkConfig(SdkConfigParams params, LDLogAdapter logAdapter, return builder.build(); } - private void configureDataSystem(LDConfig.Builder builder, SdkConfigDataSystemParams dataSystem) { + private void configureDataSystem(LDConfig.Builder builder, SdkConfigDataSystemParams dataSystem, + boolean usePost) { if (Boolean.TRUE.equals(dataSystem.useDefaultDataSystem)) { - builder.dataSystem(Components.dataSystem()); + builder.dataSystem(Components.dataSystem().usePost(usePost)); return; } SdkConfigConnectionModeConfig connModeConfig = dataSystem.connectionModeConfig; - DataSystemBuilder dsBuilder = Components.dataSystem(); + DataSystemBuilder dsBuilder = Components.dataSystem().usePost(usePost); // at the time of writing this, we did not have contract tests that could test platform state changes, // disabling automatic mode simplifies the behavior being tested 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 b6618d9e..105006a6 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/TestService.java @@ -43,7 +43,8 @@ public class TestService extends NanoHTTPD { "evaluation-hooks", "track-hooks", "client-per-context-summaries", - "client-event-source-http-errors" + "client-event-source-http-errors", + "client-use-post" }; 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 1299859b..16dc8210 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 @@ -35,13 +35,14 @@ final class DataSourceBuildInputsInternal extends DataSourceBuildInputs { ServiceEndpoints serviceEndpoints, HttpConfiguration http, boolean evaluationReasons, + boolean usePost, SelectorSource selectorSource, ScheduledExecutorService sharedExecutor, @NonNull File cacheDir, LDLogger baseLogger, @Nullable PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData perEnvironmentData ) { - super(evaluationContext, serviceEndpoints, http, evaluationReasons, + super(evaluationContext, serviceEndpoints, http, evaluationReasons, usePost, selectorSource, sharedExecutor, cacheDir, baseLogger); this.perEnvironmentData = perEnvironmentData; } @@ -60,6 +61,7 @@ static DataSourceBuildInputsInternal get(DataSourceBuildInputs inputs) { inputs.getServiceEndpoints(), inputs.getHttp(), inputs.isEvaluationReasons(), + inputs.isUsePost(), inputs.getSelectorSource(), inputs.getSharedExecutor(), inputs.getCacheDir(), 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 4d9dfea8..652488f6 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,7 @@ public Synchronizer build(DataSourceBuildInputs inputs) { StandardEndpoints.FDV2_STREAMING_REQUEST_BASE_PATH, requestor, initialReconnectDelayMillis, - inputs.isEvaluationReasons(), inputs.getHttp().isUseReport(), + inputs.isEvaluationReasons(), inputs.isUsePost(), httpProps, inputs.getSharedExecutor(), inputs.getBaseLogger(), null); } @@ -159,10 +159,12 @@ public FDv1PollingSynchronizerBuilderImpl pollIntervalMillis(int pollIntervalMil @Override public Synchronizer build(DataSourceBuildInputs inputs) { + // The FDv1 endpoints do not accept POST. REPORT is their method that carries the + // context in the request body, so usePost selects REPORT here. FeatureFetcher fetcher = new HttpFeatureFlagFetcher( inputs.getServiceEndpoints().getPollingBaseUri(), inputs.isEvaluationReasons(), - inputs.getHttp().isUseReport(), + inputs.isUsePost(), LDUtil.makeHttpProperties(inputs.getHttp()), inputs.getCacheDir(), inputs.getBaseLogger() @@ -360,9 +362,8 @@ private static FDv2Requestor makePollingRequestor(DataSourceBuildInputs inputs, "polling", inputs.getBaseLogger()); return new DefaultFDv2Requestor( inputs.getEvaluationContext(), pollingBase, - StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, - StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, - httpProps, inputs.getHttp().isUseReport(), + StandardEndpoints.FDV2_POLLING_REQUEST_BASE_PATH, + httpProps, inputs.isUsePost(), inputs.isEvaluationReasons(), null, inputs.getBaseLogger()); } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DefaultFDv2Requestor.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DefaultFDv2Requestor.java index 2ffa79e8..0a554ee5 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DefaultFDv2Requestor.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DefaultFDv2Requestor.java @@ -31,7 +31,7 @@ /** * Default OkHttp-based implementation of {@link FDv2Requestor}. *

- * Builds GET or REPORT requests to the FDv2 polling endpoint. If the current selector is + * Builds GET or POST requests to the FDv2 polling endpoint. If the current selector is * non-empty, its state is sent as the {@code basis} query parameter. ETag tracking is used * to detect 304 Not Modified responses; when the server returns 304 the application layer * treats it as {@code ChangeSetType.None} (no flags changed), so no OkHttp disk cache is @@ -40,7 +40,6 @@ * The OkHttpClient is closed by {@link #close()}. */ final class DefaultFDv2Requestor implements FDv2Requestor { - private static final String METHOD_REPORT = "REPORT"; private static final String BASIS_PARAM = "basis"; private static final String FILTER_PARAM = "filter"; private static final String WITH_REASONS_PARAM = "withReasons"; @@ -50,10 +49,10 @@ final class DefaultFDv2Requestor implements FDv2Requestor { private final OkHttpClient httpClient; private final URI pollingUri; private final okhttp3.Headers headers; - private final boolean useReport; + private final boolean usePost; private final boolean evaluationReasons; @Nullable - private final RequestBody reportBody; + private final RequestBody postBody; @Nullable private final String payloadFilter; private final LDLogger logger; @@ -67,10 +66,10 @@ final class DefaultFDv2Requestor implements FDv2Requestor { /** * @param evaluationContext the context to evaluate flags for * @param baseUri polling base URI from service endpoints - * @param getRequestPath path for GET requests (context appended as a path segment) - * @param reportRequestPath path for REPORT requests (context sent in the request body) + * @param requestPath polling request path; GET appends the base64-encoded context as a + * path segment, POST uses the path as is * @param httpProperties SDK HTTP configuration (timeouts, proxy, TLS, user-agent, etc.) - * @param useReport if true, send context in the request body via REPORT; otherwise + * @param usePost if true, send the context in the request body via POST; otherwise * append the base64-encoded context to the GET path * @param evaluationReasons if true, append {@code withReasons=true} to the query string * @param payloadFilter optional payload filter key; sent as {@code filter} query param @@ -79,14 +78,13 @@ final class DefaultFDv2Requestor implements FDv2Requestor { DefaultFDv2Requestor( @NonNull LDContext evaluationContext, @NonNull URI baseUri, - @NonNull String getRequestPath, - @NonNull String reportRequestPath, + @NonNull String requestPath, @NonNull HttpProperties httpProperties, - boolean useReport, + boolean usePost, boolean evaluationReasons, @Nullable String payloadFilter, @NonNull LDLogger logger) { - this.useReport = useReport; + this.usePost = usePost; this.evaluationReasons = evaluationReasons; this.payloadFilter = payloadFilter; this.logger = logger; @@ -94,14 +92,13 @@ final class DefaultFDv2Requestor implements FDv2Requestor { // Precompute the base polling URI for the chosen request method. For GET, the // base64-encoded context is a fixed path segment (context never changes after - // construction). For REPORT, the context goes in the request body so no path + // construction). For POST, the context goes in the request body so no path // segment is needed. - URI basePollingUri = HttpHelpers.concatenateUriPath(baseUri, - useReport ? reportRequestPath : getRequestPath); - this.pollingUri = useReport + URI basePollingUri = HttpHelpers.concatenateUriPath(baseUri, requestPath); + this.pollingUri = usePost ? basePollingUri : HttpHelpers.concatenateUriPath(basePollingUri, LDUtil.urlSafeBase64(evaluationContext)); - this.reportBody = useReport + this.postBody = usePost ? RequestBody.create(JsonSerialization.serialize(evaluationContext), JSON) : null; @@ -139,8 +136,8 @@ public Future poll(@NonNull Selector selector) { .url(requestUri.toURL()) .headers(headers); - if (useReport) { - reqBuilder.method(METHOD_REPORT, reportBody); + if (usePost) { + reqBuilder.post(postBody); } else { synchronized (etagLock) { if (!requestUri.equals(lastRequestUri)) { @@ -207,7 +204,7 @@ private void handleResponse( return; } - if (!useReport) { + if (!usePost) { synchronized (etagLock) { cachedEtag = response.header(ETAG_HEADER); } 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 769eaa31..5d90b6ea 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 @@ -34,10 +34,13 @@ class FDv2DataSourceBuilder implements ComponentConfigurer, Closeabl private final Map modeTable; private final ConnectionMode startingMode; private final ModeResolutionTable resolutionTable; + private final boolean usePost; private ConnectionMode activeMode; private boolean includeInitializers = true; // start with initializers private ScheduledExecutorService sharedExecutor; + // The useReport warning is logged on the first build only. Builds run on one thread. + private boolean useReportWarningLogged = false; FDv2DataSourceBuilder() { this(DataSystemComponents.makeDefaultModeTable(), ConnectionMode.STREAMING, ModeResolutionTable.MOBILE); @@ -54,10 +57,24 @@ class FDv2DataSourceBuilder implements ComponentConfigurer, Closeabl @NonNull Map modeTable, @NonNull ConnectionMode startingMode, @NonNull ModeResolutionTable resolutionTable + ) { + this(modeTable, startingMode, resolutionTable, false); + } + + /** + * @param usePost true if FDv2 polling and streaming requests send the context in the request + * body with POST instead of encoding it into the GET path + */ + FDv2DataSourceBuilder( + @NonNull Map modeTable, + @NonNull ConnectionMode startingMode, + @NonNull ModeResolutionTable resolutionTable, + boolean usePost ) { this.modeTable = modeTable; this.startingMode = startingMode; this.resolutionTable = resolutionTable; + this.usePost = usePost; } /** @@ -75,6 +92,15 @@ ConnectionMode getStartingMode() { return startingMode; } + /** + * Returns whether FDv2 flag requests send the context in the request body with POST. + * + * @return true if flag requests use POST + */ + boolean isUsePost() { + return usePost; + } + /** * Configures the mode to build for and whether to include initializers. * Called by {@link ConnectivityManager} before each {@link #build} call. @@ -108,6 +134,15 @@ public DataSource build(ClientContext clientContext) { ConnectionMode mode = activeMode != null ? activeMode : startingMode; + // The FDv2 endpoints take POST, not REPORT. The data system has its own usePost option, + // so the FDv1 useReport option does not apply here. + if (clientContext.getHttp().isUseReport() && !useReportWarningLogged) { + useReportWarningLogged = true; + clientContext.getBaseLogger().warn( + "useReport has no effect with the FDv2 data system and is ignored. " + + "Use DataSystemBuilder.usePost to send the evaluation context in the request body."); + } + ModeDefinition modeDef = modeTable.get(mode); if (modeDef == null) { throw new IllegalStateException( @@ -159,6 +194,7 @@ private DataSourceBuildInputsInternal makeInputs(ClientContext clientContext) { clientContext.getServiceEndpoints(), clientContext.getHttp(), clientContext.isEvaluationReasons(), + usePost, selectorSource, sharedExecutor, impl.getPlatformState().getCacheDir(), 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 ef8e0d5f..756d3792 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 @@ -53,7 +53,7 @@ *

*/ final class FDv2StreamingSynchronizer implements Synchronizer { - private static final String METHOD_REPORT = "REPORT"; + private static final String METHOD_POST = "POST"; private static final String PING = "ping"; private static final long READ_TIMEOUT_MS = 300_000; // 5 minutes private static final long MAX_RECONNECT_TIME_MS = 300_000; // 5 minutes @@ -61,7 +61,7 @@ final class FDv2StreamingSynchronizer implements Synchronizer { private final HttpProperties httpProperties; private final URI streamBaseUri; private final String streamRequestPath; - private final boolean useReport; + private final boolean usePost; private final LDContext evaluationContext; private final SelectorSource selectorSource; @Nullable @@ -92,7 +92,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 useReport true to use HTTP REPORT for the request body + * @param usePost true to send the context in the request body with POST * @param httpProperties HTTP configuration for the stream request * @param executor executor used to run the streaming loop on a background * thread; should use background-priority threads @@ -107,7 +107,7 @@ final class FDv2StreamingSynchronizer implements Synchronizer { @Nullable FDv2Requestor requestor, int initialReconnectDelayMillis, boolean evaluationReasons, - boolean useReport, + boolean usePost, @NonNull HttpProperties httpProperties, @NonNull Executor executor, @NonNull LDLogger logger, @@ -120,7 +120,7 @@ final class FDv2StreamingSynchronizer implements Synchronizer { this.requestor = requestor; this.initialReconnectDelayMillis = initialReconnectDelayMillis; this.evaluationReasons = evaluationReasons; - this.useReport = useReport; + this.usePost = usePost; this.httpProperties = httpProperties; this.executor = executor; this.logger = logger; @@ -188,9 +188,9 @@ private void startStream() { return reqBuilder.build(); }); - if (useReport) { + if (usePost) { connectStrategy = connectStrategy.methodAndBody( - METHOD_REPORT, + METHOD_POST, RequestBody.create(JsonSerialization.serialize(evaluationContext), JSON)); } @@ -244,7 +244,7 @@ private void startStream() { private URI getStreamUri() { URI uri = HttpHelpers.concatenateUriPath(streamBaseUri, streamRequestPath); - if (!useReport) { + if (!usePost) { uri = HttpHelpers.concatenateUriPath(uri, LDUtil.urlSafeBase64(evaluationContext)); } if (evaluationReasons) { 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 9056f9dc..bb2026d6 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 @@ -814,7 +814,7 @@ public LDConfig build() { ModeResolutionTable resolutionTable = ModeResolutionTable.createMobile( startingMode, backgroundMode); effectiveDataSource = new FDv2DataSourceBuilder( - modeTable, startingMode, resolutionTable); + modeTable, startingMode, resolutionTable, this.dataSystemBuilder.isUsePost()); effectiveAutoModeSwitching = this.dataSystemBuilder.getAutomaticModeSwitchingConfig(); } else { effectiveDataSource = this.dataSource == null diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java index 06c51e32..a0b415b7 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java @@ -17,8 +17,8 @@ private StandardEndpoints() {} static final String ANALYTICS_EVENTS_REQUEST_PATH = "/mobile/events/bulk"; static final String DIAGNOSTIC_EVENTS_REQUEST_PATH = "/mobile/events/diagnostic"; - static final String FDV2_POLLING_REQUEST_GET_BASE_PATH = "/sdk/poll/eval"; - static final String FDV2_POLLING_REQUEST_REPORT_BASE_PATH = "/sdk/poll/eval"; + // GET appends the base64url context as a path segment; POST sends it in the body. + static final String FDV2_POLLING_REQUEST_BASE_PATH = "/sdk/poll/eval"; static final String FDV2_STREAMING_REQUEST_BASE_PATH = "/sdk/stream/eval"; /** diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java index a96e96a4..e2b24228 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java @@ -89,6 +89,7 @@ public class DataSystemBuilder { private ConnectionMode foregroundConnectionMode = ConnectionMode.STREAMING; private ConnectionMode backgroundConnectionMode = ConnectionMode.BACKGROUND; private AutomaticModeSwitchingConfig automaticModeSwitchingConfig = AutomaticModeSwitchingConfig.enabled(); + private boolean usePost = false; private final Map connectionModeOverrides = new LinkedHashMap<>(); /** @@ -160,6 +161,30 @@ public DataSystemBuilder automaticModeSwitching(@NonNull AutomaticModeSwitchingC return this; } + /** + * Sets whether polling and streaming flag requests send the evaluation context in the + * request body with the HTTP POST method. + *

+ * By default the SDK uses GET and encodes the context into the request path. POST keeps + * the context out of the URL, so it does not appear in request logs, and it avoids URL + * length limits for large contexts. + *

+ * When the SDK falls back to the FDv1 endpoints, the context is sent in the request body + * with the REPORT method, which is the body-carrying method those endpoints accept. + *

+ * This option replaces {@link HttpConfigurationBuilder#useReport(boolean)} for the data + * system. The {@code useReport} option has no effect when a data system is configured. + *

+ * The default is {@code false}. + * + * @param usePost true to send flag requests with POST + * @return this builder + */ + public DataSystemBuilder usePost(boolean usePost) { + this.usePost = usePost; + return this; + } + /** * Overrides the data pipeline for a specific connection mode. *

@@ -221,6 +246,15 @@ public AutomaticModeSwitchingConfig getAutomaticModeSwitchingConfig() { return automaticModeSwitchingConfig; } + /** + * Returns whether flag requests send the evaluation context in the request body with POST. + * + * @return true if flag requests use POST + */ + public boolean isUsePost() { + return usePost; + } + /** * Returns any user-specified mode overrides. * diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HttpConfigurationBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HttpConfigurationBuilder.java index d59f1680..f353e5f0 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HttpConfigurationBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/HttpConfigurationBuilder.java @@ -92,6 +92,10 @@ public HttpConfigurationBuilder headerTransform(LDHeaderUpdater headerTransform) * However, the REPORT method is not always supported by operating systems or network gateways. * Therefore it is disabled in the SDK by default. You can enable it if you know your code will * not be running in an environment that disallows REPORT. + *

+ * This option has no effect when the data system is configured with + * {@link com.launchdarkly.sdk.android.LDConfig.Builder#dataSystem(DataSystemBuilder)}. Use + * {@link DataSystemBuilder#usePost(boolean)} instead to send the context in the request body. * * @param useReport true to enable the REPORT method * @return the builder diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/DataSourceBuildInputs.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/DataSourceBuildInputs.java index 14843b40..3e3290e7 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/DataSourceBuildInputs.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/DataSourceBuildInputs.java @@ -28,6 +28,7 @@ public class DataSourceBuildInputs { private final ServiceEndpoints serviceEndpoints; private final HttpConfiguration http; private final boolean evaluationReasons; + private final boolean usePost; private final SelectorSource selectorSource; private final ScheduledExecutorService sharedExecutor; private final File cacheDir; @@ -40,6 +41,7 @@ public class DataSourceBuildInputs { * @param serviceEndpoints the service endpoint URIs * @param http the HTTP configuration * @param evaluationReasons whether evaluation reasons are enabled + * @param usePost whether flag requests send the context in the request body with POST * @param selectorSource the source for obtaining the current selector * @param sharedExecutor shared executor for scheduling tasks; owned and shut down by * the calling data source, so components must not shut it down @@ -51,6 +53,7 @@ public DataSourceBuildInputs( ServiceEndpoints serviceEndpoints, HttpConfiguration http, boolean evaluationReasons, + boolean usePost, SelectorSource selectorSource, ScheduledExecutorService sharedExecutor, @NonNull File cacheDir, @@ -60,6 +63,7 @@ public DataSourceBuildInputs( this.serviceEndpoints = serviceEndpoints; this.http = http; this.evaluationReasons = evaluationReasons; + this.usePost = usePost; this.selectorSource = selectorSource; this.sharedExecutor = sharedExecutor; this.cacheDir = cacheDir; @@ -102,6 +106,17 @@ public boolean isEvaluationReasons() { return evaluationReasons; } + /** + * Returns whether polling and streaming flag requests send the evaluation context in the + * request body with the POST method. When false, the context is base64url-encoded into the + * request path and the GET method is used. + * + * @return true if flag requests use POST + */ + public boolean isUsePost() { + return usePost; + } + /** * Returns the selector source for obtaining the current FDv2 selector. * 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 6ea14957..9fe16454 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 @@ -32,7 +32,7 @@ private static DataSourceBuildInputsInternal makeInternalInputs( PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData envData ) { return new DataSourceBuildInputsInternal( - CONTEXT, null, null, false, + CONTEXT, null, null, false, false, () -> Selector.EMPTY, null, CACHE_DIR, LDLogger.none(), envData ); @@ -40,7 +40,7 @@ private static DataSourceBuildInputsInternal makeInternalInputs( private static DataSourceBuildInputs makePlainInputs() { return new DataSourceBuildInputs( - CONTEXT, null, null, false, + CONTEXT, null, null, false, false, () -> Selector.EMPTY, null, CACHE_DIR, LDLogger.none() ); @@ -72,12 +72,25 @@ public void get_withPlainInputs_preservesBaseProperties() { assertEquals(plain.getServiceEndpoints(), result.getServiceEndpoints()); assertEquals(plain.getHttp(), result.getHttp()); assertEquals(plain.isEvaluationReasons(), result.isEvaluationReasons()); + assertEquals(plain.isUsePost(), result.isUsePost()); assertEquals(plain.getSelectorSource(), result.getSelectorSource()); assertEquals(plain.getSharedExecutor(), result.getSharedExecutor()); assertEquals(plain.getCacheDir(), result.getCacheDir()); assertEquals(plain.getBaseLogger(), result.getBaseLogger()); } + @Test + public void get_withPlainInputs_preservesUsePost() { + DataSourceBuildInputs plain = new DataSourceBuildInputs( + CONTEXT, null, null, false, true, + () -> Selector.EMPTY, null, CACHE_DIR, + LDLogger.none() + ); + DataSourceBuildInputsInternal result = DataSourceBuildInputsInternal.get(plain); + + assertTrue(result.isUsePost()); + } + // ---- getPerEnvironmentDataIfAvailable() ---- @Test diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSystemBuilderTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSystemBuilderTest.java index 0195ec4c..3ec823c5 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSystemBuilderTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSystemBuilderTest.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.android; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; @@ -26,6 +27,17 @@ public void defaultBuilder_hasExpectedConnectionModesAndAutoSwitching() { assertTrue(b.getConnectionModeOverrides().isEmpty()); } + @Test + public void defaultBuilder_usePostIsFalse() { + assertFalse(Components.dataSystem().isUsePost()); + } + + @Test + public void usePost_setsFlag() { + assertTrue(Components.dataSystem().usePost(true).isUsePost()); + assertFalse(Components.dataSystem().usePost(true).usePost(false).isUsePost()); + } + @Test public void buildModeTable_containsAllStandardModes() { Map table = Components.dataSystem().buildModeTable(false); 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 new file mode 100644 index 00000000..f7ee5665 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DataSystemComponentsTest.java @@ -0,0 +1,204 @@ +package com.launchdarkly.sdk.android; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.android.interfaces.ServiceEndpoints; +import com.launchdarkly.sdk.android.subsystems.DataSourceBuildInputs; +import com.launchdarkly.sdk.android.subsystems.HttpConfiguration; +import com.launchdarkly.sdk.android.subsystems.Initializer; +import com.launchdarkly.sdk.android.subsystems.Synchronizer; +import com.launchdarkly.sdk.fdv2.Selector; +import com.launchdarkly.testhelpers.httptest.Handlers; +import com.launchdarkly.testhelpers.httptest.HttpServer; +import com.launchdarkly.testhelpers.httptest.RequestInfo; + +import org.junit.After; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.Timeout; + +import java.io.File; +import java.util.Collections; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Verifies that the built-in FDv2 data source builders wire the build inputs into the + * requests they send: the request method, the request path, and where the context travels. + */ +public class DataSystemComponentsTest { + + @Rule + public Timeout globalTimeout = Timeout.seconds(15); + + private static final LDContext CONTEXT = LDContext.create("wiring-test-context-key"); + private static final File CACHE_DIR = new File(System.getProperty("java.io.tmpdir")); + + private static final String FDV2_POLL_RESPONSE_JSON = + "{\"events\":[" + + "{\"event\":\"server-intent\",\"data\":{\"payloads\":[{\"id\":\"p1\",\"target\":100,\"intentCode\":\"xfer-full\",\"reason\":\"\"}]}}," + + "{\"event\":\"payload-transferred\",\"data\":{\"state\":\"(p:p1:100)\",\"version\":100}}" + + "]}"; + + private final ScheduledExecutorService executor = Executors.newScheduledThreadPool(2); + + @After + public void tearDown() { + executor.shutdownNow(); + } + + private DataSourceBuildInputs makeInputs(HttpServer server, boolean usePost) { + ServiceEndpoints endpoints = Components.serviceEndpoints() + .polling(server.getUri()) + .streaming(server.getUri()) + .events(server.getUri()) + .createServiceEndpoints(); + HttpConfiguration http = new HttpConfiguration(10_000, Collections.emptyMap(), null, false); + return new DataSourceBuildInputs( + CONTEXT, endpoints, http, false, usePost, + () -> Selector.EMPTY, executor, CACHE_DIR, LDLogger.none()); + } + + private static String contextPathSegment() { + return "/" + LDUtil.urlSafeBase64(CONTEXT); + } + + private static void assertContextInBody(RequestInfo request) { + assertNotNull("body should contain the serialized context", request.getBody()); + assertTrue("body should contain the context key", + request.getBody().contains("wiring-test-context-key")); + } + + private static void assertNoBody(RequestInfo request) { + assertTrue("request should have no body", + request.getBody() == null || request.getBody().isEmpty()); + } + + // ---- polling initializer ---- + + @Test + public void pollingInitializer_defaultUsesGetWithContextInPath() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.bodyJson(FDV2_POLL_RESPONSE_JSON))) { + Initializer initializer = new DataSystemComponents.PollingInitializerBuilderImpl() + .build(makeInputs(server, false)); + initializer.run().get(5, TimeUnit.SECONDS); + initializer.close(); + + RequestInfo request = server.getRecorder().requireRequest(); + assertEquals("GET", request.getMethod()); + assertEquals(StandardEndpoints.FDV2_POLLING_REQUEST_BASE_PATH + contextPathSegment(), + request.getPath()); + assertNoBody(request); + } + } + + @Test + public void pollingInitializer_usePostSendsContextInBody() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.bodyJson(FDV2_POLL_RESPONSE_JSON))) { + Initializer initializer = new DataSystemComponents.PollingInitializerBuilderImpl() + .build(makeInputs(server, true)); + initializer.run().get(5, TimeUnit.SECONDS); + initializer.close(); + + RequestInfo request = server.getRecorder().requireRequest(); + assertEquals("POST", request.getMethod()); + assertEquals(StandardEndpoints.FDV2_POLLING_REQUEST_BASE_PATH, request.getPath()); + assertContextInBody(request); + } + } + + // ---- polling synchronizer ---- + + @Test + public void pollingSynchronizer_usePostSendsContextInBody() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.bodyJson(FDV2_POLL_RESPONSE_JSON))) { + Synchronizer synchronizer = new DataSystemComponents.PollingSynchronizerBuilderImpl() + .build(makeInputs(server, true)); + synchronizer.next().get(5, TimeUnit.SECONDS); + synchronizer.close(); + + RequestInfo request = server.getRecorder().requireRequest(); + assertEquals("POST", request.getMethod()); + assertEquals(StandardEndpoints.FDV2_POLLING_REQUEST_BASE_PATH, request.getPath()); + assertContextInBody(request); + } + } + + // ---- streaming synchronizer ---- + + @Test + public void streamingSynchronizer_defaultUsesGetWithContextInPath() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.leaveOpen()))) { + Synchronizer synchronizer = new DataSystemComponents.StreamingSynchronizerBuilderImpl() + .build(makeInputs(server, false)); + synchronizer.next(); + + RequestInfo request = server.getRecorder().requireRequest(); + synchronizer.close(); + + assertEquals("GET", request.getMethod()); + assertEquals(StandardEndpoints.FDV2_STREAMING_REQUEST_BASE_PATH + contextPathSegment(), + request.getPath()); + assertNoBody(request); + } + } + + @Test + public void streamingSynchronizer_usePostSendsContextInBody() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.leaveOpen()))) { + Synchronizer synchronizer = new DataSystemComponents.StreamingSynchronizerBuilderImpl() + .build(makeInputs(server, true)); + synchronizer.next(); + + RequestInfo request = server.getRecorder().requireRequest(); + synchronizer.close(); + + assertEquals("POST", request.getMethod()); + assertEquals(StandardEndpoints.FDV2_STREAMING_REQUEST_BASE_PATH, request.getPath()); + assertContextInBody(request); + } + } + + // ---- FDv1 fallback synchronizer ---- + + @Test + public void fdv1FallbackSynchronizer_defaultUsesGetWithContextInPath() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.bodyJson("{}"))) { + Synchronizer synchronizer = new DataSystemComponents.FDv1PollingSynchronizerBuilderImpl() + .build(makeInputs(server, false)); + synchronizer.next().get(5, TimeUnit.SECONDS); + synchronizer.close(); + + RequestInfo request = server.getRecorder().requireRequest(); + assertEquals("GET", request.getMethod()); + assertEquals(StandardEndpoints.POLLING_REQUEST_GET_BASE_PATH + contextPathSegment(), + request.getPath()); + assertNoBody(request); + } + } + + @Test + public void fdv1FallbackSynchronizer_usePostSendsContextInBodyWithReport() throws Exception { + // The FDv1 endpoints do not accept POST; REPORT is their body-carrying method. + try (HttpServer server = HttpServer.start(Handlers.bodyJson("{}"))) { + Synchronizer synchronizer = new DataSystemComponents.FDv1PollingSynchronizerBuilderImpl() + .build(makeInputs(server, true)); + synchronizer.next().get(5, TimeUnit.SECONDS); + synchronizer.close(); + + RequestInfo request = server.getRecorder().requireRequest(); + assertEquals("REPORT", request.getMethod()); + assertEquals(StandardEndpoints.POLLING_REQUEST_REPORT_BASE_PATH, request.getPath()); + assertContextInBody(request); + } + } +} diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DefaultFDv2RequestorTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DefaultFDv2RequestorTest.java index a724ea87..574a6c67 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DefaultFDv2RequestorTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DefaultFDv2RequestorTest.java @@ -36,8 +36,7 @@ public class DefaultFDv2RequestorTest { private static final LDContext CONTEXT = LDContext.create("test-context-key"); // Bogus paths used by tests — deliberately not the real production paths. - private static final String GET_REQUEST_PATH = "/fake-poll-get"; - private static final String REPORT_REQUEST_PATH = "/fake-poll-report"; + private static final String REQUEST_PATH = "/fake-poll"; private static final LDLogger LOGGER = LDLogger.none(); private static HttpProperties httpProperties() { @@ -49,14 +48,13 @@ private static HttpProperties httpProperties() { null, null); } - private static DefaultFDv2Requestor makeRequestor(HttpServer server, boolean useReport, boolean evaluationReasons, String payloadFilter) { + private static DefaultFDv2Requestor makeRequestor(HttpServer server, boolean usePost, boolean evaluationReasons, String payloadFilter) { return new DefaultFDv2Requestor( CONTEXT, server.getUri(), - GET_REQUEST_PATH, - REPORT_REQUEST_PATH, + REQUEST_PATH, httpProperties(), - useReport, + usePost, evaluationReasons, payloadFilter, LOGGER); @@ -93,7 +91,7 @@ public void successfulRequestWithEvents() throws Exception { assertEquals("payload-transferred", response.getEvents().get(2).getEventType()); RequestInfo req = server.getRecorder().requireRequest(); - assertTrue("path should start with " + GET_REQUEST_PATH, req.getPath().startsWith(GET_REQUEST_PATH)); + assertTrue("path should start with " + REQUEST_PATH, req.getPath().startsWith(REQUEST_PATH)); } } } @@ -121,7 +119,7 @@ public void requestWithBasisQueryParameter() throws Exception { future.get(5, TimeUnit.SECONDS); RequestInfo req = server.getRecorder().requireRequest(); - assertTrue(req.getPath().startsWith(GET_REQUEST_PATH)); + assertTrue(req.getPath().startsWith(REQUEST_PATH)); assertTrue("query should contain basis=test-state", req.getQuery() != null && req.getQuery().contains("basis=test-state")); } } @@ -260,7 +258,7 @@ public void requestWithBasisContainingState() throws Exception { future.get(5, TimeUnit.SECONDS); RequestInfo req = server.getRecorder().requireRequest(); - assertTrue(req.getPath().startsWith(GET_REQUEST_PATH)); + assertTrue(req.getPath().startsWith(REQUEST_PATH)); assertTrue("query should contain URL-encoded basis", req.getQuery() != null && req.getQuery().contains("basis=%28p%3Apayload-1%3A100%29")); } } @@ -274,7 +272,7 @@ public void requestWithComplexBasisState() throws Exception { future.get(5, TimeUnit.SECONDS); RequestInfo req = server.getRecorder().requireRequest(); - assertTrue(req.getPath().startsWith(GET_REQUEST_PATH)); + assertTrue(req.getPath().startsWith(REQUEST_PATH)); assertTrue("query should contain URL-encoded basis", req.getQuery() != null && req.getQuery().contains("basis=%28p%3Amy-payload%3A200%29")); } } @@ -357,8 +355,7 @@ public void baseUriCanHaveContextPath() throws Exception { try (DefaultFDv2Requestor requestor = new DefaultFDv2Requestor( CONTEXT, uri, - GET_REQUEST_PATH, - REPORT_REQUEST_PATH, + REQUEST_PATH, httpProperties(), false, false, @@ -368,7 +365,7 @@ public void baseUriCanHaveContextPath() throws Exception { future.get(5, TimeUnit.SECONDS); RequestInfo req = server.getRecorder().requireRequest(); - assertTrue("path should start with context path + request path", req.getPath().startsWith("/fake-base" + GET_REQUEST_PATH)); + assertTrue("path should start with context path + request path", req.getPath().startsWith("/fake-base" + REQUEST_PATH)); } } } @@ -506,18 +503,16 @@ public void evaluationReasonsAddedToRequest() throws Exception { } @Test - public void useReportMethod() throws Exception { + public void usePostMethod() throws Exception { try (HttpServer server = HttpServer.start(Handlers.bodyJson(EMPTY_EVENTS_JSON))) { try (DefaultFDv2Requestor requestor = makeRequestor(server, true, false, null)) { requestor.poll(Selector.EMPTY).get(5, TimeUnit.SECONDS); RequestInfo req = server.getRecorder().requireRequest(); - assertEquals("REPORT", req.getMethod()); - // Context should be in the request body, not the URL path - assertTrue("path should use REPORT path without context segment", - req.getPath().startsWith(REPORT_REQUEST_PATH)); - assertFalse("path should not contain GET context segment", - req.getPath().startsWith(GET_REQUEST_PATH)); + assertEquals("POST", req.getMethod()); + // The context goes in the request body, not in the URL path + assertEquals("path should be the request path with no context segment", + REQUEST_PATH, req.getPath()); assertNotNull("body should contain serialized context", req.getBody()); assertTrue("body should contain context key", req.getBody().contains("test-context-key")); @@ -526,23 +521,38 @@ public void useReportMethod() throws Exception { } @Test - public void useReportMethodDoesNotUseEtag() throws Exception { - // ETag caching is disabled for REPORT requests since the context is in the body + public void getMethodAppendsContextToPath() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.bodyJson(EMPTY_EVENTS_JSON))) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + requestor.poll(Selector.EMPTY).get(5, TimeUnit.SECONDS); + + RequestInfo req = server.getRecorder().requireRequest(); + assertEquals("GET", req.getMethod()); + assertEquals(REQUEST_PATH + "/" + LDUtil.urlSafeBase64(CONTEXT), req.getPath()); + assertTrue("GET request should have no body", + req.getBody() == null || req.getBody().isEmpty()); + } + } + } + + @Test + public void usePostMethodDoesNotUseEtag() throws Exception { + // ETag caching is disabled for POST requests since the context is in the body Handler etagResp = Handlers.all( Handlers.header("ETag", "some-etag"), Handlers.bodyJson(EMPTY_EVENTS_JSON)); try (HttpServer server = HttpServer.start(etagResp)) { try (DefaultFDv2Requestor requestor = makeRequestor(server, true, false, null)) { - // First REPORT request — server returns an ETag, but it should not be cached + // First POST request: the server returns an ETag, but it is not stored requestor.poll(Selector.EMPTY).get(5, TimeUnit.SECONDS); RequestInfo req1 = server.getRecorder().requireRequest(); - assertNull("REPORT request should not send If-None-Match", req1.getHeader("If-None-Match")); + assertNull("POST request should not send If-None-Match", req1.getHeader("If-None-Match")); - // Second REPORT request — still no If-None-Match because ETag was never stored + // Second POST request: still no If-None-Match because the ETag was never stored requestor.poll(Selector.EMPTY).get(5, TimeUnit.SECONDS); RequestInfo req2 = server.getRecorder().requireRequest(); - assertNull("REPORT request should never send If-None-Match", req2.getHeader("If-None-Match")); + assertNull("POST request should never send If-None-Match", req2.getHeader("If-None-Match")); } } } @@ -551,7 +561,7 @@ public void useReportMethodDoesNotUseEtag() throws Exception { public void http400LogsProguardWarning() throws Exception { try (HttpServer server = HttpServer.start(Handlers.status(400))) { try (DefaultFDv2Requestor requestor = new DefaultFDv2Requestor( - CONTEXT, server.getUri(), GET_REQUEST_PATH, REPORT_REQUEST_PATH, + CONTEXT, server.getUri(), REQUEST_PATH, httpProperties(), false, false, null, logging.logger)) { Future future = requestor.poll(Selector.EMPTY); @@ -569,7 +579,7 @@ public void http400LogsProguardWarning() throws Exception { public void networkFailureThrowsException() throws Exception { try (DefaultFDv2Requestor requestor = new DefaultFDv2Requestor( CONTEXT, URI.create("http://localhost:1"), - GET_REQUEST_PATH, REPORT_REQUEST_PATH, + REQUEST_PATH, httpProperties(), false, false, null, LOGGER)) { requestor.poll(Selector.EMPTY).get(5, TimeUnit.SECONDS); } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java index a2044ab7..936190f1 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java @@ -15,6 +15,7 @@ import com.launchdarkly.sdk.android.subsystems.ClientContext; import com.launchdarkly.sdk.android.subsystems.DataSource; import com.launchdarkly.sdk.android.subsystems.DataSourceBuilder; +import com.launchdarkly.sdk.android.subsystems.FDv2SourceResult; import com.launchdarkly.sdk.android.subsystems.HttpConfiguration; import com.launchdarkly.sdk.android.subsystems.Initializer; import com.launchdarkly.sdk.android.subsystems.Synchronizer; @@ -25,7 +26,9 @@ import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; +import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicReference; public class FDv2DataSourceBuilderTest { @@ -36,9 +39,13 @@ public class FDv2DataSourceBuilderTest { public LogCaptureRule logging = new LogCaptureRule(); private ClientContext makeClientContext() { + return makeClientContext(false); + } + + private ClientContext makeClientContext(boolean useReport) { LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled).build(); MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - HttpConfiguration http = new HttpConfiguration(10_000, Collections.emptyMap(), null, false); + HttpConfiguration http = new HttpConfiguration(10_000, Collections.emptyMap(), null, useReport); ClientContext base = new ClientContext( "mobile-key", ENV_REPORTER, @@ -65,6 +72,92 @@ public void defaultBuilder_buildsFDv2DataSource() { assertTrue(ds instanceof FDv2DataSource); } + @Test + public void usePost_isPassedToDataSourceBuilders() throws Exception { + AtomicReference seenUsePost = new AtomicReference<>(); + Map customTable = new LinkedHashMap<>(); + customTable.put(ConnectionMode.POLLING, new ModeDefinition( + Collections.>singletonList(inputs -> { + seenUsePost.set(inputs.isUsePost()); + return new Initializer() { + @Override + public Future run() { + LDAwaitFuture future = new LDAwaitFuture<>(); + future.set(FDv2SourceResult.status(FDv2SourceResult.Status.shutdown(), false)); + return future; + } + + @Override + public void close() { + } + }; + }), + Collections.>emptyList(), + null + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder( + customTable, ConnectionMode.POLLING, ModeResolutionTable.MOBILE, true); + assertTrue(builder.isUsePost()); + DataSource ds = builder.build(makeClientContext()); + + // Factories are lazy: start the source so the initializer builder receives the inputs. + AwaitableCallback startCallback = new AwaitableCallback<>(); + ds.start(startCallback); + try { + startCallback.await(5000); + } catch (Exception ignored) { + // The start outcome is not under test; only the inputs the builder received. + } + AwaitableCallback stopCallback = new AwaitableCallback<>(); + ds.stop(stopCallback); + stopCallback.await(2000); + builder.close(); + + assertEquals(Boolean.TRUE, seenUsePost.get()); + } + + @Test + public void usePost_defaultsToFalse() { + assertFalse(new FDv2DataSourceBuilder().isUsePost()); + Map customTable = new LinkedHashMap<>(); + customTable.put(ConnectionMode.POLLING, new ModeDefinition( + Collections.>emptyList(), + Collections.>emptyList(), + null + )); + assertFalse(new FDv2DataSourceBuilder(customTable, ConnectionMode.POLLING).isUsePost()); + } + + @Test + public void useReport_logsWarningOnceAcrossBuilds() { + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); + ClientContext ctx = makeClientContext(true); + builder.build(ctx); + builder.build(ctx); + builder.close(); + + int warnings = 0; + for (String message : logging.logCapture.getMessageStrings()) { + if (message.contains("WARN:") && message.contains("useReport")) { + warnings++; + } + } + assertEquals(1, warnings); + logging.assertWarnLogged("DataSystemBuilder.usePost"); + } + + @Test + public void useReportDisabled_logsNoUseReportWarning() { + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); + builder.build(makeClientContext(false)); + builder.close(); + + for (String message : logging.logCapture.getMessageStrings()) { + assertFalse(message, message.contains("useReport")); + } + } + @Test public void customModeTable_buildsCorrectly() { Map customTable = new LinkedHashMap<>(); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2EntryConverterTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2EntryConverterTest.java index 7065d6d7..1f58a044 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2EntryConverterTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2EntryConverterTest.java @@ -37,7 +37,7 @@ public class FDv2EntryConverterTest { private static DataSourceBuildInputs plainInputs() { return new DataSourceBuildInputs( - CONTEXT, null, null, false, + CONTEXT, null, null, false, false, () -> Selector.EMPTY, null, CACHE_DIR, LDLogger.none()); } 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 9aa3891d..7589b5d7 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 @@ -51,8 +51,7 @@ public void tearDown() { // Bogus paths used by tests — deliberately not the real production paths. private static final String STREAM_PATH = "/fake-stream"; - private static final String POLL_GET_PATH = "/fake-poll-get"; - private static final String POLL_REPORT_PATH = "/fake-poll-report"; + private static final String POLL_PATH = "/fake-poll"; private static HttpProperties httpProperties() { return new HttpProperties( @@ -92,10 +91,10 @@ private FDv2StreamingSynchronizer makeSynchronizer( private FDv2StreamingSynchronizer makeSynchronizer( URI streamBaseUri, - boolean evaluationReasons, boolean useReport) { + boolean evaluationReasons, boolean usePost) { return new FDv2StreamingSynchronizer( CONTEXT, EMPTY_SELECTOR_SOURCE, streamBaseUri, STREAM_PATH, - null, 100, evaluationReasons, useReport, + null, 100, evaluationReasons, usePost, httpProperties(), executor, LOGGER, null); } @@ -1051,7 +1050,7 @@ public void evaluationReasonsAddedToStreamUrl() throws Exception { } @Test - public void useReportMethodForStreamRequest() throws Exception { + public void usePostMethodForStreamRequest() 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}"); @@ -1062,17 +1061,17 @@ public void useReportMethodForStreamRequest() throws Exception { Handlers.SSE.leaveOpen()))) { FDv2StreamingSynchronizer sync = makeSynchronizer(server.getUri(), - false, true /* useReport */); + false, true /* usePost */); Future resultFuture = sync.next(); FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); assertEquals(SourceResultType.CHANGE_SET, result.getResultType()); RequestInfo request = server.getRecorder().requireRequest(); - assertEquals("REPORT", request.getMethod()); - // With REPORT the context goes in the body, not the URL path - assertFalse("path should not contain base64 context segment when using REPORT", - request.getPath().contains(LDUtil.urlSafeBase64(CONTEXT))); + assertEquals("POST", request.getMethod()); + // With POST the context goes in the body, not the URL path + assertEquals("path should be the stream path with no context segment", + STREAM_PATH, request.getPath()); assertNotNull("body should contain serialized context", request.getBody()); assertTrue("body should contain context key", request.getBody().contains("test-context")); @@ -1126,7 +1125,7 @@ public void pingEventWithRequestorTriggersPoll() throws Exception { DefaultFDv2Requestor requestor = new DefaultFDv2Requestor( CONTEXT, pollServer.getUri(), - POLL_GET_PATH, POLL_REPORT_PATH, + POLL_PATH, httpProperties(), false, false, null, LOGGER); FDv2StreamingSynchronizer sync = new FDv2StreamingSynchronizer( diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigDataSystemTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigDataSystemTest.java index a29aacba..ee39916d 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigDataSystemTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigDataSystemTest.java @@ -54,6 +54,21 @@ public void dataSystem_resolutionTable_matchesCreateMobileForForegroundAndBackgr assertResolutionTablesAgree(expected, fdv2.getResolutionTable()); } + @Test + public void dataSystem_propagatesUsePostToFDv2DataSourceBuilder() { + LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled) + .mobileKey("k") + .dataSystem(Components.dataSystem().usePost(true)) + .build(); + assertTrue(((FDv2DataSourceBuilder) config.dataSource).isUsePost()); + + LDConfig defaultConfig = new LDConfig.Builder(AutoEnvAttributes.Disabled) + .mobileKey("k") + .dataSystem(Components.dataSystem()) + .build(); + assertFalse(((FDv2DataSourceBuilder) defaultConfig.dataSource).isUsePost()); + } + @Test public void dataSystem_propagatesAutomaticModeSwitchingFromDataSystemBuilder() { AutomaticModeSwitchingConfig granular = DataSystemComponents.automaticModeSwitching() diff --git a/testharness-suppressions-fdv2.txt b/testharness-suppressions-fdv2.txt index 5e53af75..ceb41bc5 100644 --- a/testharness-suppressions-fdv2.txt +++ b/testharness-suppressions-fdv2.txt @@ -1 +1,4 @@ -streaming/fdv2/fallback to FDv1 handling \ No newline at end of file +streaming/fdv2/fallback to FDv1 handling +events/feature events/single-kind anonymous context redacts all attributes +events/feature events/multi-kind with anonymous context redacts attributes appropriately +events/context properties/single-kind, allAttributesPrivate, slash-prefixed attribute name