diff --git a/build.gradle b/build.gradle index a598ec767..4e40ec5bd 100644 --- a/build.gradle +++ b/build.gradle @@ -15,7 +15,7 @@ plugins { id "java-library" id "checkstyle" id "signing" - id "com.github.johnrengelman.shadow" version "4.0.4" + id "com.github.johnrengelman.shadow" version "5.2.0" id "maven-publish" id "de.marcphilipp.nexus-publish" version "0.3.0" id "io.codearte.nexus-staging" version "0.21.2" @@ -38,13 +38,13 @@ configurations.all { allprojects { group = 'com.launchdarkly' version = "${version}" + archivesBaseName = 'launchdarkly-java-server-sdk' sourceCompatibility = 1.7 targetCompatibility = 1.7 } ext { sdkBasePackage = "com.launchdarkly.client" - sdkBaseName = "launchdarkly-java-server-sdk" // List any packages here that should be included in OSGi imports for the SDK, if they cannot // be discovered by looking in our explicit dependencies. @@ -53,23 +53,34 @@ ext { ext.libraries = [:] +ext.versions = [ + "commonsCodec": "1.10", + "gson": "2.7", + "guava": "19.0", + "jodaTime": "2.9.3", + "okhttpEventsource": "1.11.0", + "slf4j": "1.7.21", + "snakeyaml": "1.19", + "jedis": "2.9.0" +] + // Add dependencies to "libraries.internal" that are not exposed in our public API. These // will be completely omitted from the "thin" jar, and will be embedded with shaded names // in the other two SDK jars. libraries.internal = [ - "commons-codec:commons-codec:1.10", - "com.google.guava:guava:19.0", - "joda-time:joda-time:2.9.3", - "com.launchdarkly:okhttp-eventsource:1.10.2", - "org.yaml:snakeyaml:1.19", - "redis.clients:jedis:2.9.0" + "commons-codec:commons-codec:${versions.commonsCodec}", + "com.google.guava:guava:${versions.guava}", + "joda-time:joda-time:${versions.jodaTime}", + "com.launchdarkly:okhttp-eventsource:${versions.okhttpEventsource}", + "org.yaml:snakeyaml:${versions.snakeyaml}", + "redis.clients:jedis:${versions.jedis}" ] // Add dependencies to "libraries.external" that are exposed in our public API, or that have // global state that must be shared between the SDK and the caller. libraries.external = [ - "com.google.code.gson:gson:2.7", - "org.slf4j:slf4j-api:1.7.21" + "com.google.code.gson:gson:${versions.gson}", + "org.slf4j:slf4j-api:${versions.slf4j}" ] // Add dependencies to "libraries.test" that are used only in unit tests. @@ -84,8 +95,7 @@ libraries.test = [ dependencies { implementation libraries.internal - compileClasspath libraries.external - runtime libraries.internal, libraries.external + api libraries.external testImplementation libraries.test, libraries.internal, libraries.external // Unlike what the name might suggest, the "shadow" configuration specifies dependencies that @@ -93,8 +103,11 @@ dependencies { shadow libraries.external } -task wrapper(type: Wrapper) { - gradleVersion = '4.10.2' +configurations { + // We need to define "internal" as a custom configuration that contains the same things as + // "implementation", because "implementation" has special behavior in Gradle that prevents us + // from referencing it the way we do in shadeDependencies(). + internal.extendsFrom implementation } checkstyle { @@ -102,7 +115,6 @@ checkstyle { } jar { - baseName = sdkBaseName // thin classifier means that the non-shaded non-fat jar is still available // but is opt-in since users will have to specify it. classifier = 'thin' @@ -118,8 +130,6 @@ jar { // This builds the default uberjar that contains all of our dependencies except Gson and // SLF4j, in shaded form. The user is expected to provide Gson and SLF4j. shadowJar { - baseName = sdkBaseName - // No classifier means that the shaded jar becomes the default artifact classifier = '' @@ -141,12 +151,11 @@ shadowJar { // This builds the "-all"/"fat" jar, which is the same as the default uberjar except that // Gson and SLF4j are bundled and exposed (unshaded). task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { - baseName = sdkBaseName classifier = 'all' group = "shadow" description = "Builds a Shaded fat jar including SLF4J" from(project.convention.getPlugin(JavaPluginConvention).sourceSets.main.output) - configurations = [project.configurations.runtime] + configurations = [project.configurations.runtimeClasspath] exclude('META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA') // doFirst causes the following steps to be run during Gradle's execution phase rather than the @@ -228,7 +237,7 @@ def shadeDependencies(jarTask) { def excludePackages = getAllSdkPackages() + configurations.shadow.collectMany { getPackagesInDependencyJar(it)} def topLevelPackages = - configurations.runtime.collectMany { + configurations.internal.collectMany { getPackagesInDependencyJar(it).collect { it.contains(".") ? it.substring(0, it.indexOf(".")) : it } }. unique() @@ -325,7 +334,6 @@ test { idea { module { downloadJavadoc = true - downloadSources = true } } @@ -367,7 +375,6 @@ publishing { shadow(MavenPublication) { publication -> project.shadow.component(publication) - artifactId = sdkBaseName artifact jar artifact sourcesJar artifact javadocJar @@ -422,7 +429,7 @@ tasks.withType(Sign) { // dependencies of the SDK, so they can be put on the classpath as needed during tests. task exportDependencies(type: Copy, dependsOn: compileJava) { into "packaging-test/temp/dependencies-all" - from configurations.runtime.resolvedConfiguration.resolvedArtifacts.collect { it.file } + from configurations.runtimeClasspath.resolvedConfiguration.resolvedArtifacts.collect { it.file } } gitPublish { diff --git a/gradle.properties b/gradle.properties index 58b55d1e8..df3a1cc55 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,8 @@ -version=4.12.1 +version=4.12.2 # The following empty ossrh properties are used by LaunchDarkly's internal integration testing framework # and should not be needed for typical development purposes (including by third-party developers). ossrhUsername= ossrhPassword= + +# See https://github.com/gradle/gradle/issues/11308 regarding the following property +systemProp.org.gradle.internal.publish.checksums.insecure=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index ed88a042a..29953ea14 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index fb7ef980f..a2bf1313b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.2.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-bin.zip diff --git a/packaging-test/Makefile b/packaging-test/Makefile index e69d2cf40..ffc0bbe30 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -18,39 +18,31 @@ SDK_DEFAULT_JAR=$(SDK_JARS_DIR)/launchdarkly-java-server-sdk-$(SDK_VERSION).jar SDK_ALL_JAR=$(SDK_JARS_DIR)/launchdarkly-java-server-sdk-$(SDK_VERSION)-all.jar SDK_THIN_JAR=$(SDK_JARS_DIR)/launchdarkly-java-server-sdk-$(SDK_VERSION)-thin.jar -TEMP_DIR=$(BASE_DIR)/temp -TEMP_OUTPUT=$(TEMP_DIR)/test.out +export TEMP_DIR=$(BASE_DIR)/temp +export TEMP_OUTPUT=$(TEMP_DIR)/test.out # Build product of the project in ./test-app; can be run as either a regular app or an OSGi bundle TEST_APP_JAR=$(TEMP_DIR)/test-app.jar -# SLF4j implementation - we need to download this separately because it's not in the SDK dependencies -SLF4J_SIMPLE_JAR=$(TEMP_DIR)/test-slf4j-simple.jar -SLF4J_SIMPLE_JAR_URL=https://oss.sonatype.org/content/groups/public/org/slf4j/slf4j-simple/1.7.21/slf4j-simple-1.7.21.jar - # Felix OSGi container -FELIX_ARCHIVE=org.apache.felix.main.distribution-6.0.3.tar.gz -FELIX_ARCHIVE_URL=http://mirrors.ibiblio.org/apache//felix/$(FELIX_ARCHIVE) -FELIX_DIR=$(TEMP_DIR)/felix -FELIX_JAR=$(FELIX_DIR)/bin/felix.jar -TEMP_BUNDLE_DIR=$(TEMP_DIR)/bundles +export FELIX_DIR=$(TEMP_DIR)/felix +export FELIX_JAR=$(FELIX_DIR)/lib/felix.jar +export FELIX_BASE_BUNDLE_DIR=$(FELIX_DIR)/base-bundles +export TEMP_BUNDLE_DIR=$(FELIX_DIR)/app-bundles # Lists of jars to use as a classpath (for the non-OSGi runtime test) or to install as bundles (for # the OSGi test). Note that we're assuming that all of the SDK's dependencies have built-in support # for OSGi, which is currently true; if that weren't true, we would have to do something different # to put them on the system classpath in the OSGi test. -RUN_JARS_test-all-jar=$(TEST_APP_JAR) $(SDK_ALL_JAR) \ - $(SLF4J_SIMPLE_JAR) +RUN_JARS_test-all-jar=$(TEST_APP_JAR) $(SDK_ALL_JAR) RUN_JARS_test-default-jar=$(TEST_APP_JAR) $(SDK_DEFAULT_JAR) \ - $(shell ls $(TEMP_DIR)/dependencies-external/*.jar) \ - $(SLF4J_SIMPLE_JAR) + $(shell ls $(TEMP_DIR)/dependencies-external/*.jar) RUN_JARS_test-thin-jar=$(TEST_APP_JAR) $(SDK_THIN_JAR) \ $(shell ls $(TEMP_DIR)/dependencies-internal/*.jar) \ - $(shell ls $(TEMP_DIR)/dependencies-external/*.jar) \ - $(SLF4J_SIMPLE_JAR) + $(shell ls $(TEMP_DIR)/dependencies-external/*.jar) # The test-app displays this message on success -SUCCESS_MESSAGE="@@@ successfully created LD client @@@" +export SUCCESS_MESSAGE=@@@ successfully created LD client @@@ classes_prepare=echo " checking $(1)..." && jar tf $(1) | grep '\.class$$' >$(TEMP_OUTPUT) classes_should_contain=echo " should contain $(2)" && grep $(1) $(TEMP_OUTPUT) >/dev/null @@ -73,21 +65,12 @@ clean: # SECONDEXPANSION is needed so we can use "$@" inside a variable in the prerequisite list of the test targets .SECONDEXPANSION: -test-all-jar test-default-jar test-thin-jar: $$(RUN_JARS_$$@) $(TEST_APP_JAR) $(FELIX_JAR) get-sdk-dependencies $$@-classes +test-all-jar test-default-jar test-thin-jar: $$@-classes get-sdk-dependencies $$(RUN_JARS_$$@) $(TEST_APP_JAR) $(FELIX_DIR) @$(call caption,$@) - @echo " non-OSGi runtime test" - @java -classpath $(shell echo "$(RUN_JARS_$@)" | sed -e 's/ /:/g') testapp.TestApp | tee $(TEMP_OUTPUT) - @grep $(SUCCESS_MESSAGE) $(TEMP_OUTPUT) >/dev/null + ./run-non-osgi-test.sh $(RUN_JARS_$@) # Can't currently run the OSGi test for the thin jar, because some of our dependencies aren't available as OSGi bundles. @if [ "$@" != "test-thin-jar" ]; then \ - echo ""; \ - echo " OSGi runtime test"; \ - rm -rf $(TEMP_BUNDLE_DIR); \ - mkdir -p $(TEMP_BUNDLE_DIR); \ - cp $(RUN_JARS_$@) $(FELIX_DIR)/bundle/*.jar $(TEMP_BUNDLE_DIR); \ - rm -rf $(FELIX_DIR)/felix-cache; \ - cd $(FELIX_DIR) && echo "sleep 3;exit 0" | java -jar $(FELIX_JAR) -b $(TEMP_BUNDLE_DIR) | tee $(TEMP_OUTPUT); \ - grep $(SUCCESS_MESSAGE) $(TEMP_OUTPUT) >/dev/null; \ + ./run-osgi-test.sh $(RUN_JARS_$@); \ fi test-all-jar-classes: $(SDK_ALL_JAR) $(TEMP_DIR) @@ -144,13 +127,21 @@ $(TEMP_DIR)/dependencies-internal: $(TEMP_DIR)/dependencies-all cp $(TEMP_DIR)/dependencies-all/*.jar $@ rm $@/gson*.jar $@/slf4j*.jar -$(SLF4J_SIMPLE_JAR): | $(TEMP_DIR) - curl -f -L $(SLF4J_SIMPLE_JAR_URL) >$@ - -$(FELIX_JAR): | $(TEMP_DIR) - curl -f -L $(FELIX_ARCHIVE_URL) >$(TEMP_DIR)/$(FELIX_ARCHIVE) - cd $(TEMP_DIR) && tar xfz $(FELIX_ARCHIVE) && rm $(FELIX_ARCHIVE) - cd $(TEMP_DIR) && mv `ls -d felix*` felix +$(FELIX_JAR): $(FELIX_DIR) + +$(FELIX_DIR): + mkdir -p $(FELIX_DIR) + mkdir -p $(FELIX_DIR)/lib + mkdir -p $(FELIX_BASE_BUNDLE_DIR) + cd test-app && ../../gradlew createOsgi + @# createOsgi is a target provided by the osgi-run Gradle plugin; it downloads the Felix container and + @# puts it in build/osgi along with related bundles and a config file. + cp -r test-app/build/osgi/conf $(FELIX_DIR) + echo "felix.shutdown.hook=false" >>$(FELIX_DIR)/conf/config.properties + @# setting felix.shutdown.hook to false allows our test app to use System.exit() + cp test-app/build/osgi/system-libs/org.apache.felix.main-*.jar $(FELIX_JAR) + cp test-app/build/osgi/bundle/* $(FELIX_BASE_BUNDLE_DIR) + cd $(FELIX_BASE_BUNDLE_DIR) && rm -f launchdarkly-*.jar gson-*.jar $(TEMP_DIR): [ -d $@ ] || mkdir -p $@ diff --git a/packaging-test/run-non-osgi-test.sh b/packaging-test/run-non-osgi-test.sh new file mode 100755 index 000000000..dcf9c24db --- /dev/null +++ b/packaging-test/run-non-osgi-test.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +echo "" +echo " non-OSGi runtime test" +java -classpath $(echo "$@" | sed -e 's/ /:/g') testapp.TestApp | tee ${TEMP_OUTPUT} +grep "${SUCCESS_MESSAGE}" ${TEMP_OUTPUT} >/dev/null diff --git a/packaging-test/run-osgi-test.sh b/packaging-test/run-osgi-test.sh new file mode 100755 index 000000000..62439fedf --- /dev/null +++ b/packaging-test/run-osgi-test.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +echo "" +echo " OSGi runtime test" +rm -rf ${TEMP_BUNDLE_DIR} +mkdir -p ${TEMP_BUNDLE_DIR} +cp $@ ${FELIX_BASE_BUNDLE_DIR}/* ${TEMP_BUNDLE_DIR} +rm -rf ${FELIX_DIR}/felix-cache +rm -f ${TEMP_OUTPUT} +touch ${TEMP_OUTPUT} + +cd ${FELIX_DIR} && java -jar ${FELIX_JAR} -b ${TEMP_BUNDLE_DIR} | tee ${TEMP_OUTPUT} + +grep "${SUCCESS_MESSAGE}" ${TEMP_OUTPUT} >/dev/null diff --git a/packaging-test/test-app/build.gradle b/packaging-test/test-app/build.gradle index 02ba7b08f..59d3fd936 100644 --- a/packaging-test/test-app/build.gradle +++ b/packaging-test/test-app/build.gradle @@ -1,5 +1,17 @@ -apply plugin: "java" -apply plugin: "osgi" + +buildscript { + repositories { + jcenter() + mavenCentral() + } +} + +plugins { + id "java" + id "java-library" + id "biz.aQute.bnd.builder" version "5.0.1" + id "com.athaydes.osgi-run" version "1.6.0" +} repositories { mavenCentral() @@ -8,32 +20,26 @@ repositories { allprojects { group = "com.launchdarkly" version = "1.0.0" + archivesBaseName = 'test-app-bundle' sourceCompatibility = 1.7 targetCompatibility = 1.7 } dependencies { // Note, the SDK build must have already been run before this, since we're using its product as a dependency - compileClasspath fileTree(dir: "../../build/libs", include: "launchdarkly-java-server-sdk-*-thin.jar") - compileClasspath "com.google.code.gson:gson:2.7" - compileClasspath "org.slf4j:slf4j-api:1.7.21" - compileClasspath "org.osgi:osgi_R4_core:1.0" + implementation fileTree(dir: "../../build/libs", include: "launchdarkly-java-server-sdk-*-thin.jar") + implementation "com.google.code.gson:gson:2.7" + implementation "org.slf4j:slf4j-api:1.7.22" + implementation "org.osgi:osgi_R4_core:1.0" + osgiRuntime "org.slf4j:slf4j-simple:1.7.22" } jar { - baseName = 'test-app-bundle' - manifest { - instruction 'Bundle-Activator', 'testapp.TestAppOsgiEntryPoint' - } -} - -task wrapper(type: Wrapper) { - gradleVersion = '4.10.2' + bnd( + 'Bundle-Activator': 'testapp.TestAppOsgiEntryPoint' + ) } -buildscript { - repositories { - jcenter() - mavenCentral() - } +runOsgi { + bundles = [ ] // we don't need a CLI or anything like that - just the SLF4j dependency shown above } diff --git a/packaging-test/test-app/settings.gradle b/packaging-test/test-app/settings.gradle new file mode 100644 index 000000000..e2a1182e2 --- /dev/null +++ b/packaging-test/test-app/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'test-app-bundle' diff --git a/packaging-test/test-app/src/main/java/testapp/TestAppOsgiEntryPoint.java b/packaging-test/test-app/src/main/java/testapp/TestAppOsgiEntryPoint.java index f1a9db3ad..ed42ccb1a 100644 --- a/packaging-test/test-app/src/main/java/testapp/TestAppOsgiEntryPoint.java +++ b/packaging-test/test-app/src/main/java/testapp/TestAppOsgiEntryPoint.java @@ -8,9 +8,10 @@ public void start(BundleContext context) throws Exception { System.out.println("@@@ starting test bundle @@@"); TestApp.main(new String[0]); + + System.exit(0); } public void stop(BundleContext context) throws Exception { - System.out.println("@@@ stopping test bundle @@@"); } } \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index 3d7d94e5c..f446eb8e6 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -2,21 +2,28 @@ import com.launchdarkly.client.DiagnosticEvent.ConfigProperty; import com.launchdarkly.client.integrations.EventProcessorBuilder; +import com.launchdarkly.client.integrations.HttpConfigurationBuilder; import com.launchdarkly.client.integrations.PersistentDataStoreBuilder; import com.launchdarkly.client.integrations.PollingDataSourceBuilder; import com.launchdarkly.client.integrations.StreamingDataSourceBuilder; import com.launchdarkly.client.interfaces.DiagnosticDescription; +import com.launchdarkly.client.interfaces.HttpAuthentication; +import com.launchdarkly.client.interfaces.HttpConfiguration; import com.launchdarkly.client.interfaces.PersistentDataStoreFactory; import com.launchdarkly.client.utils.CachingStoreWrapper; import com.launchdarkly.client.utils.FeatureStoreCore; import com.launchdarkly.client.value.LDValue; import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Proxy; import java.net.URI; import java.util.concurrent.Future; import static com.google.common.util.concurrent.Futures.immediateFuture; +import okhttp3.Credentials; + /** * Provides configurable factories for the standard implementations of LaunchDarkly component interfaces. *

@@ -313,6 +320,55 @@ public static UpdateProcessorFactory nullUpdateProcessor() { return nullUpdateProcessorFactory; } + /** + * Returns a configurable factory for the SDK's networking configuration. + *

+ * Passing this to {@link LDConfig.Builder#http(com.launchdarkly.client.interfaces.HttpConfigurationFactory)} + * applies this configuration to all HTTP/HTTPS requests made by the SDK. + *


+   *     LDConfig config = new LDConfig.Builder()
+   *         .http(
+   *              Components.httpConfiguration()
+   *                  .connectTimeoutMillis(3000)
+   *                  .proxyHostAndPort("my-proxy", 8080)
+   *         )
+   *         .build();
+   * 
+ *

+ * These properties will override any equivalent deprecated properties that were set with {@code LDConfig.Builder}, + * such as {@link LDConfig.Builder#connectTimeout(int)}. However, setting {@link LDConfig.Builder#offline(boolean)} + * to {@code true} will supersede these settings and completely disable network requests. + * + * @return a factory object + * @since 4.13.0 + * @see LDConfig.Builder#http(com.launchdarkly.client.interfaces.HttpConfigurationFactory) + */ + public static HttpConfigurationBuilder httpConfiguration() { + return new HttpConfigurationBuilderImpl(); + } + + /** + * Configures HTTP basic authentication, for use with a proxy server. + *


+   *     LDConfig config = new LDConfig.Builder()
+   *         .http(
+   *              Components.httpConfiguration()
+   *                  .proxyHostAndPort("my-proxy", 8080)
+   *                  .proxyAuthentication(Components.httpBasicAuthentication("username", "password"))
+   *         )
+   *         .build();
+   * 
+ * + * @param username the username + * @param password the password + * @return the basic authentication strategy + * @since 4.13.0 + * @see HttpConfigurationBuilder#proxyAuth(HttpAuthentication) + */ + public static HttpAuthentication httpBasicAuthentication(String username, String password) { + return new HttpBasicAuthentication(username, password); + } + private static final class InMemoryFeatureStoreFactory implements FeatureStoreFactory, DiagnosticDescription { @Override public FeatureStore createFeatureStore() { @@ -646,6 +702,36 @@ public LDValue describeConfiguration(LDConfig config) { } } + private static final class HttpConfigurationBuilderImpl extends HttpConfigurationBuilder { + @Override + public HttpConfiguration createHttpConfiguration() { + return new HttpConfigurationImpl( + connectTimeoutMillis, + proxyHost == null ? null : new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort)), + proxyAuth, + socketTimeoutMillis, + sslSocketFactory, + trustManager, + wrapperName == null ? null : (wrapperVersion == null ? wrapperName : (wrapperName + "/" + wrapperVersion)) + ); + } + } + + private static final class HttpBasicAuthentication implements HttpAuthentication { + private final String username; + private final String password; + + HttpBasicAuthentication(String username, String password) { + this.username = username; + this.password = password; + } + + @Override + public String provideAuthorization(Iterable challenges) { + return Credentials.basic(username, password); + } + } + private static final class PersistentDataStoreBuilderImpl extends PersistentDataStoreBuilder implements DiagnosticDescription { public PersistentDataStoreBuilderImpl(PersistentDataStoreFactory persistentDataStoreFactory) { super(persistentDataStoreFactory); diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index d516a9bcc..a82890e78 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -3,6 +3,7 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.launchdarkly.client.EventSummarizer.EventSummary; +import com.launchdarkly.client.interfaces.HttpConfiguration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -529,7 +530,8 @@ private static void postJson(OkHttpClient httpClient, Headers headers, String js } break; } catch (IOException e) { - logger.warn("Unhandled exception in LaunchDarkly client when posting events to URL: " + request.url(), e); + logger.warn("Unhandled exception in LaunchDarkly client when posting events to URL: {} ({})", request.url(), e.toString()); + logger.debug(e.toString(), e); continue; } } @@ -685,7 +687,7 @@ Runnable createSendDiagnosticTask(final DiagnosticEvent diagnosticEvent) { return new Runnable() { @Override public void run() { - String json = JsonHelpers.gsonInstance().toJson(diagnosticEvent); + String json = JsonHelpers.serialize(diagnosticEvent); postJson(httpClient, headers, json, uriStr, "diagnostic event", null, null); } }; diff --git a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java index e9d59fd7b..017bcdc73 100644 --- a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java +++ b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java @@ -2,6 +2,8 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.io.Files; +import com.launchdarkly.client.interfaces.HttpConfiguration; +import com.launchdarkly.client.interfaces.SerializationException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -12,7 +14,6 @@ import java.util.HashMap; import java.util.Map; -import static com.launchdarkly.client.JsonHelpers.gsonInstance; import static com.launchdarkly.client.Util.configureHttpClientBuilder; import static com.launchdarkly.client.Util.getHeadersBuilderFor; import static com.launchdarkly.client.Util.shutdownHttpClient; @@ -63,19 +64,19 @@ public void close() { shutdownHttpClient(httpClient); } - public FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException { + public FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException, SerializationException { String body = get(GET_LATEST_FLAGS_PATH + "/" + featureKey); - return gsonInstance().fromJson(body, FeatureFlag.class); + return JsonHelpers.deserialize(body, FeatureFlag.class); } public Segment getSegment(String segmentKey) throws IOException, HttpErrorException { String body = get(GET_LATEST_SEGMENTS_PATH + "/" + segmentKey); - return gsonInstance().fromJson(body, Segment.class); + return JsonHelpers.deserialize(body, Segment.class); } public AllData getAllData() throws IOException, HttpErrorException { String body = get(GET_LATEST_ALL_PATH); - return gsonInstance().fromJson(body, AllData.class); + return JsonHelpers.deserialize(body, AllData.class); } static Map, Map> toVersionedDataMap(AllData allData) { diff --git a/src/main/java/com/launchdarkly/client/DiagnosticEvent.java b/src/main/java/com/launchdarkly/client/DiagnosticEvent.java index 62f599a21..4439f3261 100644 --- a/src/main/java/com/launchdarkly/client/DiagnosticEvent.java +++ b/src/main/java/com/launchdarkly/client/DiagnosticEvent.java @@ -91,10 +91,10 @@ static LDValue getConfigurationData(LDConfig config) { ObjectBuilder builder = LDValue.buildObject(); // Add the top-level properties that are not specific to a particular component type. - builder.put("connectTimeoutMillis", config.httpConfig.connectTimeoutUnit.toMillis(config.httpConfig.connectTimeout)); - builder.put("socketTimeoutMillis", config.httpConfig.socketTimeoutUnit.toMillis(config.httpConfig.socketTimeout)); - builder.put("usingProxy", config.httpConfig.proxy != null); - builder.put("usingProxyAuthenticator", config.httpConfig.proxyAuthenticator != null); + builder.put("connectTimeoutMillis", config.httpConfig.getConnectTimeoutMillis()); + builder.put("socketTimeoutMillis", config.httpConfig.getSocketTimeoutMillis()); + builder.put("usingProxy", config.httpConfig.getProxy() != null); + builder.put("usingProxyAuthenticator", config.httpConfig.getProxyAuthentication() != null); builder.put("offline", config.offline); builder.put("startWaitMillis", config.startWaitMillis); @@ -155,8 +155,19 @@ static class DiagnosticSdk { final String wrapperVersion; DiagnosticSdk(LDConfig config) { - this.wrapperName = config.httpConfig.wrapperName; - this.wrapperVersion = config.httpConfig.wrapperVersion; + String id = config.httpConfig.getWrapperIdentifier(); + if (id == null) { + this.wrapperName = null; + this.wrapperVersion = null; + } else { + if (id.indexOf("/") >= 0) { + this.wrapperName = id.substring(0, id.indexOf("/")); + this.wrapperVersion = id.substring(id.indexOf("/") + 1); + } else { + this.wrapperName = id; + this.wrapperVersion = null; + } + } } } diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java index 1b48346f7..1feedecb5 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -7,9 +7,11 @@ /** * Describes the reason that a flag evaluation produced a particular value. This is returned by * methods such as {@link LDClientInterface#boolVariationDetail(String, LDUser, boolean)}. - * + *

* Note that this is an enum-like class hierarchy rather than an enum, because some of the - * possible reasons have their own properties. + * possible reasons have their own properties. However, directly referencing the subclasses is + * deprecated; in a future version only the {@link EvaluationReason} base class will be visible, + * and it has getter methods for all of the possible properties. * * @since 4.3.0 */ @@ -101,6 +103,60 @@ public Kind getKind() { return kind; } + + /** + * The index of the rule that was matched (0 for the first rule in the feature flag), + * if the {@code kind} is {@link Kind#RULE_MATCH}. Otherwise this returns -1. + * + * @return the rule index or -1 + */ + public int getRuleIndex() { + return -1; + } + + /** + * The unique identifier of the rule that was matched, if the {@code kind} is + * {@link Kind#RULE_MATCH}. Otherwise {@code null}. + *

+ * Unlike the rule index, this identifier will not change if other rules are added or deleted. + * + * @return the rule identifier or null + */ + public String getRuleId() { + return null; + } + + /** + * The key of the prerequisite flag that did not return the desired variation, if the + * {@code kind} is {@link Kind#PREREQUISITE_FAILED}. Otherwise {@code null}. + * + * @return the prerequisite flag key or null + */ + public String getPrerequisiteKey() { + return null; + } + + /** + * An enumeration value indicating the general category of error, if the + * {@code kind} is {@link Kind#PREREQUISITE_FAILED}. Otherwise {@code null}. + * + * @return the error kind or null + */ + public ErrorKind getErrorKind() { + return null; + } + + /** + * The exception that caused the error condition, if the {@code kind} is + * {@link EvaluationReason.Kind#ERROR} and the {@code errorKind} is {@link ErrorKind#EXCEPTION}. + * Otherwise {@code null}. + * + * @return the exception instance + * @since 4.11.0 + */ + public Exception getException() { + return null; + } @Override public String toString() { @@ -113,7 +169,7 @@ protected EvaluationReason(Kind kind) } /** - * Returns an instance of {@link Off}. + * Returns an instance whose {@code kind} is {@link Kind#OFF}. * @return a reason object */ public static Off off() { @@ -121,7 +177,7 @@ public static Off off() { } /** - * Returns an instance of {@link TargetMatch}. + * Returns an instance whose {@code kind} is {@link Kind#TARGET_MATCH}. * @return a reason object */ public static TargetMatch targetMatch() { @@ -129,7 +185,7 @@ public static TargetMatch targetMatch() { } /** - * Returns an instance of {@link RuleMatch}. + * Returns an instance whose {@code kind} is {@link Kind#RULE_MATCH}. * @param ruleIndex the rule index * @param ruleId the rule identifier * @return a reason object @@ -139,7 +195,7 @@ public static RuleMatch ruleMatch(int ruleIndex, String ruleId) { } /** - * Returns an instance of {@link PrerequisiteFailed}. + * Returns an instance whose {@code kind} is {@link Kind#PREREQUISITE_FAILED}. * @param prerequisiteKey the flag key of the prerequisite that failed * @return a reason object */ @@ -148,7 +204,7 @@ public static PrerequisiteFailed prerequisiteFailed(String prerequisiteKey) { } /** - * Returns an instance of {@link Fallthrough}. + * Returns an instance whose {@code kind} is {@link Kind#FALLTHROUGH}. * @return a reason object */ public static Fallthrough fallthrough() { @@ -156,7 +212,7 @@ public static Fallthrough fallthrough() { } /** - * Returns an instance of {@link Error}. + * Returns an instance whose {@code kind} is {@link Kind#ERROR}. * @param errorKind describes the type of error * @return a reason object */ @@ -186,7 +242,10 @@ public static Error exception(Exception exception) { * Subclass of {@link EvaluationReason} that indicates that the flag was off and therefore returned * its configured off value. * @since 4.3.0 + * @deprecated This type will be removed in a future version. Use {@link #getKind()} instead and check + * for the {@link Kind#OFF} value. */ + @Deprecated public static class Off extends EvaluationReason { private Off() { super(Kind.OFF); @@ -199,7 +258,10 @@ private Off() { * Subclass of {@link EvaluationReason} that indicates that the user key was specifically targeted * for this flag. * @since 4.3.0 + * @deprecated This type will be removed in a future version. Use {@link #getKind()} instead and check + * for the {@link Kind#TARGET_MATCH} value. */ + @Deprecated public static class TargetMatch extends EvaluationReason { private TargetMatch() { @@ -212,7 +274,10 @@ private TargetMatch() /** * Subclass of {@link EvaluationReason} that indicates that the user matched one of the flag's rules. * @since 4.3.0 + * @deprecated This type will be removed in a future version. Use {@link #getKind()} instead and check + * for the {@link Kind#RULE_MATCH} value. */ + @Deprecated public static class RuleMatch extends EvaluationReason { private final int ruleIndex; private final String ruleId; @@ -227,6 +292,7 @@ private RuleMatch(int ruleIndex, String ruleId) { * The index of the rule that was matched (0 for the first rule in the feature flag). * @return the rule index */ + @Override public int getRuleIndex() { return ruleIndex; } @@ -235,6 +301,7 @@ public int getRuleIndex() { * A unique string identifier for the matched rule, which will not change if other rules are added or deleted. * @return the rule identifier */ + @Override public String getRuleId() { return ruleId; } @@ -263,7 +330,10 @@ public String toString() { * Subclass of {@link EvaluationReason} that indicates that the flag was considered off because it * had at least one prerequisite flag that either was off or did not return the desired variation. * @since 4.3.0 + * @deprecated This type will be removed in a future version. Use {@link #getKind()} instead and check + * for the {@link Kind#PREREQUISITE_FAILED} value. */ + @Deprecated public static class PrerequisiteFailed extends EvaluationReason { private final String prerequisiteKey; @@ -276,6 +346,7 @@ private PrerequisiteFailed(String prerequisiteKey) { * The key of the prerequisite flag that did not return the desired variation. * @return the prerequisite flag key */ + @Override public String getPrerequisiteKey() { return prerequisiteKey; } @@ -301,7 +372,10 @@ public String toString() { * Subclass of {@link EvaluationReason} that indicates that the flag was on but the user did not * match any targets or rules. * @since 4.3.0 + * @deprecated This type will be removed in a future version. Use {@link #getKind()} instead and check + * for the {@link Kind#FALLTHROUGH} value. */ + @Deprecated public static class Fallthrough extends EvaluationReason { private Fallthrough() { @@ -314,7 +388,10 @@ private Fallthrough() /** * Subclass of {@link EvaluationReason} that indicates that the flag could not be evaluated. * @since 4.3.0 + * @deprecated This type will be removed in a future version. Use {@link #getKind()} instead and check + * for the {@link Kind#ERROR} value. */ + @Deprecated public static class Error extends EvaluationReason { private final ErrorKind errorKind; private transient final Exception exception; @@ -333,6 +410,7 @@ private Error(ErrorKind errorKind, Exception exception) { * An enumeration value indicating the general category of error. * @return the error kind */ + @Override public ErrorKind getErrorKind() { return errorKind; } diff --git a/src/main/java/com/launchdarkly/client/EventOutputFormatter.java b/src/main/java/com/launchdarkly/client/EventOutputFormatter.java index 268226fed..03ba5a12c 100644 --- a/src/main/java/com/launchdarkly/client/EventOutputFormatter.java +++ b/src/main/java/com/launchdarkly/client/EventOutputFormatter.java @@ -206,22 +206,25 @@ private void writeEvaluationReason(String key, EvaluationReason er, JsonWriter j jw.name("kind"); jw.value(er.getKind().name()); - if (er instanceof EvaluationReason.Error) { - EvaluationReason.Error ere = (EvaluationReason.Error)er; + switch (er.getKind()) { + case ERROR: jw.name("errorKind"); - jw.value(ere.getErrorKind().name()); - } else if (er instanceof EvaluationReason.PrerequisiteFailed) { - EvaluationReason.PrerequisiteFailed erpf = (EvaluationReason.PrerequisiteFailed)er; + jw.value(er.getErrorKind().name()); + break; + case PREREQUISITE_FAILED: jw.name("prerequisiteKey"); - jw.value(erpf.getPrerequisiteKey()); - } else if (er instanceof EvaluationReason.RuleMatch) { - EvaluationReason.RuleMatch errm = (EvaluationReason.RuleMatch)er; + jw.value(er.getPrerequisiteKey()); + break; + case RULE_MATCH: jw.name("ruleIndex"); - jw.value(errm.getRuleIndex()); - if (errm.getRuleId() != null) { + jw.value(er.getRuleIndex()); + if (er.getRuleId() != null) { jw.name("ruleId"); - jw.value(errm.getRuleId()); + jw.value(er.getRuleId()); } + break; + default: + break; } jw.endObject(); diff --git a/src/main/java/com/launchdarkly/client/HttpConfiguration.java b/src/main/java/com/launchdarkly/client/HttpConfiguration.java deleted file mode 100644 index 7ca4593c6..000000000 --- a/src/main/java/com/launchdarkly/client/HttpConfiguration.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.launchdarkly.client; - -import java.net.Proxy; -import java.util.concurrent.TimeUnit; - -import javax.net.ssl.SSLSocketFactory; -import javax.net.ssl.X509TrustManager; - -import okhttp3.Authenticator; - -// Used internally to encapsulate top-level HTTP configuration that applies to all components. -final class HttpConfiguration { - final int connectTimeout; - final TimeUnit connectTimeoutUnit; - final Proxy proxy; - final Authenticator proxyAuthenticator; - final int socketTimeout; - final TimeUnit socketTimeoutUnit; - final SSLSocketFactory sslSocketFactory; - final X509TrustManager trustManager; - final String wrapperName; - final String wrapperVersion; - - HttpConfiguration(int connectTimeout, TimeUnit connectTimeoutUnit, Proxy proxy, Authenticator proxyAuthenticator, - int socketTimeout, TimeUnit socketTimeoutUnit, SSLSocketFactory sslSocketFactory, X509TrustManager trustManager, - String wrapperName, String wrapperVersion) { - super(); - this.connectTimeout = connectTimeout; - this.connectTimeoutUnit = connectTimeoutUnit; - this.proxy = proxy; - this.proxyAuthenticator = proxyAuthenticator; - this.socketTimeout = socketTimeout; - this.socketTimeoutUnit = socketTimeoutUnit; - this.sslSocketFactory = sslSocketFactory; - this.trustManager = trustManager; - this.wrapperName = wrapperName; - this.wrapperVersion = wrapperVersion; - } -} diff --git a/src/main/java/com/launchdarkly/client/HttpConfigurationImpl.java b/src/main/java/com/launchdarkly/client/HttpConfigurationImpl.java new file mode 100644 index 000000000..c1e9b3e7c --- /dev/null +++ b/src/main/java/com/launchdarkly/client/HttpConfigurationImpl.java @@ -0,0 +1,66 @@ +package com.launchdarkly.client; + +import com.launchdarkly.client.interfaces.HttpAuthentication; +import com.launchdarkly.client.interfaces.HttpConfiguration; + +import java.net.Proxy; + +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.X509TrustManager; + +final class HttpConfigurationImpl implements HttpConfiguration { + final int connectTimeoutMillis; + final Proxy proxy; + final HttpAuthentication proxyAuth; + final int socketTimeoutMillis; + final SSLSocketFactory sslSocketFactory; + final X509TrustManager trustManager; + final String wrapper; + + HttpConfigurationImpl(int connectTimeoutMillis, Proxy proxy, HttpAuthentication proxyAuth, + int socketTimeoutMillis, SSLSocketFactory sslSocketFactory, X509TrustManager trustManager, + String wrapper) { + this.connectTimeoutMillis = connectTimeoutMillis; + this.proxy = proxy; + this.proxyAuth = proxyAuth; + this.socketTimeoutMillis = socketTimeoutMillis; + this.sslSocketFactory = sslSocketFactory; + this.trustManager = trustManager; + this.wrapper = wrapper; + } + + @Override + public int getConnectTimeoutMillis() { + return connectTimeoutMillis; + } + + @Override + public Proxy getProxy() { + return proxy; + } + + @Override + public HttpAuthentication getProxyAuthentication() { + return proxyAuth; + } + + @Override + public int getSocketTimeoutMillis() { + return socketTimeoutMillis; + } + + @Override + public SSLSocketFactory getSslSocketFactory() { + return sslSocketFactory; + } + + @Override + public X509TrustManager getTrustManager() { + return trustManager; + } + + @Override + public String getWrapperIdentifier() { + return wrapper; + } +} diff --git a/src/main/java/com/launchdarkly/client/JsonHelpers.java b/src/main/java/com/launchdarkly/client/JsonHelpers.java index 97f4c95a5..7fb1c0095 100644 --- a/src/main/java/com/launchdarkly/client/JsonHelpers.java +++ b/src/main/java/com/launchdarkly/client/JsonHelpers.java @@ -2,20 +2,28 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; import com.google.gson.TypeAdapter; import com.google.gson.TypeAdapterFactory; import com.google.gson.reflect.TypeToken; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; +import com.launchdarkly.client.interfaces.SerializationException; import java.io.IOException; +import static com.launchdarkly.client.VersionedDataKind.FEATURES; +import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; + abstract class JsonHelpers { private static final Gson gson = new Gson(); /** * Returns a shared instance of Gson with default configuration. This should not be used for serializing * event data, since it does not have any of the configurable behavior related to private attributes. + * Code in _unit tests_ should _not_ use this method, because the tests can be run from other projects + * in an environment where the classpath contains a shaded copy of Gson instead of regular Gson. */ static Gson gsonInstance() { return gson; @@ -29,7 +37,68 @@ static Gson gsonInstanceForEventsSerialization(EventsConfiguration config) { .registerTypeAdapter(LDUser.class, new LDUser.UserAdapterWithPrivateAttributeBehavior(config)) .create(); } + + /** + * Deserializes an object from JSON. We should use this helper method instead of directly calling + * gson.fromJson() to minimize reliance on details of the framework we're using, and to ensure that we + * consistently use our wrapper exception. + * + * @param json the serialized JSON string + * @param objectClass class of object to create + * @return the deserialized object + * @throws SerializationException if Gson throws an exception + */ + static T deserialize(String json, Class objectClass) throws SerializationException { + try { + return gson.fromJson(json, objectClass); + } catch (Exception e) { + throw new SerializationException(e); + } + } + + /** + * Serializes an object to JSON. We should use this helper method instead of directly calling + * gson.toJson() to minimize reliance on details of the framework we're using (except when we need to use + * gsonInstanceForEventsSerialization, since our event serialization logic isn't well suited to using a + * simple abstraction). + * + * @param o the object to serialize + * @return the serialized JSON string + */ + static String serialize(Object o) { + return gson.toJson(o); + } + /** + * Deserializes a data model object from JSON that was already parsed by Gson. + *

+ * For built-in data model classes, our usual abstraction for deserializing from a string is inefficient in + * this case, because Gson has already parsed the original JSON and then we would have to convert the + * JsonElement back into a string and parse it again. So it's best to call Gson directly instead of going + * through our abstraction in that case, but it's also best to implement that special-casing just once here + * instead of scattered throughout the SDK. + * + * @param kind the data kind + * @param parsedJson the parsed JSON + * @return the deserialized item + */ + static VersionedData deserializeFromParsedJson(VersionedDataKind kind, JsonElement parsedJson) throws SerializationException { + VersionedData item; + try { + if (kind == FEATURES) { + item = gson.fromJson(parsedJson, FeatureFlag.class); + } else if (kind == SEGMENTS) { + item = gson.fromJson(parsedJson, Segment.class); + } else { + // This shouldn't happen since we only use this method internally with our predefined data kinds + throw new IllegalArgumentException("unknown data kind"); + } + } catch (JsonParseException e) { + throw new SerializationException(e); + } + return item; + } + /** * Implement this interface on any internal class that needs to do some kind of post-processing after * being unmarshaled from JSON. You must also add the annotation {@code JsonAdapter(JsonHelpers.PostProcessingDeserializableTypeAdapterFactory)} diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 7be875e39..cfe55e61d 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -65,6 +65,14 @@ public LDClient(String sdkKey, LDConfig config) { this.config = new LDConfig(checkNotNull(config, "config must not be null")); this.sdkKey = checkNotNull(sdkKey, "sdkKey must not be null"); + if (config.httpConfig.getProxy() != null) { + if (config.httpConfig.getProxyAuthentication() != null) { + logger.info("Using proxy: {} with authentication.", config.httpConfig.getProxy()); + } else { + logger.info("Using proxy: {} without authentication.", config.httpConfig.getProxy()); + } + } + FeatureStore store; if (this.config.deprecatedFeatureStore != null) { store = this.config.deprecatedFeatureStore; @@ -465,7 +473,8 @@ private static String getClientVersion() { String value = attr.getValue("Implementation-Version"); return value; } catch (IOException e) { - logger.warn("Unable to determine LaunchDarkly client library version", e); + logger.warn("Unable to determine LaunchDarkly client library version: {}", e.toString()); + logger.debug(e.toString(), e); return "Unknown"; } } diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index adba16f8d..2a1be1b35 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -2,39 +2,25 @@ import com.google.common.collect.ImmutableSet; import com.launchdarkly.client.integrations.EventProcessorBuilder; +import com.launchdarkly.client.integrations.HttpConfigurationBuilder; import com.launchdarkly.client.integrations.PollingDataSourceBuilder; import com.launchdarkly.client.integrations.StreamingDataSourceBuilder; +import com.launchdarkly.client.interfaces.HttpConfiguration; +import com.launchdarkly.client.interfaces.HttpConfigurationFactory; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.net.InetSocketAddress; -import java.net.Proxy; import java.net.URI; -import java.util.concurrent.TimeUnit; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.X509TrustManager; -import okhttp3.Authenticator; -import okhttp3.Credentials; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.Route; - /** * This class exposes advanced configuration options for the {@link LDClient}. Instances of this class must be constructed with a {@link com.launchdarkly.client.LDConfig.Builder}. */ public final class LDConfig { - private static final Logger logger = LoggerFactory.getLogger(LDConfig.class); - static final URI DEFAULT_BASE_URI = URI.create("https://app.launchdarkly.com"); static final URI DEFAULT_EVENTS_URI = URI.create("https://events.launchdarkly.com"); static final URI DEFAULT_STREAM_URI = URI.create("https://stream.launchdarkly.com"); private static final int DEFAULT_CAPACITY = 10000; - private static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = 2000; - private static final int DEFAULT_SOCKET_TIMEOUT_MILLIS = 10000; private static final int DEFAULT_FLUSH_INTERVAL_SECONDS = 5; private static final long MIN_POLLING_INTERVAL_MILLIS = PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL_MILLIS; private static final long DEFAULT_START_WAIT_MILLIS = 5000L; @@ -78,20 +64,20 @@ protected LDConfig(Builder builder) { this.offline = builder.offline; this.startWaitMillis = builder.startWaitMillis; - Proxy proxy = builder.proxy(); - Authenticator proxyAuthenticator = builder.proxyAuthenticator(); - if (proxy != null) { - if (proxyAuthenticator != null) { - logger.info("Using proxy: " + proxy + " with authentication."); - } else { - logger.info("Using proxy: " + proxy + " without authentication."); - } + if (builder.httpConfigFactory != null) { + this.httpConfig = builder.httpConfigFactory.createHttpConfiguration(); + } else { + this.httpConfig = Components.httpConfiguration() + .connectTimeoutMillis(builder.connectTimeoutMillis) + .proxyHostAndPort(builder.proxyPort == -1 ? null : builder.proxyHost, builder.proxyPort) + .proxyAuth(builder.proxyUsername == null || builder.proxyPassword == null ? null : + Components.httpBasicAuthentication(builder.proxyUsername, builder.proxyPassword)) + .socketTimeoutMillis(builder.socketTimeoutMillis) + .sslSocketFactory(builder.sslSocketFactory, builder.trustManager) + .wrapper(builder.wrapperName, builder.wrapperVersion) + .createHttpConfiguration(); } - this.httpConfig = new HttpConfiguration(builder.connectTimeout, builder.connectTimeoutUnit, - proxy, proxyAuthenticator, builder.socketTimeout, builder.socketTimeoutUnit, - builder.sslSocketFactory, builder.trustManager, builder.wrapperName, builder.wrapperVersion); - this.deprecatedAllAttributesPrivate = builder.allAttributesPrivate; this.deprecatedBaseURI = builder.baseURI; this.deprecatedCapacity = builder.capacity; @@ -156,10 +142,9 @@ public static class Builder { private URI baseURI = DEFAULT_BASE_URI; private URI eventsURI = DEFAULT_EVENTS_URI; private URI streamURI = DEFAULT_STREAM_URI; - private int connectTimeout = DEFAULT_CONNECT_TIMEOUT_MILLIS; - private TimeUnit connectTimeoutUnit = TimeUnit.MILLISECONDS; - private int socketTimeout = DEFAULT_SOCKET_TIMEOUT_MILLIS; - private TimeUnit socketTimeoutUnit = TimeUnit.MILLISECONDS; + private HttpConfigurationFactory httpConfigFactory = null; + private int connectTimeoutMillis = HttpConfigurationBuilder.DEFAULT_CONNECT_TIMEOUT_MILLIS; + private int socketTimeoutMillis = HttpConfigurationBuilder.DEFAULT_SOCKET_TIMEOUT_MILLIS; private boolean diagnosticOptOut = false; private int capacity = DEFAULT_CAPACITY; private int flushIntervalSeconds = DEFAULT_FLUSH_INTERVAL_SECONDS; @@ -307,6 +292,7 @@ public Builder events(EventProcessorFactory factory) { * @since 4.0.0 * @deprecated Use {@link #events(EventProcessorFactory)}. */ + @Deprecated public Builder eventProcessorFactory(EventProcessorFactory factory) { this.eventProcessorFactory = factory; return this; @@ -366,63 +352,72 @@ public Builder stream(boolean stream) { } /** - * Set the connection timeout in seconds for the configuration. This is the time allowed for the underlying HTTP client to connect - * to the LaunchDarkly server. The default is 2 seconds. - *

Both this method and {@link #connectTimeoutMillis(int) connectTimeoutMillis} affect the same property internally.

+ * Sets the SDK's networking configuration, using a factory object. This object is normally a + * configuration builder obtained from {@link Components#httpConfiguration()}, which has methods + * for setting individual HTTP-related properties. + * + * @param factory the factory object + * @return the builder + * @since 4.13.0 + * @see Components#httpConfiguration() + */ + public Builder http(HttpConfigurationFactory factory) { + this.httpConfigFactory = factory; + return this; + } + + /** + * Deprecated method for setting the connection timeout. * * @param connectTimeout the connection timeout in seconds * @return the builder + * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#connectTimeoutMillis(int)}. */ + @Deprecated public Builder connectTimeout(int connectTimeout) { - this.connectTimeout = connectTimeout; - this.connectTimeoutUnit = TimeUnit.SECONDS; - return this; + return connectTimeoutMillis(connectTimeout * 1000); } /** - * Set the socket timeout in seconds for the configuration. This is the number of seconds between successive packets that the - * client will tolerate before flagging an error. The default is 10 seconds. - *

Both this method and {@link #socketTimeoutMillis(int) socketTimeoutMillis} affect the same property internally.

+ * Deprecated method for setting the socket read timeout. * * @param socketTimeout the socket timeout in seconds * @return the builder + * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#socketTimeoutMillis(int)}. */ + @Deprecated public Builder socketTimeout(int socketTimeout) { - this.socketTimeout = socketTimeout; - this.socketTimeoutUnit = TimeUnit.SECONDS; - return this; + return socketTimeoutMillis(socketTimeout * 1000); } /** - * Set the connection timeout in milliseconds for the configuration. This is the time allowed for the underlying HTTP client to connect - * to the LaunchDarkly server. The default is 2000 ms. - *

Both this method and {@link #connectTimeout(int) connectTimeoutMillis} affect the same property internally.

+ * Deprecated method for setting the connection timeout. * * @param connectTimeoutMillis the connection timeout in milliseconds * @return the builder + * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#connectTimeoutMillis(int)}. */ + @Deprecated public Builder connectTimeoutMillis(int connectTimeoutMillis) { - this.connectTimeout = connectTimeoutMillis; - this.connectTimeoutUnit = TimeUnit.MILLISECONDS; + this.connectTimeoutMillis = connectTimeoutMillis; return this; } /** - * Set the socket timeout in milliseconds for the configuration. This is the number of milliseconds between successive packets that the - * client will tolerate before flagging an error. The default is 10,000 milliseconds. - *

Both this method and {@link #socketTimeout(int) socketTimeoutMillis} affect the same property internally.

+ * Deprecated method for setting the socket read timeout. * * @param socketTimeoutMillis the socket timeout in milliseconds * @return the builder + * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#socketTimeoutMillis(int)}. */ + @Deprecated public Builder socketTimeoutMillis(int socketTimeoutMillis) { - this.socketTimeout = socketTimeoutMillis; - this.socketTimeoutUnit = TimeUnit.MILLISECONDS; + this.socketTimeoutMillis = socketTimeoutMillis; return this; } /** - * Deprecated method for setting the event buffer flush interval + * Deprecated method for setting the event buffer flush interval. * * @param flushInterval the flush interval in seconds * @return the builder @@ -448,8 +443,9 @@ public Builder capacity(int capacity) { } /** - * Set the host to use as an HTTP proxy for making connections to LaunchDarkly. If this is not set, but - * {@link #proxyPort(int)} is specified, this will default to localhost. + * Deprecated method for specifying an HTTP proxy. + * + * If this is not set, but {@link #proxyPort(int)} is specified, this will default to localhost. *

* If neither {@link #proxyHost(String)} nor {@link #proxyPort(int)} are specified, * a proxy will not be used, and {@link LDClient} will connect to LaunchDarkly directly. @@ -457,56 +453,66 @@ public Builder capacity(int capacity) { * * @param host the proxy hostname * @return the builder + * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#proxyHostAndPort(String, int)}. */ + @Deprecated public Builder proxyHost(String host) { this.proxyHost = host; return this; } /** - * Set the port to use for an HTTP proxy for making connections to LaunchDarkly. This is required for proxied HTTP connections. + * Deprecated method for specifying the port of an HTTP proxy. * * @param port the proxy port * @return the builder + * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#proxyHostAndPort(String, int)}. */ + @Deprecated public Builder proxyPort(int port) { this.proxyPort = port; return this; } /** - * Sets the username for the optional HTTP proxy. Only used when {@link LDConfig.Builder#proxyPassword(String)} - * is also called. + * Deprecated method for specifying HTTP proxy authorization credentials. * * @param username the proxy username * @return the builder + * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#proxyAuth(com.launchdarkly.client.interfaces.HttpAuthentication)} + * and {@link Components#httpBasicAuthentication(String, String)}. */ + @Deprecated public Builder proxyUsername(String username) { this.proxyUsername = username; return this; } /** - * Sets the password for the optional HTTP proxy. Only used when {@link LDConfig.Builder#proxyUsername(String)} - * is also called. + * Deprecated method for specifying HTTP proxy authorization credentials. * * @param password the proxy password * @return the builder + * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#proxyAuth(com.launchdarkly.client.interfaces.HttpAuthentication)} + * and {@link Components#httpBasicAuthentication(String, String)}. */ + @Deprecated public Builder proxyPassword(String password) { this.proxyPassword = password; return this; } /** - * Sets the {@link SSLSocketFactory} used to secure HTTPS connections to LaunchDarkly. + * Deprecated method for specifying a custom SSL socket factory and certificate trust manager. * * @param sslSocketFactory the SSL socket factory * @param trustManager the trust manager * @return the builder * * @since 4.7.0 + * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#sslSocketFactory(SSLSocketFactory, X509TrustManager)}. */ + @Deprecated public Builder sslSocketFactory(SSLSocketFactory sslSocketFactory, X509TrustManager trustManager) { this.sslSocketFactory = sslSocketFactory; this.trustManager = trustManager; @@ -712,60 +718,33 @@ public Builder diagnosticOptOut(boolean diagnosticOptOut) { } /** - * For use by wrapper libraries to set an identifying name for the wrapper being used. This will be included in a - * header during requests to the LaunchDarkly servers to allow recording metrics on the usage of - * these wrapper libraries. + * Deprecated method of specifing a wrapper library identifier. * * @param wrapperName an identifying name for the wrapper library * @return the builder * @since 4.12.0 + * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#wrapper(String, String)}. */ + @Deprecated public Builder wrapperName(String wrapperName) { this.wrapperName = wrapperName; return this; } /** - * For use by wrapper libraries to report the version of the library in use. If {@link #wrapperName(String)} is not - * set, this field will be ignored. Otherwise the version string will be included in a header along - * with the wrapperName during requests to the LaunchDarkly servers. + * Deprecated method of specifing a wrapper library identifier. * - * @param wrapperVersion Version string for the wrapper library + * @param wrapperVersion version string for the wrapper library * @return the builder * @since 4.12.0 + * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#wrapper(String, String)}. */ + @Deprecated public Builder wrapperVersion(String wrapperVersion) { this.wrapperVersion = wrapperVersion; return this; } - // returns null if none of the proxy bits were configured. Minimum required part: port. - Proxy proxy() { - if (this.proxyPort == -1) { - return null; - } else { - return new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort)); - } - } - - Authenticator proxyAuthenticator() { - if (this.proxyUsername != null && this.proxyPassword != null) { - final String credential = Credentials.basic(proxyUsername, proxyPassword); - return new Authenticator() { - public Request authenticate(Route route, Response response) throws IOException { - if (response.request().header("Proxy-Authorization") != null) { - return null; // Give up, we've already failed to authenticate with the proxy. - } else { - return response.request().newBuilder() - .header("Proxy-Authorization", credential) - .build(); - } - } - }; - } - return null; - } - /** * Builds the configured {@link com.launchdarkly.client.LDConfig} object. * diff --git a/src/main/java/com/launchdarkly/client/PollingProcessor.java b/src/main/java/com/launchdarkly/client/PollingProcessor.java index 7e97aa3b8..df3bf609a 100644 --- a/src/main/java/com/launchdarkly/client/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/client/PollingProcessor.java @@ -3,6 +3,7 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.util.concurrent.SettableFuture; import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.launchdarkly.client.interfaces.SerializationException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -76,6 +77,8 @@ public void run() { } catch (IOException e) { logger.error("Encountered exception in LaunchDarkly client when retrieving update: {}", e.toString()); logger.debug(e.toString(), e); + } catch (SerializationException e) { + logger.error("Polling request received malformed data: {}", e.toString()); } } }, 0L, pollIntervalMillis, TimeUnit.MILLISECONDS); diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java index 764421a69..9da59b497 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -2,8 +2,9 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.util.concurrent.SettableFuture; -import com.google.gson.Gson; import com.google.gson.JsonElement; +import com.launchdarkly.client.interfaces.HttpConfiguration; +import com.launchdarkly.client.interfaces.SerializationException; import com.launchdarkly.eventsource.ConnectionErrorHandler; import com.launchdarkly.eventsource.EventHandler; import com.launchdarkly.eventsource.EventSource; @@ -15,6 +16,8 @@ import java.io.IOException; import java.net.URI; +import java.util.AbstractMap; +import java.util.Map; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; @@ -22,12 +25,28 @@ import static com.launchdarkly.client.Util.getHeadersBuilderFor; import static com.launchdarkly.client.Util.httpErrorMessage; import static com.launchdarkly.client.Util.isHttpErrorRecoverable; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; import okhttp3.Headers; import okhttp3.OkHttpClient; +/** + * Implementation of the streaming data source, not including the lower-level SSE implementation which is in + * okhttp-eventsource. + * + * Error handling works as follows: + * 1. If any event is malformed, we must assume the stream is broken and we may have missed updates. Restart it. + * 2. If we try to put updates into the data store and we get an error, we must assume something's wrong with the + * data store. We must assume that updates have been lost, so we'll restart the stream. (Starting in version 5.0, + * we will be able to do this in a smarter way and not restart the stream until the store is actually working + * again, but in 4.x we don't have the monitoring mechanism for this.) + * 3. If we receive an unrecoverable error like HTTP 401, we close the stream and don't retry. Any other HTTP + * error or network error causes a retry with backoff. + * 4. We set the Future returned by start() to tell the client initialization logic that initialization has either + * succeeded (we got an initial payload and successfully stored it) or permanently failed (we got a 401, etc.). + * Otherwise, the client initialization method may time out but we will still be retrying in the background, and + * if we succeed then the client can detect that we're initialized now by calling our Initialized method. + */ final class StreamProcessor implements UpdateProcessor { private static final String PUT = "put"; private static final String PATCH = "patch"; @@ -48,6 +67,7 @@ final class StreamProcessor implements UpdateProcessor { private volatile EventSource es; private final AtomicBoolean initialized = new AtomicBoolean(false); private volatile long esStarted = 0; + private volatile boolean lastStoreUpdateFailed = false; ConnectionErrorHandler connectionErrorHandler = createDefaultConnectionErrorHandler(); // exposed for testing @@ -123,77 +143,112 @@ public void onClosed() throws Exception { } @Override - public void onMessage(String name, MessageEvent event) throws Exception { - Gson gson = new Gson(); - switch (name) { - case PUT: { - recordStreamInit(false); - esStarted = 0; - PutData putData = gson.fromJson(event.getData(), PutData.class); - store.init(DefaultFeatureRequestor.toVersionedDataMap(putData.data)); - if (!initialized.getAndSet(true)) { - initFuture.set(null); - logger.info("Initialized LaunchDarkly client."); + public void onMessage(String name, MessageEvent event) { + try { + switch (name) { + case PUT: { + recordStreamInit(false); + esStarted = 0; + PutData putData = parseStreamJson(PutData.class, event.getData()); + try { + store.init(DefaultFeatureRequestor.toVersionedDataMap(putData.data)); + } catch (Exception e) { + throw new StreamStoreException(e); + } + if (!initialized.getAndSet(true)) { + initFuture.set(null); + logger.info("Initialized LaunchDarkly client."); + } + break; } - break; - } - case PATCH: { - PatchData data = gson.fromJson(event.getData(), PatchData.class); - if (FEATURES.getKeyFromStreamApiPath(data.path) != null) { - store.upsert(FEATURES, gson.fromJson(data.data, FeatureFlag.class)); - } else if (SEGMENTS.getKeyFromStreamApiPath(data.path) != null) { - store.upsert(SEGMENTS, gson.fromJson(data.data, Segment.class)); + case PATCH: { + PatchData data = parseStreamJson(PatchData.class, event.getData()); + Map.Entry, String> kindAndKey = getKindAndKeyFromStreamApiPath(data.path); + if (kindAndKey == null) { + break; + } + VersionedDataKind kind = kindAndKey.getKey(); + VersionedData item = deserializeFromParsedJson(kind, data.data); + try { + store.upsert(kind, item); + } catch (Exception e) { + throw new StreamStoreException(e); + } + break; } - break; - } - case DELETE: { - DeleteData data = gson.fromJson(event.getData(), DeleteData.class); - String featureKey = FEATURES.getKeyFromStreamApiPath(data.path); - if (featureKey != null) { - store.delete(FEATURES, featureKey, data.version); - } else { - String segmentKey = SEGMENTS.getKeyFromStreamApiPath(data.path); - if (segmentKey != null) { - store.delete(SEGMENTS, segmentKey, data.version); + case DELETE: { + DeleteData data = parseStreamJson(DeleteData.class, event.getData()); + Map.Entry, String> kindAndKey = getKindAndKeyFromStreamApiPath(data.path); + if (kindAndKey == null) { + break; + } + VersionedDataKind kind = kindAndKey.getKey(); + String key = kindAndKey.getValue(); + try { + store.delete(kind, key, data.version); + } catch (Exception e) { + throw new StreamStoreException(e); } + break; } - break; - } - case INDIRECT_PUT: - try { - FeatureRequestor.AllData allData = requestor.getAllData(); - store.init(DefaultFeatureRequestor.toVersionedDataMap(allData)); + case INDIRECT_PUT: + FeatureRequestor.AllData allData; + try { + allData = requestor.getAllData(); + } catch (HttpErrorException e) { + throw new StreamInputException(e); + } catch (IOException e) { + throw new StreamInputException(e); + } + try { + store.init(DefaultFeatureRequestor.toVersionedDataMap(allData)); + } catch (Exception e) { + throw new StreamStoreException(e); + } if (!initialized.getAndSet(true)) { initFuture.set(null); logger.info("Initialized LaunchDarkly client."); } - } catch (IOException e) { - logger.error("Encountered exception in LaunchDarkly client: {}", e.toString()); - logger.debug(e.toString(), e); - } - break; - case INDIRECT_PATCH: - String path = event.getData(); - try { - String featureKey = FEATURES.getKeyFromStreamApiPath(path); - if (featureKey != null) { - FeatureFlag feature = requestor.getFlag(featureKey); - store.upsert(FEATURES, feature); - } else { - String segmentKey = SEGMENTS.getKeyFromStreamApiPath(path); - if (segmentKey != null) { - Segment segment = requestor.getSegment(segmentKey); - store.upsert(SEGMENTS, segment); - } + break; + case INDIRECT_PATCH: + String path = event.getData(); + Map.Entry, String> kindAndKey = getKindAndKeyFromStreamApiPath(path); + if (kindAndKey == null) { + break; } - } catch (IOException e) { - logger.error("Encountered exception in LaunchDarkly client: {}", e.toString()); - logger.debug(e.toString(), e); - } - break; - default: - logger.warn("Unexpected event found in stream: " + event.getData()); - break; + VersionedDataKind kind = kindAndKey.getKey(); + String key = kindAndKey.getValue(); + VersionedData item; + try { + item = (Object)kind == SEGMENTS ? requestor.getSegment(key) : requestor.getFlag(key); + } catch (Exception e) { + throw new StreamInputException(e); + } + try { + store.upsert(kind, item); // silly cast due to our use of generics + } catch (Exception e) { + throw new StreamStoreException(e); + } + break; + default: + logger.warn("Unexpected event found in stream: " + event.getData()); + break; + } + } catch (StreamInputException e) { + logger.error("LaunchDarkly service request failed or received invalid data: {}", e.toString()); + logger.debug(e.toString(), e); + es.restart(); + } catch (StreamStoreException e) { + if (!lastStoreUpdateFailed) { + logger.error("Unexpected data store failure when storing updates from stream: {}", + e.getCause().toString()); + logger.debug(e.getCause().toString(), e.getCause()); + lastStoreUpdateFailed = true; + } + es.restart(); + } catch (Exception e) { + logger.error("Unexpected exception in stream processor: {}", e.toString()); + logger.debug(e.toString(), e); } } @@ -242,6 +297,61 @@ public void close() throws IOException { public boolean initialized() { return initialized.get(); } + + @SuppressWarnings("unchecked") + private static Map.Entry, String> getKindAndKeyFromStreamApiPath(String path) + throws StreamInputException { + if (path == null) { + throw new StreamInputException("missing item path"); + } + for (VersionedDataKind kind: VersionedDataKind.ALL) { + String prefix = (kind == SEGMENTS) ? "/segments/" : "/flags/"; + if (path.startsWith(prefix)) { + return new AbstractMap.SimpleEntry, String>( + (VersionedDataKind)kind, // cast is required due to our cumbersome use of generics + path.substring(prefix.length())); + } + } + return null; // we don't recognize the path - the caller should ignore this event, just as we ignore unknown event types + } + + private static T parseStreamJson(Class c, String json) throws StreamInputException { + try { + return JsonHelpers.deserialize(json, c); + } catch (SerializationException e) { + throw new StreamInputException(e); + } + } + + private static VersionedData deserializeFromParsedJson(VersionedDataKind kind, JsonElement parsedJson) + throws StreamInputException { + try { + return JsonHelpers.deserializeFromParsedJson(kind, parsedJson); + } catch (SerializationException e) { + throw new StreamInputException(e); + } + } + + // StreamInputException is either a JSON parsing error *or* a failure to query another endpoint + // (for indirect/put or indirect/patch); either way, it implies that we were unable to get valid data from LD services. + @SuppressWarnings("serial") + private static final class StreamInputException extends Exception { + public StreamInputException(String message) { + super(message); + } + + public StreamInputException(Throwable cause) { + super(cause); + } + } + + // This exception class indicates that the data store failed to persist an update. + @SuppressWarnings("serial") + private static final class StreamStoreException extends Exception { + public StreamStoreException(Throwable cause) { + super(cause); + } + } private static final class PutData { FeatureRequestor.AllData data; diff --git a/src/main/java/com/launchdarkly/client/Util.java b/src/main/java/com/launchdarkly/client/Util.java index 966a3c738..24ada3497 100644 --- a/src/main/java/com/launchdarkly/client/Util.java +++ b/src/main/java/com/launchdarkly/client/Util.java @@ -1,16 +1,26 @@ package com.launchdarkly.client; +import com.google.common.base.Function; import com.google.gson.JsonPrimitive; +import com.launchdarkly.client.interfaces.HttpAuthentication; +import com.launchdarkly.client.interfaces.HttpConfiguration; import com.launchdarkly.client.value.LDValue; import org.joda.time.DateTime; import org.joda.time.DateTimeZone; +import java.io.IOException; import java.util.concurrent.TimeUnit; +import static com.google.common.collect.Iterables.transform; + +import okhttp3.Authenticator; import okhttp3.ConnectionPool; import okhttp3.Headers; import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.Route; class Util { /** @@ -37,12 +47,8 @@ static Headers.Builder getHeadersBuilderFor(String sdkKey, HttpConfiguration con .add("Authorization", sdkKey) .add("User-Agent", "JavaClient/" + LDClient.CLIENT_VERSION); - if (config.wrapperName != null) { - String wrapperVersion = ""; - if (config.wrapperVersion != null) { - wrapperVersion = "/" + config.wrapperVersion; - } - builder.add("X-LaunchDarkly-Wrapper", config.wrapperName + wrapperVersion); + if (config.getWrapperIdentifier() != null) { + builder.add("X-LaunchDarkly-Wrapper", config.getWrapperIdentifier()); } return builder; @@ -50,23 +56,48 @@ static Headers.Builder getHeadersBuilderFor(String sdkKey, HttpConfiguration con static void configureHttpClientBuilder(HttpConfiguration config, OkHttpClient.Builder builder) { builder.connectionPool(new ConnectionPool(5, 5, TimeUnit.SECONDS)) - .connectTimeout(config.connectTimeout, config.connectTimeoutUnit) - .readTimeout(config.socketTimeout, config.socketTimeoutUnit) - .writeTimeout(config.socketTimeout, config.socketTimeoutUnit) + .connectTimeout(config.getConnectTimeoutMillis(), TimeUnit.MILLISECONDS) + .readTimeout(config.getSocketTimeoutMillis(), TimeUnit.MILLISECONDS) + .writeTimeout(config.getSocketTimeoutMillis(), TimeUnit.MILLISECONDS) .retryOnConnectionFailure(false); // we will implement our own retry logic - if (config.sslSocketFactory != null) { - builder.sslSocketFactory(config.sslSocketFactory, config.trustManager); + if (config.getSslSocketFactory() != null) { + builder.sslSocketFactory(config.getSslSocketFactory(), config.getTrustManager()); } - if (config.proxy != null) { - builder.proxy(config.proxy); - if (config.proxyAuthenticator != null) { - builder.proxyAuthenticator(config.proxyAuthenticator); + if (config.getProxy() != null) { + builder.proxy(config.getProxy()); + if (config.getProxyAuthentication() != null) { + builder.proxyAuthenticator(okhttpAuthenticatorFromHttpAuthStrategy( + config.getProxyAuthentication(), + "Proxy-Authentication", + "Proxy-Authorization" + )); } } } + static final Authenticator okhttpAuthenticatorFromHttpAuthStrategy(final HttpAuthentication strategy, + final String challengeHeaderName, final String responseHeaderName) { + return new Authenticator() { + public Request authenticate(Route route, Response response) throws IOException { + if (response.request().header(responseHeaderName) != null) { + return null; // Give up, we've already failed to authenticate + } + Iterable challenges = transform(response.challenges(), + new Function() { + public HttpAuthentication.Challenge apply(okhttp3.Challenge c) { + return new HttpAuthentication.Challenge(c.scheme(), c.realm()); + } + }); + String credential = strategy.provideAuthorization(challenges); + return response.request().newBuilder() + .header(responseHeaderName, credential) + .build(); + } + }; + } + static void shutdownHttpClient(OkHttpClient client) { if (client.dispatcher() != null) { client.dispatcher().cancelAll(); diff --git a/src/main/java/com/launchdarkly/client/integrations/HttpConfigurationBuilder.java b/src/main/java/com/launchdarkly/client/integrations/HttpConfigurationBuilder.java new file mode 100644 index 000000000..3392f0e9f --- /dev/null +++ b/src/main/java/com/launchdarkly/client/integrations/HttpConfigurationBuilder.java @@ -0,0 +1,139 @@ +package com.launchdarkly.client.integrations; + +import com.launchdarkly.client.Components; +import com.launchdarkly.client.interfaces.HttpAuthentication; +import com.launchdarkly.client.interfaces.HttpConfigurationFactory; + +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.X509TrustManager; + +/** + * Contains methods for configuring the SDK's networking behavior. + *

+ * If you want to set non-default values for any of these properties, create a builder with + * {@link Components#httpConfiguration()}, change its properties with the methods of this class, + * and pass it to {@link com.launchdarkly.client.LDConfig.Builder#http(HttpConfigurationFactory)}: + *


+ *     LDConfig config = new LDConfig.Builder()
+ *         .http(
+ *           Components.httpConfiguration()
+ *             .connectTimeoutMillis(3000)
+ *             .proxyHostAndPort("my-proxy", 8080)
+ *          )
+ *         .build();
+ * 
+ *

+ * These properties will override any equivalent deprecated properties that were set with {@code LDConfig.Builder}, + * such as {@link com.launchdarkly.client.LDConfig.Builder#connectTimeoutMillis(int)}. + *

+ * Note that this class is abstract; the actual implementation is created by calling {@link Components#httpConfiguration()}. + * + * @since 4.13.0 + */ +public abstract class HttpConfigurationBuilder implements HttpConfigurationFactory { + /** + * The default value for {@link #connectTimeoutMillis(int)}. + */ + public static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = 2000; + + /** + * The default value for {@link #socketTimeoutMillis(int)}. + */ + public static final int DEFAULT_SOCKET_TIMEOUT_MILLIS = 10000; + + protected int connectTimeoutMillis = DEFAULT_CONNECT_TIMEOUT_MILLIS; + protected HttpAuthentication proxyAuth; + protected String proxyHost; + protected int proxyPort; + protected int socketTimeoutMillis = DEFAULT_SOCKET_TIMEOUT_MILLIS; + protected SSLSocketFactory sslSocketFactory; + protected X509TrustManager trustManager; + protected String wrapperName; + protected String wrapperVersion; + + /** + * Sets the connection timeout. This is the time allowed for the SDK to make a socket connection to + * any of the LaunchDarkly services. + *

+ * The default is {@link #DEFAULT_CONNECT_TIMEOUT_MILLIS}. + * + * @param connectTimeoutMillis the connection timeout, in milliseconds + * @return the builder + */ + public HttpConfigurationBuilder connectTimeoutMillis(int connectTimeoutMillis) { + this.connectTimeoutMillis = connectTimeoutMillis; + return this; + } + + /** + * Sets an HTTP proxy for making connections to LaunchDarkly. + * + * @param host the proxy hostname + * @param port the proxy port + * @return the builder + */ + public HttpConfigurationBuilder proxyHostAndPort(String host, int port) { + this.proxyHost = host; + this.proxyPort = port; + return this; + } + + /** + * Sets an authentication strategy for use with an HTTP proxy. This has no effect unless a proxy + * was specified with {@link #proxyHostAndPort(String, int)}. + * + * @param strategy the authentication strategy + * @return the builder + */ + public HttpConfigurationBuilder proxyAuth(HttpAuthentication strategy) { + this.proxyAuth = strategy; + return this; + } + + /** + * Sets the socket timeout. This is the amount of time without receiving data on a connection that the + * SDK will tolerate before signaling an error. This does not apply to the streaming connection + * used by {@link com.launchdarkly.client.Components#streamingDataSource()}, which has its own + * non-configurable read timeout based on the expected behavior of the LaunchDarkly streaming service. + *

+ * The default is {@link #DEFAULT_SOCKET_TIMEOUT_MILLIS}. + * + * @param socketTimeoutMillis the socket timeout, in milliseconds + * @return the builder + */ + public HttpConfigurationBuilder socketTimeoutMillis(int socketTimeoutMillis) { + this.socketTimeoutMillis = socketTimeoutMillis; + return this; + } + + /** + * Specifies a custom security configuration for HTTPS connections to LaunchDarkly. + *

+ * This uses the standard Java interfaces for configuring secure socket connections and certificate + * verification. + * + * @param sslSocketFactory the SSL socket factory + * @param trustManager the trust manager + * @return the builder + */ + public HttpConfigurationBuilder sslSocketFactory(SSLSocketFactory sslSocketFactory, X509TrustManager trustManager) { + this.sslSocketFactory = sslSocketFactory; + this.trustManager = trustManager; + return this; + } + + /** + * For use by wrapper libraries to set an identifying name for the wrapper being used. This will be included in a + * header during requests to the LaunchDarkly servers to allow recording metrics on the usage of + * these wrapper libraries. + * + * @param wrapperName an identifying name for the wrapper library + * @param wrapperVersion version string for the wrapper library + * @return the builder + */ + public HttpConfigurationBuilder wrapper(String wrapperName, String wrapperVersion) { + this.wrapperName = wrapperName; + this.wrapperVersion = wrapperVersion; + return this; + } +} diff --git a/src/main/java/com/launchdarkly/client/interfaces/HttpAuthentication.java b/src/main/java/com/launchdarkly/client/interfaces/HttpAuthentication.java new file mode 100644 index 000000000..879a201ec --- /dev/null +++ b/src/main/java/com/launchdarkly/client/interfaces/HttpAuthentication.java @@ -0,0 +1,52 @@ +package com.launchdarkly.client.interfaces; + +/** + * Represents a supported method of HTTP authentication, including proxy authentication. + * + * @since 4.13.0 + */ +public interface HttpAuthentication { + /** + * Computes the {@code Authorization} or {@code Proxy-Authorization} header for an authentication challenge. + * + * @param challenges the authentication challenges provided by the server, if any (may be empty if this is + * pre-emptive authentication) + * @return the value for the authorization request header + */ + String provideAuthorization(Iterable challenges); + + /** + * Properties of an HTTP authentication challenge. + */ + public static class Challenge { + private final String scheme; + private final String realm; + + /** + * Constructs an instance. + * + * @param scheme the authentication scheme + * @param realm the authentication realm or null + */ + public Challenge(String scheme, String realm) { + this.scheme = scheme; + this.realm = realm; + } + + /** + * The authentication scheme, such as "basic". + * @return the authentication scheme + */ + public String getScheme() { + return scheme; + } + + /** + * The authentication realm, if any. + * @return the authentication realm or null + */ + public String getRealm() { + return realm; + } + } +} diff --git a/src/main/java/com/launchdarkly/client/interfaces/HttpConfiguration.java b/src/main/java/com/launchdarkly/client/interfaces/HttpConfiguration.java new file mode 100644 index 000000000..9d0cb31df --- /dev/null +++ b/src/main/java/com/launchdarkly/client/interfaces/HttpConfiguration.java @@ -0,0 +1,73 @@ +package com.launchdarkly.client.interfaces; + +import com.launchdarkly.client.integrations.HttpConfigurationBuilder; + +import java.net.Proxy; + +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.X509TrustManager; + +/** + * Encapsulates top-level HTTP configuration that applies to all SDK components. + *

+ * Use {@link HttpConfigurationBuilder} to construct an instance. + * + * @since 4.13.0 + */ +public interface HttpConfiguration { + /** + * The connection timeout. This is the time allowed for the underlying HTTP client to connect + * to the LaunchDarkly server. + * + * @return the connection timeout, in milliseconds + */ + int getConnectTimeoutMillis(); + + /** + * The proxy configuration, if any. + * + * @return a {@link Proxy} instance or null + */ + Proxy getProxy(); + + /** + * The authentication method to use for a proxy, if any. Ignored if {@link #getProxy()} is null. + * + * @return an {@link HttpAuthentication} implementation or null + */ + HttpAuthentication getProxyAuthentication(); + + /** + * The socket timeout. This is the amount of time without receiving data on a connection that the + * SDK will tolerate before signaling an error. This does not apply to the streaming connection + * used by {@link com.launchdarkly.client.Components#streamingDataSource()}, which has its own + * non-configurable read timeout based on the expected behavior of the LaunchDarkly streaming service. + * + * @return the socket timeout, in milliseconds + */ + int getSocketTimeoutMillis(); + + /** + * The configured socket factory for secure connections. + * + * @return a SSLSocketFactory or null + */ + SSLSocketFactory getSslSocketFactory(); + + /** + * The configured trust manager for secure connections, if custom certificate verification is needed. + * + * @return an X509TrustManager or null + */ + X509TrustManager getTrustManager(); + + /** + * An optional identifier used by wrapper libraries to indicate what wrapper is being used. + * + * This allows LaunchDarkly to gather metrics on the usage of wrappers that are based on the Java SDK. + * It is part of {@link HttpConfiguration} because it is included in HTTP headers. + * + * @return a wrapper identifier string or null + */ + String getWrapperIdentifier(); +} diff --git a/src/main/java/com/launchdarkly/client/interfaces/HttpConfigurationFactory.java b/src/main/java/com/launchdarkly/client/interfaces/HttpConfigurationFactory.java new file mode 100644 index 000000000..ade4a5d48 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/interfaces/HttpConfigurationFactory.java @@ -0,0 +1,16 @@ +package com.launchdarkly.client.interfaces; + +/** + * Interface for a factory that creates an {@link HttpConfiguration}. + * + * @see com.launchdarkly.client.Components#httpConfiguration() + * @see com.launchdarkly.client.LDConfig.Builder#http(HttpConfigurationFactory) + * @since 4.13.0 + */ +public interface HttpConfigurationFactory { + /** + * Creates the configuration object. + * @return an {@link HttpConfiguration} + */ + public HttpConfiguration createHttpConfiguration(); +} diff --git a/src/main/java/com/launchdarkly/client/interfaces/SerializationException.java b/src/main/java/com/launchdarkly/client/interfaces/SerializationException.java new file mode 100644 index 000000000..0473c991f --- /dev/null +++ b/src/main/java/com/launchdarkly/client/interfaces/SerializationException.java @@ -0,0 +1,23 @@ +package com.launchdarkly.client.interfaces; + +/** + * General exception class for all errors in serializing or deserializing JSON. + *

+ * The SDK uses this class to avoid depending on exception types from the underlying JSON framework + * that it uses (currently Gson). + *

+ * This is currently an unchecked exception, because adding checked exceptions to existing SDK + * interfaces would be a breaking change. In the future it will become a checked exception, to make + * error-handling requirements clearer. However, public SDK client methods will not throw this + * exception in any case; it is only relevant when implementing custom components. + */ +@SuppressWarnings("serial") +public class SerializationException extends RuntimeException { + /** + * Creates an instance. + * @param cause the underlying exception + */ + public SerializationException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java b/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java index 6fefb8e62..e49cbb7c7 100644 --- a/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java +++ b/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java @@ -5,6 +5,7 @@ import com.launchdarkly.client.FeatureStore; import com.launchdarkly.client.VersionedData; import com.launchdarkly.client.VersionedDataKind; +import com.launchdarkly.client.interfaces.SerializationException; /** * Helper methods that may be useful for implementing a {@link FeatureStore} or {@link FeatureStoreCore}. @@ -48,7 +49,7 @@ public static String marshalJson(VersionedData item) { * Thrown by {@link FeatureStoreHelpers#unmarshalJson(VersionedDataKind, String)} for a deserialization error. */ @SuppressWarnings("serial") - public static class UnmarshalException extends RuntimeException { + public static class UnmarshalException extends SerializationException { /** * Constructs an instance. * @param cause the underlying exception diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index e652c3f7b..3ec9aab0b 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -819,8 +819,7 @@ public void wrapperHeaderSentWhenSet() throws Exception { try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { LDConfig config = new LDConfig.Builder() .diagnosticOptOut(true) - .wrapperName("Scala") - .wrapperVersion("0.1.0") + .http(Components.httpConfiguration().wrapper("Scala", "0.1.0")) .build(); try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server), config)) { Event e = EventFactory.DEFAULT.newIdentifyEvent(user); @@ -832,24 +831,6 @@ public void wrapperHeaderSentWhenSet() throws Exception { } } - @Test - public void wrapperHeaderSentWithoutVersion() throws Exception { - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - LDConfig config = new LDConfig.Builder() - .diagnosticOptOut(true) - .wrapperName("Scala") - .build(); - - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server), config)) { - Event e = EventFactory.DEFAULT.newIdentifyEvent(user); - ep.sendEvent(e); - } - - RecordedRequest req = server.takeRequest(); - assertThat(req.getHeader("X-LaunchDarkly-Wrapper"), equalTo("Scala")); - } - } - @Test public void http400ErrorIsRecoverable() throws Exception { testRecoverableHttpError(400); @@ -907,7 +888,8 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(eventsSuccessResponse())) { EventProcessorBuilder ec = sendEvents().baseURI(serverWithCert.uri()); LDConfig config = new LDConfig.Builder() - .sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager) // allows us to trust the self-signed cert + .http(Components.httpConfiguration().sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager)) + // allows us to trust the self-signed cert .build(); try (DefaultEventProcessor ep = makeEventProcessor(ec, config)) { diff --git a/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java b/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java index 20fe513ef..81f847bf0 100644 --- a/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java +++ b/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java @@ -89,21 +89,12 @@ public void testDefaultDiagnosticConfiguration() { @Test public void testCustomDiagnosticConfigurationGeneralProperties() { LDConfig ldConfig = new LDConfig.Builder() - .connectTimeout(5) - .socketTimeout(20) .startWaitMillis(10_000) - .proxyPort(1234) - .proxyUsername("username") - .proxyPassword("password") .build(); LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); LDValue expected = expectedDefaultProperties() - .put("connectTimeoutMillis", 5_000) - .put("socketTimeoutMillis", 20_000) .put("startWaitMillis", 10_000) - .put("usingProxy", true) - .put("usingProxyAuthenticator", true) .build(); assertEquals(expected, diagnosticJson); @@ -209,6 +200,29 @@ public void testCustomDiagnosticConfigurationForOffline() { assertEquals(expected, diagnosticJson); } + @Test + public void testCustomDiagnosticConfigurationHttpProperties() { + LDConfig ldConfig = new LDConfig.Builder() + .http( + Components.httpConfiguration() + .connectTimeoutMillis(5_000) + .socketTimeoutMillis(20_000) + .proxyHostAndPort("localhost", 1234) + .proxyAuth(Components.httpBasicAuthentication("username", "password")) + ) + .build(); + + LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); + LDValue expected = expectedDefaultProperties() + .put("connectTimeoutMillis", 5_000) + .put("socketTimeoutMillis", 20_000) + .put("usingProxy", true) + .put("usingProxyAuthenticator", true) + .build(); + + assertEquals(expected, diagnosticJson); + } + @SuppressWarnings("deprecation") @Test public void testCustomDiagnosticConfigurationDeprecatedPropertiesForStreaming() { @@ -263,4 +277,27 @@ public void testCustomDiagnosticConfigurationDeprecatedPropertyForDaemonMode() { assertEquals(expected, diagnosticJson); } + + @SuppressWarnings("deprecation") + @Test + public void testCustomDiagnosticConfigurationDeprecatedHttpProperties() { + LDConfig ldConfig = new LDConfig.Builder() + .connectTimeout(5) + .socketTimeout(20) + .proxyPort(1234) + .proxyUsername("username") + .proxyPassword("password") + .build(); + + LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); + LDValue expected = expectedDefaultProperties() + .put("connectTimeoutMillis", 5_000) + .put("socketTimeoutMillis", 20_000) + .put("usingProxy", true) + .put("usingProxyAuthenticator", true) + .build(); + + assertEquals(expected, diagnosticJson); + } + } diff --git a/src/test/java/com/launchdarkly/client/DiagnosticSdkTest.java b/src/test/java/com/launchdarkly/client/DiagnosticSdkTest.java index ccdd229e0..f0c01184f 100644 --- a/src/test/java/com/launchdarkly/client/DiagnosticSdkTest.java +++ b/src/test/java/com/launchdarkly/client/DiagnosticSdkTest.java @@ -8,8 +8,8 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; +@SuppressWarnings("javadoc") public class DiagnosticSdkTest { - private static final Gson gson = new Gson(); @Test @@ -24,8 +24,7 @@ public void defaultFieldValues() { @Test public void getsWrapperValuesFromConfig() { LDConfig config = new LDConfig.Builder() - .wrapperName("Scala") - .wrapperVersion("0.1.0") + .http(Components.httpConfiguration().wrapper("Scala", "0.1.0")) .build(); DiagnosticSdk diagnosticSdk = new DiagnosticSdk(config); assertEquals("java-server-sdk", diagnosticSdk.name); @@ -46,8 +45,7 @@ public void gsonSerializationNoWrapper() { @Test public void gsonSerializationWithWrapper() { LDConfig config = new LDConfig.Builder() - .wrapperName("Scala") - .wrapperVersion("0.1.0") + .http(Components.httpConfiguration().wrapper("Scala", "0.1.0")) .build(); DiagnosticSdk diagnosticSdk = new DiagnosticSdk(config); JsonObject jsonObject = gson.toJsonTree(diagnosticSdk).getAsJsonObject(); diff --git a/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java b/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java index 4745aaaca..f1b409294 100644 --- a/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java +++ b/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java @@ -6,12 +6,124 @@ import org.junit.Test; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; @SuppressWarnings("javadoc") public class EvaluationReasonTest { private static final Gson gson = new Gson(); + @Test + public void offProperties() { + EvaluationReason reason = EvaluationReason.off(); + assertEquals(EvaluationReason.Kind.OFF, reason.getKind()); + assertEquals(-1, reason.getRuleIndex()); + assertNull(reason.getRuleId()); + assertNull(reason.getPrerequisiteKey()); + assertNull(reason.getErrorKind()); + assertNull(reason.getException()); + } + + @Test + public void fallthroughProperties() { + EvaluationReason reason = EvaluationReason.fallthrough(); + assertEquals(EvaluationReason.Kind.FALLTHROUGH, reason.getKind()); + assertEquals(-1, reason.getRuleIndex()); + assertNull(reason.getRuleId()); + assertNull(reason.getPrerequisiteKey()); + assertNull(reason.getErrorKind()); + assertNull(reason.getException()); + } + + @Test + public void targetMatchProperties() { + EvaluationReason reason = EvaluationReason.targetMatch(); + assertEquals(EvaluationReason.Kind.TARGET_MATCH, reason.getKind()); + assertEquals(-1, reason.getRuleIndex()); + assertNull(reason.getRuleId()); + assertNull(reason.getPrerequisiteKey()); + assertNull(reason.getErrorKind()); + assertNull(reason.getException()); + } + + @Test + public void ruleMatchProperties() { + EvaluationReason reason = EvaluationReason.ruleMatch(2, "id"); + assertEquals(EvaluationReason.Kind.RULE_MATCH, reason.getKind()); + assertEquals(2, reason.getRuleIndex()); + assertEquals("id", reason.getRuleId()); + assertNull(reason.getPrerequisiteKey()); + assertNull(reason.getErrorKind()); + assertNull(reason.getException()); + } + + @Test + public void prerequisiteFailedProperties() { + EvaluationReason reason = EvaluationReason.prerequisiteFailed("prereq-key"); + assertEquals(EvaluationReason.Kind.PREREQUISITE_FAILED, reason.getKind()); + assertEquals(-1, reason.getRuleIndex()); + assertNull(reason.getRuleId()); + assertEquals("prereq-key", reason.getPrerequisiteKey()); + assertNull(reason.getErrorKind()); + assertNull(reason.getException()); + } + + @Test + public void errorProperties() { + EvaluationReason reason = EvaluationReason.error(EvaluationReason.ErrorKind.CLIENT_NOT_READY); + assertEquals(EvaluationReason.Kind.ERROR, reason.getKind()); + assertEquals(-1, reason.getRuleIndex()); + assertNull(reason.getRuleId()); + assertNull(reason.getPrerequisiteKey()); + assertEquals(EvaluationReason.ErrorKind.CLIENT_NOT_READY, reason.getErrorKind()); + assertNull(reason.getException()); + } + + @Test + public void exceptionErrorProperties() { + Exception ex = new Exception("sorry"); + EvaluationReason reason = EvaluationReason.exception(ex); + assertEquals(EvaluationReason.Kind.ERROR, reason.getKind()); + assertEquals(-1, reason.getRuleIndex()); + assertNull(reason.getRuleId()); + assertNull(reason.getPrerequisiteKey()); + assertEquals(EvaluationReason.ErrorKind.EXCEPTION, reason.getErrorKind()); + assertEquals(ex, reason.getException()); + } + + @SuppressWarnings("deprecation") + @Test + public void deprecatedSubclassProperties() { + EvaluationReason ro = EvaluationReason.off(); + assertEquals(EvaluationReason.Off.class, ro.getClass()); + + EvaluationReason rf = EvaluationReason.fallthrough(); + assertEquals(EvaluationReason.Fallthrough.class, rf.getClass()); + + EvaluationReason rtm = EvaluationReason.targetMatch(); + assertEquals(EvaluationReason.TargetMatch.class, rtm.getClass()); + + EvaluationReason rrm = EvaluationReason.ruleMatch(2, "id"); + assertEquals(EvaluationReason.RuleMatch.class, rrm.getClass()); + assertEquals(2, ((EvaluationReason.RuleMatch)rrm).getRuleIndex()); + assertEquals("id", ((EvaluationReason.RuleMatch)rrm).getRuleId()); + + EvaluationReason rpf = EvaluationReason.prerequisiteFailed("prereq-key"); + assertEquals(EvaluationReason.PrerequisiteFailed.class, rpf.getClass()); + assertEquals("prereq-key", ((EvaluationReason.PrerequisiteFailed)rpf).getPrerequisiteKey()); + + EvaluationReason re = EvaluationReason.error(EvaluationReason.ErrorKind.CLIENT_NOT_READY); + assertEquals(EvaluationReason.Error.class, re.getClass()); + assertEquals(EvaluationReason.ErrorKind.CLIENT_NOT_READY, ((EvaluationReason.Error)re).getErrorKind()); + assertNull(((EvaluationReason.Error)re).getException()); + + Exception ex = new Exception("sorry"); + EvaluationReason ree = EvaluationReason.exception(ex); + assertEquals(EvaluationReason.Error.class, ree.getClass()); + assertEquals(EvaluationReason.ErrorKind.EXCEPTION, ((EvaluationReason.Error)ree).getErrorKind()); + assertEquals(ex, ((EvaluationReason.Error)ree).getException()); + } + @Test public void testOffReasonSerialization() { EvaluationReason reason = EvaluationReason.off(); @@ -73,9 +185,9 @@ public void testErrorSerializationWithException() { @Test public void errorInstancesAreReused() { for (EvaluationReason.ErrorKind errorKind: EvaluationReason.ErrorKind.values()) { - EvaluationReason.Error r0 = EvaluationReason.error(errorKind); + EvaluationReason r0 = EvaluationReason.error(errorKind); assertEquals(errorKind, r0.getErrorKind()); - EvaluationReason.Error r1 = EvaluationReason.error(errorKind); + EvaluationReason r1 = EvaluationReason.error(errorKind); assertSame(r0, r1); } } diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java index 71791785c..c8c072605 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java @@ -12,7 +12,7 @@ import java.util.Arrays; import static com.launchdarkly.client.EvaluationDetail.fromValue; -import static com.launchdarkly.client.JsonHelpers.gsonInstance; +import static com.launchdarkly.client.TestUtil.TEST_GSON_INSTANCE; import static com.launchdarkly.client.TestUtil.booleanFlagWithClauses; import static com.launchdarkly.client.TestUtil.fallthroughVariation; import static com.launchdarkly.client.VersionedDataKind.FEATURES; @@ -534,17 +534,17 @@ public void testSegmentMatchClauseFallsThroughIfSegmentNotFound() throws Excepti @Test public void flagIsDeserializedWithAllProperties() { String json = flagWithAllPropertiesJson().toJsonString(); - FeatureFlag flag0 = gsonInstance().fromJson(json, FeatureFlag.class); + FeatureFlag flag0 = TEST_GSON_INSTANCE.fromJson(json, FeatureFlag.class); assertFlagHasAllProperties(flag0); - FeatureFlag flag1 = gsonInstance().fromJson(gsonInstance().toJson(flag0), FeatureFlag.class); + FeatureFlag flag1 = TEST_GSON_INSTANCE.fromJson(TEST_GSON_INSTANCE.toJson(flag0), FeatureFlag.class); assertFlagHasAllProperties(flag1); } @Test public void flagIsDeserializedWithMinimalProperties() { String json = LDValue.buildObject().put("key", "flag-key").put("version", 99).build().toJsonString(); - FeatureFlag flag = gsonInstance().fromJson(json, FeatureFlag.class); + FeatureFlag flag = TEST_GSON_INSTANCE.fromJson(json, FeatureFlag.class); assertEquals("flag-key", flag.getKey()); assertEquals(99, flag.getVersion()); assertFalse(flag.isOn()); diff --git a/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java b/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java index 6b8ad7646..dacb9b0a2 100644 --- a/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java @@ -178,7 +178,8 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(resp)) { LDConfig config = new LDConfig.Builder() - .sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager) // allows us to trust the self-signed cert + .http(Components.httpConfiguration().sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager)) + // allows us to trust the self-signed cert .build(); try (DefaultFeatureRequestor r = makeRequestor(serverWithCert.server, config)) { @@ -194,8 +195,7 @@ public void httpClientCanUseProxyConfig() throws Exception { try (MockWebServer server = makeStartedServer(jsonResponse(flag1Json))) { HttpUrl serverUrl = server.url("/"); LDConfig config = new LDConfig.Builder() - .proxyHost(serverUrl.host()) - .proxyPort(serverUrl.port()) + .http(Components.httpConfiguration().proxyHostAndPort(serverUrl.host(), serverUrl.port())) .build(); try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, config.httpConfig, fakeBaseUri, true)) { diff --git a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java b/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java index 22e897712..355090516 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java @@ -70,7 +70,8 @@ public void clientStartsInPollingModeWithSelfSignedCert() throws Exception { LDConfig config = new LDConfig.Builder() .dataSource(basePollingConfig(serverWithCert.server)) .events(noEvents()) - .sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager) // allows us to trust the self-signed cert + .http(Components.httpConfiguration().sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager)) + // allows us to trust the self-signed cert .build(); try (LDClient client = new LDClient(sdkKey, config)) { @@ -126,7 +127,8 @@ public void clientStartsInStreamingModeWithSelfSignedCert() throws Exception { LDConfig config = new LDConfig.Builder() .dataSource(baseStreamingConfig(serverWithCert.server)) .events(noEvents()) - .sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager) // allows us to trust the self-signed cert + .http(Components.httpConfiguration().sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager)) + // allows us to trust the self-signed cert .build(); try (LDClient client = new LDClient(sdkKey, config)) { diff --git a/src/test/java/com/launchdarkly/client/LDConfigTest.java b/src/test/java/com/launchdarkly/client/LDConfigTest.java index 0f9d074f1..3e89c7a5a 100644 --- a/src/test/java/com/launchdarkly/client/LDConfigTest.java +++ b/src/test/java/com/launchdarkly/client/LDConfigTest.java @@ -1,128 +1,218 @@ package com.launchdarkly.client; +import com.launchdarkly.client.integrations.HttpConfigurationBuilderTest; +import com.launchdarkly.client.interfaces.HttpConfiguration; + import org.junit.Test; import java.net.InetSocketAddress; import java.net.Proxy; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.X509TrustManager; + 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; import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") public class LDConfigTest { + @SuppressWarnings("deprecation") + @Test + public void testMinimumPollingIntervalIsEnforcedProperly(){ + LDConfig config = new LDConfig.Builder().pollingIntervalMillis(10L).build(); + assertEquals(30000L, config.deprecatedPollingIntervalMillis); + } + + @SuppressWarnings("deprecation") + @Test + public void testPollingIntervalIsEnforcedProperly(){ + LDConfig config = new LDConfig.Builder().pollingIntervalMillis(30001L).build(); + assertEquals(30001L, config.deprecatedPollingIntervalMillis); + } + + @Test + public void testSendEventsDefaultsToTrue() { + LDConfig config = new LDConfig.Builder().build(); + assertEquals(true, config.deprecatedSendEvents); + } + + @SuppressWarnings("deprecation") + @Test + public void testSendEventsCanBeSetToFalse() { + LDConfig config = new LDConfig.Builder().sendEvents(false).build(); + assertEquals(false, config.deprecatedSendEvents); + } + + @Test + public void testDefaultDiagnosticOptOut() { + LDConfig config = new LDConfig.Builder().build(); + assertFalse(config.diagnosticOptOut); + } + + @Test + public void testDiagnosticOptOut() { + LDConfig config = new LDConfig.Builder().diagnosticOptOut(true).build(); + assertTrue(config.diagnosticOptOut); + } + + @Test + public void testWrapperNotConfigured() { + LDConfig config = new LDConfig.Builder().build(); + assertNull(config.httpConfig.getWrapperIdentifier()); + } + + @Test + public void testWrapperNameOnly() { + LDConfig config = new LDConfig.Builder() + .http( + Components.httpConfiguration() + .wrapper("Scala", null) + ) + .build(); + assertEquals("Scala", config.httpConfig.getWrapperIdentifier()); + } + @Test - public void testNoProxyConfigured() { + public void testWrapperWithVersion() { + LDConfig config = new LDConfig.Builder() + .http( + Components.httpConfiguration() + .wrapper("Scala", "0.1.0") + ) + .build(); + assertEquals("Scala/0.1.0", config.httpConfig.getWrapperIdentifier()); + } + + @Test + public void testHttpDefaults() { LDConfig config = new LDConfig.Builder().build(); - assertNull(config.httpConfig.proxy); - assertNull(config.httpConfig.proxyAuthenticator); + HttpConfiguration hc = config.httpConfig; + HttpConfiguration defaults = Components.httpConfiguration().createHttpConfiguration(); + assertEquals(defaults.getConnectTimeoutMillis(), hc.getConnectTimeoutMillis()); + assertNull(hc.getProxy()); + assertNull(hc.getProxyAuthentication()); + assertEquals(defaults.getSocketTimeoutMillis(), hc.getSocketTimeoutMillis()); + assertNull(hc.getSslSocketFactory()); + assertNull(hc.getTrustManager()); + assertNull(hc.getWrapperIdentifier()); } + @SuppressWarnings("deprecation") @Test - public void testOnlyProxyHostConfiguredIsNull() { + public void testDeprecatedHttpConnectTimeout() { + LDConfig config = new LDConfig.Builder().connectTimeoutMillis(999).build(); + assertEquals(999, config.httpConfig.getConnectTimeoutMillis()); + } + + @SuppressWarnings("deprecation") + @Test + public void testDeprecatedHttpConnectTimeoutSeconds() { + LDConfig config = new LDConfig.Builder().connectTimeout(999).build(); + assertEquals(999000, config.httpConfig.getConnectTimeoutMillis()); + } + + @SuppressWarnings("deprecation") + @Test + public void testDeprecatedHttpSocketTimeout() { + LDConfig config = new LDConfig.Builder().socketTimeoutMillis(999).build(); + assertEquals(999, config.httpConfig.getSocketTimeoutMillis()); + } + + @SuppressWarnings("deprecation") + @Test + public void testDeprecatedHttpSocketTimeoutSeconds() { + LDConfig config = new LDConfig.Builder().socketTimeout(999).build(); + assertEquals(999000, config.httpConfig.getSocketTimeoutMillis()); + } + + @SuppressWarnings("deprecation") + @Test + public void testDeprecatedHttpOnlyProxyHostConfiguredIsNull() { LDConfig config = new LDConfig.Builder().proxyHost("bla").build(); - assertNull(config.httpConfig.proxy); + assertNull(config.httpConfig.getProxy()); } + @SuppressWarnings("deprecation") @Test - public void testOnlyProxyPortConfiguredHasPortAndDefaultHost() { + public void testDeprecatedHttpOnlyProxyPortConfiguredHasPortAndDefaultHost() { LDConfig config = new LDConfig.Builder().proxyPort(1234).build(); - assertEquals(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("localhost", 1234)), config.httpConfig.proxy); + assertEquals(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("localhost", 1234)), config.httpConfig.getProxy()); } + + @SuppressWarnings("deprecation") @Test - public void testProxy() { + public void testDeprecatedHttpProxy() { LDConfig config = new LDConfig.Builder() .proxyHost("localhost2") .proxyPort(4444) .build(); - assertEquals(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("localhost2", 4444)), config.httpConfig.proxy); + assertEquals(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("localhost2", 4444)), config.httpConfig.getProxy()); } + @SuppressWarnings("deprecation") @Test - public void testProxyAuth() { + public void testDeprecatedHttpProxyAuth() { LDConfig config = new LDConfig.Builder() .proxyHost("localhost2") .proxyPort(4444) - .proxyUsername("proxyUser") - .proxyPassword("proxyPassword") + .proxyUsername("user") + .proxyPassword("pass") .build(); - assertNotNull(config.httpConfig.proxy); - assertNotNull(config.httpConfig.proxyAuthenticator); + assertNotNull(config.httpConfig.getProxy()); + assertNotNull(config.httpConfig.getProxyAuthentication()); + assertEquals("Basic dXNlcjpwYXNz", config.httpConfig.getProxyAuthentication().provideAuthorization(null)); } + @SuppressWarnings("deprecation") @Test - public void testProxyAuthPartialConfig() { + public void testDeprecatedHttpProxyAuthPartialConfig() { LDConfig config = new LDConfig.Builder() .proxyHost("localhost2") .proxyPort(4444) .proxyUsername("proxyUser") .build(); - assertNotNull(config.httpConfig.proxy); - assertNull(config.httpConfig.proxyAuthenticator); + assertNotNull(config.httpConfig.getProxy()); + assertNull(config.httpConfig.getProxyAuthentication()); config = new LDConfig.Builder() .proxyHost("localhost2") .proxyPort(4444) .proxyPassword("proxyPassword") .build(); - assertNotNull(config.httpConfig.proxy); - assertNull(config.httpConfig.proxyAuthenticator); + assertNotNull(config.httpConfig.getProxy()); + assertNull(config.httpConfig.getProxyAuthentication()); } @SuppressWarnings("deprecation") @Test - public void testMinimumPollingIntervalIsEnforcedProperly(){ - LDConfig config = new LDConfig.Builder().pollingIntervalMillis(10L).build(); - assertEquals(30000L, config.deprecatedPollingIntervalMillis); + public void testDeprecatedHttpSslOptions() { + SSLSocketFactory sf = new HttpConfigurationBuilderTest.StubSSLSocketFactory(); + X509TrustManager tm = new HttpConfigurationBuilderTest.StubX509TrustManager(); + LDConfig config = new LDConfig.Builder().sslSocketFactory(sf, tm).build(); + assertSame(sf, config.httpConfig.getSslSocketFactory()); + assertSame(tm, config.httpConfig.getTrustManager()); } @SuppressWarnings("deprecation") @Test - public void testPollingIntervalIsEnforcedProperly(){ - LDConfig config = new LDConfig.Builder().pollingIntervalMillis(30001L).build(); - assertEquals(30001L, config.deprecatedPollingIntervalMillis); - } - - @Test - public void testSendEventsDefaultsToTrue() { - LDConfig config = new LDConfig.Builder().build(); - assertEquals(true, config.deprecatedSendEvents); - } - - @SuppressWarnings("deprecation") - @Test - public void testSendEventsCanBeSetToFalse() { - LDConfig config = new LDConfig.Builder().sendEvents(false).build(); - assertEquals(false, config.deprecatedSendEvents); - } - - @Test - public void testDefaultDiagnosticOptOut() { - LDConfig config = new LDConfig.Builder().build(); - assertFalse(config.diagnosticOptOut); - } - - @Test - public void testDiagnosticOptOut() { - LDConfig config = new LDConfig.Builder().diagnosticOptOut(true).build(); - assertTrue(config.diagnosticOptOut); + public void testDeprecatedHttpWrapperNameOnly() { + LDConfig config = new LDConfig.Builder() + .wrapperName("Scala") + .build(); + assertEquals("Scala", config.httpConfig.getWrapperIdentifier()); } + @SuppressWarnings("deprecation") @Test - public void testWrapperNotConfigured() { - LDConfig config = new LDConfig.Builder().build(); - assertNull(config.httpConfig.wrapperName); - assertNull(config.httpConfig.wrapperVersion); - } - - @Test public void testWrapperConfigured() { + public void testDeprecatedHttpWrapperWithVersion() { LDConfig config = new LDConfig.Builder() .wrapperName("Scala") .wrapperVersion("0.1.0") .build(); - assertEquals("Scala", config.httpConfig.wrapperName); - assertEquals("0.1.0", config.httpConfig.wrapperVersion); + assertEquals("Scala/0.1.0", config.httpConfig.getWrapperIdentifier()); } } \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/client/LDUserTest.java b/src/test/java/com/launchdarkly/client/LDUserTest.java index c4c6adeb8..fd66966f6 100644 --- a/src/test/java/com/launchdarkly/client/LDUserTest.java +++ b/src/test/java/com/launchdarkly/client/LDUserTest.java @@ -18,8 +18,8 @@ import java.util.Map; import java.util.Set; -import static com.launchdarkly.client.JsonHelpers.gsonInstance; import static com.launchdarkly.client.JsonHelpers.gsonInstanceForEventsSerialization; +import static com.launchdarkly.client.TestUtil.TEST_GSON_INSTANCE; import static com.launchdarkly.client.TestUtil.defaultEventsConfig; import static com.launchdarkly.client.TestUtil.jbool; import static com.launchdarkly.client.TestUtil.jdouble; @@ -172,6 +172,7 @@ public void canSetAnonymous() { assertEquals(true, user.getAnonymous().booleanValue()); } + @SuppressWarnings("deprecation") @Test public void canSetCountry() { LDUser user = new LDUser.Builder("key").country(LDCountryCode.US).build(); @@ -216,6 +217,7 @@ public void invalidCountryNameDoesNotSetCountry() { assertEquals(LDValue.ofNull(), user.getCountry()); } + @SuppressWarnings("deprecation") @Test public void canSetPrivateCountry() { LDUser user = new LDUser.Builder("key").privateCountry(LDCountryCode.US).build(); @@ -297,8 +299,8 @@ public void canSetPrivateDeprecatedCustomJsonValue() { @Test public void testAllPropertiesInDefaultEncoding() { for (Map.Entry e: getUserPropertiesJsonMap().entrySet()) { - JsonElement expected = gsonInstance().fromJson(e.getValue(), JsonElement.class); - JsonElement actual = gsonInstance().toJsonTree(e.getKey()); + JsonElement expected = TEST_GSON_INSTANCE.fromJson(e.getValue(), JsonElement.class); + JsonElement actual = TEST_GSON_INSTANCE.toJsonTree(e.getKey()); assertEquals(expected, actual); } } @@ -306,12 +308,13 @@ public void testAllPropertiesInDefaultEncoding() { @Test public void testAllPropertiesInPrivateAttributeEncoding() { for (Map.Entry e: getUserPropertiesJsonMap().entrySet()) { - JsonElement expected = gsonInstance().fromJson(e.getValue(), JsonElement.class); - JsonElement actual = gsonInstance().toJsonTree(e.getKey()); + JsonElement expected = TEST_GSON_INSTANCE.fromJson(e.getValue(), JsonElement.class); + JsonElement actual = TEST_GSON_INSTANCE.toJsonTree(e.getKey()); assertEquals(expected, actual); } } + @SuppressWarnings("deprecation") private Map getUserPropertiesJsonMap() { ImmutableMap.Builder builder = ImmutableMap.builder(); builder.put(new LDUser.Builder("userkey").build(), "{\"key\":\"userkey\"}"); @@ -467,7 +470,7 @@ public void canAddCustomAttrWithListOfStrings() { .customString("foo", ImmutableList.of("a", "b")) .build(); JsonElement expectedAttr = makeCustomAttrWithListOfValues("foo", js("a"), js("b")); - JsonObject jo = gsonInstance().toJsonTree(user).getAsJsonObject(); + JsonObject jo = TEST_GSON_INSTANCE.toJsonTree(user).getAsJsonObject(); assertEquals(expectedAttr, jo.get("custom")); } @@ -477,7 +480,7 @@ public void canAddCustomAttrWithListOfNumbers() { .customNumber("foo", ImmutableList.of(new Integer(1), new Double(2))) .build(); JsonElement expectedAttr = makeCustomAttrWithListOfValues("foo", jint(1), jdouble(2)); - JsonObject jo = gsonInstance().toJsonTree(user).getAsJsonObject(); + JsonObject jo = TEST_GSON_INSTANCE.toJsonTree(user).getAsJsonObject(); assertEquals(expectedAttr, jo.get("custom")); } @@ -487,7 +490,7 @@ public void canAddCustomAttrWithListOfMixedValues() { .customValues("foo", ImmutableList.of(js("a"), jint(1), jbool(true))) .build(); JsonElement expectedAttr = makeCustomAttrWithListOfValues("foo", js("a"), jint(1), jbool(true)); - JsonObject jo = gsonInstance().toJsonTree(user).getAsJsonObject(); + JsonObject jo = TEST_GSON_INSTANCE.toJsonTree(user).getAsJsonObject(); assertEquals(expectedAttr, jo.get("custom")); } diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java index 8b4460a16..8858e6a3f 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -1,6 +1,7 @@ package com.launchdarkly.client; import com.launchdarkly.client.integrations.StreamingDataSourceBuilder; +import com.launchdarkly.client.interfaces.HttpConfiguration; import com.launchdarkly.eventsource.ConnectionErrorHandler; import com.launchdarkly.eventsource.EventHandler; import com.launchdarkly.eventsource.EventSource; @@ -24,9 +25,11 @@ import static com.launchdarkly.client.TestHttpUtil.eventStreamResponse; import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; +import static com.launchdarkly.client.TestUtil.featureStoreThatThrowsException; import static com.launchdarkly.client.VersionedDataKind.FEATURES; import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.expectLastCall; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.greaterThanOrEqualTo; @@ -157,15 +160,16 @@ public void headersHaveAccept() { @Test public void headersHaveWrapperWhenSet() { LDConfig config = new LDConfig.Builder() - .wrapperName("Scala") - .wrapperVersion("0.1.0") - .build(); + .http(Components.httpConfiguration().wrapper("Scala", "0.1.0")) + .build(); createStreamProcessor(config, STREAM_URI).start(); assertEquals("Scala/0.1.0", headers.get("X-LaunchDarkly-Wrapper")); } @Test public void putCausesFeatureToBeStored() throws Exception { + expectNoStreamRestart(); + createStreamProcessor(STREAM_URI).start(); MessageEvent event = new MessageEvent("{\"data\":{\"flags\":{\"" + FEATURE1_KEY + "\":" + featureJson(FEATURE1_KEY, FEATURE1_VERSION) + "}," + @@ -177,6 +181,8 @@ public void putCausesFeatureToBeStored() throws Exception { @Test public void putCausesSegmentToBeStored() throws Exception { + expectNoStreamRestart(); + createStreamProcessor(STREAM_URI).start(); MessageEvent event = new MessageEvent("{\"data\":{\"flags\":{},\"segments\":{\"" + SEGMENT1_KEY + "\":" + segmentJson(SEGMENT1_KEY, SEGMENT1_VERSION) + "}}}"); @@ -230,6 +236,8 @@ public void putCausesFutureToBeSet() throws Exception { @Test public void patchUpdatesFeature() throws Exception { + expectNoStreamRestart(); + createStreamProcessor(STREAM_URI).start(); eventHandler.onMessage("put", emptyPutEvent()); @@ -243,6 +251,8 @@ public void patchUpdatesFeature() throws Exception { @Test public void patchUpdatesSegment() throws Exception { + expectNoStreamRestart(); + createStreamProcessor(STREAM_URI).start(); eventHandler.onMessage("put", emptyPutEvent()); @@ -256,6 +266,8 @@ public void patchUpdatesSegment() throws Exception { @Test public void deleteDeletesFeature() throws Exception { + expectNoStreamRestart(); + createStreamProcessor(STREAM_URI).start(); eventHandler.onMessage("put", emptyPutEvent()); featureStore.upsert(FEATURES, FEATURE); @@ -270,6 +282,8 @@ public void deleteDeletesFeature() throws Exception { @Test public void deleteDeletesSegment() throws Exception { + expectNoStreamRestart(); + createStreamProcessor(STREAM_URI).start(); eventHandler.onMessage("put", emptyPutEvent()); featureStore.upsert(SEGMENTS, SEGMENT); @@ -284,13 +298,17 @@ public void deleteDeletesSegment() throws Exception { @Test public void indirectPutRequestsAndStoresFeature() throws Exception { - createStreamProcessor(STREAM_URI).start(); setupRequestorToReturnAllDataWithFlag(FEATURE); + expectNoStreamRestart(); replayAll(); - eventHandler.onMessage("indirect/put", new MessageEvent("")); + try (StreamProcessor sp = createStreamProcessor(STREAM_URI)) { + sp.start(); + + eventHandler.onMessage("indirect/put", new MessageEvent("")); - assertFeatureInStore(FEATURE); + assertFeatureInStore(FEATURE); + } } @Test @@ -329,27 +347,35 @@ public void indirectPutSetsFuture() throws Exception { } @Test - public void indirectPatchRequestsAndUpdatesFeature() throws Exception { - createStreamProcessor(STREAM_URI).start(); + public void indirectPatchRequestsAndUpdatesFeature() throws Exception { expect(mockRequestor.getFlag(FEATURE1_KEY)).andReturn(FEATURE); + expectNoStreamRestart(); replayAll(); - - eventHandler.onMessage("put", emptyPutEvent()); - eventHandler.onMessage("indirect/patch", new MessageEvent("/flags/" + FEATURE1_KEY)); - - assertFeatureInStore(FEATURE); + + try (StreamProcessor sp = createStreamProcessor(STREAM_URI)) { + sp.start(); + + eventHandler.onMessage("put", emptyPutEvent()); + eventHandler.onMessage("indirect/patch", new MessageEvent("/flags/" + FEATURE1_KEY)); + + assertFeatureInStore(FEATURE); + } } @Test public void indirectPatchRequestsAndUpdatesSegment() throws Exception { - createStreamProcessor(STREAM_URI).start(); expect(mockRequestor.getSegment(SEGMENT1_KEY)).andReturn(SEGMENT); + expectNoStreamRestart(); replayAll(); - eventHandler.onMessage("put", emptyPutEvent()); - eventHandler.onMessage("indirect/patch", new MessageEvent("/segments/" + SEGMENT1_KEY)); - - assertSegmentInStore(SEGMENT); + try (StreamProcessor sp = createStreamProcessor(STREAM_URI)) { + sp.start(); + + eventHandler.onMessage("put", emptyPutEvent()); + eventHandler.onMessage("indirect/patch", new MessageEvent("/segments/" + SEGMENT1_KEY)); + + assertSegmentInStore(SEGMENT); + } } @Test @@ -438,7 +464,168 @@ public void http429ErrorIsRecoverable() throws Exception { public void http500ErrorIsRecoverable() throws Exception { testRecoverableHttpError(500); } + + @Test + public void putEventWithInvalidJsonCausesStreamRestart() throws Exception { + verifyEventCausesStreamRestart("put", "{sorry"); + } + @Test + public void putEventWithWellFormedJsonButInvalidDataCausesStreamRestart() throws Exception { + verifyEventCausesStreamRestart("put", "{\"data\":{\"flags\":3}}"); + } + + @Test + public void patchEventWithInvalidJsonCausesStreamRestart() throws Exception { + verifyEventCausesStreamRestart("patch", "{sorry"); + } + + @Test + public void patchEventWithWellFormedJsonButInvalidDataCausesStreamRestart() throws Exception { + verifyEventCausesStreamRestart("patch", "{\"path\":\"/flags/flagkey\", \"data\":{\"rules\":3}}"); + } + + @Test + public void patchEventWithInvalidPathCausesNoStreamRestart() throws Exception { + verifyEventCausesNoStreamRestart("patch", "{\"path\":\"/wrong\", \"data\":{\"key\":\"flagkey\"}}"); + } + + @Test + public void deleteEventWithInvalidJsonCausesStreamRestart() throws Exception { + verifyEventCausesStreamRestart("delete", "{sorry"); + } + + @Test + public void deleteEventWithInvalidPathCausesNoStreamRestart() throws Exception { + verifyEventCausesNoStreamRestart("delete", "{\"path\":\"/wrong\", \"version\":1}"); + } + + @Test + public void indirectPatchEventWithInvalidPathDoesNotCauseStreamRestart() throws Exception { + verifyEventCausesNoStreamRestart("indirect/patch", "/wrong"); + } + + @Test + public void indirectPutWithFailedPollCausesStreamRestart() throws Exception { + expect(mockRequestor.getAllData()).andThrow(new IOException("sorry")); + verifyEventCausesStreamRestart("indirect/put", ""); + } + + @Test + public void indirectPatchWithFailedPollCausesStreamRestart() throws Exception { + expect(mockRequestor.getFlag("flagkey")).andThrow(new IOException("sorry")); + verifyEventCausesStreamRestart("indirect/patch", "/flags/flagkey"); + } + + @Test + public void storeFailureOnPutCausesStreamRestart() throws Exception { + FeatureStore badStore = featureStoreThatThrowsException(new RuntimeException("sorry")); + expectStreamRestart(); + replayAll(); + + try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { + sp.start(); + eventHandler.onMessage("put", emptyPutEvent()); + } + verifyAll(); + } + + @Test + public void storeFailureOnPatchCausesStreamRestart() throws Exception { + FeatureStore badStore = featureStoreThatThrowsException(new RuntimeException("sorry")); + expectStreamRestart(); + replayAll(); + + try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { + sp.start(); + eventHandler.onMessage("patch", + new MessageEvent("{\"path\":\"/flags/flagkey\",\"data\":{\"key\":\"flagkey\",\"version\":1}}")); + } + verifyAll(); + } + + @Test + public void storeFailureOnDeleteCausesStreamRestart() throws Exception { + FeatureStore badStore = featureStoreThatThrowsException(new RuntimeException("sorry")); + expectStreamRestart(); + replayAll(); + + try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { + sp.start(); + eventHandler.onMessage("delete", + new MessageEvent("{\"path\":\"/flags/flagkey\",\"version\":1}")); + } + verifyAll(); + } + + @Test + public void storeFailureOnIndirectPutCausesStreamRestart() throws Exception { + FeatureStore badStore = featureStoreThatThrowsException(new RuntimeException("sorry")); + setupRequestorToReturnAllDataWithFlag(FEATURE); + expectStreamRestart(); + replayAll(); + + try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { + sp.start(); + eventHandler.onMessage("indirect/put", new MessageEvent("")); + } + verifyAll(); + } + + @Test + public void storeFailureOnIndirectPatchCausesStreamRestart() throws Exception { + FeatureStore badStore = featureStoreThatThrowsException(new RuntimeException("sorry")); + setupRequestorToReturnAllDataWithFlag(FEATURE); + + expectStreamRestart(); + replayAll(); + + try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { + sp.start(); + eventHandler.onMessage("indirect/put", new MessageEvent("")); + } + verifyAll(); + } + + private void verifyEventCausesNoStreamRestart(String eventName, String eventData) throws Exception { + expectNoStreamRestart(); + verifyEventBehavior(eventName, eventData); + } + + private void verifyEventCausesStreamRestart(String eventName, String eventData) throws Exception { + expectStreamRestart(); + verifyEventBehavior(eventName, eventData); + } + + private void verifyEventBehavior(String eventName, String eventData) throws Exception { + replayAll(); + try (StreamProcessor sp = createStreamProcessor(LDConfig.DEFAULT, STREAM_URI, null)) { + sp.start(); + eventHandler.onMessage(eventName, new MessageEvent(eventData)); + } + verifyAll(); + } + + private void expectNoStreamRestart() throws Exception { + mockEventSource.start(); + expectLastCall().times(1); + mockEventSource.close(); + expectLastCall().times(1); + mockRequestor.close(); + expectLastCall().times(1); + } + + private void expectStreamRestart() throws Exception { + mockEventSource.start(); + expectLastCall().times(1); + mockEventSource.restart(); + expectLastCall().times(1); + mockEventSource.close(); + expectLastCall().times(1); + mockRequestor.close(); + expectLastCall().times(1); + } + // There are already end-to-end tests against an HTTP server in okhttp-eventsource, so we won't retest the // basic stream mechanism in detail. However, we do want to make sure that the LDConfig options are correctly // applied to the EventSource for things like TLS configuration. @@ -468,7 +655,8 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { server.server.enqueue(eventStreamResponse(STREAM_RESPONSE_WITH_EMPTY_DATA)); LDConfig config = new LDConfig.Builder() - .sslSocketFactory(server.socketFactory, server.trustManager) // allows us to trust the self-signed cert + .http(Components.httpConfiguration().sslSocketFactory(server.socketFactory, server.trustManager)) + // allows us to trust the self-signed cert .build(); try (StreamProcessor sp = createStreamProcessorWithRealHttp(config, server.uri())) { @@ -488,8 +676,7 @@ public void httpClientCanUseProxyConfig() throws Exception { try (MockWebServer server = makeStartedServer(eventStreamResponse(STREAM_RESPONSE_WITH_EMPTY_DATA))) { HttpUrl serverUrl = server.url("/"); LDConfig config = new LDConfig.Builder() - .proxyHost(serverUrl.host()) - .proxyPort(serverUrl.port()) + .http(Components.httpConfiguration().proxyHostAndPort(serverUrl.host(), serverUrl.port())) .build(); try (StreamProcessor sp = createStreamProcessorWithRealHttp(config, fakeStreamUri)) { @@ -569,6 +756,11 @@ private StreamProcessor createStreamProcessorWithRealHttp(LDConfig config, URI s streamUri, config.deprecatedReconnectTimeMs); } + private StreamProcessor createStreamProcessorWithStore(FeatureStore store) { + return new StreamProcessor(SDK_KEY, LDConfig.DEFAULT.httpConfig, mockRequestor, store, + new StubEventSourceCreator(), null, STREAM_URI, 0); + } + private String featureJson(String key, int version) { return "{\"key\":\"" + key + "\",\"version\":" + version + ",\"on\":true}"; } diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index 82eb9dc03..98663931d 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -2,6 +2,7 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.SettableFuture; +import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; @@ -26,7 +27,16 @@ @SuppressWarnings("javadoc") public class TestUtil { - + /** + * We should use this instead of JsonHelpers.gsonInstance() in any test code that might be run from + * outside of this project (for instance, from java-server-sdk-redis or other integrations), because + * in that context the SDK classes might be coming from the default jar distribution where Gson is + * shaded. Therefore, if a test method tries to call an SDK implementation method like gsonInstance() + * that returns a Gson type, or one that takes an argument of a Gson type, that might fail at runtime + * because the Gson type has been changed to a shaded version. + */ + public static final Gson TEST_GSON_INSTANCE = new Gson(); + public static FeatureStoreFactory specificFeatureStore(final FeatureStore store) { return new FeatureStoreFactory() { public FeatureStore createFeatureStore() { @@ -93,13 +103,19 @@ public Map all(VersionedDataKind kind) { } @Override - public void init(Map, Map> allData) { } + public void init(Map, Map> allData) { + throw e; + } @Override - public void delete(VersionedDataKind kind, String key, int version) { } + public void delete(VersionedDataKind kind, String key, int version) { + throw e; + } @Override - public void upsert(VersionedDataKind kind, T item) { } + public void upsert(VersionedDataKind kind, T item) { + throw e; + } @Override public boolean initialized() { diff --git a/src/test/java/com/launchdarkly/client/UtilTest.java b/src/test/java/com/launchdarkly/client/UtilTest.java index 400660247..84744dc9c 100644 --- a/src/test/java/com/launchdarkly/client/UtilTest.java +++ b/src/test/java/com/launchdarkly/client/UtilTest.java @@ -82,21 +82,8 @@ public void testDateTimeConversionInvalidString() { } @Test - public void testConnectTimeoutSpecifiedInSeconds() { - LDConfig config = new LDConfig.Builder().connectTimeout(3).build(); - OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); - configureHttpClientBuilder(config.httpConfig, httpBuilder); - OkHttpClient httpClient = httpBuilder.build(); - try { - assertEquals(3000, httpClient.connectTimeoutMillis()); - } finally { - shutdownHttpClient(httpClient); - } - } - - @Test - public void testConnectTimeoutSpecifiedInMilliseconds() { - LDConfig config = new LDConfig.Builder().connectTimeoutMillis(3000).build(); + public void testConnectTimeout() { + LDConfig config = new LDConfig.Builder().http(Components.httpConfiguration().connectTimeoutMillis(3000)).build(); OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); configureHttpClientBuilder(config.httpConfig, httpBuilder); OkHttpClient httpClient = httpBuilder.build(); @@ -108,21 +95,8 @@ public void testConnectTimeoutSpecifiedInMilliseconds() { } @Test - public void testSocketTimeoutSpecifiedInSeconds() { - LDConfig config = new LDConfig.Builder().socketTimeout(3).build(); - OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); - configureHttpClientBuilder(config.httpConfig, httpBuilder); - OkHttpClient httpClient = httpBuilder.build(); - try { - assertEquals(3000, httpClient.readTimeoutMillis()); - } finally { - shutdownHttpClient(httpClient); - } - } - - @Test - public void testSocketTimeoutSpecifiedInMilliseconds() { - LDConfig config = new LDConfig.Builder().socketTimeoutMillis(3000).build(); + public void testSocketTimeout() { + LDConfig config = new LDConfig.Builder().http(Components.httpConfiguration().socketTimeoutMillis(3000)).build(); OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); configureHttpClientBuilder(config.httpConfig, httpBuilder); OkHttpClient httpClient = httpBuilder.build(); diff --git a/src/test/java/com/launchdarkly/client/integrations/HttpConfigurationBuilderTest.java b/src/test/java/com/launchdarkly/client/integrations/HttpConfigurationBuilderTest.java new file mode 100644 index 000000000..b9a254866 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/integrations/HttpConfigurationBuilderTest.java @@ -0,0 +1,141 @@ +package com.launchdarkly.client.integrations; + +import com.launchdarkly.client.Components; +import com.launchdarkly.client.interfaces.HttpConfiguration; + +import org.junit.Test; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.Socket; +import java.net.UnknownHostException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; + +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.X509TrustManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +@SuppressWarnings("javadoc") +public class HttpConfigurationBuilderTest { + @Test + public void testDefaults() { + HttpConfiguration hc = Components.httpConfiguration().createHttpConfiguration(); + assertEquals(HttpConfigurationBuilder.DEFAULT_CONNECT_TIMEOUT_MILLIS, hc.getConnectTimeoutMillis()); + assertNull(hc.getProxy()); + assertNull(hc.getProxyAuthentication()); + assertEquals(HttpConfigurationBuilder.DEFAULT_SOCKET_TIMEOUT_MILLIS, hc.getSocketTimeoutMillis()); + assertNull(hc.getSslSocketFactory()); + assertNull(hc.getTrustManager()); + assertNull(hc.getWrapperIdentifier()); + } + + @Test + public void testConnectTimeout() { + HttpConfiguration hc = Components.httpConfiguration() + .connectTimeoutMillis(999) + .createHttpConfiguration(); + assertEquals(999, hc.getConnectTimeoutMillis()); + } + + @Test + public void testProxy() { + HttpConfiguration hc = Components.httpConfiguration() + .proxyHostAndPort("my-proxy", 1234) + .createHttpConfiguration(); + assertEquals(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("my-proxy", 1234)), hc.getProxy()); + assertNull(hc.getProxyAuthentication()); + } + + @Test + public void testProxyBasicAuth() { + HttpConfiguration hc = Components.httpConfiguration() + .proxyHostAndPort("my-proxy", 1234) + .proxyAuth(Components.httpBasicAuthentication("user", "pass")) + .createHttpConfiguration(); + assertEquals(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("my-proxy", 1234)), hc.getProxy()); + assertNotNull(hc.getProxyAuthentication()); + assertEquals("Basic dXNlcjpwYXNz", hc.getProxyAuthentication().provideAuthorization(null)); + } + + @Test + public void testSocketTimeout() { + HttpConfiguration hc = Components.httpConfiguration() + .socketTimeoutMillis(999) + .createHttpConfiguration(); + assertEquals(999, hc.getSocketTimeoutMillis()); + } + + @Test + public void testSslOptions() { + SSLSocketFactory sf = new StubSSLSocketFactory(); + X509TrustManager tm = new StubX509TrustManager(); + HttpConfiguration hc = Components.httpConfiguration().sslSocketFactory(sf, tm).createHttpConfiguration(); + assertSame(sf, hc.getSslSocketFactory()); + assertSame(tm, hc.getTrustManager()); + } + + @Test + public void testWrapperNameOnly() { + HttpConfiguration hc = Components.httpConfiguration() + .wrapper("Scala", null) + .createHttpConfiguration(); + assertEquals("Scala", hc.getWrapperIdentifier()); + } + + @Test + public void testWrapperWithVersion() { + HttpConfiguration hc = Components.httpConfiguration() + .wrapper("Scala", "0.1.0") + .createHttpConfiguration(); + assertEquals("Scala/0.1.0", hc.getWrapperIdentifier()); + } + + public static class StubSSLSocketFactory extends SSLSocketFactory { + public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) + throws IOException { + return null; + } + + public Socket createSocket(String host, int port, InetAddress localHost, int localPort) + throws IOException, UnknownHostException { + return null; + } + + public Socket createSocket(InetAddress host, int port) throws IOException { + return null; + } + + public Socket createSocket(String host, int port) throws IOException, UnknownHostException { + return null; + } + + public String[] getSupportedCipherSuites() { + return null; + } + + public String[] getDefaultCipherSuites() { + return null; + } + + public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException { + return null; + } + } + + public static class StubX509TrustManager implements X509TrustManager { + public X509Certificate[] getAcceptedIssuers() { + return null; + } + + public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {} + + public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {} + } +}