diff --git a/.circleci/config.yml b/.circleci/config.yml index 554291282..449edfca5 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -54,7 +54,6 @@ jobs: type: string docker: - image: <> - - image: redis steps: - checkout - run: cp gradle.properties.example gradle.properties @@ -85,18 +84,6 @@ jobs: $ProgressPreference = "SilentlyContinue" # prevents console errors from CircleCI host iwr -outf openjdk.msi https://developers.redhat.com/download-manager/file/java-11-openjdk-11.0.5.10-2.windows.redhat.x86_64.msi Start-Process msiexec.exe -Wait -ArgumentList '/I openjdk.msi /quiet' - - run: - name: start Redis - command: | - $ProgressPreference = "SilentlyContinue" - iwr -outf redis.zip https://github.com/MicrosoftArchive/redis/releases/download/win-3.0.504/Redis-x64-3.0.504.zip - mkdir redis - Expand-Archive -Path redis.zip -DestinationPath redis - cd redis - .\redis-server --service-install - .\redis-server --service-start - Start-Sleep -s 5 - .\redis-cli ping - run: name: build and test command: | diff --git a/.ldrelease/config.yml b/.ldrelease/config.yml index 09d702867..bdd13d5cc 100644 --- a/.ldrelease/config.yml +++ b/.ldrelease/config.yml @@ -10,8 +10,6 @@ publications: template: name: gradle - env: - LD_SKIP_DATABASE_TESTS: 1 documentation: githubPages: true diff --git a/CHANGELOG.md b/CHANGELOG.md index de4ce922b..546d9decf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ All notable changes to the LaunchDarkly Java SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [5.0.0-rc1] - 2020-04-29 +This beta release is being made available for testing and user feedback, due to the large number of changes from Java SDK 4.x. Features are still subject to change in the final 5.0.0 release. Until the final release, the beta source code will be on the [5.x branch](https://github.com/launchdarkly/java-server-sdk/tree/5.x). Javadocs can be found on [javadoc.io](https://javadoc.io/doc/com.launchdarkly/launchdarkly-server-sdk/5.0.0-rc1/index.html). + +This is a major rewrite that introduces a cleaner API design, adds new features, and makes the SDK code easier to maintain and extend. See the [Java 4.x to 5.0 migration guide](https://docs.launchdarkly.com/sdk/server-side/java/migration-4-to-5) for an in-depth look at the changes in this version; the following is a summary. + +### Added: +- You can tell the SDK to notify you whenever a feature flag's configuration has changed in any way, using `FlagChangeListener` and `LDClient.registerFlagChangeListener()`. +- Or, you can tell the SDK to notify you only if the _value_ of a flag for some particular `LDUser` has changed, using `FlagValueChangeListener` and `Components.flagValueMonitoringListener()`. +- You can monitor the status of a persistent data store (for instance, to get caching statistics, or to be notified if the store's availability changes due to a database outage) with `LDClient.getDataStoreStatusProvider()`. +- The `UserAttribute` class provides a less error-prone way to refer to user attribute names in configuration, and can also be used to get an arbitrary attribute from a user. +- The `LDGson` and `LDJackson` classes allow SDK classes like LDUser to be easily converted to or from JSON using the popular Gson and Jackson frameworks. + +### Changed: +- The minimum supported Java version is now 8. +- Package names have changed: the main SDK classes are now in `com.launchdarkly.sdk` and `com.launchdarkly.sdk.server`. +- Many rarely-used classes and interfaces have been moved out of the main SDK package into `com.launchdarkly.sdk.server.integrations` and `com.launchdarkly.sdk.server.interfaces`. +- The type `java.time.Duration` is now used for configuration properties that represent an amount of time, instead of using a number of milliseconds or seconds. +- When using a persistent data store such as Redis, if there is a database outage, the SDK will wait until the end of the outage and then restart the stream connection to ensure that it has the latest data. Previously, it would try to restart the connection immediately and continue restarting if the database was still not available, causing unnecessary overhead. +- `EvaluationDetail.getVariationIndex()` now returns `int` instead of `Integer`. +- `EvaluationReason` is now a single concrete class rather than an abstract base class. +- The SDK no longer exposes a Gson dependency or any Gson types. +- Third-party libraries like Gson, Guava, and OkHttp that are used internally by the SDK have been updated to newer versions since Java 7 compatibility is no longer required. +- The component interfaces `FeatureStore` and UpdateProcessor have been renamed to `DataStore` and `DataSource`. The factory interfaces for these components now receive SDK configuration options in a different way that does not expose other components' configurations to each other. +- The `PersistentDataStore` interface for creating your own database integrations has been simplified by moving all of the serialization and caching logic into the main SDK code. + +### Removed: +- All types and methods that were deprecated as of Java SDK 4.13.0 have been removed. This includes many `LDConfig.Builder()` methods, which have been replaced by the modular configuration syntax that was already added in the 4.12.0 and 4.13.0 releases. See the [migration guide](https://docs.launchdarkly.com/sdk/server-side/java/migration-4-to-5) for details on how to update your configuration code if you were using the older syntax. +- The Redis integration is no longer built into the main SDK library (see below). +- The deprecated New Relic integration has been removed. + +If you want to test this release and you are using Consul, DynamoDB, or Redis as a persistent data store, you will also need to update to version 2.0.0-rc1 of the [Consul integration](https://github.com/launchdarkly/java-server-sdk-consul/tree/2.x), 3.0.0-rc1 of the [DynamoDB integration](https://github.com/launchdarkly/java-server-sdk-dynamodb/tree/3.x), or 1.0.0-rc1 of the [Redis integration](http://github.com/launchdarkly/java-server-sdk-redis) (previously the Redis integration was built in; now it is a separate module). + ## [4.13.0] - 2020-04-21 ### Added: - The new methods `Components.httpConfiguration()` and `LDConfig.Builder.http()`, and the new class `HttpConfigurationBuilder`, provide a subcomponent configuration model that groups together HTTP-related options such as `connectTimeoutMillis` and `proxyHost` - similar to how `Components.streamingDataSource()` works for streaming-related options or `Components.sendEvents()` for event-related options. The individual `LDConfig.Builder` methods for those options will still work, but are deprecated and will be removed in version 5.0. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3e5c76bf0..229d7cad7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,8 +15,10 @@ We encourage pull requests and other contributions from the community. Before su ### Prerequisites -The SDK builds with [Gradle](https://gradle.org/) and should be built against Java 7. - +The SDK builds with [Gradle](https://gradle.org/) and should be built against Java 8. + +Many basic classes are implemented in the module `launchdarkly-java-sdk-common`, whose source code is in the [`launchdarkly/java-sdk-common`](https://github.com/launchdarkly/java-sdk-common) repository; this is so the common code can be shared with the LaunchDarkly Android SDK. By design, the LaunchDarkly Java SDK distribution does not expose a dependency on that module; instead, its classes and Javadoc content are embedded in the SDK jars. + ### Building To build the SDK without running any tests: @@ -40,5 +42,3 @@ To build the SDK and run all unit tests: ``` ./gradlew test ``` - -By default, the full unit test suite includes live tests of the Redis integration. Those tests expect you to have Redis running locally. To skip them, set the environment variable `LD_SKIP_DATABASE_TESTS=1` before running the tests. diff --git a/README.md b/README.md index adbf2eeb7..6075a7ab2 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,31 @@ -LaunchDarkly Server-side SDK for Java -========================= +# LaunchDarkly Server-side SDK for Java [![Circle CI](https://circleci.com/gh/launchdarkly/java-server-sdk.svg?style=shield)](https://circleci.com/gh/launchdarkly/java-server-sdk) [![Javadocs](http://javadoc.io/badge/com.launchdarkly/launchdarkly-java-server-sdk.svg)](http://javadoc.io/doc/com.launchdarkly/launchdarkly-java-server-sdk) -LaunchDarkly overview -------------------------- +## LaunchDarkly overview + [LaunchDarkly](https://www.launchdarkly.com) is a feature management platform that serves over 100 billion feature flags daily to help teams build better software, faster. [Get started](https://docs.launchdarkly.com/docs/getting-started) using LaunchDarkly today! [![Twitter Follow](https://img.shields.io/twitter/follow/launchdarkly.svg?style=social&label=Follow&maxAge=2592000)](https://twitter.com/intent/follow?screen_name=launchdarkly) -Supported Java versions ------------------------ +## Supported Java versions -This version of the LaunchDarkly SDK works with Java 7 and above. +This version of the LaunchDarkly SDK works with Java 8 and above. -Distributions -------------- +## Distributions Three variants of the SDK jar are published to Maven: -* The default uberjar - this is accessible as `com.launchdarkly:launchdarkly-java-server-sdk:jar` and is the dependency used in the "[Getting started](https://docs.launchdarkly.com/docs/java-sdk-reference#section-getting-started)" section of the SDK reference guide as well as in the [`hello-java`](https://github.com/launchdarkly/hello-java) sample app. This variant contains the SDK classes, and all of the SDK's dependencies except for Gson and SLF4J, which must be provided by the host application. The bundled dependencies have shaded package names (and are not exported in OSGi), so they will not interfere with any other versions of the same packages. -* The extended uberjar - add `all` in Maven, or `:all` in Gradle. This is the same as the default uberjar except that Gson and SLF4J are also bundled, without shading (and are exported in OSGi). +* The default uberjar - this is accessible as `com.launchdarkly:launchdarkly-java-server-sdk:jar` and is the dependency used in the "[Getting started](https://docs.launchdarkly.com/docs/java-sdk-reference#section-getting-started)" section of the SDK reference guide as well as in the [`hello-java`](https://github.com/launchdarkly/hello-java) sample app. This variant contains the SDK classes, and all of the SDK's dependencies except for SLF4J, which must be provided by the host application. The bundled dependencies have shaded package names (and are not exported in OSGi), so they will not interfere with any other versions of the same packages. +* The extended uberjar - add `all` in Maven, or `:all` in Gradle. This is the same as the default uberjar except that SLF4J is also bundled, without shading (and is exported in OSGi). * The "thin" jar - add `thin` in Maven, or `:thin` in Gradle. This contains _only_ the SDK classes. -Getting started ------------ +## Getting started Refer to the [SDK reference guide](https://docs.launchdarkly.com/docs/java-sdk-reference#section-getting-started) for instructions on getting started with using the SDK. -Logging -------- +## Logging The LaunchDarkly SDK uses [SLF4J](https://www.slf4j.org/). All loggers are namespaced under `com.launchdarkly`. For an example configuration check out the [hello-java](https://github.com/launchdarkly/hello-java) project. @@ -38,35 +33,29 @@ Be aware of two considerations when enabling the DEBUG log level: 1. Debug-level logs can be very verbose. It is not recommended that you turn on debug logging in high-volume environments. 1. Potentially sensitive information is logged including LaunchDarkly users created by you in your usage of this SDK. -Using flag data from a file ---------------------------- +## Using flag data from a file For testing purposes, the SDK can be made to read feature flag state from a file or files instead of connecting to LaunchDarkly. See FileComponents for more details. -DNS caching issues ------------------- +## DNS caching issues LaunchDarkly servers operate in a load-balancing framework which may cause their IP addresses to change. This could result in the SDK failing to connect to LaunchDarkly if an old IP address is still in your system's DNS cache. Unlike some other languages, in Java the DNS caching behavior is controlled by the Java virtual machine rather than the operating system. The default behavior varies depending on whether there is a [security manager](https://docs.oracle.com/javase/tutorial/essential/environment/security.html): if there is, IP addresses will _never_ expire. In that case, we recommend that you set the security property `networkaddress.cache.ttl`, as described [here](https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/java-dg-jvm-ttl.html), to a number of seconds such as 30 or 60 (a lower value will reduce the chance of intermittent failures, but will slightly reduce networking performance). -Learn more ----------- +## Learn more Check out our [documentation](https://docs.launchdarkly.com) for in-depth instructions on configuring and using LaunchDarkly. You can also head straight to the [complete reference guide for this SDK](https://docs.launchdarkly.com/docs/java-sdk-reference) or our [code-generated API documentation](https://launchdarkly.github.io/java-server-sdk/). -Testing -------- +## Testing We run integration tests for all our SDKs using a centralized test harness. This approach gives us the ability to test for consistency across SDKs, as well as test networking behavior in a long-running application. These tests cover each method in the SDK, and verify that event sending, flag evaluation, stream reconnection, and other aspects of the SDK all behave correctly. -Contributing ------------- +## Contributing We encourage pull requests and other contributions from the community. Check out our [contributing guidelines](CONTRIBUTING.md) for instructions on how to contribute to this SDK. -About LaunchDarkly ------------ +## About LaunchDarkly * LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can: * Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases. diff --git a/build.gradle b/build.gradle index 4e40ec5bd..863ec9204 100644 --- a/build.gradle +++ b/build.gradle @@ -1,3 +1,6 @@ +import java.nio.file.Files +import java.nio.file.FileSystems +import java.nio.file.StandardCopyOption buildscript { repositories { @@ -30,6 +33,15 @@ repositories { mavenCentral() } +configurations { + commonClasses { + transitive false + } + commonDoc { + transitive false + } +} + configurations.all { // check for updates every build for dependencies with: 'changing: true' resolutionStrategy.cacheChangingModulesFor 0, 'seconds' @@ -39,13 +51,14 @@ allprojects { group = 'com.launchdarkly' version = "${version}" archivesBaseName = 'launchdarkly-java-server-sdk' - sourceCompatibility = 1.7 - targetCompatibility = 1.7 + sourceCompatibility = 1.8 + targetCompatibility = 1.8 } ext { - sdkBasePackage = "com.launchdarkly.client" - + sdkBasePackage = "com.launchdarkly.sdk" + 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. systemPackageImports = [ "javax.net", "javax.net.ssl" ] @@ -56,9 +69,10 @@ ext.libraries = [:] ext.versions = [ "commonsCodec": "1.10", "gson": "2.7", - "guava": "19.0", - "jodaTime": "2.9.3", - "okhttpEventsource": "1.11.0", + "guava": "28.2-jre", + "jackson": "2.10.0", + "launchdarklyJavaSdkCommon": "1.0.0-rc1", + "okhttpEventsource": "2.1.0", "slf4j": "1.7.21", "snakeyaml": "1.19", "jedis": "2.9.0" @@ -68,29 +82,31 @@ ext.versions = [ // will be completely omitted from the "thin" jar, and will be embedded with shaded names // in the other two SDK jars. libraries.internal = [ + "com.launchdarkly:launchdarkly-java-sdk-common:${versions.launchdarklyJavaSdkCommon}", "commons-codec:commons-codec:${versions.commonsCodec}", + "com.google.code.gson:gson:${versions.gson}", "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:${versions.gson}", "org.slf4j:slf4j-api:${versions.slf4j}" ] // Add dependencies to "libraries.test" that are used only in unit tests. libraries.test = [ - "com.squareup.okhttp3:mockwebserver:3.12.10", - "com.squareup.okhttp3:okhttp-tls:3.12.10", + // Note that the okhttp3 test deps must be kept in sync with the okhttp version used in okhttp-eventsource + "com.squareup.okhttp3:mockwebserver:4.5.0", + "com.squareup.okhttp3:okhttp-tls:4.5.0", "org.hamcrest:hamcrest-all:1.3", "org.easymock:easymock:3.4", "junit:junit:4.12", - "ch.qos.logback:logback-classic:1.1.7" + "ch.qos.logback:logback-classic:1.1.7", + "com.fasterxml.jackson.core:jackson-core:${versions.jackson}", + "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}" ] dependencies { @@ -98,6 +114,9 @@ dependencies { api libraries.external testImplementation libraries.test, libraries.internal, libraries.external + commonClasses "com.launchdarkly:launchdarkly-java-sdk-common:${versions.launchdarklyJavaSdkCommon}" + commonDoc "com.launchdarkly:launchdarkly-java-sdk-common:${versions.launchdarklyJavaSdkCommon}:sources" + // Unlike what the name might suggest, the "shadow" configuration specifies dependencies that // should *not* be shaded by the Shadow plugin when we build our shaded jars. shadow libraries.external @@ -111,7 +130,8 @@ configurations { } checkstyle { - configFile file("${project.rootDir}/checkstyle.xml") + configFile file("${project.rootDir}/config/checkstyle/checkstyle.xml") + configDir file("${project.rootDir}/config/checkstyle") } jar { @@ -119,6 +139,8 @@ jar { // but is opt-in since users will have to specify it. classifier = 'thin' + from configurations.commonClasses.collect { zipTree(it) } + // doFirst causes the following step to be run during Gradle's execution phase rather than the // configuration phase; this is necessary because it accesses the build products doFirst { @@ -135,7 +157,6 @@ shadowJar { dependencies { exclude(dependency('org.slf4j:.*:.*')) - exclude(dependency('com.google.code.gson:.*:.*')) } // doFirst causes the following steps to be run during Gradle's execution phase rather than the @@ -146,6 +167,10 @@ shadowJar { // objects with detailed information about the resolved dependencies. addOsgiManifest(project.tasks.shadowJar, [ project.configurations.shadow ], []) } + + doLast { + replaceUnshadedClasses(project.tasks.shadowJar) + } } // This builds the "-all"/"fat" jar, which is the same as the default uberjar except that @@ -167,6 +192,10 @@ task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJ // higher version if one is provided by another bundle. addOsgiManifest(project.tasks.shadowJarAll, [ project.configurations.shadow ], [ project.configurations.shadow ]) } + + doLast { + replaceUnshadedClasses(project.tasks.shadowJarAll) + } } task testJar(type: Jar, dependsOn: testClasses) { @@ -185,24 +214,33 @@ task javadocJar(type: Jar, dependsOn: javadoc) { from javadoc.destinationDir } +javadoc { + source configurations.commonDoc.collect { zipTree(it) } + include '**/*.java' + + // Use test classpath so Javadoc won't complain about java-sdk-common classes that internally + // reference stuff we don't use directly, like Jackson + classpath = sourceSets.test.compileClasspath +} // Force the Javadoc build to fail if there are any Javadoc warnings. See: https://discuss.gradle.org/t/javadoc-fail-on-warning/18141/3 if (JavaVersion.current().isJava8Compatible()) { - tasks.withType(Javadoc) { - // The '-quiet' as second argument is actually a hack, - // since the one paramater addStringOption doesn't seem to - // work, we extra add '-quiet', which is added anyway by - // gradle. See https://github.com/gradle/gradle/issues/2354 + tasks.withType(Javadoc) { + // The '-quiet' as second argument is actually a hack, + // since the one paramater addStringOption doesn't seem to + // work, we extra add '-quiet', which is added anyway by + // gradle. See https://github.com/gradle/gradle/issues/2354 // See JDK-8200363 (https://bugs.openjdk.java.net/browse/JDK-8200363) // for information about the -Xwerror option. - options.addStringOption('Xwerror', '-quiet') - } + options.addStringOption('Xwerror', '-quiet') + } } // Returns the names of all Java packages defined in this library - not including // enclosing packages like "com" that don't have any classes in them. def getAllSdkPackages() { - def names = [] + // base package classes come from launchdarkly-java-sdk-common + def names = [ "com.launchdarkly.sdk", "com.launchdarkly.sdk.json" ] project.convention.getPlugin(JavaPluginConvention).sourceSets.main.output.each { baseDir -> if (baseDir.getPath().contains("classes" + File.separator + "java" + File.separator + "main")) { baseDir.eachFileRecurse { f -> @@ -222,8 +260,8 @@ def getAllSdkPackages() { def getPackagesInDependencyJar(jarFile) { new java.util.zip.ZipFile(jarFile).withCloseable { zf -> zf.entries().findAll { !it.directory && it.name.endsWith(".class") }.collect { - it.name.substring(0, it.name.lastIndexOf("/")).replace("/", ".") - }.unique() + it.name.contains("/") ? it.name.substring(0, it.name.lastIndexOf("/")).replace("/", ".") : "" + }.findAll { !it.equals("") }.unique() } } @@ -235,24 +273,63 @@ def getPackagesInDependencyJar(jarFile) { // phase; instead we have to run it after configuration, with the "afterEvaluate" block below. def shadeDependencies(jarTask) { def excludePackages = getAllSdkPackages() + - configurations.shadow.collectMany { getPackagesInDependencyJar(it)} + configurations.shadow.collectMany { getPackagesInDependencyJar(it) } def topLevelPackages = configurations.internal.collectMany { getPackagesInDependencyJar(it).collect { it.contains(".") ? it.substring(0, it.indexOf(".")) : it } }. unique() topLevelPackages.forEach { top -> - jarTask.relocate(top, "com.launchdarkly.shaded." + top) { + // This special-casing for javax.annotation is hacky, but the issue is that Guava pulls in a jsr305 + // implementation jar that provides javax.annotation, and we *do* want to embed and shade those classes + // so that Guava won't fail to find them and they won't conflict with anyone else's version - but we do + // *not* want references to any classes from javax.net, javax.security, etc. to be munged. + def packageToRelocate = (top == "javax") ? "javax.annotation" : top + jarTask.relocate(packageToRelocate, "com.launchdarkly.shaded." + packageToRelocate) { excludePackages.forEach { exclude(it + ".*") } } } } +def replaceUnshadedClasses(jarTask) { + // The LDGson class is a special case where we do *not* want any of the Gson class names it uses to be + // modified by shading (because its purpose is to interoperate with a non-shaded instance of Gson). + // Shadow doesn't seem to provide a way to say "make this class file immune from the changes that result + // from shading *other* classes", so the workaround is to simply recopy the original class file. Note that + // we use a wildcard to make sure we also get any inner classes. + def protectedClassFilePattern = 'com/launchdarkly/sdk/json/LDGson*.class' + jarTask.exclude protectedClassFilePattern + def protectedClassFiles = configurations.commonClasses.collectMany { + zipTree(it).matching { + include protectedClassFilePattern + } getFiles() + } + def jarPath = jarTask.archiveFile.asFile.get().toPath() + FileSystems.newFileSystem(jarPath, null).withCloseable { fs -> + protectedClassFiles.forEach { classFile -> + def classSubpath = classFile.path.substring(classFile.path.indexOf("com/launchdarkly")) + Files.copy(classFile.toPath(), fs.getPath(classSubpath), StandardCopyOption.REPLACE_EXISTING) + } + } +} + +def getFileFromClasspath(config, filePath) { + def files = config.collectMany { + zipTree(it) matching { + include filePath + } getFiles() + } + if (files.size != 1) { + throw new RuntimeException("could not find " + filePath); + } + return files[0] +} + def addOsgiManifest(jarTask, List importConfigs, List exportConfigs) { jarTask.manifest { attributes( "Implementation-Version": version, - "Bundle-SymbolicName": "com.launchdarkly.client", + "Bundle-SymbolicName": "com.launchdarkly.sdk", "Bundle-Version": version, "Bundle-Name": "LaunchDarkly SDK", "Bundle-ManifestVersion": "2", @@ -262,10 +339,14 @@ def addOsgiManifest(jarTask, List importConfigs, List bundleImport(p, a.moduleVersion.id.version, nextMajorVersion(a.moduleVersion.id.version)) }) + systemPackageImports + imports += "com.google.gson;resolution:=optional" + imports += "com.google.gson.reflect;resolution:=optional" + imports += "com.google.gson.stream;resolution:=optional" attributes("Import-Package": imports.join(",")) // Similarly, we're adding package exports for every package in whatever libraries we're diff --git a/checkstyle.xml b/config/checkstyle/checkstyle.xml similarity index 73% rename from checkstyle.xml rename to config/checkstyle/checkstyle.xml index 0b201f9c0..a1d367afe 100644 --- a/checkstyle.xml +++ b/config/checkstyle/checkstyle.xml @@ -3,6 +3,10 @@ "-//Checkstyle//DTD Checkstyle Configuration 1.3//EN" "https://checkstyle.org/dtds/configuration_1_3.dtd"> + + + + diff --git a/config/checkstyle/suppressions.xml b/config/checkstyle/suppressions.xml new file mode 100644 index 000000000..1959e98eb --- /dev/null +++ b/config/checkstyle/suppressions.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/gradle.properties b/gradle.properties index 5d1025599..e4d9755c4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=4.13.0 +version=5.0.0-rc1 # 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= diff --git a/packaging-test/Makefile b/packaging-test/Makefile index ffc0bbe30..7548fda60 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -23,6 +23,7 @@ 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 +TEST_APP_SOURCES=$(shell find $(BASE_DIR)/test-app -name *.java) $(BASE_DIR)/test-app/build.gradle # Felix OSGi container export FELIX_DIR=$(TEMP_DIR)/felix @@ -34,26 +35,25 @@ export TEMP_BUNDLE_DIR=$(FELIX_DIR)/app-bundles # 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) +RUN_JARS_test-all-jar=$(TEST_APP_JAR) $(SDK_ALL_JAR) \ + $(shell ls $(TEMP_DIR)/dependencies-external/gson*.jar 2>/dev/null) RUN_JARS_test-default-jar=$(TEST_APP_JAR) $(SDK_DEFAULT_JAR) \ - $(shell ls $(TEMP_DIR)/dependencies-external/*.jar) + $(shell ls $(TEMP_DIR)/dependencies-external/*.jar 2>/dev/null) 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) - -# The test-app displays this message on success -export SUCCESS_MESSAGE=@@@ successfully created LD client @@@ + $(shell ls $(TEMP_DIR)/dependencies-internal/*.jar 2>/dev/null) \ + $(shell ls $(TEMP_DIR)/dependencies-external/*.jar 2>/dev/null) 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 -classes_should_not_contain=echo " should not contain $(2)" && ! grep $(1) $(TEMP_OUTPUT) >/dev/null +classes_should_contain=echo " should contain $(2)" && grep "^$(1)/[^/]*$$" $(TEMP_OUTPUT) >/dev/null +classes_should_not_contain=echo " should not contain $(2)" && ! grep "^$(1)/[^/]*$$" $(TEMP_OUTPUT) verify_sdk_classes= \ - $(call classes_should_contain,'^com/launchdarkly/client/[^/]*$$',com.launchdarkly.client) && \ + $(call classes_should_contain,com/launchdarkly/sdk,com.launchdarkly.sdk) && \ + $(call classes_should_contain,com/launchdarkly/sdk/json,com.launchdarkly.sdk.json) && \ $(foreach subpkg,$(sdk_subpackage_names), \ - $(call classes_should_contain,'^com/launchdarkly/client/$(subpkg)/',com.launchdarkly.client.$(subpkg)) && ) true + $(call classes_should_contain,com/launchdarkly/sdk/$(subpkg),com.launchdarkly.sdk.$(subst /,.,$(subpkg))) && ) true sdk_subpackage_names= \ - $(shell ls -d $(PROJECT_DIR)/src/main/java/com/launchdarkly/client/*/ | sed -e 's@^.*/\([^/]*\)/@\1@') + $(shell cd $(PROJECT_DIR)/src/main/java/com/launchdarkly/sdk && find . ! -path . -type d | sed -e 's@^\./@@') caption=echo "" && echo "$(1)" @@ -61,43 +61,42 @@ all: test-all-jar test-default-jar test-thin-jar clean: rm -rf $(TEMP_DIR)/* + rm -rf test-app/build # 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: $$@-classes get-sdk-dependencies $$(RUN_JARS_$$@) $(TEST_APP_JAR) $(FELIX_DIR) @$(call caption,$@) - ./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 \ - ./run-osgi-test.sh $(RUN_JARS_$@); \ - fi + @./run-non-osgi-test.sh $(RUN_JARS_$@) + @./run-osgi-test.sh $(RUN_JARS_$@) test-all-jar-classes: $(SDK_ALL_JAR) $(TEMP_DIR) @$(call caption,$@) @$(call classes_prepare,$<) @$(call verify_sdk_classes) - @$(call classes_should_contain,'^com/google/gson/',Gson (unshaded)) - @$(call classes_should_contain,'^org/slf4j/',SLF4j (unshaded)) - @$(call classes_should_contain,'^com/launchdarkly/shaded/',shaded dependency jars) - @$(call classes_should_not_contain,'^com/launchdarkly/shaded/com/launchdarkly/client',shaded SDK classes) - @$(call classes_should_not_contain,'^com/launchdarkly/shaded/com/google/gson',shaded Gson) - @$(call classes_should_not_contain,'^com/launchdarkly/shaded/org/slf4j',shaded SLF4j) + @$(call classes_should_contain,org/slf4j,unshaded SLF4j) + @$(call classes_should_not_contain,com/launchdarkly/shaded/com/launchdarkly/sdk,shaded SDK classes) + @$(call classes_should_contain,com/launchdarkly/shaded/com/google/gson,shaded Gson) + @$(call classes_should_not_contain,com/google/gson,unshaded Gson) + @$(call classes_should_not_contain,com/launchdarkly/shaded/org/slf4j,shaded SLF4j) test-default-jar-classes: $(SDK_DEFAULT_JAR) $(TEMP_DIR) @$(call caption,$@) @$(call classes_prepare,$<) @$(call verify_sdk_classes) - @$(call classes_should_contain,'^com/launchdarkly/shaded/',shaded dependency jars) - @$(call classes_should_not_contain,'^com/launchdarkly/shaded/com/launchdarkly/client',shaded SDK classes) - @$(call classes_should_not_contain,'com/google/gson/',Gson (shaded or unshaded)) - @$(call classes_should_not_contain,'org/slf4j/',SLF4j (shaded or unshaded)) + @$(call classes_should_not_contain,com/launchdarkly/shaded/com/launchdarkly/sdk,shaded SDK classes) + @$(call classes_should_contain,com/launchdarkly/shaded/com/google/gson,shaded Gson) + @$(call classes_should_not_contain,com/launchdarkly/shaded/org/slf4j,shaded SLF4j) + @$(call classes_should_not_contain,com/google/gson,unshaded Gson) + @$(call classes_should_not_contain,org/slf4j,unshaded SLF4j) test-thin-jar-classes: $(SDK_THIN_JAR) $(TEMP_DIR) @$(call caption,$@) @$(call classes_prepare,$<) @$(call verify_sdk_classes) - @$(call classes_should_not_contain,-v '^com/launchdarkly/client/',anything other than SDK classes) + @echo " should not contain anything other than SDK classes" + @! grep -v "^com/launchdarkly/sdk" $(TEMP_OUTPUT) $(SDK_DEFAULT_JAR): cd .. && ./gradlew shadowJar @@ -108,7 +107,7 @@ $(SDK_ALL_JAR): $(SDK_THIN_JAR): cd .. && ./gradlew jar -$(TEST_APP_JAR): $(SDK_THIN_JAR) | $(TEMP_DIR) +$(TEST_APP_JAR): $(SDK_THIN_JAR) $(TEST_APP_SOURCES) | $(TEMP_DIR) cd test-app && ../../gradlew jar cp $(BASE_DIR)/test-app/build/libs/test-app-*.jar $@ @@ -120,12 +119,13 @@ $(TEMP_DIR)/dependencies-all: | $(TEMP_DIR) $(TEMP_DIR)/dependencies-external: $(TEMP_DIR)/dependencies-all [ -d $@ ] || mkdir -p $@ - cp $(TEMP_DIR)/dependencies-all/gson*.jar $(TEMP_DIR)/dependencies-all/slf4j*.jar $@ + cp $(TEMP_DIR)/dependencies-all/slf4j*.jar $@ + cp $(TEMP_DIR)/dependencies-all/gson*.jar $@ $(TEMP_DIR)/dependencies-internal: $(TEMP_DIR)/dependencies-all [ -d $@ ] || mkdir -p $@ cp $(TEMP_DIR)/dependencies-all/*.jar $@ - rm $@/gson*.jar $@/slf4j*.jar + rm $@/slf4j*.jar $(FELIX_JAR): $(FELIX_DIR) diff --git a/packaging-test/run-non-osgi-test.sh b/packaging-test/run-non-osgi-test.sh index dcf9c24db..49b8d953d 100755 --- a/packaging-test/run-non-osgi-test.sh +++ b/packaging-test/run-non-osgi-test.sh @@ -1,6 +1,36 @@ #!/bin/bash +function run_test() { + rm -f ${TEMP_OUTPUT} + touch ${TEMP_OUTPUT} + classpath=$(echo "$@" | sed -e 's/ /:/g') + java -classpath "$classpath" testapp.TestApp | tee ${TEMP_OUTPUT} + grep "TestApp: PASS" ${TEMP_OUTPUT} >/dev/null +} + +echo "" +echo " non-OSGi runtime test - with Gson" +run_test $@ +grep "LDGson tests OK" ${TEMP_OUTPUT} >/dev/null || (echo "FAIL: should have run LDGson tests but did not" && exit 1) + +# It does not make sense to test the "thin" jar without Gson. The SDK uses Gson internally +# and can't work without it; in the default jar and the "all" jar, it has its own embedded +# copy of Gson, but the "thin" jar does not include any third-party dependencies so you must +# provide all of them including Gson. +thin_sdk_regex=".*launchdarkly-java-server-sdk-[^ ]*-thin\\.jar" +if [[ "$@" =~ $thin_sdk_regex ]]; then + exit 0 +fi + 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 +echo " non-OSGi runtime test - without Gson" +deps_except_json="" +json_jar_regex=".*gson.*" +for dep in $@; do + if [[ ! "$dep" =~ $json_jar_regex ]]; then + deps_except_json="$deps_except_json $dep" + fi +done +run_test $deps_except_json +grep "skipping LDGson tests" ${TEMP_OUTPUT} >/dev/null || \ + (echo "FAIL: should have skipped LDGson tests but did not; test setup was incorrect" && exit 1) diff --git a/packaging-test/run-osgi-test.sh b/packaging-test/run-osgi-test.sh index 62439fedf..df5f69739 100755 --- a/packaging-test/run-osgi-test.sh +++ b/packaging-test/run-osgi-test.sh @@ -1,14 +1,32 @@ #!/bin/bash -echo "" -echo " OSGi runtime test" +# We can't test the "thin" jar in OSGi, because some of our third-party dependencies +# aren't available as OSGi bundles. That isn't a plausible use case anyway. +thin_sdk_regex=".*launchdarkly-java-server-sdk-[^ ]*-thin\\.jar" +if [[ "$@" =~ $thin_sdk_regex ]]; then + exit 0 +fi + 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} +function run_test() { + 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 "TestApp: PASS" ${TEMP_OUTPUT} >/dev/null +} -grep "${SUCCESS_MESSAGE}" ${TEMP_OUTPUT} >/dev/null +echo "" +echo " OSGi runtime test - with Gson" +cp $@ ${FELIX_BASE_BUNDLE_DIR}/* ${TEMP_BUNDLE_DIR} +run_test +grep "LDGson tests OK" ${TEMP_OUTPUT} >/dev/null || (echo "FAIL: should have run LDGson tests but did not" && exit 1) + +echo "" +echo " OSGi runtime test - without Gson" +rm ${TEMP_BUNDLE_DIR}/*gson*.jar +run_test +grep "skipping LDGson tests" ${TEMP_OUTPUT} >/dev/null || \ + (echo "FAIL: should have skipped LDGson tests but did not; test setup was incorrect" && exit 1) diff --git a/packaging-test/test-app/build.gradle b/packaging-test/test-app/build.gradle index 59d3fd936..9673b74eb 100644 --- a/packaging-test/test-app/build.gradle +++ b/packaging-test/test-app/build.gradle @@ -36,7 +36,11 @@ dependencies { jar { bnd( - 'Bundle-Activator': 'testapp.TestAppOsgiEntryPoint' + 'Bundle-Activator': 'testapp.TestAppOsgiEntryPoint', + 'Import-Package': 'com.launchdarkly.sdk,com.launchdarkly.sdk.json' + + ',com.launchdarkly.sdk.server,org.slf4j' + + ',org.osgi.framework' + + ',com.google.gson;resolution:=optional' ) } diff --git a/packaging-test/test-app/src/main/java/com/launchdarkly/shaded/com/newrelic/api/agent/NewRelic.java b/packaging-test/test-app/src/main/java/com/launchdarkly/shaded/com/newrelic/api/agent/NewRelic.java deleted file mode 100644 index 83bae9f3b..000000000 --- a/packaging-test/test-app/src/main/java/com/launchdarkly/shaded/com/newrelic/api/agent/NewRelic.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.launchdarkly.shaded.com.newrelic.api.agent; - -// Test to verify fix for https://github.com/launchdarkly/java-server-sdk/issues/171 -public class NewRelic { - public static void addCustomParameter(String name, String value) { - System.out.println("NewRelic class reference was shaded! Test app loaded " + NewRelic.class.getName()); - System.exit(1); // forces test failure - } -} diff --git a/packaging-test/test-app/src/main/java/com/newrelic/api/agent/NewRelic.java b/packaging-test/test-app/src/main/java/com/newrelic/api/agent/NewRelic.java deleted file mode 100644 index 5b106c460..000000000 --- a/packaging-test/test-app/src/main/java/com/newrelic/api/agent/NewRelic.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.newrelic.api.agent; - -// Test to verify fix for https://github.com/launchdarkly/java-server-sdk/issues/171 -public class NewRelic { - public static void addCustomParameter(String name, String value) { - System.out.println("NewRelic class reference was correctly resolved without shading"); - } -} diff --git a/packaging-test/test-app/src/main/java/testapp/JsonSerializationTestData.java b/packaging-test/test-app/src/main/java/testapp/JsonSerializationTestData.java new file mode 100644 index 000000000..8bd8a9493 --- /dev/null +++ b/packaging-test/test-app/src/main/java/testapp/JsonSerializationTestData.java @@ -0,0 +1,44 @@ +package testapp; + +import com.launchdarkly.sdk.*; +import java.util.*; + +public class JsonSerializationTestData { + public static class TestItem { + final Object objectToSerialize; + final String expectedJson; + + private TestItem(Object objectToSerialize, String expectedJson) { + this.objectToSerialize = objectToSerialize; + this.expectedJson = expectedJson; + } + } + + public static TestItem[] TEST_ITEMS = new TestItem[] { + new TestItem( + LDValue.buildArray().add(1).add(2).build(), + "[1,2]" + ), + new TestItem( + Collections.singletonMap("value", LDValue.buildArray().add(1).add(2).build()), + "{\"value\":[1,2]}" + ), + new TestItem( + EvaluationReason.off(), + "{\"kind\":\"OFF\"}" + ), + new TestItem( + new LDUser.Builder("userkey").build(), + "{\"key\":\"userkey\"}" + ) + }; + + public static boolean assertJsonEquals(String expectedJson, String actualJson, Object objectToSerialize) { + if (!LDValue.parse(actualJson).equals(LDValue.parse(expectedJson))) { + TestApp.addError("JSON encoding of " + objectToSerialize.getClass() + " should have been " + + expectedJson + ", was " + actualJson, null); + return false; + } + return true; + } +} \ No newline at end of file diff --git a/packaging-test/test-app/src/main/java/testapp/TestApp.java b/packaging-test/test-app/src/main/java/testapp/TestApp.java index bfec1bfdb..034852cbe 100644 --- a/packaging-test/test-app/src/main/java/testapp/TestApp.java +++ b/packaging-test/test-app/src/main/java/testapp/TestApp.java @@ -1,37 +1,74 @@ package testapp; -import com.launchdarkly.client.*; -import com.launchdarkly.client.integrations.*; -import com.google.gson.*; +import com.launchdarkly.sdk.*; +import com.launchdarkly.sdk.json.*; +import com.launchdarkly.sdk.server.*; +import java.util.*; import org.slf4j.*; public class TestApp { - private static final Logger logger = LoggerFactory.getLogger(TestApp.class); + private static final Logger logger = LoggerFactory.getLogger(TestApp.class); // proves SLF4J API is on classpath + + private static List errors = new ArrayList<>(); public static void main(String[] args) throws Exception { - // Verify that our Redis URI constant is what it should be (test for ch63221) - if (!RedisDataStoreBuilder.DEFAULT_URI.toString().equals("redis://localhost:6379")) { - System.out.println("*** error: RedisDataStoreBuilder.DEFAULT_URI is " + RedisDataStoreBuilder.DEFAULT_URI); - System.exit(1); + try { + LDConfig config = new LDConfig.Builder() + .offline(true) + .build(); + LDClient client = new LDClient("fake-sdk-key", config); + log("client creation OK"); + } catch (RuntimeException e) { + addError("client creation failed", e); } - if (!RedisFeatureStoreBuilder.DEFAULT_URI.toString().equals("redis://localhost:6379")) { - System.out.println("*** error: RedisFeatureStoreBuilder.DEFAULT_URI is " + RedisFeatureStoreBuilder.DEFAULT_URI); - System.exit(1); + + try { + boolean jsonOk = true; + for (JsonSerializationTestData.TestItem item: JsonSerializationTestData.TEST_ITEMS) { + if (!(item instanceof JsonSerializable)) { + continue; // things without our marker interface, like a Map, can't be passed to JsonSerialization.serialize + } + String actualJson = JsonSerialization.serialize((JsonSerializable)item.objectToSerialize); + if (!JsonSerializationTestData.assertJsonEquals(item.expectedJson, actualJson, item.objectToSerialize)) { + jsonOk = false; + } + } + if (jsonOk) { + log("JsonSerialization tests OK"); + } + } catch (RuntimeException e) { + addError("unexpected error in JsonSerialization tests", e); } - LDConfig config = new LDConfig.Builder() - .offline(true) - .build(); - LDClient client = new LDClient("fake-sdk-key", config); + try { + Class.forName("testapp.TestAppGsonTests"); // see TestAppGsonTests for why we're loading it in this way + } catch (NoClassDefFoundError e) { + log("skipping LDGson tests because Gson is not in the classpath"); + } catch (RuntimeException e) { + addError("unexpected error in LDGson tests", e); + } - // The following line is just for the sake of referencing Gson, so we can be sure - // that it's on the classpath as it should be (i.e. if we're using the "all" jar - // that provides its own copy of Gson). - JsonPrimitive x = new JsonPrimitive("x"); + if (errors.isEmpty()) { + log("PASS"); + } else { + for (String err: errors) { + log("ERROR: " + err); + } + log("FAIL"); + System.exit(1); + } + } - // Also do a flag evaluation, to ensure that it calls NewRelicReflector.annotateTransaction() - client.boolVariation("flag-key", new LDUser("user-key"), false); + public static void addError(String message, Throwable e) { + if (e != null) { + errors.add(message + ": " + e); + e.printStackTrace(); + } else { + errors.add(message); + } + } - System.out.println("@@@ successfully created LD client @@@"); + public static void log(String message) { + System.out.println("TestApp: " + message); } } \ No newline at end of file diff --git a/packaging-test/test-app/src/main/java/testapp/TestAppGsonTests.java b/packaging-test/test-app/src/main/java/testapp/TestAppGsonTests.java new file mode 100644 index 000000000..1ea44e03c --- /dev/null +++ b/packaging-test/test-app/src/main/java/testapp/TestAppGsonTests.java @@ -0,0 +1,42 @@ +package testapp; + +import com.google.gson.*; +import com.launchdarkly.sdk.*; +import com.launchdarkly.sdk.json.*; + +// This code is in its own class that is loaded dynamically because some of our test scenarios +// involve running TestApp without having Gson in the classpath, to make sure the SDK does not +// *require* the presence of an external Gson even though it can interoperate with one. + +public class TestAppGsonTests { + // Use static block so simply loading this class causes the tests to execute + static { + // First try referencing Gson, so we fail right away if it's not on the classpath + Class c = Gson.class; + try { + runGsonTests(); + } catch (NoClassDefFoundError e) { + // If we've even gotten to this static block, then Gson itself *is* on the application's + // classpath, so this must be some other kind of classloading error that we do want to + // report. For instance, a NoClassDefFound error for Gson at this point, if we're in + // OSGi, would mean that the SDK bundle is unable to see the external Gson classes. + TestApp.addError("unexpected error in LDGson tests", e); + } + } + + public static void runGsonTests() { + Gson gson = new GsonBuilder().registerTypeAdapterFactory(LDGson.typeAdapters()).create(); + + boolean ok = true; + for (JsonSerializationTestData.TestItem item: JsonSerializationTestData.TEST_ITEMS) { + String actualJson = gson.toJson(item.objectToSerialize); + if (!JsonSerializationTestData.assertJsonEquals(item.expectedJson, actualJson, item.objectToSerialize)) { + ok = false; + } + } + + if (ok) { + TestApp.log("LDGson tests OK"); + } + } +} \ No newline at end of file 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 ed42ccb1a..65602cd29 100644 --- a/packaging-test/test-app/src/main/java/testapp/TestAppOsgiEntryPoint.java +++ b/packaging-test/test-app/src/main/java/testapp/TestAppOsgiEntryPoint.java @@ -5,7 +5,7 @@ public class TestAppOsgiEntryPoint implements BundleActivator { public void start(BundleContext context) throws Exception { - System.out.println("@@@ starting test bundle @@@"); + System.out.println("TestApp: starting test bundle"); TestApp.main(new String[0]); diff --git a/src/main/java/com/launchdarkly/client/Clause.java b/src/main/java/com/launchdarkly/client/Clause.java deleted file mode 100644 index 8efaefd0d..000000000 --- a/src/main/java/com/launchdarkly/client/Clause.java +++ /dev/null @@ -1,110 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.value.LDValue; -import com.launchdarkly.client.value.LDValueType; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Collection; -import java.util.List; - -import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; - -class Clause { - private final static Logger logger = LoggerFactory.getLogger(Clause.class); - - private String attribute; - private Operator op; - private List values; //interpreted as an OR of values - private boolean negate; - - public Clause() { - } - - public Clause(String attribute, Operator op, List values, boolean negate) { - this.attribute = attribute; - this.op = op; - this.values = values; - this.negate = negate; - } - - String getAttribute() { - return attribute; - } - - Operator getOp() { - return op; - } - - Collection getValues() { - return values; - } - - boolean isNegate() { - return negate; - } - - boolean matchesUserNoSegments(LDUser user) { - LDValue userValue = user.getValueForEvaluation(attribute); - if (userValue.isNull()) { - return false; - } - - if (userValue.getType() == LDValueType.ARRAY) { - for (LDValue value: userValue.values()) { - if (value.getType() == LDValueType.ARRAY || value.getType() == LDValueType.OBJECT) { - logger.error("Invalid custom attribute value in user object for user key \"{}\": {}", user.getKey(), value); - return false; - } - if (matchAny(value)) { - return maybeNegate(true); - } - } - return maybeNegate(false); - } else if (userValue.getType() != LDValueType.OBJECT) { - return maybeNegate(matchAny(userValue)); - } - logger.warn("Got unexpected user attribute type \"{}\" for user key \"{}\" and attribute \"{}\"", - userValue.getType(), user.getKey(), attribute); - return false; - } - - boolean matchesUser(FeatureStore store, LDUser user) { - // In the case of a segment match operator, we check if the user is in any of the segments, - // and possibly negate - if (op == Operator.segmentMatch) { - for (LDValue j: values) { - if (j.isString()) { - Segment segment = store.get(SEGMENTS, j.stringValue()); - if (segment != null) { - if (segment.matchesUser(user)) { - return maybeNegate(true); - } - } - } - } - return maybeNegate(false); - } - - return matchesUserNoSegments(user); - } - - private boolean matchAny(LDValue userValue) { - if (op != null) { - for (LDValue v : values) { - if (op.apply(userValue, v)) { - return true; - } - } - } - return false; - } - - private boolean maybeNegate(boolean b) { - if (negate) - return !b; - else - return b; - } -} \ 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 deleted file mode 100644 index f446eb8e6..000000000 --- a/src/main/java/com/launchdarkly/client/Components.java +++ /dev/null @@ -1,757 +0,0 @@ -package com.launchdarkly.client; - -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. - *

- * Some of the configuration options in {@link LDConfig.Builder} affect the entire SDK, but others are - * specific to one area of functionality, such as how the SDK receives feature flag updates or processes - * analytics events. For the latter, the standard way to specify a configuration is to call one of the - * static methods in {@link Components} (such as {@link #streamingDataSource()}), apply any desired - * configuration change to the object that that method returns (such as {@link StreamingDataSourceBuilder#initialReconnectDelayMillis(long)}, - * and then use the corresponding method in {@link LDConfig.Builder} (such as {@link LDConfig.Builder#dataSource(UpdateProcessorFactory)}) - * to use that configured component in the SDK. - * - * @since 4.0.0 - */ -@SuppressWarnings("deprecation") -public abstract class Components { - private static final FeatureStoreFactory inMemoryFeatureStoreFactory = new InMemoryFeatureStoreFactory(); - private static final EventProcessorFactory defaultEventProcessorFactory = new DefaultEventProcessorFactory(); - private static final EventProcessorFactory nullEventProcessorFactory = new NullEventProcessorFactory(); - private static final UpdateProcessorFactory defaultUpdateProcessorFactory = new DefaultUpdateProcessorFactory(); - private static final NullUpdateProcessorFactory nullUpdateProcessorFactory = new NullUpdateProcessorFactory(); - - /** - * Returns a configuration object for using the default in-memory implementation of a data store. - *

- * Since it is the default, you do not normally need to call this method, unless you need to create - * a data store instance for testing purposes. - *

- * Note that the interface is still named {@link FeatureStoreFactory}, but in a future version it - * will be renamed to {@code DataStoreFactory}. - * - * @return a factory object - * @see LDConfig.Builder#dataStore(FeatureStoreFactory) - * @since 4.12.0 - */ - public static FeatureStoreFactory inMemoryDataStore() { - return inMemoryFeatureStoreFactory; - } - - /** - * Returns a configuration builder for some implementation of a persistent data store. - *

- * This method is used in conjunction with another factory object provided by specific components - * such as the Redis integration. The latter provides builder methods for options that are specific - * to that integration, while the {@link PersistentDataStoreBuilder} provides options that are - * applicable to any persistent data store (such as caching). For example: - * - *


-   *     LDConfig config = new LDConfig.Builder()
-   *         .dataStore(
-   *             Components.persistentDataStore(
-   *                 Redis.dataStore().url("redis://my-redis-host")
-   *             ).cacheSeconds(15)
-   *         )
-   *         .build();
-   * 
- * - * See {@link PersistentDataStoreBuilder} for more on how this method is used. - * - * @param storeFactory the factory/builder for the specific kind of persistent data store - * @return a {@link PersistentDataStoreBuilder} - * @see LDConfig.Builder#dataStore(FeatureStoreFactory) - * @see com.launchdarkly.client.integrations.Redis - * @since 4.12.0 - */ - public static PersistentDataStoreBuilder persistentDataStore(PersistentDataStoreFactory storeFactory) { - return new PersistentDataStoreBuilderImpl(storeFactory); - } - - /** - * Deprecated name for {@link #inMemoryDataStore()}. - * @return a factory object - * @deprecated Use {@link #inMemoryDataStore()}. - */ - @Deprecated - public static FeatureStoreFactory inMemoryFeatureStore() { - return inMemoryFeatureStoreFactory; - } - - /** - * Deprecated name for {@link com.launchdarkly.client.integrations.Redis#dataStore()}. - * @return a factory/builder object - * @deprecated Use {@link #persistentDataStore(PersistentDataStoreFactory)} with - * {@link com.launchdarkly.client.integrations.Redis#dataStore()}. - */ - @Deprecated - public static RedisFeatureStoreBuilder redisFeatureStore() { - return new RedisFeatureStoreBuilder(); - } - - /** - * Deprecated name for {@link com.launchdarkly.client.integrations.Redis#dataStore()}. - * @param redisUri the URI of the Redis host - * @return a factory/builder object - * @deprecated Use {@link #persistentDataStore(PersistentDataStoreFactory)} with - * {@link com.launchdarkly.client.integrations.Redis#dataStore()} and - * {@link com.launchdarkly.client.integrations.RedisDataStoreBuilder#uri(URI)}. - */ - @Deprecated - public static RedisFeatureStoreBuilder redisFeatureStore(URI redisUri) { - return new RedisFeatureStoreBuilder(redisUri); - } - - /** - * Returns a configuration builder for analytics event delivery. - *

- * The default configuration has events enabled with default settings. If you want to - * customize this behavior, call this method to obtain a builder, change its properties - * with the {@link EventProcessorBuilder} properties, and pass it to {@link LDConfig.Builder#events(EventProcessorFactory)}: - *


-   *     LDConfig config = new LDConfig.Builder()
-   *         .events(Components.sendEvents().capacity(5000).flushIntervalSeconds(2))
-   *         .build();
-   * 
- * To completely disable sending analytics events, use {@link #noEvents()} instead. - * - * @return a builder for setting streaming connection properties - * @see #noEvents() - * @see LDConfig.Builder#events - * @since 4.12.0 - */ - public static EventProcessorBuilder sendEvents() { - return new EventProcessorBuilderImpl(); - } - - /** - * Deprecated method for using the default analytics events implementation. - *

- * If you pass the return value of this method to {@link LDConfig.Builder#events(EventProcessorFactory)}, - * the behavior is as follows: - *

    - *
  • If you have set {@link LDConfig.Builder#offline(boolean)} to {@code true}, or - * {@link LDConfig.Builder#sendEvents(boolean)} to {@code false}, the SDK will not send events to - * LaunchDarkly. - *
  • Otherwise, it will send events, using the properties set by the deprecated events configuration - * methods such as {@link LDConfig.Builder#capacity(int)}. - *
- * - * @return a factory object - * @deprecated Use {@link #sendEvents()} or {@link #noEvents}. - */ - @Deprecated - public static EventProcessorFactory defaultEventProcessor() { - return defaultEventProcessorFactory; - } - - /** - * Returns a configuration object that disables analytics events. - *

- * Passing this to {@link LDConfig.Builder#events(EventProcessorFactory)} causes the SDK - * to discard all analytics events and not send them to LaunchDarkly, regardless of any other configuration. - *


-   *     LDConfig config = new LDConfig.Builder()
-   *         .events(Components.noEvents())
-   *         .build();
-   * 
- * - * @return a factory object - * @see #sendEvents() - * @see LDConfig.Builder#events(EventProcessorFactory) - * @since 4.12.0 - */ - public static EventProcessorFactory noEvents() { - return nullEventProcessorFactory; - } - - /** - * Deprecated name for {@link #noEvents()}. - * @return a factory object - * @see LDConfig.Builder#events(EventProcessorFactory) - * @deprecated Use {@link #noEvents()}. - */ - @Deprecated - public static EventProcessorFactory nullEventProcessor() { - return nullEventProcessorFactory; - } - - /** - * Returns a configurable factory for using streaming mode to get feature flag data. - *

- * By default, the SDK uses a streaming connection to receive feature flag data from LaunchDarkly. To use the - * default behavior, you do not need to call this method. However, if you want to customize the behavior of - * the connection, call this method to obtain a builder, change its properties with the - * {@link StreamingDataSourceBuilder} methods, and pass it to {@link LDConfig.Builder#dataSource(UpdateProcessorFactory)}: - *

 
-   *     LDConfig config = new LDConfig.Builder()
-   *         .dataSource(Components.streamingDataSource().initialReconnectDelayMillis(500))
-   *         .build();
-   * 
- *

- * These properties will override any equivalent deprecated properties that were set with {@code LDConfig.Builder}, - * such as {@link LDConfig.Builder#reconnectTimeMs(long)}. - *

- * (Note that the interface is still named {@link UpdateProcessorFactory}, but in a future version it - * will be renamed to {@code DataSourceFactory}.) - * - * @return a builder for setting streaming connection properties - * @see LDConfig.Builder#dataSource(UpdateProcessorFactory) - * @since 4.12.0 - */ - public static StreamingDataSourceBuilder streamingDataSource() { - return new StreamingDataSourceBuilderImpl(); - } - - /** - * Returns a configurable factory for using polling mode to get feature flag data. - *

- * This is not the default behavior; by default, the SDK uses a streaming connection to receive feature flag - * data from LaunchDarkly. In polling mode, the SDK instead makes a new HTTP request to LaunchDarkly at regular - * intervals. HTTP caching allows it to avoid redundantly downloading data if there have been no changes, but - * polling is still less efficient than streaming and should only be used on the advice of LaunchDarkly support. - *

- * To use polling mode, call this method to obtain a builder, change its properties with the - * {@link PollingDataSourceBuilder} methods, and pass it to {@link LDConfig.Builder#dataSource(UpdateProcessorFactory)}: - *


-   *     LDConfig config = new LDConfig.Builder()
-   *         .dataSource(Components.pollingDataSource().pollIntervalMillis(45000))
-   *         .build();
-   * 
- *

- * These properties will override any equivalent deprecated properties that were set with {@code LDConfig.Builder}, - * such as {@link LDConfig.Builder#pollingIntervalMillis(long)}. However, setting {@link LDConfig.Builder#offline(boolean)} - * to {@code true} will supersede this setting and completely disable network requests. - *

- * (Note that the interface is still named {@link UpdateProcessorFactory}, but in a future version it - * will be renamed to {@code DataSourceFactory}.) - * - * @return a builder for setting polling properties - * @see LDConfig.Builder#dataSource(UpdateProcessorFactory) - * @since 4.12.0 - */ - public static PollingDataSourceBuilder pollingDataSource() { - return new PollingDataSourceBuilderImpl(); - } - - /** - * Deprecated method for using the default data source implementation. - *

- * If you pass the return value of this method to {@link LDConfig.Builder#dataSource(UpdateProcessorFactory)}, - * the behavior is as follows: - *

    - *
  • If you have set {@link LDConfig.Builder#offline(boolean)} or {@link LDConfig.Builder#useLdd(boolean)} - * to {@code true}, the SDK will not connect to LaunchDarkly for feature flag data. - *
  • If you have set {@link LDConfig.Builder#stream(boolean)} to {@code false}, it will use polling mode-- - * equivalent to using {@link #pollingDataSource()} with the options set by {@link LDConfig.Builder#baseURI(URI)} - * and {@link LDConfig.Builder#pollingIntervalMillis(long)}. - *
  • Otherwise, it will use streaming mode-- equivalent to using {@link #streamingDataSource()} with - * the options set by {@link LDConfig.Builder#streamURI(URI)} and {@link LDConfig.Builder#reconnectTimeMs(long)}. - *
- * - * @return a factory object - * @deprecated Use {@link #streamingDataSource()}, {@link #pollingDataSource()}, or {@link #externalUpdatesOnly()}. - */ - @Deprecated - public static UpdateProcessorFactory defaultUpdateProcessor() { - return defaultUpdateProcessorFactory; - } - - /** - * Returns a configuration object that disables a direct connection with LaunchDarkly for feature flag updates. - *

- * Passing this to {@link LDConfig.Builder#dataSource(UpdateProcessorFactory)} causes the SDK - * not to retrieve feature flag data from LaunchDarkly, regardless of any other configuration. - * This is normally done if you are using the Relay Proxy - * in "daemon mode", where an external process-- the Relay Proxy-- connects to LaunchDarkly and populates - * a persistent data store with the feature flag data. The data store could also be populated by - * another process that is running the LaunchDarkly SDK. If there is no external process updating - * the data store, then the SDK will not have any feature flag data and will return application - * default values only. - *


-   *     LDConfig config = new LDConfig.Builder()
-   *         .dataSource(Components.externalUpdatesOnly())
-   *         .dataStore(Components.persistentDataStore(Redis.dataStore())) // assuming the Relay Proxy is using Redis
-   *         .build();
-   * 
- *

- * (Note that the interface is still named {@link UpdateProcessorFactory}, but in a future version it - * will be renamed to {@code DataSourceFactory}.) - * - * @return a factory object - * @since 4.12.0 - * @see LDConfig.Builder#dataSource(UpdateProcessorFactory) - */ - public static UpdateProcessorFactory externalUpdatesOnly() { - return nullUpdateProcessorFactory; - } - - /** - * Deprecated name for {@link #externalUpdatesOnly()}. - * @return a factory object - * @deprecated Use {@link #externalUpdatesOnly()}. - */ - @Deprecated - 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() { - return new InMemoryFeatureStore(); - } - - @Override - public LDValue describeConfiguration(LDConfig config) { - return LDValue.of("memory"); - } - } - - // This can be removed once the deprecated event config options have been removed. - private static final class DefaultEventProcessorFactory implements EventProcessorFactoryWithDiagnostics, DiagnosticDescription { - @Override - public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { - return createEventProcessor(sdkKey, config, null); - } - - public EventProcessor createEventProcessor(String sdkKey, LDConfig config, - DiagnosticAccumulator diagnosticAccumulator) { - if (config.offline || !config.deprecatedSendEvents) { - return new NullEventProcessor(); - } - return new DefaultEventProcessor(sdkKey, - config, - new EventsConfiguration( - config.deprecatedAllAttributesPrivate, - config.deprecatedCapacity, - config.deprecatedEventsURI == null ? LDConfig.DEFAULT_EVENTS_URI : config.deprecatedEventsURI, - config.deprecatedFlushInterval, - config.deprecatedInlineUsersInEvents, - config.deprecatedPrivateAttrNames, - config.deprecatedSamplingInterval, - config.deprecatedUserKeysCapacity, - config.deprecatedUserKeysFlushInterval, - EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS - ), - config.httpConfig, - diagnosticAccumulator - ); - } - - @Override - public LDValue describeConfiguration(LDConfig config) { - return LDValue.buildObject() - .put(ConfigProperty.ALL_ATTRIBUTES_PRIVATE.name, config.deprecatedAllAttributesPrivate) - .put(ConfigProperty.CUSTOM_EVENTS_URI.name, config.deprecatedEventsURI != null && - !config.deprecatedEventsURI.equals(LDConfig.DEFAULT_EVENTS_URI)) - .put(ConfigProperty.DIAGNOSTIC_RECORDING_INTERVAL_MILLIS.name, - EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS * 1000) // not configurable via deprecated API - .put(ConfigProperty.EVENTS_CAPACITY.name, config.deprecatedCapacity) - .put(ConfigProperty.EVENTS_FLUSH_INTERVAL_MILLIS.name, config.deprecatedFlushInterval * 1000) - .put(ConfigProperty.INLINE_USERS_IN_EVENTS.name, config.deprecatedInlineUsersInEvents) - .put(ConfigProperty.SAMPLING_INTERVAL.name, config.deprecatedSamplingInterval) - .put(ConfigProperty.USER_KEYS_CAPACITY.name, config.deprecatedUserKeysCapacity) - .put(ConfigProperty.USER_KEYS_FLUSH_INTERVAL_MILLIS.name, config.deprecatedUserKeysFlushInterval * 1000) - .build(); - } - } - - private static final class NullEventProcessorFactory implements EventProcessorFactory { - public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { - return new NullEventProcessor(); - } - } - - static final class NullEventProcessor implements EventProcessor { - @Override - public void sendEvent(Event e) { - } - - @Override - public void flush() { - } - - @Override - public void close() { - } - } - - // This can be removed once the deprecated polling/streaming config options have been removed. - private static final class DefaultUpdateProcessorFactory implements UpdateProcessorFactoryWithDiagnostics, - DiagnosticDescription { - @Override - public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { - return createUpdateProcessor(sdkKey, config, featureStore, null); - } - - @Override - public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore, - DiagnosticAccumulator diagnosticAccumulator) { - if (config.offline) { - return Components.externalUpdatesOnly().createUpdateProcessor(sdkKey, config, featureStore); - } - // We don't need to check config.offline or config.useLdd here; the former is checked automatically - // by StreamingDataSourceBuilder and PollingDataSourceBuilder, and setting the latter is translated - // into using externalUpdatesOnly() by LDConfig.Builder. - if (config.deprecatedStream) { - StreamingDataSourceBuilderImpl builder = (StreamingDataSourceBuilderImpl)streamingDataSource() - .baseURI(config.deprecatedStreamURI) - .pollingBaseURI(config.deprecatedBaseURI) - .initialReconnectDelayMillis(config.deprecatedReconnectTimeMs); - return builder.createUpdateProcessor(sdkKey, config, featureStore, diagnosticAccumulator); - } else { - return pollingDataSource() - .baseURI(config.deprecatedBaseURI) - .pollIntervalMillis(config.deprecatedPollingIntervalMillis) - .createUpdateProcessor(sdkKey, config, featureStore); - } - } - - @Override - public LDValue describeConfiguration(LDConfig config) { - if (config.offline) { - return nullUpdateProcessorFactory.describeConfiguration(config); - } - if (config.deprecatedStream) { - return LDValue.buildObject() - .put(ConfigProperty.STREAMING_DISABLED.name, false) - .put(ConfigProperty.CUSTOM_BASE_URI.name, - config.deprecatedBaseURI != null && !config.deprecatedBaseURI.equals(LDConfig.DEFAULT_BASE_URI)) - .put(ConfigProperty.CUSTOM_STREAM_URI.name, - config.deprecatedStreamURI != null && !config.deprecatedStreamURI.equals(LDConfig.DEFAULT_STREAM_URI)) - .put(ConfigProperty.RECONNECT_TIME_MILLIS.name, config.deprecatedReconnectTimeMs) - .put(ConfigProperty.USING_RELAY_DAEMON.name, false) - .build(); - } else { - return LDValue.buildObject() - .put(ConfigProperty.STREAMING_DISABLED.name, true) - .put(ConfigProperty.CUSTOM_BASE_URI.name, - config.deprecatedBaseURI != null && !config.deprecatedBaseURI.equals(LDConfig.DEFAULT_BASE_URI)) - .put(ConfigProperty.CUSTOM_STREAM_URI.name, false) - .put(ConfigProperty.POLLING_INTERVAL_MILLIS.name, config.deprecatedPollingIntervalMillis) - .put(ConfigProperty.USING_RELAY_DAEMON.name, false) - .build(); - } - } - - } - - private static final class NullUpdateProcessorFactory implements UpdateProcessorFactory, DiagnosticDescription { - @Override - public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { - if (config.offline) { - // If they have explicitly called offline(true) to disable everything, we'll log this slightly - // more specific message. - LDClient.logger.info("Starting LaunchDarkly client in offline mode"); - } else { - LDClient.logger.info("LaunchDarkly client will not connect to Launchdarkly for feature flag data"); - } - return new NullUpdateProcessor(); - } - - @Override - public LDValue describeConfiguration(LDConfig config) { - // We can assume that if they don't have a data source, and they *do* have a persistent data store, then - // they're using Relay in daemon mode. - return LDValue.buildObject() - .put(ConfigProperty.CUSTOM_BASE_URI.name, false) - .put(ConfigProperty.CUSTOM_STREAM_URI.name, false) - .put(ConfigProperty.STREAMING_DISABLED.name, false) - .put(ConfigProperty.USING_RELAY_DAEMON.name, - config.dataStoreFactory != null && config.dataStoreFactory != Components.inMemoryDataStore()) - .build(); - } - } - - // Package-private for visibility in tests - static final class NullUpdateProcessor implements UpdateProcessor { - @Override - public Future start() { - return immediateFuture(null); - } - - @Override - public boolean initialized() { - return true; - } - - @Override - public void close() throws IOException {} - } - - private static final class StreamingDataSourceBuilderImpl extends StreamingDataSourceBuilder - implements UpdateProcessorFactoryWithDiagnostics, DiagnosticDescription { - @Override - public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { - return createUpdateProcessor(sdkKey, config, featureStore, null); - } - - @Override - public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore, - DiagnosticAccumulator diagnosticAccumulator) { - // Note, we log startup messages under the LDClient class to keep logs more readable - - if (config.offline) { - return Components.externalUpdatesOnly().createUpdateProcessor(sdkKey, config, featureStore); - } - - LDClient.logger.info("Enabling streaming API"); - - URI streamUri = baseURI == null ? LDConfig.DEFAULT_STREAM_URI : baseURI; - URI pollUri; - if (pollingBaseURI != null) { - pollUri = pollingBaseURI; - } else { - // If they have set a custom base URI, and they did *not* set a custom polling URI, then we can - // assume they're using Relay in which case both of those values are the same. - pollUri = baseURI == null ? LDConfig.DEFAULT_BASE_URI : baseURI; - } - - DefaultFeatureRequestor requestor = new DefaultFeatureRequestor( - sdkKey, - config.httpConfig, - pollUri, - false - ); - - return new StreamProcessor( - sdkKey, - config.httpConfig, - requestor, - featureStore, - null, - diagnosticAccumulator, - streamUri, - initialReconnectDelayMillis - ); - } - - @Override - public LDValue describeConfiguration(LDConfig config) { - if (config.offline) { - return nullUpdateProcessorFactory.describeConfiguration(config); - } - return LDValue.buildObject() - .put(ConfigProperty.STREAMING_DISABLED.name, false) - .put(ConfigProperty.CUSTOM_BASE_URI.name, - (pollingBaseURI != null && !pollingBaseURI.equals(LDConfig.DEFAULT_BASE_URI)) || - (pollingBaseURI == null && baseURI != null && !baseURI.equals(LDConfig.DEFAULT_STREAM_URI))) - .put(ConfigProperty.CUSTOM_STREAM_URI.name, - baseURI != null && !baseURI.equals(LDConfig.DEFAULT_STREAM_URI)) - .put(ConfigProperty.RECONNECT_TIME_MILLIS.name, initialReconnectDelayMillis) - .put(ConfigProperty.USING_RELAY_DAEMON.name, false) - .build(); - } - } - - private static final class PollingDataSourceBuilderImpl extends PollingDataSourceBuilder implements DiagnosticDescription { - @Override - public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { - // Note, we log startup messages under the LDClient class to keep logs more readable - - if (config.offline) { - return Components.externalUpdatesOnly().createUpdateProcessor(sdkKey, config, featureStore); - } - - LDClient.logger.info("Disabling streaming API"); - LDClient.logger.warn("You should only disable the streaming API if instructed to do so by LaunchDarkly support"); - - DefaultFeatureRequestor requestor = new DefaultFeatureRequestor( - sdkKey, - config.httpConfig, - baseURI == null ? LDConfig.DEFAULT_BASE_URI : baseURI, - true - ); - return new PollingProcessor(requestor, featureStore, pollIntervalMillis); - } - - @Override - public LDValue describeConfiguration(LDConfig config) { - if (config.offline) { - return nullUpdateProcessorFactory.describeConfiguration(config); - } - return LDValue.buildObject() - .put(ConfigProperty.STREAMING_DISABLED.name, true) - .put(ConfigProperty.CUSTOM_BASE_URI.name, - baseURI != null && !baseURI.equals(LDConfig.DEFAULT_BASE_URI)) - .put(ConfigProperty.CUSTOM_STREAM_URI.name, false) - .put(ConfigProperty.POLLING_INTERVAL_MILLIS.name, pollIntervalMillis) - .put(ConfigProperty.USING_RELAY_DAEMON.name, false) - .build(); - } - } - - private static final class EventProcessorBuilderImpl extends EventProcessorBuilder - implements EventProcessorFactoryWithDiagnostics, DiagnosticDescription { - @Override - public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { - return createEventProcessor(sdkKey, config, null); - } - - @Override - public EventProcessor createEventProcessor(String sdkKey, LDConfig config, DiagnosticAccumulator diagnosticAccumulator) { - if (config.offline) { - return new NullEventProcessor(); - } - return new DefaultEventProcessor(sdkKey, - config, - new EventsConfiguration( - allAttributesPrivate, - capacity, - baseURI == null ? LDConfig.DEFAULT_EVENTS_URI : baseURI, - flushIntervalSeconds, - inlineUsersInEvents, - privateAttrNames, - 0, // deprecated samplingInterval isn't supported in new builder - userKeysCapacity, - userKeysFlushIntervalSeconds, - diagnosticRecordingIntervalSeconds - ), - config.httpConfig, - diagnosticAccumulator - ); - } - - @Override - public LDValue describeConfiguration(LDConfig config) { - return LDValue.buildObject() - .put(ConfigProperty.ALL_ATTRIBUTES_PRIVATE.name, allAttributesPrivate) - .put(ConfigProperty.CUSTOM_EVENTS_URI.name, baseURI != null && !baseURI.equals(LDConfig.DEFAULT_EVENTS_URI)) - .put(ConfigProperty.DIAGNOSTIC_RECORDING_INTERVAL_MILLIS.name, diagnosticRecordingIntervalSeconds * 1000) - .put(ConfigProperty.EVENTS_CAPACITY.name, capacity) - .put(ConfigProperty.EVENTS_FLUSH_INTERVAL_MILLIS.name, flushIntervalSeconds * 1000) - .put(ConfigProperty.INLINE_USERS_IN_EVENTS.name, inlineUsersInEvents) - .put(ConfigProperty.SAMPLING_INTERVAL.name, 0) - .put(ConfigProperty.USER_KEYS_CAPACITY.name, userKeysCapacity) - .put(ConfigProperty.USER_KEYS_FLUSH_INTERVAL_MILLIS.name, userKeysFlushIntervalSeconds * 1000) - .build(); - } - } - - 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); - } - - @Override - public FeatureStore createFeatureStore() { - FeatureStoreCore core = persistentDataStoreFactory.createPersistentDataStore(); - return CachingStoreWrapper.builder(core) - .caching(caching) - .cacheMonitor(cacheMonitor) - .build(); - } - - @Override - public LDValue describeConfiguration(LDConfig config) { - if (persistentDataStoreFactory instanceof DiagnosticDescription) { - return ((DiagnosticDescription)persistentDataStoreFactory).describeConfiguration(config); - } - return LDValue.of("?"); - } - } -} diff --git a/src/main/java/com/launchdarkly/client/EvaluationDetail.java b/src/main/java/com/launchdarkly/client/EvaluationDetail.java deleted file mode 100644 index f9eaf2a65..000000000 --- a/src/main/java/com/launchdarkly/client/EvaluationDetail.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.launchdarkly.client; - -import com.google.common.base.Objects; -import com.launchdarkly.client.value.LDValue; - -/** - * An object returned by the "variation detail" methods such as {@link LDClientInterface#boolVariationDetail(String, LDUser, boolean)}, - * combining the result of a flag evaluation with an explanation of how it was calculated. - * @param the type of the wrapped value - * @since 4.3.0 - */ -public class EvaluationDetail { - - private final EvaluationReason reason; - private final Integer variationIndex; - private final T value; - - /** - * Constructs an instance with all properties specified. - * - * @param reason an {@link EvaluationReason} (should not be null) - * @param variationIndex an optional variation index - * @param value a value of the desired type - */ - public EvaluationDetail(EvaluationReason reason, Integer variationIndex, T value) { - this.value = value; - this.variationIndex = variationIndex; - this.reason = reason; - } - - /** - * Factory method for an arbitrary value. - * - * @param the type of the value - * @param value a value of the desired type - * @param variationIndex an optional variation index - * @param reason an {@link EvaluationReason} (should not be null) - * @return an {@link EvaluationDetail} - * @since 4.8.0 - */ - public static EvaluationDetail fromValue(T value, Integer variationIndex, EvaluationReason reason) { - return new EvaluationDetail(reason, variationIndex, value); - } - - static EvaluationDetail error(EvaluationReason.ErrorKind errorKind, LDValue defaultValue) { - return new EvaluationDetail(EvaluationReason.error(errorKind), null, LDValue.normalize(defaultValue)); - } - - /** - * An object describing the main factor that influenced the flag evaluation value. - * @return an {@link EvaluationReason} - */ - public EvaluationReason getReason() { - return reason; - } - - /** - * The index of the returned value within the flag's list of variations, e.g. 0 for the first variation - - * or {@code null} if the default value was returned. - * @return the variation index or null - */ - public Integer getVariationIndex() { - return variationIndex; - } - - /** - * The result of the flag evaluation. This will be either one of the flag's variations or the default - * value that was passed to the {@code variation} method. - * @return the flag value - */ - public T getValue() { - return value; - } - - /** - * Returns true if the flag evaluation returned the default value, rather than one of the flag's - * variations. - * @return true if this is the default value - */ - public boolean isDefaultValue() { - return variationIndex == null; - } - - @Override - public boolean equals(Object other) { - if (other instanceof EvaluationDetail) { - @SuppressWarnings("unchecked") - EvaluationDetail o = (EvaluationDetail)other; - return Objects.equal(reason, o.reason) && Objects.equal(variationIndex, o.variationIndex) && Objects.equal(value, o.value); - } - return false; - } - - @Override - public int hashCode() { - return Objects.hashCode(reason, variationIndex, value); - } - - @Override - public String toString() { - return "{" + reason + "," + variationIndex + "," + value + "}"; - } -} diff --git a/src/main/java/com/launchdarkly/client/EvaluationException.java b/src/main/java/com/launchdarkly/client/EvaluationException.java deleted file mode 100644 index 174a2417e..000000000 --- a/src/main/java/com/launchdarkly/client/EvaluationException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.launchdarkly.client; - -/** - * An error indicating an abnormal result from evaluating a feature - */ -@SuppressWarnings("serial") -class EvaluationException extends Exception { - public EvaluationException(String message) { - super(message); - } -} diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java deleted file mode 100644 index 1feedecb5..000000000 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ /dev/null @@ -1,445 +0,0 @@ -package com.launchdarkly.client; - -import java.util.Objects; - -import static com.google.common.base.Preconditions.checkNotNull; - -/** - * 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. 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 - */ -public abstract class EvaluationReason { - - /** - * Enumerated type defining the possible values of {@link EvaluationReason#getKind()}. - * @since 4.3.0 - */ - public static enum Kind { - /** - * Indicates that the flag was off and therefore returned its configured off value. - */ - OFF, - /** - * Indicates that the flag was on but the user did not match any targets or rules. - */ - FALLTHROUGH, - /** - * Indicates that the user key was specifically targeted for this flag. - */ - TARGET_MATCH, - /** - * Indicates that the user matched one of the flag's rules. - */ - RULE_MATCH, - /** - * 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. - */ - PREREQUISITE_FAILED, - /** - * Indicates that the flag could not be evaluated, e.g. because it does not exist or due to an unexpected - * error. In this case the result value will be the default value that the caller passed to the client. - * Check the errorKind property for more details on the problem. - */ - ERROR; - } - - /** - * Enumerated type defining the possible values of {@link EvaluationReason.Error#getErrorKind()}. - * @since 4.3.0 - */ - public static enum ErrorKind { - /** - * Indicates that the caller tried to evaluate a flag before the client had successfully initialized. - */ - CLIENT_NOT_READY, - /** - * Indicates that the caller provided a flag key that did not match any known flag. - */ - FLAG_NOT_FOUND, - /** - * Indicates that there was an internal inconsistency in the flag data, e.g. a rule specified a nonexistent - * variation. An error message will always be logged in this case. - */ - MALFORMED_FLAG, - /** - * Indicates that the caller passed {@code null} for the user parameter, or the user lacked a key. - */ - USER_NOT_SPECIFIED, - /** - * Indicates that the result value was not of the requested type, e.g. you called - * {@link LDClientInterface#boolVariationDetail(String, LDUser, boolean)} but the value was an integer. - */ - WRONG_TYPE, - /** - * Indicates that an unexpected exception stopped flag evaluation. An error message will always be logged - * in this case, and the exception should be available via {@link EvaluationReason.Error#getException()}. - */ - EXCEPTION - } - - // static instances to avoid repeatedly allocating reasons for the same errors - private static final Error ERROR_CLIENT_NOT_READY = new Error(ErrorKind.CLIENT_NOT_READY, null); - private static final Error ERROR_FLAG_NOT_FOUND = new Error(ErrorKind.FLAG_NOT_FOUND, null); - private static final Error ERROR_MALFORMED_FLAG = new Error(ErrorKind.MALFORMED_FLAG, null); - private static final Error ERROR_USER_NOT_SPECIFIED = new Error(ErrorKind.USER_NOT_SPECIFIED, null); - private static final Error ERROR_WRONG_TYPE = new Error(ErrorKind.WRONG_TYPE, null); - private static final Error ERROR_EXCEPTION = new Error(ErrorKind.EXCEPTION, null); - - private final Kind kind; - - /** - * Returns an enum indicating the general category of the reason. - * @return a {@link Kind} value - */ - 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() { - return getKind().name(); - } - - protected EvaluationReason(Kind kind) - { - this.kind = kind; - } - - /** - * Returns an instance whose {@code kind} is {@link Kind#OFF}. - * @return a reason object - */ - public static Off off() { - return Off.instance; - } - - /** - * Returns an instance whose {@code kind} is {@link Kind#TARGET_MATCH}. - * @return a reason object - */ - public static TargetMatch targetMatch() { - return TargetMatch.instance; - } - - /** - * 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 - */ - public static RuleMatch ruleMatch(int ruleIndex, String ruleId) { - return new RuleMatch(ruleIndex, ruleId); - } - - /** - * 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 - */ - public static PrerequisiteFailed prerequisiteFailed(String prerequisiteKey) { - return new PrerequisiteFailed(prerequisiteKey); - } - - /** - * Returns an instance whose {@code kind} is {@link Kind#FALLTHROUGH}. - * @return a reason object - */ - public static Fallthrough fallthrough() { - return Fallthrough.instance; - } - - /** - * Returns an instance whose {@code kind} is {@link Kind#ERROR}. - * @param errorKind describes the type of error - * @return a reason object - */ - public static Error error(ErrorKind errorKind) { - switch (errorKind) { - case CLIENT_NOT_READY: return ERROR_CLIENT_NOT_READY; - case EXCEPTION: return ERROR_EXCEPTION; - case FLAG_NOT_FOUND: return ERROR_FLAG_NOT_FOUND; - case MALFORMED_FLAG: return ERROR_MALFORMED_FLAG; - case USER_NOT_SPECIFIED: return ERROR_USER_NOT_SPECIFIED; - case WRONG_TYPE: return ERROR_WRONG_TYPE; - default: return new Error(errorKind, null); - } - } - - /** - * Returns an instance of {@link Error} with the kind {@link ErrorKind#EXCEPTION} and an exception instance. - * @param exception the exception that caused the error - * @return a reason object - * @since 4.11.0 - */ - public static Error exception(Exception exception) { - return new Error(ErrorKind.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); - } - - private static final Off instance = new 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() - { - super(Kind.TARGET_MATCH); - } - - private static final TargetMatch instance = new 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; - - private RuleMatch(int ruleIndex, String ruleId) { - super(Kind.RULE_MATCH); - this.ruleIndex = ruleIndex; - this.ruleId = 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; - } - - /** - * 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; - } - - @Override - public boolean equals(Object other) { - if (other instanceof RuleMatch) { - RuleMatch o = (RuleMatch)other; - return ruleIndex == o.ruleIndex && Objects.equals(ruleId, o.ruleId); - } - return false; - } - - @Override - public int hashCode() { - return Objects.hash(ruleIndex, ruleId); - } - - @Override - public String toString() { - return getKind().name() + "(" + ruleIndex + (ruleId == null ? "" : ("," + ruleId)) + ")"; - } - } - - /** - * 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; - - private PrerequisiteFailed(String prerequisiteKey) { - super(Kind.PREREQUISITE_FAILED); - this.prerequisiteKey = checkNotNull(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; - } - - @Override - public boolean equals(Object other) { - return (other instanceof PrerequisiteFailed) && - ((PrerequisiteFailed)other).prerequisiteKey.equals(prerequisiteKey); - } - - @Override - public int hashCode() { - return prerequisiteKey.hashCode(); - } - - @Override - public String toString() { - return getKind().name() + "(" + prerequisiteKey + ")"; - } - } - - /** - * 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() - { - super(Kind.FALLTHROUGH); - } - - private static final Fallthrough instance = new 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; - // The exception field is transient because we don't want it to be included in the JSON representation that - // is used in analytics events; the LD event service wouldn't know what to do with it (and it would include - // a potentially large amount of stacktrace data). - - private Error(ErrorKind errorKind, Exception exception) { - super(Kind.ERROR); - checkNotNull(errorKind); - this.errorKind = errorKind; - this.exception = exception; - } - - /** - * An enumeration value indicating the general category of error. - * @return the error kind - */ - @Override - public ErrorKind getErrorKind() { - return errorKind; - } - - /** - * Returns the exception that caused the error condition, if applicable. - *

- * This is only set if {@link #getErrorKind()} is {@link ErrorKind#EXCEPTION}. - * - * @return the exception instance - * @since 4.11.0 - */ - public Exception getException() { - return exception; - } - - @Override - public boolean equals(Object other) { - return other instanceof Error && errorKind == ((Error) other).errorKind && Objects.equals(exception, ((Error) other).exception); - } - - @Override - public int hashCode() { - return Objects.hash(errorKind, exception); - } - - @Override - public String toString() { - return getKind().name() + "(" + errorKind.name() + (exception == null ? "" : ("," + exception)) + ")"; - } - } -} diff --git a/src/main/java/com/launchdarkly/client/Event.java b/src/main/java/com/launchdarkly/client/Event.java deleted file mode 100644 index 40ff0053c..000000000 --- a/src/main/java/com/launchdarkly/client/Event.java +++ /dev/null @@ -1,181 +0,0 @@ -package com.launchdarkly.client; - -import com.google.gson.JsonElement; -import com.launchdarkly.client.value.LDValue; - -/** - * Base class for all analytics events that are generated by the client. Also defines all of its own subclasses. - */ -public class Event { - final long creationDate; - final LDUser user; - - /** - * Base event constructor. - * @param creationDate the timetamp in milliseconds - * @param user the user associated with the event - */ - public Event(long creationDate, LDUser user) { - this.creationDate = creationDate; - this.user = user; - } - - /** - * A custom event created with {@link LDClientInterface#track(String, LDUser)} or one of its overloads. - */ - public static final class Custom extends Event { - final String key; - final LDValue data; - final Double metricValue; - - /** - * Constructs a custom event. - * @param timestamp the timestamp in milliseconds - * @param key the event key - * @param user the user associated with the event - * @param data custom data if any (null is the same as {@link LDValue#ofNull()}) - * @param metricValue custom metric value if any - * @since 4.8.0 - */ - public Custom(long timestamp, String key, LDUser user, LDValue data, Double metricValue) { - super(timestamp, user); - this.key = key; - this.data = data == null ? LDValue.ofNull() : data; - this.metricValue = metricValue; - } - - /** - * Deprecated constructor. - * @param timestamp the timestamp in milliseconds - * @param key the event key - * @param user the user associated with the event - * @param data custom data if any (null is the same as {@link LDValue#ofNull()}) - * @deprecated - */ - @Deprecated - public Custom(long timestamp, String key, LDUser user, JsonElement data) { - this(timestamp, key, user, LDValue.unsafeFromJsonElement(data), null); - } - } - - /** - * An event created with {@link LDClientInterface#identify(LDUser)}. - */ - public static final class Identify extends Event { - /** - * Constructs an identify event. - * @param timestamp the timestamp in milliseconds - * @param user the user associated with the event - */ - public Identify(long timestamp, LDUser user) { - super(timestamp, user); - } - } - - /** - * An event created internally by the SDK to hold user data that may be referenced by multiple events. - */ - public static final class Index extends Event { - /** - * Constructs an index event. - * @param timestamp the timestamp in milliseconds - * @param user the user associated with the event - */ - public Index(long timestamp, LDUser user) { - super(timestamp, user); - } - } - - /** - * An event generated by a feature flag evaluation. - */ - public static final class FeatureRequest extends Event { - final String key; - final Integer variation; - final LDValue value; - final LDValue defaultVal; - final Integer version; - final String prereqOf; - final boolean trackEvents; - final Long debugEventsUntilDate; - final EvaluationReason reason; - final boolean debug; - - /** - * Constructs a feature request event. - * @param timestamp the timestamp in milliseconds - * @param key the flag key - * @param user the user associated with the event - * @param version the flag version, or null if the flag was not found - * @param variation the result variation, or null if there was an error - * @param value the result value - * @param defaultVal the default value passed by the application - * @param reason the evaluation reason, if it is to be included in the event - * @param prereqOf if this flag was evaluated as a prerequisite, this is the key of the flag that referenced it - * @param trackEvents true if full event tracking is turned on for this flag - * @param debugEventsUntilDate if non-null, the time until which event debugging should be enabled - * @param debug true if this is a debugging event - * @since 4.8.0 - */ - public FeatureRequest(long timestamp, String key, LDUser user, Integer version, Integer variation, LDValue value, - LDValue defaultVal, EvaluationReason reason, String prereqOf, boolean trackEvents, Long debugEventsUntilDate, boolean debug) { - super(timestamp, user); - this.key = key; - this.version = version; - this.variation = variation; - this.value = value; - this.defaultVal = defaultVal; - this.prereqOf = prereqOf; - this.trackEvents = trackEvents; - this.debugEventsUntilDate = debugEventsUntilDate; - this.reason = reason; - this.debug = debug; - } - - /** - * Deprecated constructor. - * @param timestamp the timestamp in milliseconds - * @param key the flag key - * @param user the user associated with the event - * @param version the flag version, or null if the flag was not found - * @param variation the result variation, or null if there was an error - * @param value the result value - * @param defaultVal the default value passed by the application - * @param prereqOf if this flag was evaluated as a prerequisite, this is the key of the flag that referenced it - * @param trackEvents true if full event tracking is turned on for this flag - * @param debugEventsUntilDate if non-null, the time until which event debugging should be enabled - * @param debug true if this is a debugging event - * @deprecated - */ - @Deprecated - public FeatureRequest(long timestamp, String key, LDUser user, Integer version, Integer variation, JsonElement value, - JsonElement defaultVal, String prereqOf, boolean trackEvents, Long debugEventsUntilDate, boolean debug) { - this(timestamp, key, user, version, variation, LDValue.unsafeFromJsonElement(value), LDValue.unsafeFromJsonElement(defaultVal), - null, prereqOf, trackEvents, debugEventsUntilDate, debug); - } - - /** - * Deprecated constructor. - * @param timestamp the timestamp in milliseconds - * @param key the flag key - * @param user the user associated with the event - * @param version the flag version, or null if the flag was not found - * @param variation the result variation, or null if there was an error - * @param value the result value - * @param defaultVal the default value passed by the application - * @param reason the evaluation reason, if it is to be included in the event - * @param prereqOf if this flag was evaluated as a prerequisite, this is the key of the flag that referenced it - * @param trackEvents true if full event tracking is turned on for this flag - * @param debugEventsUntilDate if non-null, the time until which event debugging should be enabled - * @param debug true if this is a debugging event - * @deprecated - */ - @Deprecated - public FeatureRequest(long timestamp, String key, LDUser user, Integer version, Integer variation, JsonElement value, - JsonElement defaultVal, String prereqOf, boolean trackEvents, Long debugEventsUntilDate, EvaluationReason reason, boolean debug) { - this(timestamp, key, user, version, variation, LDValue.unsafeFromJsonElement(value), LDValue.unsafeFromJsonElement(defaultVal), - reason, prereqOf, trackEvents, debugEventsUntilDate, debug); - } - } - -} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/EventProcessorFactoryWithDiagnostics.java b/src/main/java/com/launchdarkly/client/EventProcessorFactoryWithDiagnostics.java deleted file mode 100644 index fac2c631a..000000000 --- a/src/main/java/com/launchdarkly/client/EventProcessorFactoryWithDiagnostics.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.launchdarkly.client; - -interface EventProcessorFactoryWithDiagnostics extends EventProcessorFactory { - EventProcessor createEventProcessor(String sdkKey, LDConfig config, - DiagnosticAccumulator diagnosticAccumulator); -} diff --git a/src/main/java/com/launchdarkly/client/EventsConfiguration.java b/src/main/java/com/launchdarkly/client/EventsConfiguration.java deleted file mode 100644 index 10f132130..000000000 --- a/src/main/java/com/launchdarkly/client/EventsConfiguration.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.launchdarkly.client; - -import com.google.common.collect.ImmutableSet; - -import java.net.URI; -import java.util.Set; - -// Used internally to encapsulate the various config/builder properties for events. -final class EventsConfiguration { - final boolean allAttributesPrivate; - final int capacity; - final URI eventsUri; - final int flushIntervalSeconds; - final boolean inlineUsersInEvents; - final ImmutableSet privateAttrNames; - final int samplingInterval; - final int userKeysCapacity; - final int userKeysFlushIntervalSeconds; - final int diagnosticRecordingIntervalSeconds; - - EventsConfiguration(boolean allAttributesPrivate, int capacity, URI eventsUri, int flushIntervalSeconds, - boolean inlineUsersInEvents, Set privateAttrNames, int samplingInterval, - int userKeysCapacity, int userKeysFlushIntervalSeconds, int diagnosticRecordingIntervalSeconds) { - super(); - this.allAttributesPrivate = allAttributesPrivate; - this.capacity = capacity; - this.eventsUri = eventsUri == null ? LDConfig.DEFAULT_EVENTS_URI : eventsUri; - this.flushIntervalSeconds = flushIntervalSeconds; - this.inlineUsersInEvents = inlineUsersInEvents; - this.privateAttrNames = privateAttrNames == null ? ImmutableSet.of() : ImmutableSet.copyOf(privateAttrNames); - this.samplingInterval = samplingInterval; - this.userKeysCapacity = userKeysCapacity; - this.userKeysFlushIntervalSeconds = userKeysFlushIntervalSeconds; - this.diagnosticRecordingIntervalSeconds = diagnosticRecordingIntervalSeconds; - } -} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java deleted file mode 100644 index 2abe060a5..000000000 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ /dev/null @@ -1,257 +0,0 @@ -package com.launchdarkly.client; - -import com.google.gson.annotations.JsonAdapter; -import com.launchdarkly.client.value.LDValue; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.ArrayList; -import java.util.List; - -import static com.google.common.base.Preconditions.checkNotNull; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; - -@JsonAdapter(JsonHelpers.PostProcessingDeserializableTypeAdapterFactory.class) -class FeatureFlag implements VersionedData, JsonHelpers.PostProcessingDeserializable { - private final static Logger logger = LoggerFactory.getLogger(FeatureFlag.class); - - private String key; - private int version; - private boolean on; - private List prerequisites; - private String salt; - private List targets; - private List rules; - private VariationOrRollout fallthrough; - private Integer offVariation; //optional - private List variations; - private boolean clientSide; - private boolean trackEvents; - private boolean trackEventsFallthrough; - private Long debugEventsUntilDate; - private boolean deleted; - - // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation - FeatureFlag() {} - - FeatureFlag(String key, int version, boolean on, List prerequisites, String salt, List targets, - List rules, VariationOrRollout fallthrough, Integer offVariation, List variations, - boolean clientSide, boolean trackEvents, boolean trackEventsFallthrough, - Long debugEventsUntilDate, boolean deleted) { - this.key = key; - this.version = version; - this.on = on; - this.prerequisites = prerequisites; - this.salt = salt; - this.targets = targets; - this.rules = rules; - this.fallthrough = fallthrough; - this.offVariation = offVariation; - this.variations = variations; - this.clientSide = clientSide; - this.trackEvents = trackEvents; - this.trackEventsFallthrough = trackEventsFallthrough; - this.debugEventsUntilDate = debugEventsUntilDate; - this.deleted = deleted; - } - - EvalResult evaluate(LDUser user, FeatureStore featureStore, EventFactory eventFactory) { - List prereqEvents = new ArrayList<>(); - - if (user == null || user.getKey() == null) { - // this should have been prevented by LDClient.evaluateInternal - logger.warn("Null user or null user key when evaluating flag \"{}\"; returning null", key); - return new EvalResult(EvaluationDetail.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, LDValue.ofNull()), prereqEvents); - } - - EvaluationDetail details = evaluate(user, featureStore, prereqEvents, eventFactory); - return new EvalResult(details, prereqEvents); - } - - private EvaluationDetail evaluate(LDUser user, FeatureStore featureStore, List events, - EventFactory eventFactory) { - if (!isOn()) { - return getOffValue(EvaluationReason.off()); - } - - EvaluationReason prereqFailureReason = checkPrerequisites(user, featureStore, events, eventFactory); - if (prereqFailureReason != null) { - return getOffValue(prereqFailureReason); - } - - // Check to see if targets match - if (targets != null) { - for (Target target: targets) { - if (target.getValues().contains(user.getKey().stringValue())) { - return getVariation(target.getVariation(), EvaluationReason.targetMatch()); - } - } - } - // Now walk through the rules and see if any match - if (rules != null) { - for (int i = 0; i < rules.size(); i++) { - Rule rule = rules.get(i); - if (rule.matchesUser(featureStore, user)) { - EvaluationReason.RuleMatch precomputedReason = rule.getRuleMatchReason(); - EvaluationReason.RuleMatch reason = precomputedReason != null ? precomputedReason : EvaluationReason.ruleMatch(i, rule.getId()); - return getValueForVariationOrRollout(rule, user, reason); - } - } - } - // Walk through the fallthrough and see if it matches - return getValueForVariationOrRollout(fallthrough, user, EvaluationReason.fallthrough()); - } - - // Checks prerequisites if any; returns null if successful, or an EvaluationReason if we have to - // short-circuit due to a prerequisite failure. - private EvaluationReason checkPrerequisites(LDUser user, FeatureStore featureStore, List events, - EventFactory eventFactory) { - if (prerequisites == null) { - return null; - } - for (int i = 0; i < prerequisites.size(); i++) { - boolean prereqOk = true; - Prerequisite prereq = prerequisites.get(i); - FeatureFlag prereqFeatureFlag = featureStore.get(FEATURES, prereq.getKey()); - if (prereqFeatureFlag == null) { - logger.error("Could not retrieve prerequisite flag \"{}\" when evaluating \"{}\"", prereq.getKey(), key); - prereqOk = false; - } else { - EvaluationDetail prereqEvalResult = prereqFeatureFlag.evaluate(user, featureStore, events, eventFactory); - // Note that if the prerequisite flag is off, we don't consider it a match no matter what its - // off variation was. But we still need to evaluate it in order to generate an event. - if (!prereqFeatureFlag.isOn() || prereqEvalResult == null || prereqEvalResult.getVariationIndex() != prereq.getVariation()) { - prereqOk = false; - } - events.add(eventFactory.newPrerequisiteFeatureRequestEvent(prereqFeatureFlag, user, prereqEvalResult, this)); - } - if (!prereqOk) { - EvaluationReason.PrerequisiteFailed precomputedReason = prereq.getPrerequisiteFailedReason(); - return precomputedReason != null ? precomputedReason : EvaluationReason.prerequisiteFailed(prereq.getKey()); - } - } - return null; - } - - private EvaluationDetail getVariation(int variation, EvaluationReason reason) { - if (variation < 0 || variation >= variations.size()) { - logger.error("Data inconsistency in feature flag \"{}\": invalid variation index", key); - return EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, LDValue.ofNull()); - } - LDValue value = LDValue.normalize(variations.get(variation)); - // normalize() ensures that nulls become LDValue.ofNull() - Gson may give us nulls - return EvaluationDetail.fromValue(value, variation, reason); - } - - private EvaluationDetail getOffValue(EvaluationReason reason) { - if (offVariation == null) { // off variation unspecified - return default value - return EvaluationDetail.fromValue(LDValue.ofNull(), null, reason); - } - return getVariation(offVariation, reason); - } - - private EvaluationDetail getValueForVariationOrRollout(VariationOrRollout vr, LDUser user, EvaluationReason reason) { - Integer index = vr.variationIndexForUser(user, key, salt); - if (index == null) { - logger.error("Data inconsistency in feature flag \"{}\": variation/rollout object with no variation or rollout", key); - return EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, LDValue.ofNull()); - } - return getVariation(index, reason); - } - - public int getVersion() { - return version; - } - - public String getKey() { - return key; - } - - public boolean isTrackEvents() { - return trackEvents; - } - - public boolean isTrackEventsFallthrough() { - return trackEventsFallthrough; - } - - public Long getDebugEventsUntilDate() { - return debugEventsUntilDate; - } - - public boolean isDeleted() { - return deleted; - } - - boolean isOn() { - return on; - } - - List getPrerequisites() { - return prerequisites; - } - - String getSalt() { - return salt; - } - - List getTargets() { - return targets; - } - - List getRules() { - return rules; - } - - VariationOrRollout getFallthrough() { - return fallthrough; - } - - List getVariations() { - return variations; - } - - Integer getOffVariation() { - return offVariation; - } - - boolean isClientSide() { - return clientSide; - } - - // Precompute some invariant values for improved efficiency during evaluations - called from JsonHelpers.PostProcessingDeserializableTypeAdapter - public void afterDeserialized() { - if (prerequisites != null) { - for (Prerequisite p: prerequisites) { - p.setPrerequisiteFailedReason(EvaluationReason.prerequisiteFailed(p.getKey())); - } - } - if (rules != null) { - for (int i = 0; i < rules.size(); i++) { - Rule r = rules.get(i); - r.setRuleMatchReason(EvaluationReason.ruleMatch(i, r.getId())); - } - } - } - - static class EvalResult { - private final EvaluationDetail details; - private final List prerequisiteEvents; - - private EvalResult(EvaluationDetail details, List prerequisiteEvents) { - checkNotNull(details); - checkNotNull(prerequisiteEvents); - this.details = details; - this.prerequisiteEvents = prerequisiteEvents; - } - - EvaluationDetail getDetails() { - return details; - } - - List getPrerequisiteEvents() { - return prerequisiteEvents; - } - } -} diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java b/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java deleted file mode 100644 index 52c1ba4c8..000000000 --- a/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java +++ /dev/null @@ -1,130 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.value.LDValue; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -class FeatureFlagBuilder { - private String key; - private int version; - private boolean on; - private List prerequisites = new ArrayList<>(); - private String salt; - private List targets = new ArrayList<>(); - private List rules = new ArrayList<>(); - private VariationOrRollout fallthrough; - private Integer offVariation; - private List variations = new ArrayList<>(); - private boolean clientSide; - private boolean trackEvents; - private boolean trackEventsFallthrough; - private Long debugEventsUntilDate; - private boolean deleted; - - FeatureFlagBuilder(String key) { - this.key = key; - } - - FeatureFlagBuilder(FeatureFlag f) { - if (f != null) { - this.key = f.getKey(); - this.version = f.getVersion(); - this.on = f.isOn(); - this.prerequisites = f.getPrerequisites(); - this.salt = f.getSalt(); - this.targets = f.getTargets(); - this.rules = f.getRules(); - this.fallthrough = f.getFallthrough(); - this.offVariation = f.getOffVariation(); - this.variations = f.getVariations(); - this.clientSide = f.isClientSide(); - this.trackEvents = f.isTrackEvents(); - this.trackEventsFallthrough = f.isTrackEventsFallthrough(); - this.debugEventsUntilDate = f.getDebugEventsUntilDate(); - this.deleted = f.isDeleted(); - } - } - - FeatureFlagBuilder version(int version) { - this.version = version; - return this; - } - - FeatureFlagBuilder on(boolean on) { - this.on = on; - return this; - } - - FeatureFlagBuilder prerequisites(List prerequisites) { - this.prerequisites = prerequisites; - return this; - } - - FeatureFlagBuilder salt(String salt) { - this.salt = salt; - return this; - } - - FeatureFlagBuilder targets(List targets) { - this.targets = targets; - return this; - } - - FeatureFlagBuilder rules(List rules) { - this.rules = rules; - return this; - } - - FeatureFlagBuilder fallthrough(VariationOrRollout fallthrough) { - this.fallthrough = fallthrough; - return this; - } - - FeatureFlagBuilder offVariation(Integer offVariation) { - this.offVariation = offVariation; - return this; - } - - FeatureFlagBuilder variations(List variations) { - this.variations = variations; - return this; - } - - FeatureFlagBuilder variations(LDValue... variations) { - return variations(Arrays.asList(variations)); - } - - FeatureFlagBuilder clientSide(boolean clientSide) { - this.clientSide = clientSide; - return this; - } - - FeatureFlagBuilder trackEvents(boolean trackEvents) { - this.trackEvents = trackEvents; - return this; - } - - FeatureFlagBuilder trackEventsFallthrough(boolean trackEventsFallthrough) { - this.trackEventsFallthrough = trackEventsFallthrough; - return this; - } - - FeatureFlagBuilder debugEventsUntilDate(Long debugEventsUntilDate) { - this.debugEventsUntilDate = debugEventsUntilDate; - return this; - } - - FeatureFlagBuilder deleted(boolean deleted) { - this.deleted = deleted; - return this; - } - - FeatureFlag build() { - FeatureFlag flag = new FeatureFlag(key, version, on, prerequisites, salt, targets, rules, fallthrough, offVariation, variations, - clientSide, trackEvents, trackEventsFallthrough, debugEventsUntilDate, deleted); - flag.afterDeserialized(); - return flag; - } -} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/FeatureRequestor.java b/src/main/java/com/launchdarkly/client/FeatureRequestor.java deleted file mode 100644 index 6c23d0407..000000000 --- a/src/main/java/com/launchdarkly/client/FeatureRequestor.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.launchdarkly.client; - -import java.io.Closeable; -import java.io.IOException; -import java.util.Map; - -interface FeatureRequestor extends Closeable { - FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException; - - Segment getSegment(String segmentKey) throws IOException, HttpErrorException; - - AllData getAllData() throws IOException, HttpErrorException; - - static class AllData { - final Map flags; - final Map segments; - - AllData(Map flags, Map segments) { - this.flags = flags; - this.segments = segments; - } - } -} diff --git a/src/main/java/com/launchdarkly/client/FeatureStore.java b/src/main/java/com/launchdarkly/client/FeatureStore.java deleted file mode 100644 index 0ea551299..000000000 --- a/src/main/java/com/launchdarkly/client/FeatureStore.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.launchdarkly.client; - -import java.io.Closeable; -import java.util.Map; - -/** - * A thread-safe, versioned store for feature flags and related objects received from the - * streaming API. Implementations should permit concurrent access and updates. - *

- * Delete and upsert requests are versioned-- if the version number in the request is less than - * the currently stored version of the object, the request should be ignored. - *

- * These semantics support the primary use case for the store, which synchronizes a collection - * of objects based on update messages that may be received out-of-order. - * @since 3.0.0 - */ -public interface FeatureStore extends Closeable { - /** - * Returns the object to which the specified key is mapped, or - * null if the key is not associated or the associated object has - * been deleted. - * - * @param class of the object that will be returned - * @param kind the kind of object to get - * @param key the key whose associated object is to be returned - * @return the object to which the specified key is mapped, or - * null if the key is not associated or the associated object has - * been deleted. - */ - T get(VersionedDataKind kind, String key); - - /** - * Returns a {@link java.util.Map} of all associated objects of a given kind. - * - * @param class of the objects that will be returned in the map - * @param kind the kind of objects to get - * @return a map of all associated object. - */ - Map all(VersionedDataKind kind); - - /** - * Initializes (or re-initializes) the store with the specified set of objects. Any existing entries - * will be removed. Implementations can assume that this set of objects is up to date-- there is no - * need to perform individual version comparisons between the existing objects and the supplied - * features. - *

- * If possible, the store should update the entire data set atomically. If that is not possible, it - * should iterate through the outer map and then the inner map in the order provided (the SDK - * will use a Map subclass that has a defined ordering), storing each item, and then delete any - * leftover items at the very end. - *

- * The store should not attempt to modify any of the Maps, and if it needs to retain the data in - * memory it should copy the Maps. - * - * @param allData all objects to be stored - */ - void init(Map, Map> allData); - - /** - * Deletes the object associated with the specified key, if it exists and its version - * is less than or equal to the specified version. - * - * @param class of the object to be deleted - * @param kind the kind of object to delete - * @param key the key of the object to be deleted - * @param version the version for the delete operation - */ - void delete(VersionedDataKind kind, String key, int version); - - /** - * Update or insert the object associated with the specified key, if its version - * is less than or equal to the version specified in the argument object. - * - * @param class of the object to be updated - * @param kind the kind of object to update - * @param item the object to update or insert - */ - void upsert(VersionedDataKind kind, T item); - - /** - * Returns true if this store has been initialized. - * - * @return true if this store has been initialized - */ - boolean initialized(); - -} diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java deleted file mode 100644 index 4c73d2f53..000000000 --- a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java +++ /dev/null @@ -1,289 +0,0 @@ -package com.launchdarkly.client; - -import com.google.common.cache.CacheBuilder; -import com.launchdarkly.client.integrations.PersistentDataStoreBuilder; - -import java.util.Objects; -import java.util.concurrent.TimeUnit; - -/** - * Parameters that can be used for {@link FeatureStore} implementations that support local caching. - * If a store implementation uses this class, then it is using the standard caching mechanism that - * is built into the SDK, and is guaranteed to support all the properties defined in this class. - *

- * This is an immutable class that uses a fluent interface. Obtain an instance by calling the static - * methods {@link #disabled()} or {@link #enabled()}; then, if desired, you can use chained methods - * to set other properties: - * - *


- *     Components.redisFeatureStore()
- *         .caching(
- *             FeatureStoreCacheConfig.enabled()
- *                 .ttlSeconds(30)
- *                 .staleValuesPolicy(FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH)
- *         )
- * 
- * - * @see RedisFeatureStoreBuilder#caching(FeatureStoreCacheConfig) - * @since 4.6.0 - * @deprecated This has been superseded by the {@link PersistentDataStoreBuilder} interface. - */ -@Deprecated -public final class FeatureStoreCacheConfig { - /** - * The default TTL, in seconds, used by {@link #DEFAULT}. - */ - public static final long DEFAULT_TIME_SECONDS = 15; - - /** - * The caching parameters that feature store should use by default. Caching is enabled, with a - * TTL of {@link #DEFAULT_TIME_SECONDS} and the {@link StaleValuesPolicy#EVICT} policy. - */ - public static final FeatureStoreCacheConfig DEFAULT = - new FeatureStoreCacheConfig(DEFAULT_TIME_SECONDS, TimeUnit.SECONDS, StaleValuesPolicy.EVICT); - - private static final FeatureStoreCacheConfig DISABLED = - new FeatureStoreCacheConfig(0, TimeUnit.MILLISECONDS, StaleValuesPolicy.EVICT); - - private final long cacheTime; - private final TimeUnit cacheTimeUnit; - private final StaleValuesPolicy staleValuesPolicy; - - /** - * Possible values for {@link FeatureStoreCacheConfig#staleValuesPolicy(StaleValuesPolicy)}. - */ - public enum StaleValuesPolicy { - /** - * Indicates that when the cache TTL expires for an item, it is evicted from the cache. The next - * attempt to read that item causes a synchronous read from the underlying data store; if that - * fails, no value is available. This is the default behavior. - * - * @see CacheBuilder#expireAfterWrite(long, TimeUnit) - */ - EVICT, - /** - * Indicates that the cache should refresh stale values instead of evicting them. - *

- * In this mode, an attempt to read an expired item causes a synchronous read from the underlying - * data store, like {@link #EVICT}--but if an error occurs during this refresh, the cache will - * continue to return the previously cached values (if any). This is useful if you prefer the most - * recently cached feature rule set to be returned for evaluation over the default value when - * updates go wrong. - *

- * See: CacheBuilder - * for more specific information on cache semantics. This mode is equivalent to {@code expireAfterWrite}. - */ - REFRESH, - /** - * Indicates that the cache should refresh stale values asynchronously instead of evicting them. - *

- * This is the same as {@link #REFRESH}, except that the attempt to refresh the value is done - * on another thread (using a {@link java.util.concurrent.Executor}). In the meantime, the cache - * will continue to return the previously cached value (if any) in a non-blocking fashion to threads - * requesting the stale key. Any exception encountered during the asynchronous reload will cause - * the previously cached value to be retained. - *

- * This setting is ideal to enable when you desire high performance reads and can accept returning - * stale values for the period of the async refresh. For example, configuring this feature store - * with a very low cache time and enabling this feature would see great performance benefit by - * decoupling calls from network I/O. - *

- * See: CacheBuilder for - * more specific information on cache semantics. - */ - REFRESH_ASYNC; - - /** - * Used internally for backward compatibility. - * @return the equivalent enum value - * @since 4.12.0 - */ - public PersistentDataStoreBuilder.StaleValuesPolicy toNewEnum() { - switch (this) { - case REFRESH: - return PersistentDataStoreBuilder.StaleValuesPolicy.REFRESH; - case REFRESH_ASYNC: - return PersistentDataStoreBuilder.StaleValuesPolicy.REFRESH_ASYNC; - default: - return PersistentDataStoreBuilder.StaleValuesPolicy.EVICT; - } - } - - /** - * Used internally for backward compatibility. - * @param policy the enum value in the new API - * @return the equivalent enum value - * @since 4.12.0 - */ - public static StaleValuesPolicy fromNewEnum(PersistentDataStoreBuilder.StaleValuesPolicy policy) { - switch (policy) { - case REFRESH: - return StaleValuesPolicy.REFRESH; - case REFRESH_ASYNC: - return StaleValuesPolicy.REFRESH_ASYNC; - default: - return StaleValuesPolicy.EVICT; - } - } - }; - - /** - * Returns a parameter object indicating that caching should be disabled. Specifying any additional - * properties on this object will have no effect. - * @return a {@link FeatureStoreCacheConfig} instance - */ - public static FeatureStoreCacheConfig disabled() { - return DISABLED; - } - - /** - * Returns a parameter object indicating that caching should be enabled, using the default TTL of - * {@link #DEFAULT_TIME_SECONDS}. You can further modify the cache properties using the other - * methods of this class. - * @return a {@link FeatureStoreCacheConfig} instance - */ - public static FeatureStoreCacheConfig enabled() { - return DEFAULT; - } - - private FeatureStoreCacheConfig(long cacheTime, TimeUnit cacheTimeUnit, StaleValuesPolicy staleValuesPolicy) { - this.cacheTime = cacheTime; - this.cacheTimeUnit = cacheTimeUnit; - this.staleValuesPolicy = staleValuesPolicy; - } - - /** - * Returns true if caching will be enabled. - * @return true if the cache TTL is nonzero - */ - public boolean isEnabled() { - return getCacheTime() != 0; - } - - /** - * Returns true if caching is enabled and does not have a finite TTL. - * @return true if the cache TTL is negative - */ - public boolean isInfiniteTtl() { - return getCacheTime() < 0; - } - - /** - * Returns the cache TTL. - *

- * If the value is zero, caching is disabled. - *

- * If the value is negative, data is cached forever (i.e. it will only be read again from the database - * if the SDK is restarted). Use the "cached forever" mode with caution: it means that in a scenario - * where multiple processes are sharing the database, and the current process loses connectivity to - * LaunchDarkly while other processes are still receiving updates and writing them to the database, - * the current process will have stale data. - * - * @return the cache TTL in whatever units were specified - * @see #getCacheTimeUnit() - */ - public long getCacheTime() { - return cacheTime; - } - - /** - * Returns the time unit for the cache TTL. - * @return the time unit - */ - public TimeUnit getCacheTimeUnit() { - return cacheTimeUnit; - } - - /** - * Returns the cache TTL converted to milliseconds. - *

- * If the value is zero, caching is disabled. - *

- * If the value is negative, data is cached forever (i.e. it will only be read again from the database - * if the SDK is restarted). Use the "cached forever" mode with caution: it means that in a scenario - * where multiple processes are sharing the database, and the current process loses connectivity to - * LaunchDarkly while other processes are still receiving updates and writing them to the database, - * the current process will have stale data. - * - * @return the TTL in milliseconds - */ - public long getCacheTimeMillis() { - return cacheTimeUnit.toMillis(cacheTime); - } - - /** - * Returns the {@link StaleValuesPolicy} setting. - * @return the expiration policy - */ - public StaleValuesPolicy getStaleValuesPolicy() { - return staleValuesPolicy; - } - - /** - * Specifies the cache TTL. Items will be evicted or refreshed (depending on {@link #staleValuesPolicy(StaleValuesPolicy)}) - * after this amount of time from the time when they were originally cached. If the time is less - * than or equal to zero, caching is disabled. - * after this amount of time from the time when they were originally cached. - *

- * If the value is zero, caching is disabled. - *

- * If the value is negative, data is cached forever (i.e. it will only be read again from the database - * if the SDK is restarted). Use the "cached forever" mode with caution: it means that in a scenario - * where multiple processes are sharing the database, and the current process loses connectivity to - * LaunchDarkly while other processes are still receiving updates and writing them to the database, - * the current process will have stale data. - * - * @param cacheTime the cache TTL in whatever units you wish - * @param timeUnit the time unit - * @return an updated parameters object - */ - public FeatureStoreCacheConfig ttl(long cacheTime, TimeUnit timeUnit) { - return new FeatureStoreCacheConfig(cacheTime, timeUnit, staleValuesPolicy); - } - - /** - * Shortcut for calling {@link #ttl(long, TimeUnit)} with {@link TimeUnit#MILLISECONDS}. - * - * @param millis the cache TTL in milliseconds - * @return an updated parameters object - */ - public FeatureStoreCacheConfig ttlMillis(long millis) { - return ttl(millis, TimeUnit.MILLISECONDS); - } - - /** - * Shortcut for calling {@link #ttl(long, TimeUnit)} with {@link TimeUnit#SECONDS}. - * - * @param seconds the cache TTL in seconds - * @return an updated parameters object - */ - public FeatureStoreCacheConfig ttlSeconds(long seconds) { - return ttl(seconds, TimeUnit.SECONDS); - } - - /** - * Specifies how the cache (if any) should deal with old values when the cache TTL expires. The default - * is {@link StaleValuesPolicy#EVICT}. This property has no effect if caching is disabled. - * - * @param policy a {@link StaleValuesPolicy} constant - * @return an updated parameters object - */ - public FeatureStoreCacheConfig staleValuesPolicy(StaleValuesPolicy policy) { - return new FeatureStoreCacheConfig(cacheTime, cacheTimeUnit, policy); - } - - @Override - public boolean equals(Object other) { - if (other instanceof FeatureStoreCacheConfig) { - FeatureStoreCacheConfig o = (FeatureStoreCacheConfig) other; - return o.cacheTime == this.cacheTime && o.cacheTimeUnit == this.cacheTimeUnit && - o.staleValuesPolicy == this.staleValuesPolicy; - } - return false; - } - - @Override - public int hashCode() { - return Objects.hash(cacheTime, cacheTimeUnit, staleValuesPolicy); - } -} diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreClientWrapper.java b/src/main/java/com/launchdarkly/client/FeatureStoreClientWrapper.java deleted file mode 100644 index 5c2bba097..000000000 --- a/src/main/java/com/launchdarkly/client/FeatureStoreClientWrapper.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.launchdarkly.client; - -import java.io.IOException; -import java.util.Map; - -/** - * Provides additional behavior that the client requires before or after feature store operations. - * Currently this just means sorting the data set for init(). In the future we may also use this - * to provide an update listener capability. - * - * @since 4.6.1 - */ -class FeatureStoreClientWrapper implements FeatureStore { - private final FeatureStore store; - - public FeatureStoreClientWrapper(FeatureStore store) { - this.store = store; - } - - @Override - public void init(Map, Map> allData) { - store.init(FeatureStoreDataSetSorter.sortAllCollections(allData)); - } - - @Override - public T get(VersionedDataKind kind, String key) { - return store.get(kind, key); - } - - @Override - public Map all(VersionedDataKind kind) { - return store.all(kind); - } - - @Override - public void delete(VersionedDataKind kind, String key, int version) { - store.delete(kind, key, version); - } - - @Override - public void upsert(VersionedDataKind kind, T item) { - store.upsert(kind, item); - } - - @Override - public boolean initialized() { - return store.initialized(); - } - - @Override - public void close() throws IOException { - store.close(); - } -} diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreDataSetSorter.java b/src/main/java/com/launchdarkly/client/FeatureStoreDataSetSorter.java deleted file mode 100644 index d2ae25fc3..000000000 --- a/src/main/java/com/launchdarkly/client/FeatureStoreDataSetSorter.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.launchdarkly.client; - -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.ImmutableSortedMap; - -import java.util.Comparator; -import java.util.HashMap; -import java.util.Map; - -/** - * Implements a dependency graph ordering for data to be stored in a feature store. We must use this - * on every data set that will be passed to {@link com.launchdarkly.client.FeatureStore#init(Map)}. - * - * @since 4.6.1 - */ -abstract class FeatureStoreDataSetSorter { - /** - * Returns a copy of the input map that has the following guarantees: the iteration order of the outer - * map will be in ascending order by {@link VersionedDataKind#getPriority()}; and for each data kind - * that returns true for {@link VersionedDataKind#isDependencyOrdered()}, the inner map will have an - * iteration order where B is before A if A has a dependency on B. - * - * @param allData the unordered data set - * @return a map with a defined ordering - */ - public static Map, Map> sortAllCollections( - Map, Map> allData) { - ImmutableSortedMap.Builder, Map> builder = - ImmutableSortedMap.orderedBy(dataKindPriorityOrder); - for (Map.Entry, Map> entry: allData.entrySet()) { - VersionedDataKind kind = entry.getKey(); - builder.put(kind, sortCollection(kind, entry.getValue())); - } - return builder.build(); - } - - private static Map sortCollection(VersionedDataKind kind, Map input) { - if (!kind.isDependencyOrdered() || input.isEmpty()) { - return input; - } - - Map remainingItems = new HashMap<>(input); - ImmutableMap.Builder builder = ImmutableMap.builder(); - // Note, ImmutableMap guarantees that the iteration order will be the same as the builder insertion order - - while (!remainingItems.isEmpty()) { - // pick a random item that hasn't been updated yet - for (Map.Entry entry: remainingItems.entrySet()) { - addWithDependenciesFirst(kind, entry.getValue(), remainingItems, builder); - break; - } - } - - return builder.build(); - } - - private static void addWithDependenciesFirst(VersionedDataKind kind, - VersionedData item, - Map remainingItems, - ImmutableMap.Builder builder) { - remainingItems.remove(item.getKey()); // we won't need to visit this item again - for (String prereqKey: kind.getDependencyKeys(item)) { - VersionedData prereqItem = remainingItems.get(prereqKey); - if (prereqItem != null) { - addWithDependenciesFirst(kind, prereqItem, remainingItems, builder); - } - } - builder.put(item.getKey(), item); - } - - private static Comparator> dataKindPriorityOrder = new Comparator>() { - @Override - public int compare(VersionedDataKind o1, VersionedDataKind o2) { - return o1.getPriority() - o2.getPriority(); - } - }; -} diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreFactory.java b/src/main/java/com/launchdarkly/client/FeatureStoreFactory.java deleted file mode 100644 index c019de9c9..000000000 --- a/src/main/java/com/launchdarkly/client/FeatureStoreFactory.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.launchdarkly.client; - -/** - * Interface for a factory that creates some implementation of {@link FeatureStore}. - * @see Components - * @since 4.0.0 - */ -public interface FeatureStoreFactory { - /** - * Creates an implementation instance. - * @return a {@link FeatureStore} - */ - FeatureStore createFeatureStore(); -} diff --git a/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java b/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java deleted file mode 100644 index d2937cc79..000000000 --- a/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java +++ /dev/null @@ -1,143 +0,0 @@ -package com.launchdarkly.client; - -import com.google.common.collect.ImmutableMap; -import com.launchdarkly.client.interfaces.DiagnosticDescription; -import com.launchdarkly.client.value.LDValue; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.util.HashMap; -import java.util.Map; - -/** - * A thread-safe, versioned store for feature flags and related data based on a - * {@link HashMap}. This is the default implementation of {@link FeatureStore}. - */ -public class InMemoryFeatureStore implements FeatureStore, DiagnosticDescription { - private static final Logger logger = LoggerFactory.getLogger(InMemoryFeatureStore.class); - - private volatile ImmutableMap, Map> allData = ImmutableMap.of(); - private volatile boolean initialized = false; - private Object writeLock = new Object(); - - @Override - public T get(VersionedDataKind kind, String key) { - Map items = allData.get(kind); - if (items == null) { - logger.debug("[get] no objects exist for \"{}\". Returning null", kind.getNamespace()); - return null; - } - Object o = items.get(key); - if (o == null) { - logger.debug("[get] Key: {} not found in \"{}\". Returning null", key, kind.getNamespace()); - return null; - } - if (!kind.getItemClass().isInstance(o)) { - logger.warn("[get] Unexpected object class {} found for key: {} in \"{}\". Returning null", - o.getClass().getName(), key, kind.getNamespace()); - return null; - } - T item = kind.getItemClass().cast(o); - if (item.isDeleted()) { - logger.debug("[get] Key: {} has been deleted. Returning null", key); - return null; - } - logger.debug("[get] Key: {} with version: {} found in \"{}\".", key, item.getVersion(), kind.getNamespace()); - return item; - } - - @Override - public Map all(VersionedDataKind kind) { - Map fs = new HashMap<>(); - Map items = allData.get(kind); - if (items != null) { - for (Map.Entry entry : items.entrySet()) { - if (!entry.getValue().isDeleted()) { - fs.put(entry.getKey(), kind.getItemClass().cast(entry.getValue())); - } - } - } - return fs; - } - - @Override - public void init(Map, Map> allData) { - synchronized (writeLock) { - ImmutableMap.Builder, Map> newData = ImmutableMap.builder(); - for (Map.Entry, Map> entry: allData.entrySet()) { - // Note, the FeatureStore contract specifies that we should clone all of the maps. This doesn't - // really make a difference in regular use of the SDK, but not doing it could cause unexpected - // behavior in tests. - newData.put(entry.getKey(), ImmutableMap.copyOf(entry.getValue())); - } - this.allData = newData.build(); // replaces the entire map atomically - this.initialized = true; - } - } - - @Override - public void delete(VersionedDataKind kind, String key, int version) { - upsert(kind, kind.makeDeletedItem(key, version)); - } - - @Override - public void upsert(VersionedDataKind kind, T item) { - String key = item.getKey(); - synchronized (writeLock) { - Map existingItems = this.allData.get(kind); - VersionedData oldItem = null; - if (existingItems != null) { - oldItem = existingItems.get(key); - if (oldItem != null && oldItem.getVersion() >= item.getVersion()) { - return; - } - } - // The following logic is necessary because ImmutableMap.Builder doesn't support overwriting an existing key - ImmutableMap.Builder, Map> newData = ImmutableMap.builder(); - for (Map.Entry, Map> e: this.allData.entrySet()) { - if (!e.getKey().equals(kind)) { - newData.put(e.getKey(), e.getValue()); - } - } - if (existingItems == null) { - newData.put(kind, ImmutableMap.of(key, item)); - } else { - ImmutableMap.Builder itemsBuilder = ImmutableMap.builder(); - if (oldItem == null) { - itemsBuilder.putAll(existingItems); - } else { - for (Map.Entry e: existingItems.entrySet()) { - if (!e.getKey().equals(key)) { - itemsBuilder.put(e.getKey(), e.getValue()); - } - } - } - itemsBuilder.put(key, item); - newData.put(kind, itemsBuilder.build()); - } - this.allData = newData.build(); // replaces the entire map atomically - } - } - - @Override - public boolean initialized() { - return initialized; - } - - /** - * Does nothing; this class does not have any resources to release - * - * @throws IOException will never happen - */ - @Override - public void close() throws IOException { - return; - } - - @Override - public LDValue describeConfiguration(LDConfig config) { - return LDValue.of("memory"); - } -} diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java deleted file mode 100644 index 2a1be1b35..000000000 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ /dev/null @@ -1,757 +0,0 @@ -package com.launchdarkly.client; - -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 java.net.URI; - -import javax.net.ssl.SSLSocketFactory; -import javax.net.ssl.X509TrustManager; - -/** - * 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 { - 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_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; - private static final int DEFAULT_SAMPLING_INTERVAL = 0; - private static final int DEFAULT_USER_KEYS_CAPACITY = 1000; - private static final int DEFAULT_USER_KEYS_FLUSH_INTERVAL_SECONDS = 60 * 5; - private static final long DEFAULT_RECONNECT_TIME_MILLIS = StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS; - - protected static final LDConfig DEFAULT = new Builder().build(); - - final UpdateProcessorFactory dataSourceFactory; - final FeatureStoreFactory dataStoreFactory; - final boolean diagnosticOptOut; - final EventProcessorFactory eventProcessorFactory; - final HttpConfiguration httpConfig; - final boolean offline; - final long startWaitMillis; - - final URI deprecatedBaseURI; - final URI deprecatedEventsURI; - final URI deprecatedStreamURI; - final int deprecatedCapacity; - final int deprecatedFlushInterval; - final boolean deprecatedStream; - final FeatureStore deprecatedFeatureStore; - final boolean deprecatedAllAttributesPrivate; - final ImmutableSet deprecatedPrivateAttrNames; - final boolean deprecatedSendEvents; - final long deprecatedPollingIntervalMillis; - final int deprecatedSamplingInterval; - final long deprecatedReconnectTimeMs; - final int deprecatedUserKeysCapacity; - final int deprecatedUserKeysFlushInterval; - final boolean deprecatedInlineUsersInEvents; - - protected LDConfig(Builder builder) { - this.dataStoreFactory = builder.dataStoreFactory; - this.eventProcessorFactory = builder.eventProcessorFactory; - this.dataSourceFactory = builder.dataSourceFactory; - this.diagnosticOptOut = builder.diagnosticOptOut; - this.offline = builder.offline; - this.startWaitMillis = builder.startWaitMillis; - - 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.deprecatedAllAttributesPrivate = builder.allAttributesPrivate; - this.deprecatedBaseURI = builder.baseURI; - this.deprecatedCapacity = builder.capacity; - this.deprecatedEventsURI = builder.eventsURI; - this.deprecatedFeatureStore = builder.featureStore; - this.deprecatedFlushInterval = builder.flushIntervalSeconds; - this.deprecatedInlineUsersInEvents = builder.inlineUsersInEvents; - if (builder.pollingIntervalMillis < MIN_POLLING_INTERVAL_MILLIS) { - this.deprecatedPollingIntervalMillis = MIN_POLLING_INTERVAL_MILLIS; - } else { - this.deprecatedPollingIntervalMillis = builder.pollingIntervalMillis; - } - this.deprecatedPrivateAttrNames = builder.privateAttrNames; - this.deprecatedSendEvents = builder.sendEvents; - this.deprecatedStream = builder.stream; - this.deprecatedStreamURI = builder.streamURI; - this.deprecatedSamplingInterval = builder.samplingInterval; - this.deprecatedReconnectTimeMs = builder.reconnectTimeMillis; - this.deprecatedUserKeysCapacity = builder.userKeysCapacity; - this.deprecatedUserKeysFlushInterval = builder.userKeysFlushInterval; - } - - LDConfig(LDConfig config) { - this.dataSourceFactory = config.dataSourceFactory; - this.dataStoreFactory = config.dataStoreFactory; - this.diagnosticOptOut = config.diagnosticOptOut; - this.eventProcessorFactory = config.eventProcessorFactory; - this.httpConfig = config.httpConfig; - this.offline = config.offline; - this.startWaitMillis = config.startWaitMillis; - - this.deprecatedAllAttributesPrivate = config.deprecatedAllAttributesPrivate; - this.deprecatedBaseURI = config.deprecatedBaseURI; - this.deprecatedCapacity = config.deprecatedCapacity; - this.deprecatedEventsURI = config.deprecatedEventsURI; - this.deprecatedFeatureStore = config.deprecatedFeatureStore; - this.deprecatedFlushInterval = config.deprecatedFlushInterval; - this.deprecatedInlineUsersInEvents = config.deprecatedInlineUsersInEvents; - this.deprecatedPollingIntervalMillis = config.deprecatedPollingIntervalMillis; - this.deprecatedPrivateAttrNames = config.deprecatedPrivateAttrNames; - this.deprecatedReconnectTimeMs = config.deprecatedReconnectTimeMs; - this.deprecatedSamplingInterval = config.deprecatedSamplingInterval; - this.deprecatedSendEvents = config.deprecatedSendEvents; - this.deprecatedStream = config.deprecatedStream; - this.deprecatedStreamURI = config.deprecatedStreamURI; - this.deprecatedUserKeysCapacity = config.deprecatedUserKeysCapacity; - this.deprecatedUserKeysFlushInterval = config.deprecatedUserKeysFlushInterval; - } - - /** - * A builder that helps construct - * {@link com.launchdarkly.client.LDConfig} objects. Builder calls can be chained, enabling the - * following pattern: - *

-   * LDConfig config = new LDConfig.Builder()
-   *      .connectTimeoutMillis(3)
-   *      .socketTimeoutMillis(3)
-   *      .build()
-   * 
- */ - public static class Builder { - private URI baseURI = DEFAULT_BASE_URI; - private URI eventsURI = DEFAULT_EVENTS_URI; - private URI streamURI = DEFAULT_STREAM_URI; - 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; - private String proxyHost = "localhost"; - private int proxyPort = -1; - private String proxyUsername = null; - private String proxyPassword = null; - private boolean stream = true; - private boolean offline = false; - private boolean allAttributesPrivate = false; - private boolean sendEvents = true; - private long pollingIntervalMillis = MIN_POLLING_INTERVAL_MILLIS; - private FeatureStore featureStore = null; - private FeatureStoreFactory dataStoreFactory = null; - private EventProcessorFactory eventProcessorFactory = null; - private UpdateProcessorFactory dataSourceFactory = null; - private long startWaitMillis = DEFAULT_START_WAIT_MILLIS; - private int samplingInterval = DEFAULT_SAMPLING_INTERVAL; - private long reconnectTimeMillis = DEFAULT_RECONNECT_TIME_MILLIS; - private ImmutableSet privateAttrNames = ImmutableSet.of(); - private int userKeysCapacity = DEFAULT_USER_KEYS_CAPACITY; - private int userKeysFlushInterval = DEFAULT_USER_KEYS_FLUSH_INTERVAL_SECONDS; - private boolean inlineUsersInEvents = false; - private SSLSocketFactory sslSocketFactory = null; - private X509TrustManager trustManager = null; - private String wrapperName = null; - private String wrapperVersion = null; - - /** - * Creates a builder with all configuration parameters set to the default - */ - public Builder() { - } - - /** - * Deprecated method for setting the base URI for the polling service. - *

- * This method has no effect if you have used {@link #dataSource(UpdateProcessorFactory)} to - * specify polling or streaming options, which is the preferred method. - * - * @param baseURI the base URL of the LaunchDarkly server for this configuration. - * @return the builder - * @deprecated Use {@link Components#streamingDataSource()} with {@link StreamingDataSourceBuilder#pollingBaseURI(URI)}, - * or {@link Components#pollingDataSource()} with {@link PollingDataSourceBuilder#baseURI(URI)}. - */ - @Deprecated - public Builder baseURI(URI baseURI) { - this.baseURI = baseURI; - return this; - } - - /** - * Deprecated method for setting the base URI of the LaunchDarkly analytics event service. - * - * @param eventsURI the events URL of the LaunchDarkly server for this configuration - * @return the builder - * @deprecated Use @link {@link Components#sendEvents()} wtih {@link EventProcessorBuilder#baseURI(URI)}. - */ - @Deprecated - public Builder eventsURI(URI eventsURI) { - this.eventsURI = eventsURI; - return this; - } - - /** - * Deprecated method for setting the base URI for the streaming service. - *

- * This method has no effect if you have used {@link #dataSource(UpdateProcessorFactory)} to - * specify polling or streaming options, which is the preferred method. - * - * @param streamURI the base URL of the LaunchDarkly streaming server - * @return the builder - * @deprecated Use {@link Components#streamingDataSource()} with {@link StreamingDataSourceBuilder#pollingBaseURI(URI)}. - */ - @Deprecated - public Builder streamURI(URI streamURI) { - this.streamURI = streamURI; - return this; - } - - /** - * Sets the implementation of the data store to be used for holding feature flags and - * related data received from LaunchDarkly, using a factory object. The default is - * {@link Components#inMemoryDataStore()}; for database integrations, use - * {@link Components#persistentDataStore(com.launchdarkly.client.interfaces.PersistentDataStoreFactory)}. - *

- * Note that the interface is still called {@link FeatureStoreFactory}, but in a future version - * it will be renamed to {@code DataStoreFactory}. - * - * @param factory the factory object - * @return the builder - * @since 4.12.0 - */ - public Builder dataStore(FeatureStoreFactory factory) { - this.dataStoreFactory = factory; - return this; - } - - /** - * Sets the implementation of {@link FeatureStore} to be used for holding feature flags and - * related data received from LaunchDarkly. The default is {@link InMemoryFeatureStore}, but - * you may use {@link RedisFeatureStore} or a custom implementation. - * @param store the feature store implementation - * @return the builder - * @deprecated Please use {@link #featureStoreFactory(FeatureStoreFactory)}. - */ - @Deprecated - public Builder featureStore(FeatureStore store) { - this.featureStore = store; - return this; - } - - /** - * Deprecated name for {@link #dataStore(FeatureStoreFactory)}. - * @param factory the factory object - * @return the builder - * @since 4.0.0 - * @deprecated Use {@link #dataStore(FeatureStoreFactory)}. - */ - @Deprecated - public Builder featureStoreFactory(FeatureStoreFactory factory) { - this.dataStoreFactory = factory; - return this; - } - - /** - * Sets the implementation of {@link EventProcessor} to be used for processing analytics events. - *

- * The default is {@link Components#sendEvents()}, but you may choose to use a custom implementation - * (for instance, a test fixture), or disable events with {@link Components#noEvents()}. - * - * @param factory a builder/factory object for event configuration - * @return the builder - * @since 4.12.0 - */ - public Builder events(EventProcessorFactory factory) { - this.eventProcessorFactory = factory; - return this; - } - - /** - * Deprecated name for {@link #events(EventProcessorFactory)}. - * @param factory the factory object - * @return the builder - * @since 4.0.0 - * @deprecated Use {@link #events(EventProcessorFactory)}. - */ - @Deprecated - public Builder eventProcessorFactory(EventProcessorFactory factory) { - this.eventProcessorFactory = factory; - return this; - } - - /** - * Sets the implementation of the component that receives feature flag data from LaunchDarkly, - * using a factory object. Depending on the implementation, the factory may be a builder that - * allows you to set other configuration options as well. - *

- * The default is {@link Components#streamingDataSource()}. You may instead use - * {@link Components#pollingDataSource()}, or a test fixture such as - * {@link com.launchdarkly.client.integrations.FileData#dataSource()}. See those methods - * for details on how to configure them. - *

- * Note that the interface is still named {@link UpdateProcessorFactory}, but in a future version - * it will be renamed to {@code DataSourceFactory}. - * - * @param factory the factory object - * @return the builder - * @since 4.12.0 - */ - public Builder dataSource(UpdateProcessorFactory factory) { - this.dataSourceFactory = factory; - return this; - } - - /** - * Deprecated name for {@link #dataSource(UpdateProcessorFactory)}. - * @param factory the factory object - * @return the builder - * @since 4.0.0 - * @deprecated Use {@link #dataSource(UpdateProcessorFactory)}. - */ - @Deprecated - public Builder updateProcessorFactory(UpdateProcessorFactory factory) { - this.dataSourceFactory = factory; - return this; - } - - /** - * Deprecated method for enabling or disabling streaming mode. - *

- * By default, streaming is enabled. It should only be disabled on the advice of LaunchDarkly support. - *

- * This method has no effect if you have specified a data source with {@link #dataSource(UpdateProcessorFactory)}, - * which is the preferred method. - * - * @param stream whether streaming mode should be enabled - * @return the builder - * @deprecated Use {@link Components#streamingDataSource()} or {@link Components#pollingDataSource()}. - */ - @Deprecated - public Builder stream(boolean stream) { - this.stream = stream; - return this; - } - - /** - * 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) { - return connectTimeoutMillis(connectTimeout * 1000); - } - - /** - * 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) { - return socketTimeoutMillis(socketTimeout * 1000); - } - - /** - * 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.connectTimeoutMillis = connectTimeoutMillis; - return this; - } - - /** - * 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.socketTimeoutMillis = socketTimeoutMillis; - return this; - } - - /** - * Deprecated method for setting the event buffer flush interval. - * - * @param flushInterval the flush interval in seconds - * @return the builder - * @deprecated Use {@link Components#sendEvents()} with {@link EventProcessorBuilder#flushIntervalSeconds(int)}. - */ - @Deprecated - public Builder flushInterval(int flushInterval) { - this.flushIntervalSeconds = flushInterval; - return this; - } - - /** - * Deprecated method for setting the capacity of the events buffer. - * - * @param capacity the capacity of the event buffer - * @return the builder - * @deprecated Use {@link Components#sendEvents()} with {@link EventProcessorBuilder#capacity(int)}. - */ - @Deprecated - public Builder capacity(int capacity) { - this.capacity = capacity; - return this; - } - - /** - * 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. - *

- * - * @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; - } - - /** - * 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; - } - - /** - * 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; - } - - /** - * 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; - } - - /** - * 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; - return this; - } - - /** - * Deprecated method for using the LaunchDarkly Relay Proxy in daemon mode. - *

- * See {@link Components#externalUpdatesOnly()} for the preferred way to do this. - * - * @param useLdd true to use the relay in daemon mode; false to use streaming or polling - * @return the builder - * @deprecated Use {@link Components#externalUpdatesOnly()}. - */ - @Deprecated - public Builder useLdd(boolean useLdd) { - if (useLdd) { - return dataSource(Components.externalUpdatesOnly()); - } else { - return dataSource(null); - } - } - - /** - * Set whether this client is offline. - *

- * In offline mode, the SDK will not make network connections to LaunchDarkly for any purpose. Feature - * flag data will only be available if it already exists in the data store, and analytics events will - * not be sent. - *

- * This is equivalent to calling {@code dataSource(Components.externalUpdatesOnly())} and - * {@code events(Components.noEvents())}. It overrides any other values you may have set for - * {@link #dataSource(UpdateProcessorFactory)} or {@link #events(EventProcessorFactory)}. - * - * @param offline when set to true no calls to LaunchDarkly will be made - * @return the builder - */ - public Builder offline(boolean offline) { - this.offline = offline; - return this; - } - - /** - * Deprecated method for making all user attributes private. - * - * @param allPrivate true if all user attributes should be private - * @return the builder - * @deprecated Use {@link Components#sendEvents()} with {@link EventProcessorBuilder#allAttributesPrivate(boolean)}. - */ - @Deprecated - public Builder allAttributesPrivate(boolean allPrivate) { - this.allAttributesPrivate = allPrivate; - return this; - } - - /** - * Deprecated method for disabling analytics events. - * - * @param sendEvents when set to false, no events will be sent to LaunchDarkly - * @return the builder - * @deprecated Use {@link Components#noEvents()}. - */ - @Deprecated - public Builder sendEvents(boolean sendEvents) { - this.sendEvents = sendEvents; - return this; - } - - /** - * Deprecated method for setting the polling interval in polling mode. - *

- * Values less than the default of 30000 will be set to the default. - *

- * This method has no effect if you have not disabled streaming mode, or if you have specified - * a non-polling data source with {@link #dataSource(UpdateProcessorFactory)}. - * - * @param pollingIntervalMillis rule update polling interval in milliseconds - * @return the builder - * @deprecated Use {@link Components#pollingDataSource()} and {@link PollingDataSourceBuilder#pollIntervalMillis(long)}. - */ - @Deprecated - public Builder pollingIntervalMillis(long pollingIntervalMillis) { - this.pollingIntervalMillis = pollingIntervalMillis; - return this; - } - - /** - * Set how long the constructor will block awaiting a successful connection to LaunchDarkly. - * Setting this to 0 will not block and cause the constructor to return immediately. - * Default value: 5000 - * - * @param startWaitMillis milliseconds to wait - * @return the builder - */ - public Builder startWaitMillis(long startWaitMillis) { - this.startWaitMillis = startWaitMillis; - return this; - } - - /** - * Enable event sampling. When set to the default of zero, sampling is disabled and all events - * are sent back to LaunchDarkly. When set to greater than zero, there is a 1 in - * samplingInterval chance events will be will be sent. - *

Example: if you want 5% sampling rate, set samplingInterval to 20. - * - * @param samplingInterval the sampling interval - * @return the builder - * @deprecated This feature will be removed in a future version of the SDK. - */ - @Deprecated - public Builder samplingInterval(int samplingInterval) { - this.samplingInterval = samplingInterval; - return this; - } - - /** - * Deprecated method for setting the initial reconnect delay for the streaming connection. - *

- * This method has no effect if you have disabled streaming mode, or if you have specified a - * non-streaming data source with {@link #dataSource(UpdateProcessorFactory)}. - * - * @param reconnectTimeMs the reconnect time base value in milliseconds - * @return the builder - * @deprecated Use {@link Components#streamingDataSource()} and {@link StreamingDataSourceBuilder#initialReconnectDelayMillis(long)}. - */ - @Deprecated - public Builder reconnectTimeMs(long reconnectTimeMs) { - this.reconnectTimeMillis = reconnectTimeMs; - return this; - } - - /** - * Deprecated method for specifying globally private user attributes. - * - * @param names a set of names that will be removed from user data set to LaunchDarkly - * @return the builder - * @deprecated Use {@link Components#sendEvents()} with {@link EventProcessorBuilder#privateAttributeNames(String...)}. - */ - @Deprecated - public Builder privateAttributeNames(String... names) { - this.privateAttrNames = ImmutableSet.copyOf(names); - return this; - } - - /** - * Deprecated method for setting the number of user keys that can be cached for analytics events. - * - * @param capacity the maximum number of user keys to remember - * @return the builder - * @deprecated Use {@link Components#sendEvents()} with {@link EventProcessorBuilder#userKeysCapacity(int)}. - */ - @Deprecated - public Builder userKeysCapacity(int capacity) { - this.userKeysCapacity = capacity; - return this; - } - - /** - * Deprecated method for setting the expiration time of the user key cache for analytics events. - * - * @param flushInterval the flush interval in seconds - * @return the builder - * @deprecated Use {@link Components#sendEvents()} with {@link EventProcessorBuilder#userKeysFlushIntervalSeconds(int)}. - */ - @Deprecated - public Builder userKeysFlushInterval(int flushInterval) { - this.userKeysFlushInterval = flushInterval; - return this; - } - - /** - * Deprecated method for setting whether to include full user details in every analytics event. - * - * @param inlineUsersInEvents true if you want full user details in each event - * @return the builder - * @deprecated Use {@link Components#sendEvents()} with {@link EventProcessorBuilder#inlineUsersInEvents(boolean)}. - */ - @Deprecated - public Builder inlineUsersInEvents(boolean inlineUsersInEvents) { - this.inlineUsersInEvents = inlineUsersInEvents; - return this; - } - - /** - * Set to true to opt out of sending diagnostics data. - *

- * Unless {@code diagnosticOptOut} is set to true, the client will send some diagnostics data to the - * LaunchDarkly servers in order to assist in the development of future SDK improvements. These diagnostics - * consist of an initial payload containing some details of SDK in use, the SDK's configuration, and the platform - * the SDK is being run on; as well as payloads sent periodically with information on irregular occurrences such - * as dropped events. - * - * @see com.launchdarkly.client.integrations.EventProcessorBuilder#diagnosticRecordingIntervalSeconds(int) - * - * @param diagnosticOptOut true if you want to opt out of sending any diagnostics data - * @return the builder - * @since 4.12.0 - */ - public Builder diagnosticOptOut(boolean diagnosticOptOut) { - this.diagnosticOptOut = diagnosticOptOut; - return this; - } - - /** - * 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; - } - - /** - * Deprecated method of specifing a wrapper library identifier. - * - * @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; - } - - /** - * Builds the configured {@link com.launchdarkly.client.LDConfig} object. - * - * @return the {@link com.launchdarkly.client.LDConfig} configured by this builder - */ - public LDConfig build() { - return new LDConfig(this); - } - } -} diff --git a/src/main/java/com/launchdarkly/client/LDCountryCode.java b/src/main/java/com/launchdarkly/client/LDCountryCode.java deleted file mode 100644 index ad344e4dc..000000000 --- a/src/main/java/com/launchdarkly/client/LDCountryCode.java +++ /dev/null @@ -1,2658 +0,0 @@ -/* - * Copyright (C) 2012-2014 Neo Visionaries Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Taken verbatim from https://github.com/TakahikoKawasaki/nv-i18n and moved to - * the com.launchdarkly.client package to avoid class loading issues. - */ -package com.launchdarkly.client; - - -import java.util.ArrayList; -import java.util.Currency; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.regex.Pattern; - - -/** - * ISO 3166-1 country code. - * - *

- * Enum names of this enum themselves are represented by - * ISO 3166-1 alpha-2 - * code (2-letter upper-case alphabets). There are instance methods to get the - * country name ({@link #getName()}), the - * ISO 3166-1 alpha-3 - * code ({@link #getAlpha3()}) and the - * ISO 3166-1 numeric - * code ({@link #getNumeric()}). - * In addition, there are static methods to get a {@code CountryCode} instance that - * corresponds to a given alpha-2/alpha-3/numeric code ({@link #getByCode(String)}, - * {@link #getByCode(int)}). - *

- * - *
- * // List all the country codes.
- * for (CountryCode code : CountryCode.values())
- * {
- *     // For example, "[US] United States" is printed.
- *     System.out.format("[%s] %s\n", code, code.{@link #getName()});
- * }
- *
- * // Get a CountryCode instance by ISO 3166-1 code.
- * CountryCode code = CountryCode.{@link #getByCode(String) getByCode}("JP");
- *
- * // Print all the information. Output will be:
- * //
- * //     Country name            = Japan
- * //     ISO 3166-1 alpha-2 code = JP
- * //     ISO 3166-1 alpha-3 code = JPN
- * //     ISO 3166-1 numeric code = 392
- * //     Assignment state        = OFFICIALLY_ASSIGNED
- * //
- * System.out.println("Country name            = " + code.{@link #getName()});
- * System.out.println("ISO 3166-1 alpha-2 code = " + code.{@link #getAlpha2()});
- * System.out.println("ISO 3166-1 alpha-3 code = " + code.{@link #getAlpha3()});
- * System.out.println("ISO 3166-1 numeric code = " + code.{@link #getNumeric()});
- * System.out.println("Assignment state        = " + code.{@link #getAssignment()});
- *
- * // Convert to a Locale instance.
- * {@link Locale} locale = code.{@link #toLocale()};
- *
- * // Get a CountryCode by a Locale instance.
- * code = CountryCode.{@link #getByLocale(Locale) getByLocale}(locale);
- *
- * // Get the currency of the country.
- * {@link Currency} currency = code.{@link #getCurrency()};
- *
- * // Get a list by a regular expression for names.
- * //
- * // The list will contain:
- * //
- * //     CountryCode.AE : United Arab Emirates
- * //     CountryCode.GB : United Kingdom
- * //     CountryCode.TZ : Tanzania, United Republic of
- * //     CountryCode.UK : United Kingdom
- * //     CountryCode.UM : United States Minor Outlying Islands
- * //     CountryCode.US : United States
- * //
- * List<CountryCode> list = CountryCode.{@link #findByName(String) findByName}(".*United.*");
- * 
- * - * @author Takahiko Kawasaki - */ -@SuppressWarnings({"deprecation", "DeprecatedIsStillUsed"}) -@Deprecated -public enum LDCountryCode -{ - /** - * Ascension Island - * [AC, ASC, -1, - * Exceptionally reserved] - */ - AC("Ascension Island", "ASC", -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * Andorra - * [AD, AND, 16, - * Officially assigned] - */ - AD("Andorra", "AND", 20, Assignment.OFFICIALLY_ASSIGNED), - - /** - * United Arab Emirates - * [AE, AE, 784, - * Officially assigned] - */ - AE("United Arab Emirates", "ARE", 784, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Afghanistan - * [AF, AFG, 4, - * Officially assigned] - */ - AF("Afghanistan", "AFG", 4, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Antigua and Barbuda - * [AG, ATG, 28, - * Officially assigned] - */ - AG("Antigua and Barbuda", "ATG", 28, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Anguilla - * [AI, AIA, 660, - * Officially assigned] - */ - AI("Anguilla", "AIA", 660, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Albania - * [AL, ALB, 8, - * Officially assigned] - */ - AL("Albania", "ALB", 8, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Armenia - * [AM, ARM, 51, - * Officially assigned] - */ - AM("Armenia", "ARM", 51, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Netherlands Antilles - * [AN, ANHH, 530, - * Traditionally reserved] - */ - AN("Netherlands Antilles", "ANHH", 530, Assignment.TRANSITIONALLY_RESERVED), - - /** - * Angola - * [AO, AGO, 24, - * Officially assigned] - */ - AO("Angola", "AGO", 24, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Antarctica - * [AQ, ATA, 10, - * Officially assigned] - */ - AQ("Antarctica", "ATA", 10, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Argentina - * [AR, ARG, 32, - * Officially assigned] - */ - AR("Argentina", "ARG", 32, Assignment.OFFICIALLY_ASSIGNED), - - /** - * American Samoa - * [AS, ASM, 16, - * Officially assigned] - */ - AS("American Samoa", "ASM", 16, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Austria - * [AT, AUT, 40, - * Officially assigned] - */ - AT("Austria", "AUT", 40, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Australia - * [AU, AUS, 36, - * Officially assigned] - */ - AU("Australia", "AUS", 36, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Aruba - * [AW, ABW, 533, - * Officially assigned] - */ - AW("Aruba", "ABW", 533, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Åland Islands - * [AX, ALA, 248, - * Officially assigned] - */ - AX("\u212Bland Islands", "ALA", 248, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Azerbaijan - * [AZ, AZE, 31, - * Officially assigned] - */ - AZ("Azerbaijan", "AZE", 31, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bosnia and Herzegovina - * [BA, BIH, 70, - * Officially assigned] - */ - BA("Bosnia and Herzegovina", "BIH", 70, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Barbados - * [BB, BRB, 52, - * Officially assigned] - */ - BB("Barbados", "BRB", 52, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bangladesh - * [BD, BGD, 50, - * Officially assigned] - */ - BD("Bangladesh", "BGD", 50, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Belgium - * [BE, BEL, 56, - * Officially assigned] - */ - BE("Belgium", "BEL", 56, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Burkina Faso - * [BF, BFA, 854, - * Officially assigned] - */ - BF("Burkina Faso", "BFA", 854, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bulgaria - * [BG, BGR, 100, - * Officially assigned] - */ - BG("Bulgaria", "BGR", 100, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bahrain - * [BH, BHR, 48, - * Officially assigned] - */ - BH("Bahrain", "BHR", 48, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Burundi - * [BI, BDI, 108, - * Officially assigned] - */ - BI("Burundi", "BDI", 108, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Benin - * [BJ, BEN, 204, - * Officially assigned] - */ - BJ("Benin", "BEN", 204, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Saint Barthélemy - * [BL, BLM, 652, - * Officially assigned] - */ - BL("Saint Barth\u00E9lemy", "BLM", 652, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bermuda - * [BM, BMU, 60, - * Officially assigned] - */ - BM("Bermuda", "BMU", 60, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Brunei Darussalam - * [BN, BRN, 96, - * Officially assigned] - */ - BN("Brunei Darussalam", "BRN", 96, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bolivia, Plurinational State of - * [BO, BOL, 68, - * Officially assigned] - */ - BO("Bolivia, Plurinational State of", "BOL", 68, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bonaire, Sint Eustatius and Saba - * [BQ, BES, 535, - * Officially assigned] - */ - BQ("Bonaire, Sint Eustatius and Saba", "BES", 535, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Brazil - * [BR, BRA, 76, - * Officially assigned] - */ - BR("Brazil", "BRA", 76, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bahamas - * [BS, BHS, 44, - * Officially assigned] - */ - BS("Bahamas", "BHS", 44, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bhutan - * [BT, BTN, 64, - * Officially assigned] - */ - BT("Bhutan", "BTN", 64, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Burma - * [BU, BUMM, 104, - * Officially assigned] - * - * @see #MM - */ - BU("Burma", "BUMM", 104, Assignment.TRANSITIONALLY_RESERVED), - - /** - * Bouvet Island - * [BV, BVT, 74, - * Officially assigned] - */ - BV("Bouvet Island", "BVT", 74, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Botswana - * [BW, BWA, 72, - * Officially assigned] - */ - BW("Botswana", "BWA", 72, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Belarus - * [BY, BLR, 112, - * Officially assigned] - */ - BY("Belarus", "BLR", 112, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Belize - * [BZ, BLZ, 84, - * Officially assigned] - */ - BZ("Belize", "BLZ", 84, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Canada - * [CA, CAN, 124, - * Officially assigned] - */ - CA("Canada", "CAN", 124, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.CANADA; - } - }, - - /** - * Cocos (Keeling) Islands - * [CC, CCK, 166, - * Officially assigned] - */ - CC("Cocos (Keeling) Islands", "CCK", 166, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Congo, the Democratic Republic of the - * [CD, COD, 180, - * Officially assigned] - */ - CD("Congo, the Democratic Republic of the", "COD", 180, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Central African Republic - * [CF, CAF, 140, - * Officially assigned] - */ - CF("Central African Republic", "CAF", 140, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Congo - * [CG, COG, 178, - * Officially assigned] - */ - CG("Congo", "COG", 178, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Switzerland - * [CH, CHE, 756, - * Officially assigned] - */ - CH("Switzerland", "CHE", 756, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Côte d'Ivoire - * [CI, CIV, 384, - * Officially assigned] - */ - CI("C\u00F4te d'Ivoire", "CIV", 384, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Cook Islands - * [CK, COK, 184, - * Officially assigned] - */ - CK("Cook Islands", "COK", 184, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Chile - * [CL, CHL, 152, - * Officially assigned] - */ - CL("Chile", "CHL", 152, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Cameroon - * [CM, CMR, 120, - * Officially assigned] - */ - CM("Cameroon", "CMR", 120, Assignment.OFFICIALLY_ASSIGNED), - - /** - * China - * [CN, CHN, 156, - * Officially assigned] - */ - CN("China", "CHN", 156, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.CHINA; - } - }, - - /** - * Colombia - * [CO, COL, 170, - * Officially assigned] - */ - CO("Colombia", "COL", 170, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Clipperton Island - * [CP, CPT, -1, - * Exceptionally reserved] - */ - CP("Clipperton Island", "CPT", -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * Costa Rica - * [CR, CRI, 188, - * Officially assigned] - */ - CR("Costa Rica", "CRI", 188, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Serbia and Montenegro - * [CS, CSXX, 891, - * Traditionally reserved] - */ - CS("Serbia and Montenegro", "CSXX", 891, Assignment.TRANSITIONALLY_RESERVED), - - /** - * Cuba - * [CU, CUB, 192, - * Officially assigned] - */ - CU("Cuba", "CUB", 192, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Cape Verde - * [CV, CPV, 132, - * Officially assigned] - */ - CV("Cape Verde", "CPV", 132, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Curaçao - * [CW, CUW, 531, - * Officially assigned] - */ - CW("Cura\u00E7ao", "CUW", 531, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Christmas Island - * [CX, CXR, 162, - * Officially assigned] - */ - CX("Christmas Island", "CXR", 162, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Cyprus - * [CY, CYP, 196, - * Officially assigned] - */ - CY("Cyprus", "CYP", 196, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Czech Republic - * [CZ, CZE, 203, - * Officially assigned] - */ - CZ("Czech Republic", "CZE", 203, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Germany - * [DE, DEU, 276, - * Officially assigned] - */ - DE("Germany", "DEU", 276, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.GERMANY; - } - }, - - /** - * Diego Garcia - * [DG, DGA, -1, - * Exceptionally reserved] - */ - DG("Diego Garcia", "DGA", -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * Djibouti - * [DJ, DJI, 262, - * Officially assigned] - */ - DJ("Djibouti", "DJI", 262, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Denmark - * [DK, DNK, 208, - * Officially assigned] - */ - DK("Denmark", "DNK", 208, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Dominica - * [DM, DMA, 212, - * Officially assigned] - */ - DM("Dominica", "DMA", 212, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Dominican Republic - * [DO, DOM, 214, - * Officially assigned] - */ - DO("Dominican Republic", "DOM", 214, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Algeria - * [DZ, DZA, 12, - * Officially assigned] - */ - DZ("Algeria", "DZA", 12, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Ceuta, - * Melilla - * [EA, null, -1, - * Exceptionally reserved] - */ - EA("Ceuta, Melilla", null, -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * Ecuador - * [EC, ECU, 218, - * Officially assigned] - */ - EC("Ecuador", "ECU", 218, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Estonia - * [EE, EST, 233, - * Officially assigned] - */ - EE("Estonia", "EST", 233, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Egypt - * [EG, EGY, 818, - * Officially assigned] - */ - EG("Egypt", "EGY", 818, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Western Sahara - * [EH, ESH, 732, - * Officially assigned] - */ - EH("Western Sahara", "ESH", 732, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Eritrea - * [ER, ERI, 232, - * Officially assigned] - */ - ER("Eritrea", "ERI", 232, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Spain - * [ES, ESP, 724, - * Officially assigned] - */ - ES("Spain", "ESP", 724, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Ethiopia - * [ET, ETH, 231, - * Officially assigned] - */ - ET("Ethiopia", "ETH", 231, Assignment.OFFICIALLY_ASSIGNED), - - /** - * European Union - * [EU, null, -1, - * Exceptionally reserved] - */ - EU("European Union", null, -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * Finland - * [FI, FIN, 246, - * Officially assigned] - * - * @see #SF - */ - FI("Finland", "FIN", 246, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Fiji - * [FJ, FJI, 242, - * Officially assigned] - */ - FJ("Fiji", "FJI", 242, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Falkland Islands (Malvinas) - * [FK, FLK, 238, - * Officially assigned] - */ - FK("Falkland Islands (Malvinas)", "FLK", 238, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Micronesia, Federated States of - * [FM, FSM, 583, - * Officially assigned] - */ - FM("Micronesia, Federated States of", "FSM", 583, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Faroe Islands - * [FO, FRO, 234, - * Officially assigned] - */ - FO("Faroe Islands", "FRO", 234, Assignment.OFFICIALLY_ASSIGNED), - - /** - * France - * [FR, FRA, 250, - * Officially assigned] - */ - FR("France", "FRA", 250, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.FRANCE; - } - }, - - /** - * France, Metropolitan - * [FX, FXX, -1, - * Exceptionally reserved] - */ - FX("France, Metropolitan", "FXX", -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * Gabon - * [GA, GAB, 266, - * Officially assigned] - */ - GA("Gabon", "GAB", 266, Assignment.OFFICIALLY_ASSIGNED), - - /** - * United Kingdom - * [GB, GBR, 826, - * Officially assigned] - */ - GB("United Kingdom", "GBR", 826, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.UK; - } - }, - - /** - * Grenada - * [GD, GRD, 308, - * Officially assigned] - */ - GD("Grenada", "GRD", 308, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Georgia - * [GE, GEO, 268, - * Officially assigned] - */ - GE("Georgia", "GEO", 268, Assignment.OFFICIALLY_ASSIGNED), - - /** - * French Guiana - * [GF, GUF, 254, - * Officially assigned] - */ - GF("French Guiana", "GUF", 254, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Guernsey - * [GG, GGY, 831, - * Officially assigned] - */ - GG("Guernsey", "GGY", 831, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Ghana - * [GH, GHA, 288, - * Officially assigned] - */ - GH("Ghana", "GHA", 288, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Gibraltar - * [GI, GIB, 292, - * Officially assigned] - */ - GI("Gibraltar", "GIB", 292, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Greenland - * [GL, GRL, 304, - * Officially assigned] - */ - GL("Greenland", "GRL", 304, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Gambia - * [GM, GMB, 270, - * Officially assigned] - */ - GM("Gambia", "GMB", 270, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Guinea - * [GN, GIN, 324, - * Officially assigned] - */ - GN("Guinea", "GIN", 324, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Guadeloupe - * [GP, GLP, 312, - * Officially assigned] - */ - GP("Guadeloupe", "GLP", 312, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Equatorial Guinea - * [GQ, GNQ, 226, - * Officially assigned] - */ - GQ("Equatorial Guinea", "GNQ", 226, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Greece - * [GR, GRC, 300, - * Officially assigned] - */ - GR("Greece", "GRC", 300, Assignment.OFFICIALLY_ASSIGNED), - - /** - * South Georgia and the South Sandwich Islands - * [GS, SGS, 239, - * Officially assigned] - */ - GS("South Georgia and the South Sandwich Islands", "SGS", 239, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Guatemala - * [GT, GTM, 320, - * Officially assigned] - */ - GT("Guatemala", "GTM", 320, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Guam - * [GU, GUM, 316, - * Officially assigned] - */ - GU("Guam", "GUM", 316, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Guinea-Bissau - * [GW, GNB, 624, - * Officially assigned] - */ - GW("Guinea-Bissau", "GNB", 624, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Guyana - * [GY, GUY, 328, - * Officially assigned] - */ - GY("Guyana", "GUY", 328, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Hong Kong - * [HK, HKG, 344, - * Officially assigned] - */ - HK("Hong Kong", "HKG", 344, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Heard Island and McDonald Islands - * [HM, HMD, 334, - * Officially assigned] - */ - HM("Heard Island and McDonald Islands", "HMD", 334, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Honduras - * [HN, HND, 340, - * Officially assigned] - */ - HN("Honduras", "HND", 340, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Croatia - * [HR, HRV, 191, - * Officially assigned] - */ - HR("Croatia", "HRV", 191, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Haiti - * [HT, HTI, 332, - * Officially assigned] - */ - HT("Haiti", "HTI", 332, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Hungary - * [HU, HUN, 348, - * Officially assigned] - */ - HU("Hungary", "HUN", 348, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Canary Islands - * [IC, null, -1, - * Exceptionally reserved] - */ - IC("Canary Islands", null, -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * Indonesia - * [ID, IDN, 360, - * Officially assigned] - */ - ID("Indonesia", "IDN", 360, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Ireland - * [IE, IRL, 372, - * Officially assigned] - */ - IE("Ireland", "IRL", 372, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Israel - * [IL, ISR, 376, - * Officially assigned] - */ - IL("Israel", "ISR", 376, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Isle of Man - * [IM, IMN, 833, - * Officially assigned] - */ - IM("Isle of Man", "IMN", 833, Assignment.OFFICIALLY_ASSIGNED), - - /** - * India - * [IN, IND, 356, - * Officially assigned] - */ - IN("India", "IND", 356, Assignment.OFFICIALLY_ASSIGNED), - - /** - * British Indian Ocean Territory - * [IO, IOT, 86, - * Officially assigned] - */ - IO("British Indian Ocean Territory", "IOT", 86, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Iraq - * [IQ, IRQ, 368, - * Officially assigned] - */ - IQ("Iraq", "IRQ", 368, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Iran, Islamic Republic of - * [IR, IRN, 364, - * Officially assigned] - */ - IR("Iran, Islamic Republic of", "IRN", 364, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Iceland - * [IS, ISL, 352, - * Officially assigned] - */ - IS("Iceland", "ISL", 352, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Italy - * [IT, ITA, 380, - * Officially assigned] - */ - IT("Italy", "ITA", 380, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.ITALY; - } - }, - - /** - * Jersey - * [JE, JEY, 832, - * Officially assigned] - */ - JE("Jersey", "JEY", 832, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Jamaica - * [JM, JAM, 388, - * Officially assigned] - */ - JM("Jamaica", "JAM", 388, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Jordan - * [JO, JOR, 400, - * Officially assigned] - */ - JO("Jordan", "JOR", 400, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Japan - * [JP, JPN, 392, - * Officially assigned] - */ - JP("Japan", "JPN", 392, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.JAPAN; - } - }, - - /** - * Kenya - * [KE, KEN, 404, - * Officially assigned] - */ - KE("Kenya", "KEN", 404, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Kyrgyzstan - * [KG, KGZ, 417, - * Officially assigned] - */ - KG("Kyrgyzstan", "KGZ", 417, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Cambodia - * [KH, KHM, 116, - * Officially assigned] - */ - KH("Cambodia", "KHM", 116, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Kiribati - * [KI, KIR, 296, - * Officially assigned] - */ - KI("Kiribati", "KIR", 296, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Comoros - * [KM, COM, 174, - * Officially assigned] - */ - KM("Comoros", "COM", 174, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Saint Kitts and Nevis - * [KN, KNA, 659, - * Officially assigned] - */ - KN("Saint Kitts and Nevis", "KNA", 659, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Korea, Democratic People's Republic of - * [KP, PRK, 408, - * Officially assigned] - */ - KP("Korea, Democratic People's Republic of", "PRK", 408, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Korea, Republic of - * [KR, KOR, 410, - * Officially assigned] - */ - KR("Korea, Republic of", "KOR", 410, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.KOREA; - } - }, - - /** - * Kuwait - * [KW, KWT, 414, - * Officially assigned] - */ - KW("Kuwait", "KWT", 414, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Cayman Islands - * [KY, CYM, 136, - * Officially assigned] - */ - KY("Cayman Islands", "CYM", 136, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Kazakhstan - * [KZ, KAZ, 398, - * Officially assigned] - */ - KZ("Kazakhstan", "KAZ", 398, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Lao People's Democratic Republic - * [LA, LAO, 418, - * Officially assigned] - */ - LA("Lao People's Democratic Republic", "LAO", 418, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Lebanon - * [LB, LBN, 422, - * Officially assigned] - */ - LB("Lebanon", "LBN", 422, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Saint Lucia - * [LC, LCA, 662, - * Officially assigned] - */ - LC("Saint Lucia", "LCA", 662, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Liechtenstein - * [LI, LIE, 438, - * Officially assigned] - */ - LI("Liechtenstein", "LIE", 438, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Sri Lanka - * [LK, LKA, 144, - * Officially assigned] - */ - LK("Sri Lanka", "LKA", 144, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Liberia - * [LR, LBR, 430, - * Officially assigned] - */ - LR("Liberia", "LBR", 430, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Lesotho - * [LS, LSO, 426, - * Officially assigned] - */ - LS("Lesotho", "LSO", 426, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Lithuania - * [LT, LTU, 440, - * Officially assigned] - */ - LT("Lithuania", "LTU", 440, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Luxembourg - * [LU, LUX, 442, - * Officially assigned] - */ - LU("Luxembourg", "LUX", 442, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Latvia - * [LV, LVA, 428, - * Officially assigned] - */ - LV("Latvia", "LVA", 428, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Libya - * [LY, LBY, 434, - * Officially assigned] - */ - LY("Libya", "LBY", 434, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Morocco - * [MA, MAR, 504, - * Officially assigned] - */ - MA("Morocco", "MAR", 504, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Monaco - * [MC, MCO, 492, - * Officially assigned] - */ - MC("Monaco", "MCO", 492, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Moldova, Republic of - * [MD, MDA, 498, - * Officially assigned] - */ - MD("Moldova, Republic of", "MDA", 498, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Montenegro - * [ME, MNE, 499, - * Officially assigned] - */ - ME("Montenegro", "MNE", 499, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Saint Martin (French part) - * [MF, MAF, 663, - * Officially assigned] - */ - MF("Saint Martin (French part)", "MAF", 663, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Madagascar - * [MG, MDG, 450, - * Officially assigned] - */ - MG("Madagascar", "MDG", 450, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Marshall Islands - * [MH, MHL, 584, - * Officially assigned] - */ - MH("Marshall Islands", "MHL", 584, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Macedonia, the former Yugoslav Republic of - * [MK, MKD, 807, - * Officially assigned] - */ - MK("Macedonia, the former Yugoslav Republic of", "MKD", 807, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Mali - * [ML, MLI, 466, - * Officially assigned] - */ - ML("Mali", "MLI", 466, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Myanmar - * [MM, MMR, 104, - * Officially assigned] - * - * @see #BU - */ - MM("Myanmar", "MMR", 104, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Mongolia - * [MN, MNG, 496, - * Officially assigned] - */ - MN("Mongolia", "MNG", 496, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Macao - * [MO, MCO, 492, - * Officially assigned] - */ - MO("Macao", "MAC", 446, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Northern Mariana Islands - * [MP, MNP, 580, - * Officially assigned] - */ - MP("Northern Mariana Islands", "MNP", 580, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Martinique - * [MQ, MTQ, 474, - * Officially assigned] - */ - MQ("Martinique", "MTQ", 474, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Mauritania - * [MR, MRT, 478, - * Officially assigned] - */ - MR("Mauritania", "MRT", 478, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Montserrat - * [MS, MSR, 500, - * Officially assigned] - */ - MS("Montserrat", "MSR", 500, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Malta - * [MT, MLT, 470, - * Officially assigned] - */ - MT("Malta", "MLT", 470, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Mauritius - * [MU, MUS, 480, - * Officially assigned]] - */ - MU("Mauritius", "MUS", 480, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Maldives - * [MV, MDV, 462, - * Officially assigned] - */ - MV("Maldives", "MDV", 462, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Malawi - * [MW, MWI, 454, - * Officially assigned] - */ - MW("Malawi", "MWI", 454, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Mexico - * [MX, MEX, 484, - * Officially assigned] - */ - MX("Mexico", "MEX", 484, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Malaysia - * [MY, MYS, 458, - * Officially assigned] - */ - MY("Malaysia", "MYS", 458, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Mozambique - * [MZ, MOZ, 508, - * Officially assigned] - */ - MZ("Mozambique", "MOZ", 508, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Namibia - * [NA, NAM, 516, - * Officially assigned] - */ - NA("Namibia", "NAM", 516, Assignment.OFFICIALLY_ASSIGNED), - - /** - * New Caledonia - * [NC, NCL, 540, - * Officially assigned] - */ - NC("New Caledonia", "NCL", 540, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Niger - * [NE, NER, 562, - * Officially assigned] - */ - NE("Niger", "NER", 562, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Norfolk Island - * [NF, NFK, 574, - * Officially assigned] - */ - NF("Norfolk Island", "NFK", 574, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Nigeria - * [NG, NGA, 566, - * Officially assigned] - */ - NG("Nigeria","NGA", 566, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Nicaragua - * [NI, NIC, 558, - * Officially assigned] - */ - NI("Nicaragua", "NIC", 558, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Netherlands - * [NL, NLD, 528, - * Officially assigned] - */ - NL("Netherlands", "NLD", 528, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Norway - * [NO, NOR, 578, - * Officially assigned] - */ - NO("Norway", "NOR", 578, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Nepal - * [NP, NPL, 524, - * Officially assigned] - */ - NP("Nepal", "NPL", 524, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Nauru - * [NR, NRU, 520, - * Officially assigned] - */ - NR("Nauru", "NRU", 520, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Neutral Zone - * [NT, NTHH, 536, - * Traditionally reserved] - */ - NT("Neutral Zone", "NTHH", 536, Assignment.TRANSITIONALLY_RESERVED), - - /** - * Niue - * [NU, NIU, 570, - * Officially assigned] - */ - NU("Niue", "NIU", 570, Assignment.OFFICIALLY_ASSIGNED), - - /** - * New Zealand - * [NZ, NZL, 554, - * Officially assigned] - */ - NZ("New Zealand", "NZL", 554, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Oman - * [OM, OMN, 512, - * Officially assigned] - */ - OM("Oman", "OMN", 512, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Panama - * [PA, PAN, 591, - * Officially assigned] - */ - PA("Panama", "PAN", 591, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Peru - * [PE, PER, 604, - * Officially assigned] - */ - PE("Peru", "PER", 604, Assignment.OFFICIALLY_ASSIGNED), - - /** - * French Polynesia - * [PF, PYF, 258, - * Officially assigned] - */ - PF("French Polynesia", "PYF", 258, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Papua New Guinea - * [PG, PNG, 598, - * Officially assigned] - */ - PG("Papua New Guinea", "PNG", 598, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Philippines - * [PH, PHL, 608, - * Officially assigned] - */ - PH("Philippines", "PHL", 608, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Pakistan - * [PK, PAK, 586, - * Officially assigned] - */ - PK("Pakistan", "PAK", 586, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Poland - * [PL, POL, 616, - * Officially assigned] - */ - PL("Poland", "POL", 616, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Saint Pierre and Miquelon - * [PM, SPM, 666, - * Officially assigned] - */ - PM("Saint Pierre and Miquelon", "SPM", 666, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Pitcairn - * [PN, PCN, 612, - * Officially assigned] - */ - PN("Pitcairn", "PCN", 612, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Puerto Rico - * [PR, PRI, 630, - * Officially assigned] - */ - PR("Puerto Rico", "PRI", 630, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Palestine, State of - * [PS, PSE, 275, - * Officially assigned] - */ - PS("Palestine, State of", "PSE", 275, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Portugal - * [PT, PRT, 620, - * Officially assigned] - */ - PT("Portugal", "PRT", 620, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Palau - * [PW, PLW, 585, - * Officially assigned] - */ - PW("Palau", "PLW", 585, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Paraguay - * [PY, PRY, 600, - * Officially assigned] - */ - PY("Paraguay", "PRY", 600, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Qatar - * [QA, QAT, 634, - * Officially assigned] - */ - QA("Qatar", "QAT", 634, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Réunion - * [RE, REU, 638, - * Officially assigned] - */ - RE("R\u00E9union", "REU", 638, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Romania - * [RO, ROU, 642, - * Officially assigned] - */ - RO("Romania", "ROU", 642, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Serbia - * [RS, SRB, 688, - * Officially assigned] - */ - RS("Serbia", "SRB", 688, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Russian Federation - * [RU, RUS, 643, - * Officially assigned] - */ - RU("Russian Federation", "RUS", 643, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Rwanda - * [RW, RWA, 646, - * Officially assigned] - */ - RW("Rwanda", "RWA", 646, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Saudi Arabia - * [SA, SAU, 682, - * Officially assigned] - */ - SA("Saudi Arabia", "SAU", 682, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Solomon Islands - * [SB, SLB, 90, - * Officially assigned] - */ - SB("Solomon Islands", "SLB", 90, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Seychelles - * [SC, SYC, 690, - * Officially assigned] - */ - SC("Seychelles", "SYC", 690, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Sudan - * [SD, SDN, 729, - * Officially assigned] - */ - SD("Sudan", "SDN", 729, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Sweden - * [SE, SWE, 752, - * Officially assigned] - */ - SE("Sweden", "SWE", 752, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Finland - * [SF, FIN, 246, - * Traditionally reserved] - * - * @see #FI - */ - SF("Finland", "FIN", 246, Assignment.TRANSITIONALLY_RESERVED), - - /** - * Singapore - * [SG, SGP, 702, - * Officially assigned] - */ - SG("Singapore", "SGP", 702, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Saint Helena, Ascension and Tristan da Cunha - * [SH, SHN, 654, - * Officially assigned] - */ - SH("Saint Helena, Ascension and Tristan da Cunha", "SHN", 654, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Slovenia - * [SI, SVN, 705, - * Officially assigned] - */ - SI("Slovenia", "SVN", 705, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Svalbard and Jan Mayen - * [SJ, SJM, 744, - * Officially assigned] - */ - SJ("Svalbard and Jan Mayen", "SJM", 744, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Slovakia - * [SK, SVK, 703, - * Officially assigned] - */ - SK("Slovakia", "SVK", 703, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Sierra Leone - * [SL, SLE, 694, - * Officially assigned] - */ - SL("Sierra Leone", "SLE", 694, Assignment.OFFICIALLY_ASSIGNED), - - /** - * San Marino - * [SM, SMR, 674, - * Officially assigned] - */ - SM("San Marino", "SMR", 674, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Senegal - * [SN, SEN, 686, - * Officially assigned] - */ - SN("Senegal", "SEN", 686, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Somalia - * [SO, SOM, 706, - * Officially assigned] - */ - SO("Somalia", "SOM", 706, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Suriname - * [SR, SUR, 740, - * Officially assigned] - */ - SR("Suriname", "SUR", 740, Assignment.OFFICIALLY_ASSIGNED), - - /** - * South Sudan - * [SS, SSD, 728, - * Officially assigned] - */ - SS("South Sudan", "SSD", 728, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Sao Tome and Principe - * [ST, STP, 678, - * Officially assigned] - */ - ST("Sao Tome and Principe", "STP", 678, Assignment.OFFICIALLY_ASSIGNED), - - /** - * USSR - * [SU, SUN, -1, - * Exceptionally reserved] - */ - SU("USSR", "SUN", -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * El Salvador - * [SV, SLV, 222, - * Officially assigned] - */ - SV("El Salvador", "SLV", 222, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Sint Maarten (Dutch part) - * [SX, SXM, 534, - * Officially assigned] - */ - SX("Sint Maarten (Dutch part)", "SXM", 534, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Syrian Arab Republic - * [SY, SYR, 760, - * Officially assigned] - */ - SY("Syrian Arab Republic", "SYR", 760, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Swaziland - * [SZ, SWZ, 748, - * Officially assigned] - */ - SZ("Swaziland", "SWZ", 748, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Tristan da Cunha - * [TA, TAA, -1, - * Exceptionally reserved. - */ - TA("Tristan da Cunha", "TAA", -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * Turks and Caicos Islands - * [TC, TCA, 796, - * Officially assigned] - */ - TC("Turks and Caicos Islands", "TCA", 796, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Chad - * [TD, TCD, 148, - * Officially assigned] - */ - TD("Chad", "TCD", 148, Assignment.OFFICIALLY_ASSIGNED), - - /** - * French Southern Territories - * [TF, ATF, 260, - * Officially assigned] - */ - TF("French Southern Territories", "ATF", 260, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Togo - * [TG, TGO, 768, - * Officially assigned] - */ - TG("Togo", "TGO", 768, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Thailand - * [TH, THA, 764, - * Officially assigned] - */ - TH("Thailand", "THA", 764, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Tajikistan - * [TJ, TJK, 762, - * Officially assigned] - */ - TJ("Tajikistan", "TJK", 762, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Tokelau - * [TK, TKL, 772, - * Officially assigned] - */ - TK("Tokelau", "TKL", 772, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Timor-Leste - * [TL, TLS, 626, - * Officially assigned] - */ - TL("Timor-Leste", "TLS", 626, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Turkmenistan - * [TM, TKM, 795, - * Officially assigned] - */ - TM("Turkmenistan", "TKM", 795, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Tunisia - * [TN, TUN, 788, - * Officially assigned] - */ - TN("Tunisia", "TUN", 788, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Tonga - * [TO, TON, 776, - * Officially assigned] - */ - TO("Tonga", "TON", 776, Assignment.OFFICIALLY_ASSIGNED), - - /** - * East Timor - * [TP, TPTL, 0, - * Traditionally reserved] - * - *

- * ISO 3166-1 numeric code is unknown. - *

- */ - TP("East Timor", "TPTL", 0, Assignment.TRANSITIONALLY_RESERVED), - - /** - * Turkey - * [TR, TUR, 792, - * Officially assigned] - */ - TR("Turkey", "TUR", 792, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Trinidad and Tobago - * [TT, TTO, 780, - * Officially assigned] - */ - TT("Trinidad and Tobago", "TTO", 780, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Tuvalu - * [TV, TUV, 798, - * Officially assigned] - */ - TV("Tuvalu", "TUV", 798, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Taiwan, Province of China - * [TW, TWN, 158, - * Officially assigned] - */ - TW("Taiwan, Province of China", "TWN", 158, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.TAIWAN; - } - }, - - /** - * Tanzania, United Republic of - * [TZ, TZA, 834, - * Officially assigned] - */ - TZ("Tanzania, United Republic of", "TZA", 834, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Ukraine - * [UA, UKR, 804, - * Officially assigned] - */ - UA("Ukraine", "UKR", 804, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Uganda - * [UG, UGA, 800, - * Officially assigned] - */ - UG("Uganda", "UGA", 800, Assignment.OFFICIALLY_ASSIGNED), - - /** - * United Kingdom - * [UK, null, -1, - * Exceptionally reserved] - */ - UK("United Kingdom", null, -1, Assignment.EXCEPTIONALLY_RESERVED) - { - @Override - public Locale toLocale() - { - return Locale.UK; - } - }, - - /** - * United States Minor Outlying Islands - * [UM, UMI, 581, - * Officially assigned] - */ - UM("United States Minor Outlying Islands", "UMI", 581, Assignment.OFFICIALLY_ASSIGNED), - - /** - * United States - * [US, USA, 840, - * Officially assigned] - */ - US("United States", "USA", 840, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.US; - } - }, - - /** - * Uruguay - * [UY, URY, 858, - * Officially assigned] - */ - UY("Uruguay", "URY", 858, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Uzbekistan - * [UZ, UZB, 860, - * Officially assigned] - */ - UZ("Uzbekistan", "UZB", 860, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Holy See (Vatican City State) - * [VA, VAT, 336, - * Officially assigned] - */ - VA("Holy See (Vatican City State)", "VAT", 336, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Saint Vincent and the Grenadines - * [VC, VCT, 670, - * Officially assigned] - */ - VC("Saint Vincent and the Grenadines", "VCT", 670, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Venezuela, Bolivarian Republic of - * [VE, VEN, 862, - * Officially assigned] - */ - VE("Venezuela, Bolivarian Republic of", "VEN", 862, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Virgin Islands, British - * [VG, VGB, 92, - * Officially assigned] - */ - VG("Virgin Islands, British", "VGB", 92, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Virgin Islands, U.S. - * [VI, VIR, 850, - * Officially assigned] - */ - VI("Virgin Islands, U.S.", "VIR", 850, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Viet Nam - * [VN, VNM, 704, - * Officially assigned] - */ - VN("Viet Nam", "VNM", 704, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Vanuatu - * [VU, VUT, 548, - * Officially assigned] - */ - VU("Vanuatu", "VUT", 548, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Wallis and Futuna - * [WF, WLF, 876, - * Officially assigned] - */ - WF("Wallis and Futuna", "WLF", 876, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Samoa - * [WS, WSM, 882, - * Officially assigned] - */ - WS("Samoa", "WSM", 882, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Kosovo, Republic of - * [XK, XXK, -1, - * User assigned] - */ - XK("Kosovo, Republic of", "XXK", -1, Assignment.USER_ASSIGNED), - - /** - * Yemen - * [YE, YEM, 887, - * Officially assigned] - */ - YE("Yemen", "YEM", 887, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Mayotte - * [YT, MYT, 175, - * Officially assigned] - */ - YT("Mayotte", "MYT", 175, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Yugoslavia - * [YU, YUCS, 890, - * Traditionally reserved] - */ - YU("Yugoslavia", "YUCS", 890, Assignment.TRANSITIONALLY_RESERVED), - - /** - * South Africa - * [ZA, ZAF, 710, - * Officially assigned] - */ - ZA("South Africa", "ZAF", 710, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Zambia - * [ZM, ZMB, 894, - * Officially assigned] - */ - ZM("Zambia", "ZMB", 894, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Zaire - * [ZR, ZRCD, 0, - * Traditionally reserved] - * - *

- * ISO 3166-1 numeric code is unknown. - *

- */ - ZR("Zaire", "ZRCD", 0, Assignment.TRANSITIONALLY_RESERVED), - - /** - * Zimbabwe - * [ZW, ZWE, 716, - * Officially assigned] - */ - ZW("Zimbabwe", "ZWE", 716, Assignment.OFFICIALLY_ASSIGNED), - ; - - - /** - * Code assignment state in ISO 3166-1. - * - * @see Decoding table of ISO 3166-1 alpha-2 codes - */ - enum Assignment - { - /** - * Officially assigned. - * - * Assigned to a country, territory, or area of geographical interest. - */ - OFFICIALLY_ASSIGNED, - - /** - * User assigned. - * - * Free for assignment at the disposal of users. - */ - USER_ASSIGNED, - - /** - * Exceptionally reserved. - * - * Reserved on request for restricted use. - */ - EXCEPTIONALLY_RESERVED, - - /** - * Transitionally reserved. - * - * Deleted from ISO 3166-1 but reserved transitionally. - */ - TRANSITIONALLY_RESERVED, - - /** - * Indeterminately reserved. - * - * Used in coding systems associated with ISO 3166-1. - */ - INDETERMINATELY_RESERVED, - - /** - * Not used. - * - * Not used in ISO 3166-1 in deference to international property - * organization names. - */ - NOT_USED - } - - - private static final Map alpha3Map = new HashMap<>(); - private static final Map numericMap = new HashMap<>(); - - - static - { - for (LDCountryCode cc : values()) - { - if (cc.getAlpha3() != null) - { - alpha3Map.put(cc.getAlpha3(), cc); - } - - if (cc.getNumeric() != -1) - { - numericMap.put(cc.getNumeric(), cc); - } - } - } - - - private final String name; - private final String alpha3; - private final int numeric; - private final Assignment assignment; - - - private LDCountryCode(String name, String alpha3, int numeric, Assignment assignment) - { - this.name = name; - this.alpha3 = alpha3; - this.numeric = numeric; - this.assignment = assignment; - } - - - /** - * Get the country name. - * - * @return - * The country name. - */ - public String getName() - { - return name; - } - - - /** - * Get the ISO 3166-1 alpha-2 code. - * - * @return - * The ISO 3166-1 alpha-2 code. - */ - public String getAlpha2() - { - return name(); - } - - - /** - * Get the ISO 3166-1 alpha-3 code. - * - * @return - * The ISO 3166-1 alpha-3 code. - * Some country codes reserved exceptionally (such as {@link #EU}) - * returns {@code null}. - */ - public String getAlpha3() - { - return alpha3; - } - - - /** - * Get the ISO 3166-1 numeric code. - * - * @return - * The ISO 3166-1 numeric code. - * Country codes reserved exceptionally (such as {@link #EU}) - * returns {@code -1}. - */ - public int getNumeric() - { - return numeric; - } - - - /** - * Get the assignment state of this country code in ISO 3166-1. - * - * @return - * The assignment state. - * - * @see Decoding table of ISO 3166-1 alpha-2 codes - */ - public Assignment getAssignment() - { - return assignment; - } - - - /** - * Convert this {@code CountryCode} instance to a {@link Locale} instance. - * - *

- * In most cases, this method creates a new {@code Locale} instance - * every time it is called, but some {@code CountryCode} instances return - * their corresponding entries in {@code Locale} class. For example, - * {@link #CA CountryCode.CA} always returns {@link Locale#CANADA}. - *

- * - *

- * The table below lists {@code CountryCode} entries whose {@code toLocale()} - * do not create new Locale instances but return entries in - * {@code Locale} class. - *

- * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - *
CountryCodeLocale
{@link LDCountryCode#CA CountryCode.CA}{@link Locale#CANADA}
{@link LDCountryCode#CN CountryCode.CN}{@link Locale#CHINA}
{@link LDCountryCode#DE CountryCode.DE}{@link Locale#GERMANY}
{@link LDCountryCode#FR CountryCode.FR}{@link Locale#FRANCE}
{@link LDCountryCode#GB CountryCode.GB}{@link Locale#UK}
{@link LDCountryCode#IT CountryCode.IT}{@link Locale#ITALY}
{@link LDCountryCode#JP CountryCode.JP}{@link Locale#JAPAN}
{@link LDCountryCode#KR CountryCode.KR}{@link Locale#KOREA}
{@link LDCountryCode#TW CountryCode.TW}{@link Locale#TAIWAN}
{@link LDCountryCode#US CountryCode.US}{@link Locale#US}
- * - * @return - * A {@code Locale} instance that matches this {@code CountryCode}. - */ - public Locale toLocale() - { - return new Locale("", name()); - } - - - /** - * Get the currency. - * - *

- * This method is an alias of {@link Currency}{@code .}{@link - * Currency#getInstance(Locale) getInstance}{@code (}{@link - * #toLocale()}{@code )}. The only difference is that this method - * returns {@code null} when {@code Currency.getInstance(Locale)} - * throws {@code IllegalArgumentException}. - *

- * - *

- * This method returns {@code null} when the territory represented by - * this {@code CountryCode} instance does not have a currency. - * {@link #AQ} (Antarctica) is one example. - *

- * - *

- * In addition, this method returns {@code null} also when the ISO 3166 - * code represented by this {@code CountryCode} instance is not - * supported by the implementation of {@link - * Currency#getInstance(Locale)}. At the time of this writing, - * {@link #SS} (South Sudan) is one example. - *

- * - * @return - * A {@code Currency} instance. In some cases, null - * is returned. - * - * @since 1.4 - * - * @see Currency#getInstance(Locale) - */ - public Currency getCurrency() - { - try - { - return Currency.getInstance(toLocale()); - } - catch (IllegalArgumentException e) - { - // Currency.getInstance(Locale) throws IllegalArgumentException - // when the given ISO 3166 code is not supported. - return null; - } - } - - - /** - * Get a {@code CountryCode} that corresponds to the given ISO 3166-1 - * alpha-2 or - * alpha-3 code. - * - *

- * This method calls {@link #getByCode(String, boolean) getByCode}{@code (code, true)}. - * Note that the behavior has changed since the version 1.13. In the older versions, - * this method was an alias of {@code getByCode(code, false)}. - *

- * - * @param code - * An ISO 3166-1 alpha-2 or alpha-3 code. - * - * @return - * A {@code CountryCode} instance, or {@code null} if not found. - * - * @see #getByCode(String, boolean) - */ - public static LDCountryCode getByCode(String code) - { - return getByCode(code, true); - } - - - /** - * Get a {@code CountryCode} that corresponds to the given ISO 3166-1 - * alpha-2 or - * alpha-3 code. - * - *

- * This method calls {@link #getByCode(String, boolean) getByCode}{@code (code, false)}. - *

- * - * @param code - * An ISO 3166-1 alpha-2 or alpha-3 code. - * - * @return - * A {@code CountryCode} instance, or {@code null} if not found. - * - * @since 1.13 - * - * @see #getByCode(String, boolean) - */ - public static LDCountryCode getByCodeIgnoreCase(String code) - { - return getByCode(code, false); - } - - - /** - * Get a {@code CountryCode} that corresponds to the given ISO 3166-1 - * alpha-2 or - * alpha-3 code. - * - * @param code - * An ISO 3166-1 alpha-2 or alpha-3 code. - * - * @param caseSensitive - * If {@code true}, the given code should consist of upper-case letters only. - * If {@code false}, this method internally canonicalizes the given code by - * {@link String#toUpperCase()} and then performs search. For example, - * {@code getByCode("jp", true)} returns {@code null}, but on the other hand, - * {@code getByCode("jp", false)} returns {@link #JP CountryCode.JP}. - * - * @return - * A {@code CountryCode} instance, or {@code null} if not found. - */ - public static LDCountryCode getByCode(String code, boolean caseSensitive) - { - if (code == null) - { - return null; - } - - switch (code.length()) - { - case 2: - code = canonicalize(code, caseSensitive); - return getByAlpha2Code(code); - - case 3: - code = canonicalize(code, caseSensitive); - return getByAlpha3Code(code); - - default: - return null; - } - } - - - /** - * Get a {@code CountryCode} that corresponds to the country code of - * the given {@link Locale} instance. - * - * @param locale - * A {@code Locale} instance. - * - * @return - * A {@code CountryCode} instance, or {@code null} if not found. - * - * @see Locale#getCountry() - */ - public static LDCountryCode getByLocale(Locale locale) - { - if (locale == null) - { - return null; - } - - // Locale.getCountry() returns either an empty string or - // an uppercase ISO 3166 2-letter code. - return getByCode(locale.getCountry(), true); - } - - - /** - * Canonicalize the given country code. - * - * @param code - * ISO 3166-1 alpha-2 or alpha-3 country code. - * - * @param caseSensitive - * {@code true} if the code should be handled case-sensitively. - * - * @return - * If {@code code} is {@code null} or an empty string, - * {@code null} is returned. - * Otherwise, if {@code caseSensitive} is {@code true}, - * {@code code} is returned as is. - * Otherwise, {@code code.toUpperCase()} is returned. - */ - static String canonicalize(String code, boolean caseSensitive) - { - if (code == null || code.length() == 0) - { - return null; - } - - if (caseSensitive) - { - return code; - } - else - { - return code.toUpperCase(); - } - } - - - private static LDCountryCode getByAlpha2Code(String code) - { - try - { - return Enum.valueOf(LDCountryCode.class, code); - } - catch (IllegalArgumentException e) - { - return null; - } - } - - - private static LDCountryCode getByAlpha3Code(String code) - { - return alpha3Map.get(code); - } - - - /** - * Get a {@code CountryCode} that corresponds to the given - * ISO 3166-1 - * numeric code. - * - * @param code - * An ISO 3166-1 numeric code. - * - * @return - * A {@code CountryCode} instance, or {@code null} if not found. - * If 0 or a negative value is given, {@code null} is returned. - */ - public static LDCountryCode getByCode(int code) - { - if (code <= 0) - { - return null; - } - - return numericMap.get(code); - } - - - /** - * Get a list of {@code CountryCode} by a name regular expression. - * - *

- * This method is almost equivalent to {@link #findByName(Pattern) - * findByName}{@code (Pattern.compile(regex))}. - *

- * - * @param regex - * Regular expression for names. - * - * @return - * List of {@code CountryCode}. If nothing has matched, - * an empty list is returned. - * - * @throws IllegalArgumentException - * {@code regex} is {@code null}. - * - * @throws java.util.regex.PatternSyntaxException - * {@code regex} failed to be compiled. - * - * @since 1.11 - */ - public static List findByName(String regex) - { - if (regex == null) - { - throw new IllegalArgumentException("regex is null."); - } - - // Compile the regular expression. This may throw - // java.util.regex.PatternSyntaxException. - Pattern pattern = Pattern.compile(regex); - - return findByName(pattern); - } - - - /** - * Get a list of {@code CountryCode} by a name pattern. - * - *

- * For example, the list obtained by the code snippet below: - *

- * - *
-   * Pattern pattern = Pattern.compile(".*United.*");
-   * List<CountryCode> list = CountryCode.findByName(pattern);
- * - *

- * contains 6 {@code CountryCode}s as listed below. - *

- * - *
    - *
  1. {@link #AE} : United Arab Emirates - *
  2. {@link #GB} : United Kingdom - *
  3. {@link #TZ} : Tanzania, United Republic of - *
  4. {@link #UK} : United Kingdom - *
  5. {@link #UM} : United States Minor Outlying Islands - *
  6. {@link #US} : United States - *
- * - * @param pattern - * Pattern to match names. - * - * @return - * List of {@code CountryCode}. If nothing has matched, - * an empty list is returned. - * - * @throws IllegalArgumentException - * {@code pattern} is {@code null}. - * - * @since 1.11 - */ - public static List findByName(Pattern pattern) - { - if (pattern == null) - { - throw new IllegalArgumentException("pattern is null."); - } - - List list = new ArrayList<>(); - - for (LDCountryCode entry : values()) - { - // If the name matches the given pattern. - if (pattern.matcher(entry.getName()).matches()) - { - list.add(entry); - } - } - - return list; - } -} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/LDUser.java b/src/main/java/com/launchdarkly/client/LDUser.java deleted file mode 100644 index 47d938b1d..000000000 --- a/src/main/java/com/launchdarkly/client/LDUser.java +++ /dev/null @@ -1,886 +0,0 @@ -package com.launchdarkly.client; - -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.ImmutableSet; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonPrimitive; -import com.google.gson.TypeAdapter; -import com.google.gson.stream.JsonReader; -import com.google.gson.stream.JsonWriter; -import com.launchdarkly.client.value.LDValue; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.TreeSet; -import java.util.regex.Pattern; - -/** - * A {@code LDUser} object contains specific attributes of a user browsing your site. The only mandatory property property is the {@code key}, - * which must uniquely identify each user. For authenticated users, this may be a username or e-mail address. For anonymous users, - * this could be an IP address or session ID. - *

- * Besides the mandatory {@code key}, {@code LDUser} supports two kinds of optional attributes: interpreted attributes (e.g. {@code ip} and {@code country}) - * and custom attributes. LaunchDarkly can parse interpreted attributes and attach meaning to them. For example, from an {@code ip} address, LaunchDarkly can - * do a geo IP lookup and determine the user's country. - *

- * Custom attributes are not parsed by LaunchDarkly. They can be used in custom rules-- for example, a custom attribute such as "customer_ranking" can be used to - * launch a feature to the top 10% of users on a site. - *

- * If you want to pass an LDUser object to the front end to be used with the JavaScript SDK, simply call {@code Gson.toJson()} or - * {@code Gson.toJsonTree()} on it. - */ -public class LDUser { - private static final Logger logger = LoggerFactory.getLogger(LDUser.class); - - // Note that these fields are all stored internally as LDValue rather than String so that - // we don't waste time repeatedly converting them to LDValue in the rule evaluation logic. - private final LDValue key; - private LDValue secondary; - private LDValue ip; - private LDValue email; - private LDValue name; - private LDValue avatar; - private LDValue firstName; - private LDValue lastName; - private LDValue anonymous; - private LDValue country; - private Map custom; - Set privateAttributeNames; - - protected LDUser(Builder builder) { - if (builder.key == null || builder.key.equals("")) { - logger.warn("User was created with null/empty key"); - } - this.key = LDValue.of(builder.key); - this.ip = LDValue.of(builder.ip); - this.country = LDValue.of(builder.country); - this.secondary = LDValue.of(builder.secondary); - this.firstName = LDValue.of(builder.firstName); - this.lastName = LDValue.of(builder.lastName); - this.email = LDValue.of(builder.email); - this.name = LDValue.of(builder.name); - this.avatar = LDValue.of(builder.avatar); - this.anonymous = builder.anonymous == null ? LDValue.ofNull() : LDValue.of(builder.anonymous); - this.custom = builder.custom == null ? null : ImmutableMap.copyOf(builder.custom); - this.privateAttributeNames = builder.privateAttrNames == null ? null : ImmutableSet.copyOf(builder.privateAttrNames); - } - - /** - * Create a user with the given key - * - * @param key a {@code String} that uniquely identifies a user - */ - public LDUser(String key) { - this.key = LDValue.of(key); - this.secondary = this.ip = this.email = this.name = this.avatar = this.firstName = this.lastName = this.anonymous = this.country = - LDValue.ofNull(); - this.custom = null; - this.privateAttributeNames = null; - } - - protected LDValue getValueForEvaluation(String attribute) { - // Don't use Enum.valueOf because we don't want to trigger unnecessary exceptions - for (UserAttribute builtIn: UserAttribute.values()) { - if (builtIn.name().equals(attribute)) { - return builtIn.get(this); - } - } - return getCustom(attribute); - } - - LDValue getKey() { - return key; - } - - String getKeyAsString() { - return key.stringValue(); - } - - // All of the LDValue getters are guaranteed not to return null (although the LDValue may *be* a JSON null). - - LDValue getIp() { - return ip; - } - - LDValue getCountry() { - return country; - } - - LDValue getSecondary() { - return secondary; - } - - LDValue getName() { - return name; - } - - LDValue getFirstName() { - return firstName; - } - - LDValue getLastName() { - return lastName; - } - - LDValue getEmail() { - return email; - } - - LDValue getAvatar() { - return avatar; - } - - LDValue getAnonymous() { - return anonymous; - } - - LDValue getCustom(String key) { - if (custom != null) { - return LDValue.normalize(custom.get(key)); - } - return LDValue.ofNull(); - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - LDUser ldUser = (LDUser) o; - - return Objects.equals(key, ldUser.key) && - Objects.equals(secondary, ldUser.secondary) && - Objects.equals(ip, ldUser.ip) && - Objects.equals(email, ldUser.email) && - Objects.equals(name, ldUser.name) && - Objects.equals(avatar, ldUser.avatar) && - Objects.equals(firstName, ldUser.firstName) && - Objects.equals(lastName, ldUser.lastName) && - Objects.equals(anonymous, ldUser.anonymous) && - Objects.equals(country, ldUser.country) && - Objects.equals(custom, ldUser.custom) && - Objects.equals(privateAttributeNames, ldUser.privateAttributeNames); - } - - @Override - public int hashCode() { - return Objects.hash(key, secondary, ip, email, name, avatar, firstName, lastName, anonymous, country, custom, privateAttributeNames); - } - - // Used internally when including users in analytics events, to ensure that private attributes are stripped out. - static class UserAdapterWithPrivateAttributeBehavior extends TypeAdapter { - private final EventsConfiguration config; - - public UserAdapterWithPrivateAttributeBehavior(EventsConfiguration config) { - this.config = config; - } - - @Override - public void write(JsonWriter out, LDUser user) throws IOException { - if (user == null) { - out.value((String)null); - return; - } - - // Collect the private attribute names (use TreeSet to make ordering predictable for tests) - Set privateAttributeNames = new TreeSet(config.privateAttrNames); - - out.beginObject(); - // The key can never be private - out.name("key").value(user.getKeyAsString()); - - if (!user.getSecondary().isNull()) { - if (!checkAndAddPrivate("secondary", user, privateAttributeNames)) { - out.name("secondary").value(user.getSecondary().stringValue()); - } - } - if (!user.getIp().isNull()) { - if (!checkAndAddPrivate("ip", user, privateAttributeNames)) { - out.name("ip").value(user.getIp().stringValue()); - } - } - if (!user.getEmail().isNull()) { - if (!checkAndAddPrivate("email", user, privateAttributeNames)) { - out.name("email").value(user.getEmail().stringValue()); - } - } - if (!user.getName().isNull()) { - if (!checkAndAddPrivate("name", user, privateAttributeNames)) { - out.name("name").value(user.getName().stringValue()); - } - } - if (!user.getAvatar().isNull()) { - if (!checkAndAddPrivate("avatar", user, privateAttributeNames)) { - out.name("avatar").value(user.getAvatar().stringValue()); - } - } - if (!user.getFirstName().isNull()) { - if (!checkAndAddPrivate("firstName", user, privateAttributeNames)) { - out.name("firstName").value(user.getFirstName().stringValue()); - } - } - if (!user.getLastName().isNull()) { - if (!checkAndAddPrivate("lastName", user, privateAttributeNames)) { - out.name("lastName").value(user.getLastName().stringValue()); - } - } - if (!user.getAnonymous().isNull()) { - out.name("anonymous").value(user.getAnonymous().booleanValue()); - } - if (!user.getCountry().isNull()) { - if (!checkAndAddPrivate("country", user, privateAttributeNames)) { - out.name("country").value(user.getCountry().stringValue()); - } - } - writeCustomAttrs(out, user, privateAttributeNames); - writePrivateAttrNames(out, privateAttributeNames); - - out.endObject(); - } - - private void writePrivateAttrNames(JsonWriter out, Set names) throws IOException { - if (names.isEmpty()) { - return; - } - out.name("privateAttrs"); - out.beginArray(); - for (String name : names) { - out.value(name); - } - out.endArray(); - } - - private boolean checkAndAddPrivate(String key, LDUser user, Set privateAttrs) { - boolean result = config.allAttributesPrivate || config.privateAttrNames.contains(key) || (user.privateAttributeNames != null && user.privateAttributeNames.contains(key)); - if (result) { - privateAttrs.add(key); - } - return result; - } - - private void writeCustomAttrs(JsonWriter out, LDUser user, Set privateAttributeNames) throws IOException { - boolean beganObject = false; - if (user.custom == null) { - return; - } - for (Map.Entry entry : user.custom.entrySet()) { - if (!checkAndAddPrivate(entry.getKey(), user, privateAttributeNames)) { - if (!beganObject) { - out.name("custom"); - out.beginObject(); - beganObject = true; - } - out.name(entry.getKey()); - JsonHelpers.gsonInstance().toJson(entry.getValue(), LDValue.class, out); - } - } - if (beganObject) { - out.endObject(); - } - } - - @Override - public LDUser read(JsonReader in) throws IOException { - // We never need to unmarshal user objects, so there's no need to implement this - return null; - } - } - - /** - * A builder that helps construct {@link LDUser} objects. Builder - * calls can be chained, enabling the following pattern: - *

-   * LDUser user = new LDUser.Builder("key")
-   *      .country("US")
-   *      .ip("192.168.0.1")
-   *      .build()
-   * 
- */ - public static class Builder { - private String key; - private String secondary; - private String ip; - private String firstName; - private String lastName; - private String email; - private String name; - private String avatar; - private Boolean anonymous; - private String country; - private Map custom; - private Set privateAttrNames; - - /** - * Creates a builder with the specified key. - * - * @param key the unique key for this user - */ - public Builder(String key) { - this.key = key; - } - - /** - * Creates a builder based on an existing user. - * - * @param user an existing {@code LDUser} - */ - public Builder(LDUser user) { - this.key = user.getKey().stringValue(); - this.secondary = user.getSecondary().stringValue(); - this.ip = user.getIp().stringValue(); - this.firstName = user.getFirstName().stringValue(); - this.lastName = user.getLastName().stringValue(); - this.email = user.getEmail().stringValue(); - this.name = user.getName().stringValue(); - this.avatar = user.getAvatar().stringValue(); - this.anonymous = user.getAnonymous().isNull() ? null : user.getAnonymous().booleanValue(); - this.country = user.getCountry().stringValue(); - this.custom = user.custom == null ? null : new HashMap<>(user.custom); - this.privateAttrNames = user.privateAttributeNames == null ? null : new HashSet<>(user.privateAttributeNames); - } - - /** - * Sets the IP for a user. - * - * @param s the IP address for the user - * @return the builder - */ - public Builder ip(String s) { - this.ip = s; - return this; - } - - /** - * Sets the IP for a user, and ensures that the IP attribute is not sent back to LaunchDarkly. - * - * @param s the IP address for the user - * @return the builder - */ - public Builder privateIp(String s) { - addPrivate("ip"); - return ip(s); - } - - /** - * Sets the secondary key for a user. This affects - * feature flag targeting - * as follows: if you have chosen to bucket users by a specific attribute, the secondary key (if set) - * is used to further distinguish between users who are otherwise identical according to that attribute. - * @param s the secondary key for the user - * @return the builder - */ - public Builder secondary(String s) { - this.secondary = s; - return this; - } - - /** - * Sets the secondary key for a user, and ensures that the secondary key attribute is not sent back to - * LaunchDarkly. - * @param s the secondary key for the user - * @return the builder - */ - public Builder privateSecondary(String s) { - addPrivate("secondary"); - return secondary(s); - } - - /** - * Set the country for a user. - *

- * In the current SDK version the country should be a valid ISO 3166-1 - * alpha-2 or alpha-3 code. If it is not a valid ISO-3166-1 code, an attempt will be made to look up the country by its name. - * If that fails, a warning will be logged, and the country will not be set. In the next major release, this validation - * will be removed, and the country field will be treated as a normal string. - * - * @param s the country for the user - * @return the builder - */ - @SuppressWarnings("deprecation") - public Builder country(String s) { - LDCountryCode countryCode = LDCountryCode.getByCode(s, false); - - if (countryCode == null) { - List codes = LDCountryCode.findByName("^" + Pattern.quote(s) + ".*"); - - if (codes.isEmpty()) { - logger.warn("Invalid country. Expected valid ISO-3166-1 code: " + s); - } else if (codes.size() > 1) { - // See if any of the codes is an exact match - for (LDCountryCode c : codes) { - if (c.getName().equals(s)) { - country = c.getAlpha2(); - return this; - } - } - logger.warn("Ambiguous country. Provided code matches multiple countries: " + s); - country = codes.get(0).getAlpha2(); - } else { - country = codes.get(0).getAlpha2(); - } - } else { - country = countryCode.getAlpha2(); - } - - return this; - } - - /** - * Set the country for a user, and ensures that the country attribute will not be sent back to LaunchDarkly. - *

- * In the current SDK version the country should be a valid ISO 3166-1 - * alpha-2 or alpha-3 code. If it is not a valid ISO-3166-1 code, an attempt will be made to look up the country by its name. - * If that fails, a warning will be logged, and the country will not be set. In the next major release, this validation - * will be removed, and the country field will be treated as a normal string. - * - * @param s the country for the user - * @return the builder - */ - public Builder privateCountry(String s) { - addPrivate("country"); - return country(s); - } - - /** - * Set the country for a user. - * - * @param country the country for the user - * @return the builder - * @deprecated As of version 4.10.0. In the next major release the SDK will no longer include the - * LDCountryCode class. Applications should use {@link #country(String)} instead. - */ - @Deprecated - public Builder country(LDCountryCode country) { - this.country = country == null ? null : country.getAlpha2(); - return this; - } - - /** - * Set the country for a user, and ensures that the country attribute will not be sent back to LaunchDarkly. - * - * @param country the country for the user - * @return the builder - * @deprecated As of version 4.10.0. In the next major release the SDK will no longer include the - * LDCountryCode class. Applications should use {@link #privateCountry(String)} instead. - */ - @Deprecated - public Builder privateCountry(LDCountryCode country) { - addPrivate("country"); - return country(country); - } - - /** - * Sets the user's first name - * - * @param firstName the user's first name - * @return the builder - */ - public Builder firstName(String firstName) { - this.firstName = firstName; - return this; - } - - - /** - * Sets the user's first name, and ensures that the first name attribute will not be sent back to LaunchDarkly. - * - * @param firstName the user's first name - * @return the builder - */ - public Builder privateFirstName(String firstName) { - addPrivate("firstName"); - return firstName(firstName); - } - - - /** - * Sets whether this user is anonymous. - * - * @param anonymous whether the user is anonymous - * @return the builder - */ - public Builder anonymous(boolean anonymous) { - this.anonymous = anonymous; - return this; - } - - /** - * Sets the user's last name. - * - * @param lastName the user's last name - * @return the builder - */ - public Builder lastName(String lastName) { - this.lastName = lastName; - return this; - } - - /** - * Sets the user's last name, and ensures that the last name attribute will not be sent back to LaunchDarkly. - * - * @param lastName the user's last name - * @return the builder - */ - public Builder privateLastName(String lastName) { - addPrivate("lastName"); - return lastName(lastName); - } - - - /** - * Sets the user's full name. - * - * @param name the user's full name - * @return the builder - */ - public Builder name(String name) { - this.name = name; - return this; - } - - /** - * Sets the user's full name, and ensures that the name attribute will not be sent back to LaunchDarkly. - * - * @param name the user's full name - * @return the builder - */ - public Builder privateName(String name) { - addPrivate("name"); - return name(name); - } - - /** - * Sets the user's avatar. - * - * @param avatar the user's avatar - * @return the builder - */ - public Builder avatar(String avatar) { - this.avatar = avatar; - return this; - } - - /** - * Sets the user's avatar, and ensures that the avatar attribute will not be sent back to LaunchDarkly. - * - * @param avatar the user's avatar - * @return the builder - */ - public Builder privateAvatar(String avatar) { - addPrivate("avatar"); - return avatar(avatar); - } - - - /** - * Sets the user's e-mail address. - * - * @param email the e-mail address - * @return the builder - */ - public Builder email(String email) { - this.email = email; - return this; - } - - /** - * Sets the user's e-mail address, and ensures that the e-mail address attribute will not be sent back to LaunchDarkly. - * - * @param email the e-mail address - * @return the builder - */ - public Builder privateEmail(String email) { - addPrivate("email"); - return email(email); - } - - /** - * Adds a {@link java.lang.String}-valued custom attribute. When set to one of the - * built-in - * user attribute keys, this custom attribute will be ignored. - * - * @param k the key for the custom attribute - * @param v the value for the custom attribute - * @return the builder - */ - public Builder custom(String k, String v) { - return custom(k, v == null ? null : new JsonPrimitive(v)); - } - - /** - * Adds a {@link java.lang.Number}-valued custom attribute. When set to one of the - * built-in - * user attribute keys, this custom attribute will be ignored. - * - * @param k the key for the custom attribute - * @param n the value for the custom attribute - * @return the builder - */ - public Builder custom(String k, Number n) { - return custom(k, n == null ? null : new JsonPrimitive(n)); - } - - /** - * Add a {@link java.lang.Boolean}-valued custom attribute. When set to one of the - * built-in - * user attribute keys, this custom attribute will be ignored. - * - * @param k the key for the custom attribute - * @param b the value for the custom attribute - * @return the builder - */ - public Builder custom(String k, Boolean b) { - return custom(k, b == null ? null : new JsonPrimitive(b)); - } - - /** - * Add a custom attribute whose value can be any JSON type, using {@link LDValue}. When set to one of the - * built-in - * user attribute keys, this custom attribute will be ignored. - * - * @param k the key for the custom attribute - * @param v the value for the custom attribute - * @return the builder - * @since 4.8.0 - */ - public Builder custom(String k, LDValue v) { - checkCustomAttribute(k); - if (k != null && v != null) { - if (custom == null) { - custom = new HashMap<>(); - } - custom.put(k, v); - } - return this; - } - - /** - * Add a custom attribute whose value can be any JSON type. This is equivalent to {@link #custom(String, LDValue)} - * but uses the Gson type {@link JsonElement}. Using {@link LDValue} is preferred; the Gson types may be removed - * from the public API in the future. - * - * @param k the key for the custom attribute - * @param v the value for the custom attribute - * @return the builder - * @deprecated Use {@link #custom(String, LDValue)}. - */ - @Deprecated - public Builder custom(String k, JsonElement v) { - return custom(k, LDValue.unsafeFromJsonElement(v)); - } - - /** - * Add a list of {@link java.lang.String}-valued custom attributes. When set to one of the - * built-in - * user attribute keys, this custom attribute will be ignored. - * - * @param k the key for the list - * @param vs the values for the attribute - * @return the builder - */ - public Builder customString(String k, List vs) { - JsonArray array = new JsonArray(); - for (String v : vs) { - if (v != null) { - array.add(new JsonPrimitive(v)); - } - } - return custom(k, array); - } - - /** - * Add a list of {@link java.lang.Number}-valued custom attributes. When set to one of the - * built-in - * user attribute keys, this custom attribute will be ignored. - * - * @param k the key for the list - * @param vs the values for the attribute - * @return the builder - */ - public Builder customNumber(String k, List vs) { - JsonArray array = new JsonArray(); - for (Number v : vs) { - if (v != null) { - array.add(new JsonPrimitive(v)); - } - } - return custom(k, array); - } - - /** - * Add a custom attribute with a list of arbitrary JSON values. When set to one of the - * built-in - * user attribute keys, this custom attribute will be ignored. - * - * @param k the key for the list - * @param vs the values for the attribute - * @return the builder - */ - public Builder customValues(String k, List vs) { - JsonArray array = new JsonArray(); - for (JsonElement v : vs) { - if (v != null) { - array.add(v); - } - } - return custom(k, array); - } - - /** - * Add a {@link java.lang.String}-valued custom attribute that will not be sent back to LaunchDarkly. - * When set to one of the - * built-in - * user attribute keys, this custom attribute will be ignored. - * - * @param k the key for the custom attribute - * @param v the value for the custom attribute - * @return the builder - */ - public Builder privateCustom(String k, String v) { - addPrivate(k); - return custom(k, v); - } - - /** - * Add a {@link java.lang.Number}-valued custom attribute that will not be sent back to LaunchDarkly. - * When set to one of the - * built-in - * user attribute keys, this custom attribute will be ignored. - * - * @param k the key for the custom attribute - * @param n the value for the custom attribute - * @return the builder - */ - public Builder privateCustom(String k, Number n) { - addPrivate(k); - return custom(k, n); - } - - /** - * Add a {@link java.lang.Boolean}-valued custom attribute that will not be sent back to LaunchDarkly. - * When set to one of the - * built-in - * user attribute keys, this custom attribute will be ignored. - * - * @param k the key for the custom attribute - * @param b the value for the custom attribute - * @return the builder - */ - public Builder privateCustom(String k, Boolean b) { - addPrivate(k); - return custom(k, b); - } - - /** - * Add a custom attribute of any JSON type, that will not be sent back to LaunchDarkly. - * When set to one of the - * built-in - * user attribute keys, this custom attribute will be ignored. - * - * @param k the key for the custom attribute - * @param v the value for the custom attribute - * @return the builder - * @since 4.8.0 - */ - public Builder privateCustom(String k, LDValue v) { - addPrivate(k); - return custom(k, v); - } - - /** - * Add a custom attribute of any JSON type, that will not be sent back to LaunchDarkly. - * When set to one of the - * built-in - * user attribute keys, this custom attribute will be ignored. - * - * @param k the key for the custom attribute - * @param v the value for the custom attribute - * @return the builder - * @deprecated Use {@link #privateCustom(String, LDValue)}. - */ - @Deprecated - public Builder privateCustom(String k, JsonElement v) { - addPrivate(k); - return custom(k, v); - } - - /** - * Add a list of {@link java.lang.String}-valued custom attributes. When set to one of the - * - * built-in user attribute keys, this custom attribute will be ignored. The custom attribute value will not be sent - * back to LaunchDarkly in analytics events. - * - * @param k the key for the list. When set to one of the built-in user attribute keys, this custom attribute will be ignored. - * @param vs the values for the attribute - * @return the builder - */ - public Builder privateCustomString(String k, List vs) { - addPrivate(k); - return customString(k, vs); - } - - /** - * Add a list of {@link java.lang.Integer}-valued custom attributes. When set to one of the - * - * built-in user attribute keys, this custom attribute will be ignored. The custom attribute value will not be sent - * back to LaunchDarkly in analytics events. - * - * @param k the key for the list. When set to one of the built-in user attribute keys, this custom attribute will be ignored. - * @param vs the values for the attribute - * @return the builder - */ - public Builder privateCustomNumber(String k, List vs) { - addPrivate(k); - return customNumber(k, vs); - } - - /** - * Add a custom attribute with a list of arbitrary JSON values. When set to one of the - * - * built-in user attribute keys, this custom attribute will be ignored. The custom attribute value will not be sent - * back to LaunchDarkly in analytics events. - * - * @param k the key for the list - * @param vs the values for the attribute - * @return the builder - */ - public Builder privateCustomValues(String k, List vs) { - addPrivate(k); - return customValues(k, vs); - } - - private void checkCustomAttribute(String key) { - for (UserAttribute a : UserAttribute.values()) { - if (a.name().equals(key)) { - logger.warn("Built-in attribute key: " + key + " added as custom attribute! This custom attribute will be ignored during Feature Flag evaluation"); - return; - } - } - } - - private void addPrivate(String key) { - if (privateAttrNames == null) { - privateAttrNames = new HashSet<>(); - } - privateAttrNames.add(key); - } - - /** - * Builds the configured {@link com.launchdarkly.client.LDUser} object. - * - * @return the {@link com.launchdarkly.client.LDUser} configured by this builder - */ - public LDUser build() { - return new LDUser(this); - } - } -} diff --git a/src/main/java/com/launchdarkly/client/NewRelicReflector.java b/src/main/java/com/launchdarkly/client/NewRelicReflector.java deleted file mode 100644 index 91a09c52c..000000000 --- a/src/main/java/com/launchdarkly/client/NewRelicReflector.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.launchdarkly.client; - -import com.google.common.base.Joiner; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.lang.reflect.Method; - -final class NewRelicReflector { - - private static Class newRelic = null; - - private static Method addCustomParameter = null; - - private static final Logger logger = LoggerFactory.getLogger(NewRelicReflector.class); - - static { - try { - newRelic = Class.forName(getNewRelicClassName()); - addCustomParameter = newRelic.getDeclaredMethod("addCustomParameter", String.class, String.class); - } catch (ClassNotFoundException | NoSuchMethodException e) { - logger.info("No NewRelic agent detected"); - } - } - - static String getNewRelicClassName() { - // This ungainly logic is a workaround for the overly aggressive behavior of the Shadow plugin, which - // will transform any class or package names passed to Class.forName() if they are string literals; - // it will even transform the string "com". - String com = Joiner.on("").join(new String[] { "c", "o", "m" }); - return Joiner.on(".").join(new String[] { com, "newrelic", "api", "agent", "NewRelic" }); - } - - static void annotateTransaction(String featureKey, String value) { - if (addCustomParameter != null) { - try { - addCustomParameter.invoke(null, featureKey, value); - } catch (Exception e) { - logger.error("Unexpected error in LaunchDarkly NewRelic integration: {}", e.toString()); - logger.debug(e.toString(), e); - } - } - } -} diff --git a/src/main/java/com/launchdarkly/client/OperandType.java b/src/main/java/com/launchdarkly/client/OperandType.java deleted file mode 100644 index 1892c8357..000000000 --- a/src/main/java/com/launchdarkly/client/OperandType.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.launchdarkly.client; - -import com.google.gson.JsonPrimitive; -import com.launchdarkly.client.value.LDValue; - -/** - * Operator value that can be applied to {@link JsonPrimitive} objects. Incompatible types or other errors - * will always yield false. This enum can be directly deserialized from JSON, avoiding the need for a mapping - * of strings to operators. - */ -enum OperandType { - string, - number, - date, - semVer; - - public static OperandType bestGuess(LDValue value) { - return value.isNumber() ? number : string; - } - - public Object getValueAsType(LDValue value) { - switch (this) { - case string: - return value.stringValue(); - case number: - return value.isNumber() ? Double.valueOf(value.doubleValue()) : null; - case date: - return Util.jsonPrimitiveToDateTime(value); - case semVer: - if (!value.isString()) { - return null; - } - try { - return SemanticVersion.parse(value.stringValue(), true); - } catch (SemanticVersion.InvalidVersionException e) { - return null; - } - default: - return null; - } - } -} diff --git a/src/main/java/com/launchdarkly/client/Operator.java b/src/main/java/com/launchdarkly/client/Operator.java deleted file mode 100644 index e87c92090..000000000 --- a/src/main/java/com/launchdarkly/client/Operator.java +++ /dev/null @@ -1,133 +0,0 @@ -package com.launchdarkly.client; - -import com.google.gson.JsonPrimitive; -import com.launchdarkly.client.value.LDValue; - -import java.util.regex.Pattern; - -/** - * Operator value that can be applied to {@link JsonPrimitive} objects. Incompatible types or other errors - * will always yield false. This enum can be directly deserialized from JSON, avoiding the need for a mapping - * of strings to operators. - */ -enum Operator { - in { - @Override - public boolean apply(LDValue uValue, LDValue cValue) { - if (uValue.equals(cValue)) { - return true; - } - OperandType type = OperandType.bestGuess(uValue); - if (type == OperandType.bestGuess(cValue)) { - return compareValues(ComparisonOp.EQ, uValue, cValue, type); - } - return false; - } - }, - endsWith { - @Override - public boolean apply(LDValue uValue, LDValue cValue) { - return uValue.isString() && cValue.isString() && uValue.stringValue().endsWith(cValue.stringValue()); - } - }, - startsWith { - @Override - public boolean apply(LDValue uValue, LDValue cValue) { - return uValue.isString() && cValue.isString() && uValue.stringValue().startsWith(cValue.stringValue()); - } - }, - matches { - public boolean apply(LDValue uValue, LDValue cValue) { - return uValue.isString() && cValue.isString() && - Pattern.compile(cValue.stringValue()).matcher(uValue.stringValue()).find(); - } - }, - contains { - public boolean apply(LDValue uValue, LDValue cValue) { - return uValue.isString() && cValue.isString() && uValue.stringValue().contains(cValue.stringValue()); - } - }, - lessThan { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.LT, uValue, cValue, OperandType.number); - } - }, - lessThanOrEqual { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.LTE, uValue, cValue, OperandType.number); - } - }, - greaterThan { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.GT, uValue, cValue, OperandType.number); - } - }, - greaterThanOrEqual { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.GTE, uValue, cValue, OperandType.number); - } - }, - before { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.LT, uValue, cValue, OperandType.date); - } - }, - after { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.GT, uValue, cValue, OperandType.date); - } - }, - semVerEqual { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.EQ, uValue, cValue, OperandType.semVer); - } - }, - semVerLessThan { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.LT, uValue, cValue, OperandType.semVer); - } - }, - semVerGreaterThan { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.GT, uValue, cValue, OperandType.semVer); - } - }, - segmentMatch { - public boolean apply(LDValue uValue, LDValue cValue) { - // We shouldn't call apply() for this operator, because it is really implemented in - // Clause.matchesUser(). - return false; - } - }; - - abstract boolean apply(LDValue uValue, LDValue cValue); - - private static boolean compareValues(ComparisonOp op, LDValue uValue, LDValue cValue, OperandType asType) { - Object uValueObj = asType.getValueAsType(uValue); - Object cValueObj = asType.getValueAsType(cValue); - return uValueObj != null && cValueObj != null && op.apply(uValueObj, cValueObj); - } - - private static enum ComparisonOp { - EQ, - LT, - LTE, - GT, - GTE; - - @SuppressWarnings("unchecked") - public boolean apply(Object a, Object b) { - if (a instanceof Comparable && a.getClass() == b.getClass()) { - int n = ((Comparable)a).compareTo(b); - switch (this) { - case EQ: return (n == 0); - case LT: return (n < 0); - case LTE: return (n <= 0); - case GT: return (n > 0); - case GTE: return (n >= 0); - } - } - return false; - } - } -} diff --git a/src/main/java/com/launchdarkly/client/PollingProcessor.java b/src/main/java/com/launchdarkly/client/PollingProcessor.java deleted file mode 100644 index df3bf609a..000000000 --- a/src/main/java/com/launchdarkly/client/PollingProcessor.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.launchdarkly.client; - -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; - -import java.io.IOException; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ThreadFactory; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; - -import static com.launchdarkly.client.Util.httpErrorMessage; -import static com.launchdarkly.client.Util.isHttpErrorRecoverable; - -final class PollingProcessor implements UpdateProcessor { - private static final Logger logger = LoggerFactory.getLogger(PollingProcessor.class); - - @VisibleForTesting final FeatureRequestor requestor; - private final FeatureStore store; - @VisibleForTesting final long pollIntervalMillis; - private AtomicBoolean initialized = new AtomicBoolean(false); - private ScheduledExecutorService scheduler = null; - - PollingProcessor(FeatureRequestor requestor, FeatureStore featureStore, long pollIntervalMillis) { - this.requestor = requestor; // note that HTTP configuration is applied to the requestor when it is created - this.store = featureStore; - this.pollIntervalMillis = pollIntervalMillis; - } - - @Override - public boolean initialized() { - return initialized.get(); - } - - @Override - public void close() throws IOException { - logger.info("Closing LaunchDarkly PollingProcessor"); - if (scheduler != null) { - scheduler.shutdown(); - } - requestor.close(); - } - - @Override - public Future start() { - logger.info("Starting LaunchDarkly polling client with interval: " - + pollIntervalMillis + " milliseconds"); - final SettableFuture initFuture = SettableFuture.create(); - ThreadFactory threadFactory = new ThreadFactoryBuilder() - .setNameFormat("LaunchDarkly-PollingProcessor-%d") - .build(); - scheduler = Executors.newScheduledThreadPool(1, threadFactory); - - scheduler.scheduleAtFixedRate(new Runnable() { - @Override - public void run() { - try { - FeatureRequestor.AllData allData = requestor.getAllData(); - store.init(DefaultFeatureRequestor.toVersionedDataMap(allData)); - if (!initialized.getAndSet(true)) { - logger.info("Initialized LaunchDarkly client."); - initFuture.set(null); - } - } catch (HttpErrorException e) { - logger.error(httpErrorMessage(e.getStatus(), "polling request", "will retry")); - if (!isHttpErrorRecoverable(e.getStatus())) { - scheduler.shutdown(); - initFuture.set(null); // if client is initializing, make it stop waiting; has no effect if already inited - } - } 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); - - return initFuture; - } -} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/Prerequisite.java b/src/main/java/com/launchdarkly/client/Prerequisite.java deleted file mode 100644 index 7901444e5..000000000 --- a/src/main/java/com/launchdarkly/client/Prerequisite.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.launchdarkly.client; - -class Prerequisite { - private String key; - private int variation; - - private transient EvaluationReason.PrerequisiteFailed prerequisiteFailedReason; - - // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation - Prerequisite() {} - - Prerequisite(String key, int variation) { - this.key = key; - this.variation = variation; - } - - String getKey() { - return key; - } - - int getVariation() { - return variation; - } - - // This value is precomputed when we deserialize a FeatureFlag from JSON - EvaluationReason.PrerequisiteFailed getPrerequisiteFailedReason() { - return prerequisiteFailedReason; - } - - void setPrerequisiteFailedReason(EvaluationReason.PrerequisiteFailed prerequisiteFailedReason) { - this.prerequisiteFailedReason = prerequisiteFailedReason; - } -} diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java deleted file mode 100644 index ebd36913c..000000000 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.launchdarkly.client; - -import com.google.common.cache.CacheStats; -import com.launchdarkly.client.utils.CachingStoreWrapper; - -import java.io.IOException; -import java.util.Map; - -/** - * Deprecated implementation class for the Redis-based persistent data store. - *

- * Instead of referencing this class directly, use {@link com.launchdarkly.client.integrations.Redis#dataStore()} to obtain a builder object. - * - * @deprecated Use {@link com.launchdarkly.client.integrations.Redis#dataStore()} - */ -@Deprecated -public class RedisFeatureStore implements FeatureStore { - // The actual implementation is now in the com.launchdarkly.integrations package. This class remains - // visible for backward compatibility, but simply delegates to an instance of the underlying store. - - private final FeatureStore wrappedStore; - - @Override - public void init(Map, Map> allData) { - wrappedStore.init(allData); - } - - @Override - public T get(VersionedDataKind kind, String key) { - return wrappedStore.get(kind, key); - } - - @Override - public Map all(VersionedDataKind kind) { - return wrappedStore.all(kind); - } - - @Override - public void upsert(VersionedDataKind kind, T item) { - wrappedStore.upsert(kind, item); - } - - @Override - public void delete(VersionedDataKind kind, String key, int version) { - wrappedStore.delete(kind, key, version); - } - - @Override - public boolean initialized() { - return wrappedStore.initialized(); - } - - @Override - public void close() throws IOException { - wrappedStore.close(); - } - - /** - * Return the underlying Guava cache stats object. - *

- * In the newer data store API, there is a different way to do this. See - * {@link com.launchdarkly.client.integrations.PersistentDataStoreBuilder#cacheMonitor(com.launchdarkly.client.integrations.CacheMonitor)}. - * - * @return the cache statistics object. - */ - public CacheStats getCacheStats() { - return ((CachingStoreWrapper)wrappedStore).getCacheStats(); - } - - /** - * Creates a new store instance that connects to Redis based on the provided {@link RedisFeatureStoreBuilder}. - *

- * See the {@link RedisFeatureStoreBuilder} for information on available configuration options and what they do. - * - * @param builder the configured builder to construct the store with. - */ - protected RedisFeatureStore(RedisFeatureStoreBuilder builder) { - wrappedStore = builder.wrappedOuterBuilder.createFeatureStore(); - } - - /** - * Creates a new store instance that connects to Redis with a default connection (localhost port 6379) and no in-memory cache. - * @deprecated Please use {@link Components#redisFeatureStore()} instead. - */ - @Deprecated - public RedisFeatureStore() { - this(new RedisFeatureStoreBuilder().caching(FeatureStoreCacheConfig.disabled())); - } -} diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java deleted file mode 100644 index 60a45f94e..000000000 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java +++ /dev/null @@ -1,291 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.integrations.CacheMonitor; -import com.launchdarkly.client.integrations.PersistentDataStoreBuilder; -import com.launchdarkly.client.integrations.Redis; -import com.launchdarkly.client.integrations.RedisDataStoreBuilder; -import com.launchdarkly.client.interfaces.DiagnosticDescription; -import com.launchdarkly.client.value.LDValue; - -import java.net.URI; -import java.net.URISyntaxException; -import java.util.concurrent.TimeUnit; - -import redis.clients.jedis.JedisPoolConfig; - -/** - * Deprecated builder class for the Redis-based persistent data store. - *

- * The replacement for this class is {@link com.launchdarkly.client.integrations.RedisDataStoreBuilder}. - * This class is retained for backward compatibility and will be removed in a future version. - * - * @deprecated Use {@link com.launchdarkly.client.integrations.Redis#dataStore()} - */ -@Deprecated -public final class RedisFeatureStoreBuilder implements FeatureStoreFactory, DiagnosticDescription { - /** - * The default value for the Redis URI: {@code redis://localhost:6379} - * @since 4.0.0 - */ - public static final URI DEFAULT_URI = RedisDataStoreBuilder.DEFAULT_URI; - - /** - * The default value for {@link #prefix(String)}. - * @since 4.0.0 - */ - public static final String DEFAULT_PREFIX = RedisDataStoreBuilder.DEFAULT_PREFIX; - - /** - * The default value for {@link #cacheTime(long, TimeUnit)} (in seconds). - * @deprecated Use {@link FeatureStoreCacheConfig#DEFAULT}. - * @since 4.0.0 - */ - public static final long DEFAULT_CACHE_TIME_SECONDS = FeatureStoreCacheConfig.DEFAULT_TIME_SECONDS; - - final PersistentDataStoreBuilder wrappedOuterBuilder; - final RedisDataStoreBuilder wrappedBuilder; - - // We have to keep track of these caching parameters separately in order to support some deprecated setters - boolean refreshStaleValues = false; - boolean asyncRefresh = false; - - // These constructors are called only from Components - RedisFeatureStoreBuilder() { - wrappedBuilder = Redis.dataStore(); - wrappedOuterBuilder = Components.persistentDataStore(wrappedBuilder); - - // In order to make the cacheStats() method on the deprecated RedisFeatureStore class work, we need to - // turn on cache monitoring. In the newer API, cache monitoring would only be turned on if the application - // specified its own CacheMonitor, but in the deprecated API there's no way to know if they will want the - // statistics or not. - wrappedOuterBuilder.cacheMonitor(new CacheMonitor()); - } - - RedisFeatureStoreBuilder(URI uri) { - this(); - wrappedBuilder.uri(uri); - } - - /** - * The constructor accepts the mandatory fields that must be specified at a minimum to construct a {@link com.launchdarkly.client.RedisFeatureStore}. - * - * @param uri the uri of the Redis resource to connect to. - * @param cacheTimeSecs the cache time in seconds. See {@link RedisFeatureStoreBuilder#cacheTime(long, TimeUnit)} for more information. - * @deprecated Please use {@link Components#redisFeatureStore(java.net.URI)}. - */ - public RedisFeatureStoreBuilder(URI uri, long cacheTimeSecs) { - this(); - wrappedBuilder.uri(uri); - wrappedOuterBuilder.cacheSeconds(cacheTimeSecs); - } - - /** - * The constructor accepts the mandatory fields that must be specified at a minimum to construct a {@link com.launchdarkly.client.RedisFeatureStore}. - * - * @param scheme the URI scheme to use - * @param host the hostname to connect to - * @param port the port to connect to - * @param cacheTimeSecs the cache time in seconds. See {@link RedisFeatureStoreBuilder#cacheTime(long, TimeUnit)} for more information. - * @throws URISyntaxException if the URI is not valid - * @deprecated Please use {@link Components#redisFeatureStore(java.net.URI)}. - */ - public RedisFeatureStoreBuilder(String scheme, String host, int port, long cacheTimeSecs) throws URISyntaxException { - this(); - wrappedBuilder.uri(new URI(scheme, null, host, port, null, null, null)); - wrappedOuterBuilder.cacheSeconds(cacheTimeSecs); - } - - /** - * Specifies the database number to use. - *

- * The database number can also be specified in the Redis URI, in the form {@code redis://host:port/NUMBER}. Any - * non-null value that you set with {@link #database(Integer)} will override the URI. - * - * @param database the database number, or null to fall back to the URI or the default - * @return the builder - * - * @since 4.7.0 - */ - public RedisFeatureStoreBuilder database(Integer database) { - wrappedBuilder.database(database); - return this; - } - - /** - * Specifies a password that will be sent to Redis in an AUTH command. - *

- * It is also possible to include a password in the Redis URI, in the form {@code redis://:PASSWORD@host:port}. Any - * password that you set with {@link #password(String)} will override the URI. - * - * @param password the password - * @return the builder - * - * @since 4.7.0 - */ - public RedisFeatureStoreBuilder password(String password) { - wrappedBuilder.password(password); - return this; - } - - /** - * Optionally enables TLS for secure connections to Redis. - *

- * This is equivalent to specifying a Redis URI that begins with {@code rediss:} rather than {@code redis:}. - *

- * Note that not all Redis server distributions support TLS. - * - * @param tls true to enable TLS - * @return the builder - * - * @since 4.7.0 - */ - public RedisFeatureStoreBuilder tls(boolean tls) { - wrappedBuilder.tls(tls); - return this; - } - - /** - * Specifies whether local caching should be enabled and if so, sets the cache properties. Local - * caching is enabled by default; see {@link FeatureStoreCacheConfig#DEFAULT}. To disable it, pass - * {@link FeatureStoreCacheConfig#disabled()} to this method. - * - * @param caching a {@link FeatureStoreCacheConfig} object specifying caching parameters - * @return the builder - * - * @since 4.6.0 - */ - public RedisFeatureStoreBuilder caching(FeatureStoreCacheConfig caching) { - wrappedOuterBuilder.cacheTime(caching.getCacheTime(), caching.getCacheTimeUnit()); - wrappedOuterBuilder.staleValuesPolicy(caching.getStaleValuesPolicy().toNewEnum()); - return this; - } - - /** - * Deprecated method for setting the cache expiration policy to {@link FeatureStoreCacheConfig.StaleValuesPolicy#REFRESH} - * or {@link FeatureStoreCacheConfig.StaleValuesPolicy#REFRESH_ASYNC}. - * - * @param enabled turns on lazy refresh of cached values - * @return the builder - * - * @deprecated Use {@link #caching(FeatureStoreCacheConfig)} and - * {@link FeatureStoreCacheConfig#staleValuesPolicy(com.launchdarkly.client.FeatureStoreCacheConfig.StaleValuesPolicy)}. - */ - public RedisFeatureStoreBuilder refreshStaleValues(boolean enabled) { - this.refreshStaleValues = enabled; - updateCachingStaleValuesPolicy(); - return this; - } - - /** - * Deprecated method for setting the cache expiration policy to {@link FeatureStoreCacheConfig.StaleValuesPolicy#REFRESH_ASYNC}. - * - * @param enabled turns on asynchronous refresh of cached values (only if {@link #refreshStaleValues(boolean)} - * is also true) - * @return the builder - * - * @deprecated Use {@link #caching(FeatureStoreCacheConfig)} and - * {@link FeatureStoreCacheConfig#staleValuesPolicy(com.launchdarkly.client.FeatureStoreCacheConfig.StaleValuesPolicy)}. - */ - public RedisFeatureStoreBuilder asyncRefresh(boolean enabled) { - this.asyncRefresh = enabled; - updateCachingStaleValuesPolicy(); - return this; - } - - private void updateCachingStaleValuesPolicy() { - // We need this logic in order to support the existing behavior of the deprecated methods above: - // asyncRefresh is supposed to have no effect unless refreshStaleValues is true - if (refreshStaleValues) { - wrappedOuterBuilder.staleValuesPolicy(this.asyncRefresh ? - PersistentDataStoreBuilder.StaleValuesPolicy.REFRESH_ASYNC : - PersistentDataStoreBuilder.StaleValuesPolicy.REFRESH); - } else { - wrappedOuterBuilder.staleValuesPolicy(PersistentDataStoreBuilder.StaleValuesPolicy.EVICT); - } - } - - /** - * Optionally configures the namespace prefix for all keys stored in Redis. - * - * @param prefix the namespace prefix - * @return the builder - */ - public RedisFeatureStoreBuilder prefix(String prefix) { - wrappedBuilder.prefix(prefix); - return this; - } - - /** - * Deprecated method for enabling local caching and setting the cache TTL. Local caching is enabled - * by default; see {@link FeatureStoreCacheConfig#DEFAULT}. - * - * @param cacheTime the time value to cache for, or 0 to disable local caching - * @param timeUnit the time unit for the time value - * @return the builder - * - * @deprecated use {@link #caching(FeatureStoreCacheConfig)} and {@link FeatureStoreCacheConfig#ttl(long, TimeUnit)}. - */ - public RedisFeatureStoreBuilder cacheTime(long cacheTime, TimeUnit timeUnit) { - wrappedOuterBuilder.cacheTime(cacheTime, timeUnit); - return this; - } - - /** - * Optional override if you wish to specify your own configuration to the underlying Jedis pool. - * - * @param poolConfig the Jedis pool configuration. - * @return the builder - */ - public RedisFeatureStoreBuilder poolConfig(JedisPoolConfig poolConfig) { - wrappedBuilder.poolConfig(poolConfig); - return this; - } - - /** - * Optional override which sets the connection timeout for the underlying Jedis pool which otherwise defaults to - * {@link redis.clients.jedis.Protocol#DEFAULT_TIMEOUT} - * - * @param connectTimeout the timeout - * @param timeUnit the time unit for the timeout - * @return the builder - */ - public RedisFeatureStoreBuilder connectTimeout(int connectTimeout, TimeUnit timeUnit) { - wrappedBuilder.connectTimeout(connectTimeout, timeUnit); - return this; - } - - /** - * Optional override which sets the connection timeout for the underlying Jedis pool which otherwise defaults to - * {@link redis.clients.jedis.Protocol#DEFAULT_TIMEOUT} - * - * @param socketTimeout the socket timeout - * @param timeUnit the time unit for the timeout - * @return the builder - */ - public RedisFeatureStoreBuilder socketTimeout(int socketTimeout, TimeUnit timeUnit) { - wrappedBuilder.socketTimeout(socketTimeout, timeUnit); - return this; - } - - /** - * Build a {@link RedisFeatureStore} based on the currently configured builder object. - * @return the {@link RedisFeatureStore} configured by this builder. - */ - public RedisFeatureStore build() { - return new RedisFeatureStore(this); - } - - /** - * Synonym for {@link #build()}. - * @return the {@link RedisFeatureStore} configured by this builder. - * @since 4.0.0 - */ - public RedisFeatureStore createFeatureStore() { - return build(); - } - - @Override - public LDValue describeConfiguration(LDConfig config) { - return LDValue.of("Redis"); - } -} diff --git a/src/main/java/com/launchdarkly/client/Rule.java b/src/main/java/com/launchdarkly/client/Rule.java deleted file mode 100644 index 49939348d..000000000 --- a/src/main/java/com/launchdarkly/client/Rule.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.launchdarkly.client; - -import java.util.List; - -/** - * Expresses a set of AND-ed matching conditions for a user, along with either the fixed variation or percent rollout - * to serve if the conditions match. - * Invariant: one of the variation or rollout must be non-nil. - */ -class Rule extends VariationOrRollout { - private String id; - private List clauses; - private boolean trackEvents; - - private transient EvaluationReason.RuleMatch ruleMatchReason; - - // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation - Rule() { - super(); - } - - Rule(String id, List clauses, Integer variation, Rollout rollout, boolean trackEvents) { - super(variation, rollout); - this.id = id; - this.clauses = clauses; - this.trackEvents = trackEvents; - } - - Rule(String id, List clauses, Integer variation, Rollout rollout) { - this(id, clauses, variation, rollout, false); - } - - String getId() { - return id; - } - - List getClauses() { - return clauses; - } - - boolean isTrackEvents() { - return trackEvents; - } - - // This value is precomputed when we deserialize a FeatureFlag from JSON - EvaluationReason.RuleMatch getRuleMatchReason() { - return ruleMatchReason; - } - - void setRuleMatchReason(EvaluationReason.RuleMatch ruleMatchReason) { - this.ruleMatchReason = ruleMatchReason; - } - - boolean matchesUser(FeatureStore store, LDUser user) { - for (Clause clause : clauses) { - if (!clause.matchesUser(store, user)) { - return false; - } - } - return true; - } -} diff --git a/src/main/java/com/launchdarkly/client/Segment.java b/src/main/java/com/launchdarkly/client/Segment.java deleted file mode 100644 index 872c2ada5..000000000 --- a/src/main/java/com/launchdarkly/client/Segment.java +++ /dev/null @@ -1,136 +0,0 @@ -package com.launchdarkly.client; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -@SuppressWarnings("deprecation") -class Segment implements VersionedData { - private String key; - private Set included; - private Set excluded; - private String salt; - private List rules; - private int version; - private boolean deleted; - - // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation - Segment() {} - - private Segment(Builder builder) { - this.key = builder.key; - this.included = builder.included; - this.excluded = builder.excluded; - this.salt = builder.salt; - this.rules = builder.rules; - this.version = builder.version; - this.deleted = builder.deleted; - } - - public String getKey() { - return key; - } - - public Iterable getIncluded() { - return included; - } - - public Iterable getExcluded() { - return excluded; - } - - public String getSalt() { - return salt; - } - - public Iterable getRules() { - return rules; - } - - public int getVersion() { - return version; - } - - public boolean isDeleted() { - return deleted; - } - - public boolean matchesUser(LDUser user) { - String key = user.getKeyAsString(); - if (key == null) { - return false; - } - if (included.contains(key)) { - return true; - } - if (excluded.contains(key)) { - return false; - } - for (SegmentRule rule: rules) { - if (rule.matchUser(user, key, salt)) { - return true; - } - } - return false; - } - - public static class Builder { - private String key; - private Set included = new HashSet<>(); - private Set excluded = new HashSet<>(); - private String salt = ""; - private List rules = new ArrayList<>(); - private int version = 0; - private boolean deleted; - - public Builder(String key) { - this.key = key; - } - - public Builder(Segment from) { - this.key = from.key; - this.included = new HashSet<>(from.included); - this.excluded = new HashSet<>(from.excluded); - this.salt = from.salt; - this.rules = new ArrayList<>(from.rules); - this.version = from.version; - this.deleted = from.deleted; - } - - public Segment build() { - return new Segment(this); - } - - public Builder included(Collection included) { - this.included = new HashSet<>(included); - return this; - } - - public Builder excluded(Collection excluded) { - this.excluded = new HashSet<>(excluded); - return this; - } - - public Builder salt(String salt) { - this.salt = salt; - return this; - } - - public Builder rules(Collection rules) { - this.rules = new ArrayList<>(rules); - return this; - } - - public Builder version(int version) { - this.version = version; - return this; - } - - public Builder deleted(boolean deleted) { - this.deleted = deleted; - return this; - } - } -} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/SegmentRule.java b/src/main/java/com/launchdarkly/client/SegmentRule.java deleted file mode 100644 index 79b3df68f..000000000 --- a/src/main/java/com/launchdarkly/client/SegmentRule.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.launchdarkly.client; - -import java.util.List; - -/** - * Internal data model class. - * - * @deprecated This class was made public in error and will be removed in a future release. It is used internally by the SDK. - */ -@Deprecated -public class SegmentRule { - private final List clauses; - private final Integer weight; - private final String bucketBy; - - /** - * Used internally to construct an instance. - * @param clauses the clauses in the rule - * @param weight the rollout weight - * @param bucketBy the attribute for computing a rollout - */ - public SegmentRule(List clauses, Integer weight, String bucketBy) { - this.clauses = clauses; - this.weight = weight; - this.bucketBy = bucketBy; - } - - /** - * Used internally to match a user against a segment. - * @param user the user to match - * @param segmentKey the segment key - * @param salt the segment's salt string - * @return true if the user matches - */ - public boolean matchUser(LDUser user, String segmentKey, String salt) { - for (Clause c: clauses) { - if (!c.matchesUserNoSegments(user)) { - return false; - } - } - - // If the Weight is absent, this rule matches - if (weight == null) { - return true; - } - - // All of the clauses are met. See if the user buckets in - String by = (bucketBy == null) ? "key" : bucketBy; - double bucket = VariationOrRollout.bucketUser(user, segmentKey, by, salt); - double weight = (double)this.weight / 100000.0; - return bucket < weight; - } -} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java deleted file mode 100644 index 9da59b497..000000000 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ /dev/null @@ -1,402 +0,0 @@ -package com.launchdarkly.client; - -import com.google.common.annotations.VisibleForTesting; -import com.google.common.util.concurrent.SettableFuture; -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; -import com.launchdarkly.eventsource.MessageEvent; -import com.launchdarkly.eventsource.UnsuccessfulResponseException; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -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; - -import static com.launchdarkly.client.Util.configureHttpClientBuilder; -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.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"; - private static final String DELETE = "delete"; - private static final String INDIRECT_PUT = "indirect/put"; - private static final String INDIRECT_PATCH = "indirect/patch"; - private static final Logger logger = LoggerFactory.getLogger(StreamProcessor.class); - private static final int DEAD_CONNECTION_INTERVAL_MS = 300 * 1000; - - private final FeatureStore store; - private final HttpConfiguration httpConfig; - private final Headers headers; - @VisibleForTesting final URI streamUri; - @VisibleForTesting final long initialReconnectDelayMillis; - @VisibleForTesting final FeatureRequestor requestor; - private final DiagnosticAccumulator diagnosticAccumulator; - private final EventSourceCreator eventSourceCreator; - 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 - - public static interface EventSourceCreator { - EventSource createEventSource(EventHandler handler, URI streamUri, long initialReconnectDelayMillis, - ConnectionErrorHandler errorHandler, Headers headers, HttpConfiguration httpConfig); - } - - StreamProcessor( - String sdkKey, - HttpConfiguration httpConfig, - FeatureRequestor requestor, - FeatureStore featureStore, - EventSourceCreator eventSourceCreator, - DiagnosticAccumulator diagnosticAccumulator, - URI streamUri, - long initialReconnectDelayMillis - ) { - this.store = featureStore; - this.httpConfig = httpConfig; - this.requestor = requestor; - this.diagnosticAccumulator = diagnosticAccumulator; - this.eventSourceCreator = eventSourceCreator != null ? eventSourceCreator : new DefaultEventSourceCreator(); - this.streamUri = streamUri; - this.initialReconnectDelayMillis = initialReconnectDelayMillis; - - this.headers = getHeadersBuilderFor(sdkKey, httpConfig) - .add("Accept", "text/event-stream") - .build(); - } - - private ConnectionErrorHandler createDefaultConnectionErrorHandler() { - return new ConnectionErrorHandler() { - @Override - public Action onConnectionError(Throwable t) { - recordStreamInit(true); - if (t instanceof UnsuccessfulResponseException) { - int status = ((UnsuccessfulResponseException)t).getCode(); - logger.error(httpErrorMessage(status, "streaming connection", "will retry")); - if (!isHttpErrorRecoverable(status)) { - return Action.SHUTDOWN; - } - } - esStarted = System.currentTimeMillis(); - return Action.PROCEED; - } - }; - } - - @Override - public Future start() { - final SettableFuture initFuture = SettableFuture.create(); - - ConnectionErrorHandler wrappedConnectionErrorHandler = new ConnectionErrorHandler() { - @Override - public Action onConnectionError(Throwable t) { - Action result = connectionErrorHandler.onConnectionError(t); - if (result == Action.SHUTDOWN) { - initFuture.set(null); // if client is initializing, make it stop waiting; has no effect if already inited - } - return result; - } - }; - - EventHandler handler = new EventHandler() { - - @Override - public void onOpen() throws Exception { - } - - @Override - public void onClosed() throws Exception { - } - - @Override - 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; - } - 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; - } - 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; - } - 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."); - } - break; - case INDIRECT_PATCH: - String path = event.getData(); - Map.Entry, String> kindAndKey = getKindAndKeyFromStreamApiPath(path); - if (kindAndKey == null) { - 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); - } - } - - @Override - public void onComment(String comment) { - logger.debug("Received a heartbeat"); - } - - @Override - public void onError(Throwable throwable) { - logger.warn("Encountered EventSource error: {}", throwable.toString()); - logger.debug(throwable.toString(), throwable); - } - }; - - es = eventSourceCreator.createEventSource(handler, - URI.create(streamUri.toASCIIString() + "/all"), - initialReconnectDelayMillis, - wrappedConnectionErrorHandler, - headers, - httpConfig); - esStarted = System.currentTimeMillis(); - es.start(); - return initFuture; - } - - private void recordStreamInit(boolean failed) { - if (diagnosticAccumulator != null && esStarted != 0) { - diagnosticAccumulator.recordStreamInit(esStarted, System.currentTimeMillis() - esStarted, failed); - } - } - - @Override - public void close() throws IOException { - logger.info("Closing LaunchDarkly StreamProcessor"); - if (es != null) { - es.close(); - } - if (store != null) { - store.close(); - } - requestor.close(); - } - - @Override - 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; - - @SuppressWarnings("unused") // used by Gson - public PutData() { } - } - - private static final class PatchData { - String path; - JsonElement data; - - @SuppressWarnings("unused") // used by Gson - public PatchData() { } - } - - private static final class DeleteData { - String path; - int version; - - @SuppressWarnings("unused") // used by Gson - public DeleteData() { } - } - - private class DefaultEventSourceCreator implements EventSourceCreator { - public EventSource createEventSource(EventHandler handler, URI streamUri, long initialReconnectDelayMillis, - ConnectionErrorHandler errorHandler, Headers headers, final HttpConfiguration httpConfig) { - EventSource.Builder builder = new EventSource.Builder(handler, streamUri) - .clientBuilderActions(new EventSource.Builder.ClientConfigurer() { - public void configure(OkHttpClient.Builder builder) { - configureHttpClientBuilder(httpConfig, builder); - } - }) - .connectionErrorHandler(errorHandler) - .headers(headers) - .reconnectTimeMs(initialReconnectDelayMillis) - .readTimeoutMs(DEAD_CONNECTION_INTERVAL_MS) - .connectTimeoutMs(EventSource.DEFAULT_CONNECT_TIMEOUT_MS) - .writeTimeoutMs(EventSource.DEFAULT_WRITE_TIMEOUT_MS); - // Note that this is not the same read timeout that can be set in LDConfig. We default to a smaller one - // there because we don't expect long delays within any *non*-streaming response that the LD client gets. - // A read timeout on the stream will result in the connection being cycled, so we set this to be slightly - // more than the expected interval between heartbeat signals. - - return builder.build(); - } - } -} diff --git a/src/main/java/com/launchdarkly/client/Target.java b/src/main/java/com/launchdarkly/client/Target.java deleted file mode 100644 index 54eb154da..000000000 --- a/src/main/java/com/launchdarkly/client/Target.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.launchdarkly.client; - -import java.util.Set; - -class Target { - private Set values; - private int variation; - - // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation - Target() {} - - Target(Set values, int variation) { - this.values = values; - this.variation = variation; - } - - Set getValues() { - return values; - } - - int getVariation() { - return variation; - } -} diff --git a/src/main/java/com/launchdarkly/client/TestFeatureStore.java b/src/main/java/com/launchdarkly/client/TestFeatureStore.java deleted file mode 100644 index e2725147d..000000000 --- a/src/main/java/com/launchdarkly/client/TestFeatureStore.java +++ /dev/null @@ -1,133 +0,0 @@ -package com.launchdarkly.client; - -import static com.launchdarkly.client.VersionedDataKind.FEATURES; - -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; - -import com.google.gson.JsonElement; -import com.google.gson.JsonPrimitive; -import com.launchdarkly.client.value.LDValue; - -/** - * A decorated {@link InMemoryFeatureStore} which provides functionality to create (or override) true or false feature flags for all users. - *

- * Using this store is useful for testing purposes when you want to have runtime support for turning specific features on or off. - * - * @deprecated Will be replaced by a file-based test fixture. - */ -@Deprecated -public class TestFeatureStore extends InMemoryFeatureStore { - static List TRUE_FALSE_VARIATIONS = Arrays.asList(LDValue.of(true), LDValue.of(false)); - - private AtomicInteger version = new AtomicInteger(0); - private volatile boolean initializedForTests = false; - - /** - * Sets the value of a boolean feature flag for all users. - * - * @param key the key of the feature flag - * @param value the new value of the feature flag - * @return the feature flag - */ - public FeatureFlag setBooleanValue(String key, Boolean value) { - FeatureFlag newFeature = new FeatureFlagBuilder(key) - .on(false) - .offVariation(value ? 0 : 1) - .variations(TRUE_FALSE_VARIATIONS) - .version(version.incrementAndGet()) - .build(); - upsert(FEATURES, newFeature); - return newFeature; - } - - /** - * Turns a feature, identified by key, to evaluate to true for every user. If the feature rules already exist in the store then it will override it to be true for every {@link LDUser}. - * If the feature rule is not currently in the store, it will create one that is true for every {@link LDUser}. - * - * @param key the key of the feature flag to evaluate to true - * @return the feature flag - */ - public FeatureFlag setFeatureTrue(String key) { - return setBooleanValue(key, true); - } - - /** - * Turns a feature, identified by key, to evaluate to false for every user. If the feature rules already exist in the store then it will override it to be false for every {@link LDUser}. - * If the feature rule is not currently in the store, it will create one that is false for every {@link LDUser}. - * - * @param key the key of the feature flag to evaluate to false - * @return the feature flag - */ - public FeatureFlag setFeatureFalse(String key) { - return setBooleanValue(key, false); - } - - /** - * Sets the value of an integer multivariate feature flag, for all users. - * @param key the key of the flag - * @param value the new value of the flag - * @return the feature flag - */ - public FeatureFlag setIntegerValue(String key, Integer value) { - return setJsonValue(key, new JsonPrimitive(value)); - } - - /** - * Sets the value of a double multivariate feature flag, for all users. - * @param key the key of the flag - * @param value the new value of the flag - * @return the feature flag - */ - public FeatureFlag setDoubleValue(String key, Double value) { - return setJsonValue(key, new JsonPrimitive(value)); - } - - /** - * Sets the value of a string multivariate feature flag, for all users. - * @param key the key of the flag - * @param value the new value of the flag - * @return the feature flag - */ - public FeatureFlag setStringValue(String key, String value) { - return setJsonValue(key, new JsonPrimitive(value)); - } - - /** - * Sets the value of a JsonElement multivariate feature flag, for all users. - * @param key the key of the flag - * @param value the new value of the flag - * @return the feature flag - */ - public FeatureFlag setJsonValue(String key, JsonElement value) { - FeatureFlag newFeature = new FeatureFlagBuilder(key) - .on(false) - .offVariation(0) - .variations(Arrays.asList(LDValue.fromJsonElement(value))) - .version(version.incrementAndGet()) - .build(); - upsert(FEATURES, newFeature); - return newFeature; - } - - @Override - public void init(Map, Map> allData) { - super.init(allData); - initializedForTests = true; - } - - @Override - public boolean initialized() { - return initializedForTests; - } - - /** - * Sets the initialization status that the feature store will report to the SDK - * @param value true if the store should show as initialized - */ - public void setInitialized(boolean value) { - initializedForTests = value; - } -} diff --git a/src/main/java/com/launchdarkly/client/UpdateProcessor.java b/src/main/java/com/launchdarkly/client/UpdateProcessor.java deleted file mode 100644 index 52bd712ce..000000000 --- a/src/main/java/com/launchdarkly/client/UpdateProcessor.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.launchdarkly.client; - -import java.io.Closeable; -import java.io.IOException; -import java.util.concurrent.Future; - -import static com.google.common.util.concurrent.Futures.immediateFuture; - -/** - * Interface for an object that receives updates to feature flags, user segments, and anything - * else that might come from LaunchDarkly, and passes them to a {@link FeatureStore}. - * @since 4.0.0 - */ -public interface UpdateProcessor extends Closeable { - /** - * Starts the client. - * @return {@link Future}'s completion status indicates the client has been initialized. - */ - Future start(); - - /** - * Returns true once the client has been initialized and will never return false again. - * @return true if the client has been initialized - */ - boolean initialized(); - - /** - * Tells the component to shut down and release any resources it is using. - * @throws IOException if there is an error while closing - */ - void close() throws IOException; - - /** - * An implementation of {@link UpdateProcessor} that does nothing. - * - * @deprecated Use {@link Components#externalUpdatesOnly()} instead of referring to this implementation class directly. - */ - // This was exposed because everything in an interface is public. The SDK itself no longer refers to this class; - // instead it uses Components.NullUpdateProcessor. - @Deprecated - static final class NullUpdateProcessor implements UpdateProcessor { - @Override - public Future start() { - return immediateFuture(null); - } - - @Override - public boolean initialized() { - return true; - } - - @Override - public void close() throws IOException {} - } -} diff --git a/src/main/java/com/launchdarkly/client/UpdateProcessorFactory.java b/src/main/java/com/launchdarkly/client/UpdateProcessorFactory.java deleted file mode 100644 index 1b3a73e8d..000000000 --- a/src/main/java/com/launchdarkly/client/UpdateProcessorFactory.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.launchdarkly.client; - -/** - * Interface for a factory that creates some implementation of {@link UpdateProcessor}. - * @see Components - * @since 4.0.0 - */ -public interface UpdateProcessorFactory { - /** - * Creates an implementation instance. - * @param sdkKey the SDK key for your LaunchDarkly environment - * @param config the LaunchDarkly configuration - * @param featureStore the {@link FeatureStore} to use for storing the latest flag state - * @return an {@link UpdateProcessor} - */ - public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore); -} diff --git a/src/main/java/com/launchdarkly/client/UpdateProcessorFactoryWithDiagnostics.java b/src/main/java/com/launchdarkly/client/UpdateProcessorFactoryWithDiagnostics.java deleted file mode 100644 index a7a63bb31..000000000 --- a/src/main/java/com/launchdarkly/client/UpdateProcessorFactoryWithDiagnostics.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.launchdarkly.client; - -interface UpdateProcessorFactoryWithDiagnostics extends UpdateProcessorFactory { - UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore, - DiagnosticAccumulator diagnosticAccumulator); -} diff --git a/src/main/java/com/launchdarkly/client/UserAttribute.java b/src/main/java/com/launchdarkly/client/UserAttribute.java deleted file mode 100644 index 1da2e02a5..000000000 --- a/src/main/java/com/launchdarkly/client/UserAttribute.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.value.LDValue; - -enum UserAttribute { - key { - LDValue get(LDUser user) { - return user.getKey(); - } - }, - secondary { - LDValue get(LDUser user) { - return null; //Not used for evaluation. - } - }, - ip { - LDValue get(LDUser user) { - return user.getIp(); - } - }, - email { - LDValue get(LDUser user) { - return user.getEmail(); - } - }, - avatar { - LDValue get(LDUser user) { - return user.getAvatar(); - } - }, - firstName { - LDValue get(LDUser user) { - return user.getFirstName(); - } - }, - lastName { - LDValue get(LDUser user) { - return user.getLastName(); - } - }, - name { - LDValue get(LDUser user) { - return user.getName(); - } - }, - country { - LDValue get(LDUser user) { - return user.getCountry(); - } - }, - anonymous { - LDValue get(LDUser user) { - return user.getAnonymous(); - } - }; - - /** - * Gets value for Rule evaluation for a user. - * - * @param user - * @return - */ - abstract LDValue get(LDUser user); -} diff --git a/src/main/java/com/launchdarkly/client/VariationOrRollout.java b/src/main/java/com/launchdarkly/client/VariationOrRollout.java deleted file mode 100644 index e3f0ad29b..000000000 --- a/src/main/java/com/launchdarkly/client/VariationOrRollout.java +++ /dev/null @@ -1,127 +0,0 @@ -package com.launchdarkly.client; - - -import com.launchdarkly.client.value.LDValue; - -import org.apache.commons.codec.digest.DigestUtils; - -import java.util.List; - -/** - * Contains either a fixed variation or percent rollout to serve. - * Invariant: one of the variation or rollout must be non-nil. - */ -class VariationOrRollout { - private static final float long_scale = (float) 0xFFFFFFFFFFFFFFFL; - - private Integer variation; - private Rollout rollout; - - // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation - VariationOrRollout() {} - - VariationOrRollout(Integer variation, Rollout rollout) { - this.variation = variation; - this.rollout = rollout; - } - - Integer getVariation() { - return variation; - } - - Rollout getRollout() { - return rollout; - } - - // Attempt to determine the variation index for a given user. Returns null if no index can be computed - // due to internal inconsistency of the data (i.e. a malformed flag). - Integer variationIndexForUser(LDUser user, String key, String salt) { - if (variation != null) { - return variation; - } else if (rollout != null && rollout.variations != null && !rollout.variations.isEmpty()) { - String bucketBy = rollout.bucketBy == null ? "key" : rollout.bucketBy; - float bucket = bucketUser(user, key, bucketBy, salt); - float sum = 0F; - for (WeightedVariation wv : rollout.variations) { - sum += (float) wv.weight / 100000F; - if (bucket < sum) { - return wv.variation; - } - } - // The user's bucket value was greater than or equal to the end of the last bucket. This could happen due - // to a rounding error, or due to the fact that we are scaling to 100000 rather than 99999, or the flag - // data could contain buckets that don't actually add up to 100000. Rather than returning an error in - // this case (or changing the scaling, which would potentially change the results for *all* users), we - // will simply put the user in the last bucket. - return rollout.variations.get(rollout.variations.size() - 1).variation; - } - return null; - } - - static float bucketUser(LDUser user, String key, String attr, String salt) { - LDValue userValue = user.getValueForEvaluation(attr); - String idHash = getBucketableStringValue(userValue); - if (idHash != null) { - if (!user.getSecondary().isNull()) { - idHash = idHash + "." + user.getSecondary().stringValue(); - } - String hash = DigestUtils.sha1Hex(key + "." + salt + "." + idHash).substring(0, 15); - long longVal = Long.parseLong(hash, 16); - return (float) longVal / long_scale; - } - return 0F; - } - - private static String getBucketableStringValue(LDValue userValue) { - switch (userValue.getType()) { - case STRING: - return userValue.stringValue(); - case NUMBER: - return userValue.isInt() ? String.valueOf(userValue.intValue()) : null; - default: - return null; - } - } - - static final class Rollout { - private List variations; - private String bucketBy; - - // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation - Rollout() {} - - Rollout(List variations, String bucketBy) { - this.variations = variations; - this.bucketBy = bucketBy; - } - - List getVariations() { - return variations; - } - - String getBucketBy() { - return bucketBy; - } - } - - static final class WeightedVariation { - private int variation; - private int weight; - - // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation - WeightedVariation() {} - - WeightedVariation(int variation, int weight) { - this.variation = variation; - this.weight = weight; - } - - int getVariation() { - return variation; - } - - int getWeight() { - return weight; - } - } -} diff --git a/src/main/java/com/launchdarkly/client/VersionedData.java b/src/main/java/com/launchdarkly/client/VersionedData.java deleted file mode 100644 index 98bd19c34..000000000 --- a/src/main/java/com/launchdarkly/client/VersionedData.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.launchdarkly.client; - -/** - * Common interface for string-keyed, versioned objects that can be kept in a {@link FeatureStore}. - * @since 3.0.0 - */ -public interface VersionedData { - /** - * The key for this item, unique within the namespace of each {@link VersionedDataKind}. - * @return the key - */ - String getKey(); - /** - * The version number for this item. - * @return the version number - */ - int getVersion(); - /** - * True if this is a placeholder for a deleted item. - * @return true if deleted - */ - boolean isDeleted(); -} diff --git a/src/main/java/com/launchdarkly/client/VersionedDataKind.java b/src/main/java/com/launchdarkly/client/VersionedDataKind.java deleted file mode 100644 index 16cf1badc..000000000 --- a/src/main/java/com/launchdarkly/client/VersionedDataKind.java +++ /dev/null @@ -1,168 +0,0 @@ -package com.launchdarkly.client; - -import com.google.common.base.Function; -import com.google.common.collect.ImmutableList; - -import static com.google.common.collect.Iterables.transform; - -/** - * The descriptor for a specific kind of {@link VersionedData} objects that may exist in a {@link FeatureStore}. - * You will not need to refer to this type unless you are directly manipulating a {@code FeatureStore} - * or writing your own {@code FeatureStore} implementation. If you are implementing a custom store, for - * maximum forward compatibility you should only refer to {@link VersionedData}, {@link VersionedDataKind}, - * and {@link VersionedDataKind#ALL}, and avoid any dependencies on specific type descriptor instances - * or any specific fields of the types they describe. - * @param the item type - * @since 3.0.0 - */ -public abstract class VersionedDataKind { - - /** - * A short string that serves as the unique name for the collection of these objects, e.g. "features". - * @return a namespace string - */ - public abstract String getNamespace(); - - /** - * The Java class for objects of this type. - * @return a Java class - */ - public abstract Class getItemClass(); - - /** - * The path prefix for objects of this type in events received from the streaming API. - * @return the URL path - */ - public abstract String getStreamApiPath(); - - /** - * Return an instance of this type with the specified key and version, and deleted=true. - * @param key the unique key - * @param version the version number - * @return a new instance - */ - public abstract T makeDeletedItem(String key, int version); - - /** - * Used internally to determine the order in which collections are updated. The default value is - * arbitrary; the built-in data kinds override it for specific data model reasons. - * - * @return a zero-based integer; collections with a lower priority are updated first - * @since 4.7.0 - */ - public int getPriority() { - return getNamespace().length() + 10; - } - - /** - * Returns true if the SDK needs to store items of this kind in an order that is based on - * {@link #getDependencyKeys(VersionedData)}. - * - * @return true if dependency ordering should be used - * @since 4.7.0 - */ - public boolean isDependencyOrdered() { - return false; - } - - /** - * Gets all keys of items that this one directly depends on, if this kind of item can have - * dependencies. - *

- * Note that this does not use the generic type T, because it is called from code that only knows - * about VersionedData, so it will need to do a type cast. However, it can rely on the item being - * of the correct class. - * - * @param item the item - * @return keys of dependencies of the item - * @since 4.7.0 - */ - public Iterable getDependencyKeys(VersionedData item) { - return ImmutableList.of(); - } - - @Override - public String toString() { - return "{" + getNamespace() + "}"; - } - - /** - * Used internally to match data URLs in the streaming API. - * @param path path from an API message - * @return the parsed key if the path refers to an object of this kind, otherwise null - */ - String getKeyFromStreamApiPath(String path) { - return path.startsWith(getStreamApiPath()) ? path.substring(getStreamApiPath().length()) : null; - } - - static abstract class Impl extends VersionedDataKind { - private final String namespace; - private final Class itemClass; - private final String streamApiPath; - private final int priority; - - Impl(String namespace, Class itemClass, String streamApiPath, int priority) { - this.namespace = namespace; - this.itemClass = itemClass; - this.streamApiPath = streamApiPath; - this.priority = priority; - } - - public String getNamespace() { - return namespace; - } - - public Class getItemClass() { - return itemClass; - } - - public String getStreamApiPath() { - return streamApiPath; - } - - public int getPriority() { - return priority; - } - } - - /** - * The {@link VersionedDataKind} instance that describes feature flag data. - */ - public static VersionedDataKind FEATURES = new Impl("features", FeatureFlag.class, "/flags/", 1) { - public FeatureFlag makeDeletedItem(String key, int version) { - return new FeatureFlagBuilder(key).deleted(true).version(version).build(); - } - - public boolean isDependencyOrdered() { - return true; - } - - public Iterable getDependencyKeys(VersionedData item) { - FeatureFlag flag = (FeatureFlag)item; - if (flag.getPrerequisites() == null || flag.getPrerequisites().isEmpty()) { - return ImmutableList.of(); - } - return transform(flag.getPrerequisites(), new Function() { - public String apply(Prerequisite p) { - return p.getKey(); - } - }); - } - }; - - /** - * The {@link VersionedDataKind} instance that describes user segment data. - */ - public static VersionedDataKind SEGMENTS = new Impl("segments", Segment.class, "/segments/", 0) { - - public Segment makeDeletedItem(String key, int version) { - return new Segment.Builder(key).deleted(true).version(version).build(); - } - }; - - /** - * A list of all existing instances of {@link VersionedDataKind}. - * @since 4.1.0 - */ - public static Iterable> ALL = ImmutableList.of(FEATURES, SEGMENTS); -} diff --git a/src/main/java/com/launchdarkly/client/files/FileComponents.java b/src/main/java/com/launchdarkly/client/files/FileComponents.java deleted file mode 100644 index 63a575555..000000000 --- a/src/main/java/com/launchdarkly/client/files/FileComponents.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.launchdarkly.client.files; - -/** - * Deprecated entry point for the file data source. - * @since 4.5.0 - * @deprecated Use {@link com.launchdarkly.client.integrations.FileData}. - */ -@Deprecated -public abstract class FileComponents { - /** - * Creates a {@link FileDataSourceFactory} which you can use to configure the file data - * source. - * @return a {@link FileDataSourceFactory} - */ - public static FileDataSourceFactory fileDataSource() { - return new FileDataSourceFactory(); - } -} diff --git a/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java b/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java deleted file mode 100644 index ded4a2dd5..000000000 --- a/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.launchdarkly.client.files; - -import com.launchdarkly.client.FeatureStore; -import com.launchdarkly.client.LDConfig; -import com.launchdarkly.client.UpdateProcessor; -import com.launchdarkly.client.UpdateProcessorFactory; -import com.launchdarkly.client.integrations.FileDataSourceBuilder; -import com.launchdarkly.client.integrations.FileData; - -import java.nio.file.InvalidPathException; -import java.nio.file.Path; - -/** - * Deprecated name for {@link FileDataSourceBuilder}. Use {@link FileData#dataSource()} to obtain the - * new builder. - * - * @since 4.5.0 - * @deprecated - */ -public class FileDataSourceFactory implements UpdateProcessorFactory { - private final FileDataSourceBuilder wrappedBuilder = new FileDataSourceBuilder(); - - /** - * Adds any number of source files for loading flag data, specifying each file path as a string. The files will - * not actually be loaded until the LaunchDarkly client starts up. - *

- * Files will be parsed as JSON if their first non-whitespace character is '{'. Otherwise, they will be parsed as YAML. - * - * @param filePaths path(s) to the source file(s); may be absolute or relative to the current working directory - * @return the same factory object - * - * @throws InvalidPathException if one of the parameters is not a valid file path - */ - public FileDataSourceFactory filePaths(String... filePaths) throws InvalidPathException { - wrappedBuilder.filePaths(filePaths); - return this; - } - - /** - * Adds any number of source files for loading flag data, specifying each file path as a Path. The files will - * not actually be loaded until the LaunchDarkly client starts up. - *

- * Files will be parsed as JSON if their first non-whitespace character is '{'. Otherwise, they will be parsed as YAML. - * - * @param filePaths path(s) to the source file(s); may be absolute or relative to the current working directory - * @return the same factory object - */ - public FileDataSourceFactory filePaths(Path... filePaths) { - wrappedBuilder.filePaths(filePaths); - return this; - } - - /** - * Specifies whether the data source should watch for changes to the source file(s) and reload flags - * whenever there is a change. By default, it will not, so the flags will only be loaded once. - *

- * Note that auto-updating will only work if all of the files you specified have valid directory paths at - * startup time; if a directory does not exist, creating it later will not result in files being loaded from it. - * - * @param autoUpdate true if flags should be reloaded whenever a source file changes - * @return the same factory object - */ - public FileDataSourceFactory autoUpdate(boolean autoUpdate) { - wrappedBuilder.autoUpdate(autoUpdate); - return this; - } - - /** - * Used internally by the LaunchDarkly client. - */ - @Override - public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { - return wrappedBuilder.createUpdateProcessor(sdkKey, config, featureStore); - } -} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/files/package-info.java b/src/main/java/com/launchdarkly/client/files/package-info.java deleted file mode 100644 index da8abb785..000000000 --- a/src/main/java/com/launchdarkly/client/files/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Deprecated package replaced by {@link com.launchdarkly.client.integrations.FileData}. - */ -package com.launchdarkly.client.files; diff --git a/src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java b/src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java deleted file mode 100644 index 977982d9f..000000000 --- a/src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java +++ /dev/null @@ -1,151 +0,0 @@ -package com.launchdarkly.client.integrations; - -import java.util.Objects; -import java.util.concurrent.Callable; - -/** - * A conduit that an application can use to monitor caching behavior of a persistent data store. - * - * @see PersistentDataStoreBuilder#cacheMonitor(CacheMonitor) - * @since 4.12.0 - */ -public final class CacheMonitor { - private Callable source; - - /** - * Constructs a new instance. - */ - public CacheMonitor() {} - - /** - * Called internally by the SDK to establish a source for the statistics. - * @param source provided by an internal SDK component - * @deprecated Referencing this method directly is deprecated. In a future version, it will - * only be visible to SDK implementation code. - */ - @Deprecated - public void setSource(Callable source) { - this.source = source; - } - - /** - * Queries the current cache statistics. - * - * @return a {@link CacheStats} instance, or null if not available - */ - public CacheStats getCacheStats() { - try { - return source == null ? null : source.call(); - } catch (Exception e) { - return null; - } - } - - /** - * A snapshot of cache statistics. The statistics are cumulative across the lifetime of the data store. - *

- * This is based on the data provided by Guava's caching framework. The SDK currently uses Guava - * internally, but is not guaranteed to always do so, and to avoid embedding Guava API details in - * the SDK API this is provided as a separate class. - * - * @since 4.12.0 - */ - public static final class CacheStats { - private final long hitCount; - private final long missCount; - private final long loadSuccessCount; - private final long loadExceptionCount; - private final long totalLoadTime; - private final long evictionCount; - - /** - * Constructs a new instance. - * - * @param hitCount number of queries that produced a cache hit - * @param missCount number of queries that produced a cache miss - * @param loadSuccessCount number of cache misses that loaded a value without an exception - * @param loadExceptionCount number of cache misses that tried to load a value but got an exception - * @param totalLoadTime number of nanoseconds spent loading new values - * @param evictionCount number of cache entries that have been evicted - */ - public CacheStats(long hitCount, long missCount, long loadSuccessCount, long loadExceptionCount, - long totalLoadTime, long evictionCount) { - this.hitCount = hitCount; - this.missCount = missCount; - this.loadSuccessCount = loadSuccessCount; - this.loadExceptionCount = loadExceptionCount; - this.totalLoadTime = totalLoadTime; - this.evictionCount = evictionCount; - } - - /** - * The number of data queries that received cached data instead of going to the underlying data store. - * @return the number of cache hits - */ - public long getHitCount() { - return hitCount; - } - - /** - * The number of data queries that did not find cached data and went to the underlying data store. - * @return the number of cache misses - */ - public long getMissCount() { - return missCount; - } - - /** - * The number of times a cache miss resulted in successfully loading a data store item (or finding - * that it did not exist in the store). - * @return the number of successful loads - */ - public long getLoadSuccessCount() { - return loadSuccessCount; - } - - /** - * The number of times that an error occurred while querying the underlying data store. - * @return the number of failed loads - */ - public long getLoadExceptionCount() { - return loadExceptionCount; - } - - /** - * The total number of nanoseconds that the cache has spent loading new values. - * @return total time spent for all cache loads - */ - public long getTotalLoadTime() { - return totalLoadTime; - } - - /** - * The number of times cache entries have been evicted. - * @return the number of evictions - */ - public long getEvictionCount() { - return evictionCount; - } - - @Override - public boolean equals(Object other) { - if (!(other instanceof CacheStats)) { - return false; - } - CacheStats o = (CacheStats)other; - return hitCount == o.hitCount && missCount == o.missCount && loadSuccessCount == o.loadSuccessCount && - loadExceptionCount == o.loadExceptionCount && totalLoadTime == o.totalLoadTime && evictionCount == o.evictionCount; - } - - @Override - public int hashCode() { - return Objects.hash(hitCount, missCount, loadSuccessCount, loadExceptionCount, totalLoadTime, evictionCount); - } - - @Override - public String toString() { - return "{hit=" + hitCount + ", miss=" + missCount + ", loadSuccess=" + loadSuccessCount + - ", loadException=" + loadExceptionCount + ", totalLoadTime=" + totalLoadTime + ", evictionCount=" + evictionCount + "}"; - } - } -} diff --git a/src/main/java/com/launchdarkly/client/integrations/Redis.java b/src/main/java/com/launchdarkly/client/integrations/Redis.java deleted file mode 100644 index 90bed1e00..000000000 --- a/src/main/java/com/launchdarkly/client/integrations/Redis.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.launchdarkly.client.integrations; - -/** - * Integration between the LaunchDarkly SDK and Redis. - * - * @since 4.12.0 - */ -public abstract class Redis { - /** - * Returns a builder object for creating a Redis-backed data store. - *

- * This object can be modified with {@link RedisDataStoreBuilder} methods for any desired - * custom Redis options. Then, pass it to {@link com.launchdarkly.client.Components#persistentDataStore(com.launchdarkly.client.interfaces.PersistentDataStoreFactory)} - * and set any desired caching options. Finally, pass the result to - * {@link com.launchdarkly.client.LDConfig.Builder#dataStore(com.launchdarkly.client.FeatureStoreFactory)}. - * For example: - * - *


-   *     LDConfig config = new LDConfig.Builder()
-   *         .dataStore(
-   *             Components.persistentDataStore(
-   *                 Redis.dataStore().url("redis://my-redis-host")
-   *             ).cacheSeconds(15)
-   *         )
-   *         .build();
-   * 
- * - * @return a data store configuration object - */ - public static RedisDataStoreBuilder dataStore() { - return new RedisDataStoreBuilder(); - } - - private Redis() {} -} diff --git a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java deleted file mode 100644 index cf65e012c..000000000 --- a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java +++ /dev/null @@ -1,185 +0,0 @@ -package com.launchdarkly.client.integrations; - -import com.google.common.base.Joiner; -import com.launchdarkly.client.LDConfig; -import com.launchdarkly.client.interfaces.DiagnosticDescription; -import com.launchdarkly.client.interfaces.PersistentDataStoreFactory; -import com.launchdarkly.client.utils.FeatureStoreCore; -import com.launchdarkly.client.value.LDValue; - -import java.net.URI; -import java.util.concurrent.TimeUnit; - -import static com.google.common.base.Preconditions.checkNotNull; - -import redis.clients.jedis.JedisPoolConfig; -import redis.clients.jedis.Protocol; - -/** - * A builder for configuring the Redis-based persistent data store. - *

- * Obtain an instance of this class by calling {@link Redis#dataStore()}. After calling its methods - * to specify any desired custom settings, you can pass it directly into the SDK configuration with - * {@link com.launchdarkly.client.LDConfig.Builder#dataStore(com.launchdarkly.client.FeatureStoreFactory)}. - * You do not need to call {@link #createPersistentDataStore()} yourself to build the actual data store; that - * will be done by the SDK. - *

- * Builder calls can be chained, for example: - * - *


-   *     LDConfig config = new LDConfig.Builder()
-   *         .dataStore(
-   *             Components.persistentDataStore(
-   *                 Redis.dataStore()
-   *                     .url("redis://my-redis-host")
-   *                     .database(1)
-   *             ).cacheSeconds(15)
-   *         )
-   *         .build();
- * 
- * - * @since 4.12.0 - */ -public final class RedisDataStoreBuilder implements PersistentDataStoreFactory, DiagnosticDescription { - /** - * The default value for the Redis URI: {@code redis://localhost:6379} - */ - public static final URI DEFAULT_URI = makeDefaultRedisURI(); - - /** - * The default value for {@link #prefix(String)}. - */ - public static final String DEFAULT_PREFIX = "launchdarkly"; - - URI uri = DEFAULT_URI; - String prefix = DEFAULT_PREFIX; - int connectTimeout = Protocol.DEFAULT_TIMEOUT; - int socketTimeout = Protocol.DEFAULT_TIMEOUT; - Integer database = null; - String password = null; - boolean tls = false; - JedisPoolConfig poolConfig = null; - - private static URI makeDefaultRedisURI() { - // This ungainly logic is a workaround for the overly aggressive behavior of the Shadow plugin, which - // wants to transform any string literal starting with "redis" because the root package of Jedis is - // "redis". - return URI.create(Joiner.on("").join("r", "e", "d", "i", "s") + "://localhost:6379"); - } - - // These constructors are called only from Implementations - RedisDataStoreBuilder() { - } - - /** - * Specifies the database number to use. - *

- * The database number can also be specified in the Redis URI, in the form {@code redis://host:port/NUMBER}. Any - * non-null value that you set with {@link #database(Integer)} will override the URI. - * - * @param database the database number, or null to fall back to the URI or the default - * @return the builder - */ - public RedisDataStoreBuilder database(Integer database) { - this.database = database; - return this; - } - - /** - * Specifies a password that will be sent to Redis in an AUTH command. - *

- * It is also possible to include a password in the Redis URI, in the form {@code redis://:PASSWORD@host:port}. Any - * password that you set with {@link #password(String)} will override the URI. - * - * @param password the password - * @return the builder - */ - public RedisDataStoreBuilder password(String password) { - this.password = password; - return this; - } - - /** - * Optionally enables TLS for secure connections to Redis. - *

- * This is equivalent to specifying a Redis URI that begins with {@code rediss:} rather than {@code redis:}. - *

- * Note that not all Redis server distributions support TLS. - * - * @param tls true to enable TLS - * @return the builder - */ - public RedisDataStoreBuilder tls(boolean tls) { - this.tls = tls; - return this; - } - - /** - * Specifies a Redis host URI other than {@link #DEFAULT_URI}. - * - * @param redisUri the URI of the Redis host - * @return the builder - */ - public RedisDataStoreBuilder uri(URI redisUri) { - this.uri = checkNotNull(redisUri); - return this; - } - - /** - * Optionally configures the namespace prefix for all keys stored in Redis. - * - * @param prefix the namespace prefix - * @return the builder - */ - public RedisDataStoreBuilder prefix(String prefix) { - this.prefix = prefix; - return this; - } - - /** - * Optional override if you wish to specify your own configuration to the underlying Jedis pool. - * - * @param poolConfig the Jedis pool configuration. - * @return the builder - */ - public RedisDataStoreBuilder poolConfig(JedisPoolConfig poolConfig) { - this.poolConfig = poolConfig; - return this; - } - - /** - * Optional override which sets the connection timeout for the underlying Jedis pool which otherwise defaults to - * {@link redis.clients.jedis.Protocol#DEFAULT_TIMEOUT} - * - * @param connectTimeout the timeout - * @param timeUnit the time unit for the timeout - * @return the builder - */ - public RedisDataStoreBuilder connectTimeout(int connectTimeout, TimeUnit timeUnit) { - this.connectTimeout = (int) timeUnit.toMillis(connectTimeout); - return this; - } - - /** - * Optional override which sets the connection timeout for the underlying Jedis pool which otherwise defaults to - * {@link redis.clients.jedis.Protocol#DEFAULT_TIMEOUT} - * - * @param socketTimeout the socket timeout - * @param timeUnit the time unit for the timeout - * @return the builder - */ - public RedisDataStoreBuilder socketTimeout(int socketTimeout, TimeUnit timeUnit) { - this.socketTimeout = (int) timeUnit.toMillis(socketTimeout); - return this; - } - - @Override - public FeatureStoreCore createPersistentDataStore() { - return new RedisDataStoreImpl(this); - } - - @Override - public LDValue describeConfiguration(LDConfig config) { - return LDValue.of("Redis"); - } -} diff --git a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java deleted file mode 100644 index c81a02762..000000000 --- a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java +++ /dev/null @@ -1,196 +0,0 @@ -package com.launchdarkly.client.integrations; - -import com.google.common.annotations.VisibleForTesting; -import com.launchdarkly.client.VersionedData; -import com.launchdarkly.client.VersionedDataKind; -import com.launchdarkly.client.utils.FeatureStoreCore; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static com.launchdarkly.client.utils.FeatureStoreHelpers.marshalJson; -import static com.launchdarkly.client.utils.FeatureStoreHelpers.unmarshalJson; - -import redis.clients.jedis.Jedis; -import redis.clients.jedis.JedisPool; -import redis.clients.jedis.JedisPoolConfig; -import redis.clients.jedis.Transaction; -import redis.clients.util.JedisURIHelper; - -final class RedisDataStoreImpl implements FeatureStoreCore { - private static final Logger logger = LoggerFactory.getLogger(RedisDataStoreImpl.class); - - private final JedisPool pool; - private final String prefix; - private UpdateListener updateListener; - - RedisDataStoreImpl(RedisDataStoreBuilder builder) { - // There is no builder for JedisPool, just a large number of constructor overloads. Unfortunately, - // the overloads that accept a URI do not accept the other parameters we need to set, so we need - // to decompose the URI. - String host = builder.uri.getHost(); - int port = builder.uri.getPort(); - String password = builder.password == null ? JedisURIHelper.getPassword(builder.uri) : builder.password; - int database = builder.database == null ? JedisURIHelper.getDBIndex(builder.uri): builder.database.intValue(); - boolean tls = builder.tls || builder.uri.getScheme().equals("rediss"); - - String extra = tls ? " with TLS" : ""; - if (password != null) { - extra = extra + (extra.isEmpty() ? " with" : " and") + " password"; - } - logger.info(String.format("Connecting to Redis feature store at %s:%d/%d%s", host, port, database, extra)); - - JedisPoolConfig poolConfig = (builder.poolConfig != null) ? builder.poolConfig : new JedisPoolConfig(); - JedisPool pool = new JedisPool(poolConfig, - host, - port, - builder.connectTimeout, - builder.socketTimeout, - password, - database, - null, // clientName - tls, - null, // sslSocketFactory - null, // sslParameters - null // hostnameVerifier - ); - - String prefix = (builder.prefix == null || builder.prefix.isEmpty()) ? - RedisDataStoreBuilder.DEFAULT_PREFIX : - builder.prefix; - - this.pool = pool; - this.prefix = prefix; - } - - @Override - public VersionedData getInternal(VersionedDataKind kind, String key) { - try (Jedis jedis = pool.getResource()) { - VersionedData item = getRedis(kind, key, jedis); - if (item != null) { - logger.debug("[get] Key: {} with version: {} found in \"{}\".", key, item.getVersion(), kind.getNamespace()); - } - return item; - } - } - - @Override - public Map getAllInternal(VersionedDataKind kind) { - try (Jedis jedis = pool.getResource()) { - Map allJson = jedis.hgetAll(itemsKey(kind)); - Map result = new HashMap<>(); - - for (Map.Entry entry : allJson.entrySet()) { - VersionedData item = unmarshalJson(kind, entry.getValue()); - result.put(entry.getKey(), item); - } - return result; - } - } - - @Override - public void initInternal(Map, Map> allData) { - try (Jedis jedis = pool.getResource()) { - Transaction t = jedis.multi(); - - for (Map.Entry, Map> entry: allData.entrySet()) { - String baseKey = itemsKey(entry.getKey()); - t.del(baseKey); - for (VersionedData item: entry.getValue().values()) { - t.hset(baseKey, item.getKey(), marshalJson(item)); - } - } - - t.set(initedKey(), ""); - t.exec(); - } - } - - @Override - public VersionedData upsertInternal(VersionedDataKind kind, VersionedData newItem) { - while (true) { - Jedis jedis = null; - try { - jedis = pool.getResource(); - String baseKey = itemsKey(kind); - jedis.watch(baseKey); - - if (updateListener != null) { - updateListener.aboutToUpdate(baseKey, newItem.getKey()); - } - - VersionedData oldItem = getRedis(kind, newItem.getKey(), jedis); - - if (oldItem != null && oldItem.getVersion() >= newItem.getVersion()) { - logger.debug("Attempted to {} key: {} version: {}" + - " with a version that is the same or older: {} in \"{}\"", - newItem.isDeleted() ? "delete" : "update", - newItem.getKey(), oldItem.getVersion(), newItem.getVersion(), kind.getNamespace()); - return oldItem; - } - - Transaction tx = jedis.multi(); - tx.hset(baseKey, newItem.getKey(), marshalJson(newItem)); - List result = tx.exec(); - if (result.isEmpty()) { - // if exec failed, it means the watch was triggered and we should retry - logger.debug("Concurrent modification detected, retrying"); - continue; - } - - return newItem; - } finally { - if (jedis != null) { - jedis.unwatch(); - jedis.close(); - } - } - } - } - - @Override - public boolean initializedInternal() { - try (Jedis jedis = pool.getResource()) { - return jedis.exists(initedKey()); - } - } - - @Override - public void close() throws IOException { - logger.info("Closing LaunchDarkly RedisFeatureStore"); - pool.destroy(); - } - - @VisibleForTesting - void setUpdateListener(UpdateListener updateListener) { - this.updateListener = updateListener; - } - - private String itemsKey(VersionedDataKind kind) { - return prefix + ":" + kind.getNamespace(); - } - - private String initedKey() { - return prefix + ":$inited"; - } - - private T getRedis(VersionedDataKind kind, String key, Jedis jedis) { - String json = jedis.hget(itemsKey(kind), key); - - if (json == null) { - logger.debug("[get] Key: {} not found in \"{}\". Returning null", key, kind.getNamespace()); - return null; - } - - return unmarshalJson(kind, json); - } - - static interface UpdateListener { - void aboutToUpdate(String baseKey, String itemKey); - } -} diff --git a/src/main/java/com/launchdarkly/client/integrations/package-info.java b/src/main/java/com/launchdarkly/client/integrations/package-info.java deleted file mode 100644 index 079858106..000000000 --- a/src/main/java/com/launchdarkly/client/integrations/package-info.java +++ /dev/null @@ -1,13 +0,0 @@ -/** - * This package contains integration tools for connecting the SDK to other software components, or - * configuring how it connects to LaunchDarkly. - *

- * In the current main LaunchDarkly Java SDK library, this package contains {@link com.launchdarkly.client.integrations.Redis} - * (for using Redis as a store for flag data) and {@link com.launchdarkly.client.integrations.FileData} - * (for reading flags from a file in testing). Other SDK add-on libraries, such as database integrations, - * will define their classes in {@code com.launchdarkly.client.integrations} as well. - *

- * The general pattern for factory methods in this package is {@code ToolName#componentType()}, - * such as {@code Redis#dataStore()} or {@code FileData#dataSource()}. - */ -package com.launchdarkly.client.integrations; diff --git a/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java b/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java deleted file mode 100644 index 16a5b5544..000000000 --- a/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.launchdarkly.client.interfaces; - -import com.launchdarkly.client.integrations.PersistentDataStoreBuilder; -import com.launchdarkly.client.utils.FeatureStoreCore; - -/** - * Interface for a factory that creates some implementation of a persistent data store. - *

- * This interface is implemented by database integrations. Usage is described in - * {@link com.launchdarkly.client.Components#persistentDataStore}. - * - * @see com.launchdarkly.client.Components - * @since 4.12.0 - */ -public interface PersistentDataStoreFactory { - /** - * Called internally from {@link PersistentDataStoreBuilder} to create the implementation object - * for the specific type of data store. - * - * @return the implementation object - * @deprecated Do not reference this method directly, as the {@link FeatureStoreCore} interface - * will be replaced in 5.0. - */ - @Deprecated - FeatureStoreCore createPersistentDataStore(); -} diff --git a/src/main/java/com/launchdarkly/client/package-info.java b/src/main/java/com/launchdarkly/client/package-info.java deleted file mode 100644 index 14ba4590e..000000000 --- a/src/main/java/com/launchdarkly/client/package-info.java +++ /dev/null @@ -1,8 +0,0 @@ -/** - * The main package for the LaunchDarkly Java SDK. - *

- * You will most often use {@link com.launchdarkly.client.LDClient} (the SDK client), - * {@link com.launchdarkly.client.LDConfig} (configuration options for the client), and - * {@link com.launchdarkly.client.LDUser} (user properties for feature flag evaluation). - */ -package com.launchdarkly.client; diff --git a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java deleted file mode 100644 index a4c7853ce..000000000 --- a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java +++ /dev/null @@ -1,391 +0,0 @@ -package com.launchdarkly.client.utils; - -import com.google.common.base.Optional; -import com.google.common.cache.CacheBuilder; -import com.google.common.cache.CacheLoader; -import com.google.common.cache.CacheStats; -import com.google.common.cache.LoadingCache; -import com.google.common.collect.ImmutableMap; -import com.google.common.util.concurrent.ListeningExecutorService; -import com.google.common.util.concurrent.MoreExecutors; -import com.google.common.util.concurrent.ThreadFactoryBuilder; -import com.launchdarkly.client.FeatureStore; -import com.launchdarkly.client.FeatureStoreCacheConfig; -import com.launchdarkly.client.VersionedData; -import com.launchdarkly.client.VersionedDataKind; -import com.launchdarkly.client.integrations.CacheMonitor; - -import java.io.IOException; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.Callable; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.ThreadFactory; -import java.util.concurrent.atomic.AtomicBoolean; - -/** - * CachingStoreWrapper is a partial implementation of {@link FeatureStore} that delegates the basic - * functionality to an instance of {@link FeatureStoreCore}. It provides optional caching behavior and - * other logic that would otherwise be repeated in every feature store implementation. This makes it - * easier to create new database integrations by implementing only the database-specific logic. - *

- * Construct instances of this class with {@link CachingStoreWrapper#builder(FeatureStoreCore)}. - * - * @since 4.6.0 - * @deprecated Referencing this class directly is deprecated; it is now part of the implementation - * of {@link com.launchdarkly.client.integrations.PersistentDataStoreBuilder} and will be made - * package-private starting in version 5.0. - */ -@Deprecated -public class CachingStoreWrapper implements FeatureStore { - private static final String CACHE_REFRESH_THREAD_POOL_NAME_FORMAT = "CachingStoreWrapper-refresher-pool-%d"; - - private final FeatureStoreCore core; - private final FeatureStoreCacheConfig caching; - private final LoadingCache> itemCache; - private final LoadingCache, ImmutableMap> allCache; - private final LoadingCache initCache; - private final AtomicBoolean inited = new AtomicBoolean(false); - private final ListeningExecutorService executorService; - - /** - * Creates a new builder. - * @param core the {@link FeatureStoreCore} instance - * @return the builder - */ - public static CachingStoreWrapper.Builder builder(FeatureStoreCore core) { - return new Builder(core); - } - - protected CachingStoreWrapper(final FeatureStoreCore core, FeatureStoreCacheConfig caching, CacheMonitor cacheMonitor) { - this.core = core; - this.caching = caching; - - if (!caching.isEnabled()) { - itemCache = null; - allCache = null; - initCache = null; - executorService = null; - } else { - CacheLoader> itemLoader = new CacheLoader>() { - @Override - public Optional load(CacheKey key) throws Exception { - return Optional.fromNullable(core.getInternal(key.kind, key.key)); - } - }; - CacheLoader, ImmutableMap> allLoader = new CacheLoader, ImmutableMap>() { - @Override - public ImmutableMap load(VersionedDataKind kind) throws Exception { - return itemsOnlyIfNotDeleted(core.getAllInternal(kind)); - } - }; - CacheLoader initLoader = new CacheLoader() { - @Override - public Boolean load(String key) throws Exception { - return core.initializedInternal(); - } - }; - - if (caching.getStaleValuesPolicy() == FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH_ASYNC) { - ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat(CACHE_REFRESH_THREAD_POOL_NAME_FORMAT).setDaemon(true).build(); - ExecutorService parentExecutor = Executors.newSingleThreadExecutor(threadFactory); - executorService = MoreExecutors.listeningDecorator(parentExecutor); - - // Note that the REFRESH_ASYNC mode is only used for itemCache, not allCache, since retrieving all flags is - // less frequently needed and we don't want to incur the extra overhead. - itemLoader = CacheLoader.asyncReloading(itemLoader, executorService); - } else { - executorService = null; - } - - itemCache = newCacheBuilder(caching, cacheMonitor).build(itemLoader); - allCache = newCacheBuilder(caching, cacheMonitor).build(allLoader); - initCache = newCacheBuilder(caching, cacheMonitor).build(initLoader); - - if (cacheMonitor != null) { - cacheMonitor.setSource(new CacheStatsSource()); - } - } - } - - private static CacheBuilder newCacheBuilder(FeatureStoreCacheConfig caching, CacheMonitor cacheMonitor) { - CacheBuilder builder = CacheBuilder.newBuilder(); - if (!caching.isInfiniteTtl()) { - if (caching.getStaleValuesPolicy() == FeatureStoreCacheConfig.StaleValuesPolicy.EVICT) { - // We are using an "expire after write" cache. This will evict stale values and block while loading the latest - // from the underlying data store. - builder = builder.expireAfterWrite(caching.getCacheTime(), caching.getCacheTimeUnit()); - } else { - // We are using a "refresh after write" cache. This will not automatically evict stale values, allowing them - // to be returned if failures occur when updating them. - builder = builder.refreshAfterWrite(caching.getCacheTime(), caching.getCacheTimeUnit()); - } - } - if (cacheMonitor != null) { - builder = builder.recordStats(); - } - return builder; - } - - @Override - public void close() throws IOException { - if (executorService != null) { - executorService.shutdownNow(); - } - core.close(); - } - - @SuppressWarnings("unchecked") - @Override - public T get(VersionedDataKind kind, String key) { - if (itemCache != null) { - Optional cachedItem = itemCache.getUnchecked(CacheKey.forItem(kind, key)); - if (cachedItem != null) { - return (T)itemOnlyIfNotDeleted(cachedItem.orNull()); - } - } - return (T)itemOnlyIfNotDeleted(core.getInternal(kind, key)); - } - - @SuppressWarnings("unchecked") - @Override - public Map all(VersionedDataKind kind) { - if (allCache != null) { - Map items = (Map)allCache.getUnchecked(kind); - if (items != null) { - return items; - } - } - return itemsOnlyIfNotDeleted(core.getAllInternal(kind)); - } - - @SuppressWarnings("unchecked") - @Override - public void init(Map, Map> allData) { - Map, Map> castMap = // silly generic wildcard problem - (Map, Map>)((Map)allData); - try { - core.initInternal(castMap); - } catch (RuntimeException e) { - // Normally, if the underlying store failed to do the update, we do not want to update the cache - - // the idea being that it's better to stay in a consistent state of having old data than to act - // like we have new data but then suddenly fall back to old data when the cache expires. However, - // if the cache TTL is infinite, then it makes sense to update the cache always. - if (allCache != null && itemCache != null && caching.isInfiniteTtl()) { - updateAllCache(castMap); - inited.set(true); - } - throw e; - } - - if (allCache != null && itemCache != null) { - allCache.invalidateAll(); - itemCache.invalidateAll(); - updateAllCache(castMap); - } - inited.set(true); - } - - private void updateAllCache(Map, Map> allData) { - for (Map.Entry, Map> e0: allData.entrySet()) { - VersionedDataKind kind = e0.getKey(); - allCache.put(kind, itemsOnlyIfNotDeleted(e0.getValue())); - for (Map.Entry e1: e0.getValue().entrySet()) { - itemCache.put(CacheKey.forItem(kind, e1.getKey()), Optional.of(e1.getValue())); - } - } - } - - @Override - public void delete(VersionedDataKind kind, String key, int version) { - upsert(kind, kind.makeDeletedItem(key, version)); - } - - @Override - public void upsert(VersionedDataKind kind, T item) { - VersionedData newState = item; - RuntimeException failure = null; - try { - newState = core.upsertInternal(kind, item); - } catch (RuntimeException e) { - failure = e; - } - // Normally, if the underlying store failed to do the update, we do not want to update the cache - - // the idea being that it's better to stay in a consistent state of having old data than to act - // like we have new data but then suddenly fall back to old data when the cache expires. However, - // if the cache TTL is infinite, then it makes sense to update the cache always. - if (failure == null || caching.isInfiniteTtl()) { - if (itemCache != null) { - itemCache.put(CacheKey.forItem(kind, item.getKey()), Optional.fromNullable(newState)); - } - if (allCache != null) { - // If the cache has a finite TTL, then we should remove the "all items" cache entry to force - // a reread the next time All is called. However, if it's an infinite TTL, we need to just - // update the item within the existing "all items" entry (since we want things to still work - // even if the underlying store is unavailable). - if (caching.isInfiniteTtl()) { - try { - ImmutableMap cachedAll = allCache.get(kind); - Map newValues = new HashMap<>(); - newValues.putAll(cachedAll); - newValues.put(item.getKey(), newState); - allCache.put(kind, ImmutableMap.copyOf(newValues)); - } catch (Exception e) { - // An exception here means that we did not have a cached value for All, so it tried to query - // the underlying store, which failed (not surprisingly since it just failed a moment ago - // when we tried to do an update). This should not happen in infinite-cache mode, but if it - // does happen, there isn't really anything we can do. - } - } else { - allCache.invalidate(kind); - } - } - } - if (failure != null) { - throw failure; - } - } - - @Override - public boolean initialized() { - if (inited.get()) { - return true; - } - boolean result; - if (initCache != null) { - result = initCache.getUnchecked("arbitrary-key"); - } else { - result = core.initializedInternal(); - } - if (result) { - inited.set(true); - } - return result; - } - - /** - * Return the underlying Guava cache stats object. - * - * @return the cache statistics object - */ - public CacheStats getCacheStats() { - if (itemCache != null) { - return itemCache.stats(); - } - return null; - } - - /** - * Return the underlying implementation object. - * - * @return the underlying implementation object - */ - public FeatureStoreCore getCore() { - return core; - } - - private VersionedData itemOnlyIfNotDeleted(VersionedData item) { - return (item != null && item.isDeleted()) ? null : item; - } - - @SuppressWarnings("unchecked") - private ImmutableMap itemsOnlyIfNotDeleted(Map items) { - ImmutableMap.Builder builder = ImmutableMap.builder(); - if (items != null) { - for (Map.Entry item: items.entrySet()) { - if (!item.getValue().isDeleted()) { - builder.put(item.getKey(), (T) item.getValue()); - } - } - } - return builder.build(); - } - - private final class CacheStatsSource implements Callable { - public CacheMonitor.CacheStats call() { - if (itemCache == null || allCache == null) { - return null; - } - CacheStats itemStats = itemCache.stats(); - CacheStats allStats = allCache.stats(); - return new CacheMonitor.CacheStats( - itemStats.hitCount() + allStats.hitCount(), - itemStats.missCount() + allStats.missCount(), - itemStats.loadSuccessCount() + allStats.loadSuccessCount(), - itemStats.loadExceptionCount() + allStats.loadExceptionCount(), - itemStats.totalLoadTime() + allStats.totalLoadTime(), - itemStats.evictionCount() + allStats.evictionCount()); - } - } - - private static class CacheKey { - final VersionedDataKind kind; - final String key; - - public static CacheKey forItem(VersionedDataKind kind, String key) { - return new CacheKey(kind, key); - } - - private CacheKey(VersionedDataKind kind, String key) { - this.kind = kind; - this.key = key; - } - - @Override - public boolean equals(Object other) { - if (other instanceof CacheKey) { - CacheKey o = (CacheKey) other; - return o.kind.getNamespace().equals(this.kind.getNamespace()) && - o.key.equals(this.key); - } - return false; - } - - @Override - public int hashCode() { - return kind.getNamespace().hashCode() * 31 + key.hashCode(); - } - } - - /** - * Builder for instances of {@link CachingStoreWrapper}. - */ - public static class Builder { - private final FeatureStoreCore core; - private FeatureStoreCacheConfig caching = FeatureStoreCacheConfig.DEFAULT; - private CacheMonitor cacheMonitor = null; - - Builder(FeatureStoreCore core) { - this.core = core; - } - - /** - * Sets the local caching properties. - * @param caching a {@link FeatureStoreCacheConfig} object specifying cache parameters - * @return the builder - */ - public Builder caching(FeatureStoreCacheConfig caching) { - this.caching = caching; - return this; - } - - /** - * Sets the cache monitor instance. - * @param cacheMonitor an instance of {@link CacheMonitor} - * @return the builder - */ - public Builder cacheMonitor(CacheMonitor cacheMonitor) { - this.cacheMonitor = cacheMonitor; - return this; - } - - /** - * Creates and configures the wrapper object. - * @return a {@link CachingStoreWrapper} instance - */ - public CachingStoreWrapper build() { - return new CachingStoreWrapper(core, caching, cacheMonitor); - } - } -} diff --git a/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java b/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java deleted file mode 100644 index b4d2e3066..000000000 --- a/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.launchdarkly.client.utils; - -import com.launchdarkly.client.FeatureStore; -import com.launchdarkly.client.VersionedData; -import com.launchdarkly.client.VersionedDataKind; - -import java.io.Closeable; -import java.util.Map; - -/** - * FeatureStoreCore is an interface for a simplified subset of the functionality of - * {@link FeatureStore}, to be used in conjunction with {@link CachingStoreWrapper}. This allows - * developers of custom FeatureStore implementations to avoid repeating logic that would - * commonly be needed in any such implementation, such as caching. Instead, they can implement - * only FeatureStoreCore and then create a CachingStoreWrapper. - *

- * Note that these methods do not take any generic type parameters; all storeable entities are - * treated as implementations of the {@link VersionedData} interface, and a {@link VersionedDataKind} - * instance is used to specify what kind of entity is being referenced. If entities will be - * marshaled and unmarshaled, this must be done by reflection, using the type specified by - * {@link VersionedDataKind#getItemClass()}; the methods in {@link FeatureStoreHelpers} may be - * useful for this. - * - * @since 4.6.0 - */ -public interface FeatureStoreCore extends Closeable { - /** - * Returns the object to which the specified key is mapped, or null if no such item exists. - * The method should not attempt to filter out any items based on their isDeleted() property, - * nor to cache any items. - * - * @param kind the kind of object to get - * @param key the key whose associated object is to be returned - * @return the object to which the specified key is mapped, or null - */ - VersionedData getInternal(VersionedDataKind kind, String key); - - /** - * Returns a {@link java.util.Map} of all associated objects of a given kind. The method - * should not attempt to filter out any items based on their isDeleted() property, nor to - * cache any items. - * - * @param kind the kind of objects to get - * @return a map of all associated objects. - */ - Map getAllInternal(VersionedDataKind kind); - - /** - * Initializes (or re-initializes) the store with the specified set of objects. Any existing entries - * will be removed. Implementations can assume that this set of objects is up to date-- there is no - * need to perform individual version comparisons between the existing objects and the supplied - * data. - *

- * If possible, the store should update the entire data set atomically. If that is not possible, it - * should iterate through the outer map and then the inner map in the order provided (the SDK - * will use a Map subclass that has a defined ordering), storing each item, and then delete any - * leftover items at the very end. - * - * @param allData all objects to be stored - */ - void initInternal(Map, Map> allData); - - /** - * Updates or inserts the object associated with the specified key. If an item with the same key - * already exists, it should update it only if the new item's getVersion() value is greater than - * the old one. It should return the final state of the item, i.e. if the update succeeded then - * it returns the item that was passed in, and if the update failed due to the version check - * then it returns the item that is currently in the data store (this ensures that - * CachingStoreWrapper will update the cache correctly). - * - * @param kind the kind of object to update - * @param item the object to update or insert - * @return the state of the object after the update - */ - VersionedData upsertInternal(VersionedDataKind kind, VersionedData item); - - /** - * Returns true if this store has been initialized. In a shared data store, it should be able to - * detect this even if initInternal was called in a different process, i.e. the test should be - * based on looking at what is in the data store. The method does not need to worry about caching - * this value; CachingStoreWrapper will only call it when necessary. - * - * @return true if this store has been initialized - */ - boolean initializedInternal(); -} diff --git a/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java b/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java deleted file mode 100644 index e49cbb7c7..000000000 --- a/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.launchdarkly.client.utils; - -import com.google.gson.Gson; -import com.google.gson.JsonParseException; -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}. - * - * @since 4.6.0 - */ -public abstract class FeatureStoreHelpers { - private static final Gson gson = new Gson(); - - /** - * Unmarshals a feature store item from a JSON string. This is a very simple wrapper around a Gson - * method, just to allow external feature store implementations to make use of the Gson instance - * that's inside the SDK rather than having to import Gson themselves. - * - * @param class of the object that will be returned - * @param kind specifies the type of item being decoded - * @param data the JSON string - * @return the unmarshaled item - * @throws UnmarshalException if the JSON string was invalid - */ - public static T unmarshalJson(VersionedDataKind kind, String data) { - try { - return gson.fromJson(data, kind.getItemClass()); - } catch (JsonParseException e) { - throw new UnmarshalException(e); - } - } - - /** - * Marshals a feature store item into a JSON string. This is a very simple wrapper around a Gson - * method, just to allow external feature store implementations to make use of the Gson instance - * that's inside the SDK rather than having to import Gson themselves. - * @param item the item to be marshaled - * @return the JSON string - */ - public static String marshalJson(VersionedData item) { - return gson.toJson(item); - } - - /** - * Thrown by {@link FeatureStoreHelpers#unmarshalJson(VersionedDataKind, String)} for a deserialization error. - */ - @SuppressWarnings("serial") - public static class UnmarshalException extends SerializationException { - /** - * Constructs an instance. - * @param cause the underlying exception - */ - public UnmarshalException(Throwable cause) { - super(cause); - } - } -} diff --git a/src/main/java/com/launchdarkly/client/utils/package-info.java b/src/main/java/com/launchdarkly/client/utils/package-info.java deleted file mode 100644 index 5be71fa92..000000000 --- a/src/main/java/com/launchdarkly/client/utils/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Helper classes that may be useful in custom integrations. - */ -package com.launchdarkly.client.utils; diff --git a/src/main/java/com/launchdarkly/client/value/ArrayBuilder.java b/src/main/java/com/launchdarkly/client/value/ArrayBuilder.java deleted file mode 100644 index e68b7a204..000000000 --- a/src/main/java/com/launchdarkly/client/value/ArrayBuilder.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.launchdarkly.client.value; - -import com.google.common.collect.ImmutableList; - -/** - * A builder created by {@link LDValue#buildArray()}. Builder methods are not thread-safe. - * - * @since 4.8.0 - */ -public final class ArrayBuilder { - private final ImmutableList.Builder builder = ImmutableList.builder(); - - /** - * Adds a new element to the builder. - * @param value the new element - * @return the same builder - */ - public ArrayBuilder add(LDValue value) { - builder.add(value); - return this; - } - - /** - * Adds a new element to the builder. - * @param value the new element - * @return the same builder - */ - public ArrayBuilder add(boolean value) { - return add(LDValue.of(value)); - } - - /** - * Adds a new element to the builder. - * @param value the new element - * @return the same builder - */ - public ArrayBuilder add(int value) { - return add(LDValue.of(value)); - } - - /** - * Adds a new element to the builder. - * @param value the new element - * @return the same builder - */ - public ArrayBuilder add(long value) { - return add(LDValue.of(value)); - } - - /** - * Adds a new element to the builder. - * @param value the new element - * @return the same builder - */ - public ArrayBuilder add(float value) { - return add(LDValue.of(value)); - } - - /** - * Adds a new element to the builder. - * @param value the new element - * @return the same builder - */ - public ArrayBuilder add(double value) { - return add(LDValue.of(value)); - } - - /** - * Adds a new element to the builder. - * @param value the new element - * @return the same builder - */ - public ArrayBuilder add(String value) { - return add(LDValue.of(value)); - } - - /** - * Returns an array containing the builder's current elements. Subsequent changes to the builder - * will not affect this value (it uses copy-on-write logic, so the previous values will only be - * copied to a new list if you continue to add elements after calling {@link #build()}. - * @return an {@link LDValue} that is an array - */ - public LDValue build() { - return LDValueArray.fromList(builder.build()); - } -} diff --git a/src/main/java/com/launchdarkly/client/value/LDValue.java b/src/main/java/com/launchdarkly/client/value/LDValue.java deleted file mode 100644 index 996e7b41d..000000000 --- a/src/main/java/com/launchdarkly/client/value/LDValue.java +++ /dev/null @@ -1,672 +0,0 @@ -package com.launchdarkly.client.value; - -import com.google.common.base.Function; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.Iterables; -import com.google.gson.Gson; -import com.google.gson.JsonElement; -import com.google.gson.annotations.JsonAdapter; -import com.google.gson.stream.JsonWriter; -import com.launchdarkly.client.LDClientInterface; -import com.launchdarkly.client.LDUser; - -import java.io.IOException; -import java.util.Map; - -/** - * An immutable instance of any data type that is allowed in JSON. - *

- * This is used with the client's {@link LDClientInterface#jsonValueVariation(String, LDUser, LDValue)} - * method, and is also used internally to hold feature flag values. - *

- * While the LaunchDarkly SDK uses Gson for JSON parsing, some of the Gson value types (object - * and array) are mutable. In contexts where it is important for data to remain immutable after - * it is created, these values are represented with {@link LDValue} instead. It is easily - * convertible to primitive types and provides array element/object property accessors. - * - * @since 4.8.0 - */ -@JsonAdapter(LDValueTypeAdapter.class) -public abstract class LDValue { - static final Gson gson = new Gson(); - - private boolean haveComputedJsonElement = false; - private JsonElement computedJsonElement = null; - - /** - * Returns the same value if non-null, or {@link #ofNull()} if null. - * - * @param value an {@link LDValue} or null - * @return an {@link LDValue} which will never be a null reference - */ - public static LDValue normalize(LDValue value) { - return value == null ? ofNull() : value; - } - - /** - * Returns an instance for a null value. The same instance is always used. - * - * @return an LDValue containing null - */ - public static LDValue ofNull() { - return LDValueNull.INSTANCE; - } - - /** - * Returns an instance for a boolean value. The same instances for {@code true} and {@code false} - * are always used. - * - * @param value a boolean value - * @return an LDValue containing that value - */ - public static LDValue of(boolean value) { - return LDValueBool.fromBoolean(value); - } - - /** - * Returns an instance for a numeric value. - * - * @param value an integer numeric value - * @return an LDValue containing that value - */ - public static LDValue of(int value) { - return LDValueNumber.fromDouble(value); - } - - /** - * Returns an instance for a numeric value. - *

- * Note that the LaunchDarkly service, and most of the SDKs, represent numeric values internally - * in 64-bit floating-point, which has slightly less precision than a signed 64-bit {@code long}; - * therefore, the full range of {@code long} values cannot be accurately represented. If you need - * to set a user attribute to a numeric value with more significant digits than will fit in a - * {@code double}, it is best to encode it as a string. - * - * @param value a long integer numeric value - * @return an LDValue containing that value - */ - public static LDValue of(long value) { - return LDValueNumber.fromDouble(value); - } - - /** - * Returns an instance for a numeric value. - * - * @param value a floating-point numeric value - * @return an LDValue containing that value - */ - public static LDValue of(float value) { - return LDValueNumber.fromDouble(value); - } - - /** - * Returns an instance for a numeric value. - * - * @param value a floating-point numeric value - * @return an LDValue containing that value - */ - public static LDValue of(double value) { - return LDValueNumber.fromDouble(value); - } - - /** - * Returns an instance for a string value (or a null). - * - * @param value a nullable String reference - * @return an LDValue containing a string, or {@link #ofNull()} if the value was null. - */ - public static LDValue of(String value) { - return value == null ? ofNull() : LDValueString.fromString(value); - } - - /** - * Starts building an array value. - *


-   *     LDValue arrayOfInts = LDValue.buildArray().add(LDValue.int(1), LDValue.int(2)).build():
-   * 
- * If the values are all of the same type, you may also use {@link LDValue.Converter#arrayFrom(Iterable)} - * or {@link LDValue.Converter#arrayOf(Object...)}. - * - * @return an {@link ArrayBuilder} - */ - public static ArrayBuilder buildArray() { - return new ArrayBuilder(); - } - - /** - * Starts building an object value. - *

-   *     LDValue objectVal = LDValue.buildObject().put("key", LDValue.int(1)).build():
-   * 
- * If the values are all of the same type, you may also use {@link LDValue.Converter#objectFrom(Map)}. - * - * @return an {@link ObjectBuilder} - */ - public static ObjectBuilder buildObject() { - return new ObjectBuilder(); - } - - /** - * Returns an instance based on a {@link JsonElement} value. If the value is a complex type, it is - * deep-copied; primitive types are used as is. - * - * @param value a nullable {@link JsonElement} reference - * @return an LDValue containing the specified value, or {@link #ofNull()} if the value was null. - * @deprecated The Gson types may be removed from the public API at some point; it is preferable to - * use factory methods like {@link #of(boolean)}. - */ - @Deprecated - public static LDValue fromJsonElement(JsonElement value) { - return value == null || value.isJsonNull() ? ofNull() : LDValueJsonElement.copyValue(value); - } - - /** - * Returns an instance that wraps an existing {@link JsonElement} value without copying it. This - * method exists only to support deprecated SDK methods where a {@link JsonElement} is needed, to - * avoid the inefficiency of a deep-copy; application code should not use it, since it can break - * the immutability contract of {@link LDValue}. - * - * @param value a nullable {@link JsonElement} reference - * @return an LDValue containing the specified value, or {@link #ofNull()} if the value was null. - * @deprecated This method will be removed in a future version. Application code should use - * {@link #fromJsonElement(JsonElement)} or, preferably, factory methods like {@link #of(boolean)}. - */ - @Deprecated - public static LDValue unsafeFromJsonElement(JsonElement value) { - return value == null || value.isJsonNull() ? ofNull() : LDValueJsonElement.wrapUnsafeValue(value); - } - - /** - * Gets the JSON type for this value. - * - * @return the appropriate {@link LDValueType} - */ - public abstract LDValueType getType(); - - /** - * Tests whether this value is a null. - * - * @return {@code true} if this is a null value - */ - public boolean isNull() { - return false; - } - - /** - * Returns this value as a boolean if it is explicitly a boolean. Otherwise returns {@code false}. - * - * @return a boolean - */ - public boolean booleanValue() { - return false; - } - - /** - * Tests whether this value is a number (not a numeric string). - * - * @return {@code true} if this is a numeric value - */ - public boolean isNumber() { - return false; - } - - /** - * Tests whether this value is a number that is also an integer. - *

- * JSON does not have separate types for integer and floating-point values; they are both just - * numbers. This method returns true if and only if the actual numeric value has no fractional - * component, so {@code LDValue.of(2).isInt()} and {@code LDValue.of(2.0f).isInt()} are both true. - * - * @return {@code true} if this is an integer value - */ - public boolean isInt() { - return false; - } - - /** - * Returns this value as an {@code int} if it is numeric. Returns zero for all non-numeric values. - *

- * If the value is a number but not an integer, it will be rounded toward zero (truncated). - * This is consistent with Java casting behavior, and with most other LaunchDarkly SDKs. - * - * @return an {@code int} value - */ - public int intValue() { - return 0; - } - - /** - * Returns this value as a {@code long} if it is numeric. Returns zero for all non-numeric values. - *

- * If the value is a number but not an integer, it will be rounded toward zero (truncated). - * This is consistent with Java casting behavior, and with most other LaunchDarkly SDKs. - * - * @return a {@code long} value - */ - public long longValue() { - return 0; - } - - /** - * Returns this value as a {@code float} if it is numeric. Returns zero for all non-numeric values. - * - * @return a {@code float} value - */ - public float floatValue() { - return 0; - } - - /** - * Returns this value as a {@code double} if it is numeric. Returns zero for all non-numeric values. - * - * @return a {@code double} value - */ - public double doubleValue() { - return 0; - } - - /** - * Tests whether this value is a string. - * - * @return {@code true} if this is a string value - */ - public boolean isString() { - return false; - } - - /** - * Returns this value as a {@code String} if it is a string. Returns {@code null} for all non-string values. - * - * @return a nullable string value - */ - public String stringValue() { - return null; - } - - /** - * Returns the number of elements in an array or object. Returns zero for all other types. - * - * @return the number of array elements or object properties - */ - public int size() { - return 0; - } - - /** - * Enumerates the property names in an object. Returns an empty iterable for all other types. - * - * @return the property names - */ - public Iterable keys() { - return ImmutableList.of(); - } - - /** - * Enumerates the values in an array or object. Returns an empty iterable for all other types. - * - * @return an iterable of {@link LDValue} values - */ - public Iterable values() { - return ImmutableList.of(); - } - - /** - * Enumerates the values in an array or object, converting them to a specific type. Returns an empty - * iterable for all other types. - *

- * This is an efficient method because it does not copy values to a new list, but returns a view - * into the existing array. - *

- * Example: - *


-   *     LDValue anArrayOfInts = LDValue.Convert.Integer.arrayOf(1, 2, 3);
-   *     for (int i: anArrayOfInts.valuesAs(LDValue.Convert.Integer)) { println(i); }
-   * 
- * - * @param the desired type - * @param converter the {@link Converter} for the specified type - * @return an iterable of values of the specified type - */ - public Iterable valuesAs(final Converter converter) { - return Iterables.transform(values(), new Function() { - public T apply(LDValue value) { - return converter.toType(value); - } - }); - } - - /** - * Returns an array element by index. Returns {@link #ofNull()} if this is not an array or if the - * index is out of range (will never throw an exception). - * - * @param index the array index - * @return the element value or {@link #ofNull()} - */ - public LDValue get(int index) { - return ofNull(); - } - - /** - * Returns an object property by name. Returns {@link #ofNull()} if this is not an object or if the - * key is not found (will never throw an exception). - * - * @param name the property name - * @return the property value or {@link #ofNull()} - */ - public LDValue get(String name) { - return ofNull(); - } - - /** - * Converts this value to its JSON serialization. - * - * @return a JSON string - */ - public String toJsonString() { - return gson.toJson(this); - } - - /** - * Converts this value to a {@link JsonElement}. If the value is a complex type, it is deep-copied - * deep-copied, so modifying the return value will not affect the {@link LDValue}. - * - * @return a {@link JsonElement}, or {@code null} if the value is a null - * @deprecated The Gson types may be removed from the public API at some point; it is preferable to - * use getters like {@link #booleanValue()} and {@link #getType()}. - */ - @Deprecated - public JsonElement asJsonElement() { - return LDValueJsonElement.deepCopy(asUnsafeJsonElement()); - } - - /** - * Returns the original {@link JsonElement} if the value was created from one, otherwise converts the - * value to a {@link JsonElement}. This method exists only to support deprecated SDK methods where a - * {@link JsonElement} is needed, to avoid the inefficiency of a deep-copy; application code should not - * use it, since it can break the immutability contract of {@link LDValue}. - * - * @return a {@link JsonElement}, or {@code null} if the value is a null - * @deprecated This method will be removed in a future version. Application code should always use - * {@link #asJsonElement()}. - */ - @Deprecated - public JsonElement asUnsafeJsonElement() { - // Lazily compute this value - synchronized (this) { - if (!haveComputedJsonElement) { - computedJsonElement = computeJsonElement(); - haveComputedJsonElement = true; - } - return computedJsonElement; - } - } - - abstract JsonElement computeJsonElement(); - - abstract void write(JsonWriter writer) throws IOException; - - static boolean isInteger(double value) { - return value == (double)((int)value); - } - - @Override - public String toString() { - return toJsonString(); - } - - // equals() and hashCode() are defined here in the base class so that we don't have to worry about - // whether a value is stored as LDValueJsonElement vs. one of our own primitive types. - - @Override - public boolean equals(Object o) { - if (o instanceof LDValue) { - LDValue other = (LDValue)o; - if (getType() == other.getType()) { - switch (getType()) { - case NULL: return other.isNull(); - case BOOLEAN: return booleanValue() == other.booleanValue(); - case NUMBER: return doubleValue() == other.doubleValue(); - case STRING: return stringValue().equals(other.stringValue()); - case ARRAY: - if (size() != other.size()) { - return false; - } - for (int i = 0; i < size(); i++) { - if (!get(i).equals(other.get(i))) { - return false; - } - } - return true; - case OBJECT: - if (size() != other.size()) { - return false; - } - for (String name: keys()) { - if (!get(name).equals(other.get(name))) { - return false; - } - } - return true; - } - } - } - return false; - } - - @Override - public int hashCode() { - switch (getType()) { - case NULL: return 0; - case BOOLEAN: return booleanValue() ? 1 : 0; - case NUMBER: return intValue(); - case STRING: return stringValue().hashCode(); - case ARRAY: - int ah = 0; - for (LDValue v: values()) { - ah = ah * 31 + v.hashCode(); - } - return ah; - case OBJECT: - int oh = 0; - for (String name: keys()) { - oh = (oh * 31 + name.hashCode()) * 31 + get(name).hashCode(); - } - return oh; - default: return 0; - } - } - - /** - * Defines a conversion between {@link LDValue} and some other type. - *

- * Besides converting individual values, this provides factory methods like {@link #arrayOf} - * which transform a collection of the specified type to the corresponding {@link LDValue} - * complex type. - * - * @param the type to convert from/to - * @since 4.8.0 - */ - public static abstract class Converter { - /** - * Converts a value of the specified type to an {@link LDValue}. - *

- * This method should never throw an exception; if for some reason the value is invalid, - * it should return {@link LDValue#ofNull()}. - * - * @param value a value of this type - * @return an {@link LDValue} - */ - public abstract LDValue fromType(T value); - - /** - * Converts an {@link LDValue} to a value of the specified type. - *

- * This method should never throw an exception; if the conversion cannot be done, it should - * return the default value of the given type (zero for numbers, null for nullable types). - * - * @param value an {@link LDValue} - * @return a value of this type - */ - public abstract T toType(LDValue value); - - /** - * Initializes an {@link LDValue} as an array, from a sequence of this type. - *

- * Values are copied, so subsequent changes to the source values do not affect the array. - *

- * Example: - *


-     *     List<Integer> listOfInts = ImmutableList.<Integer>builder().add(1).add(2).add(3).build();
-     *     LDValue arrayValue = LDValue.Convert.Integer.arrayFrom(listOfInts);
-     * 
- * - * @param values a sequence of elements of the specified type - * @return a value representing a JSON array, or {@link LDValue#ofNull()} if the parameter was null - * @see LDValue#buildArray() - */ - public LDValue arrayFrom(Iterable values) { - ArrayBuilder ab = LDValue.buildArray(); - for (T value: values) { - ab.add(fromType(value)); - } - return ab.build(); - } - - /** - * Initializes an {@link LDValue} as an array, from a sequence of this type. - *

- * Values are copied, so subsequent changes to the source values do not affect the array. - *

- * Example: - *


-     *     LDValue arrayValue = LDValue.Convert.Integer.arrayOf(1, 2, 3);
-     * 
- * - * @param values a sequence of elements of the specified type - * @return a value representing a JSON array, or {@link LDValue#ofNull()} if the parameter was null - * @see LDValue#buildArray() - */ - @SuppressWarnings("unchecked") - public LDValue arrayOf(T... values) { - ArrayBuilder ab = LDValue.buildArray(); - for (T value: values) { - ab.add(fromType(value)); - } - return ab.build(); - } - - /** - * Initializes an {@link LDValue} as an object, from a map containing this type. - *

- * Values are copied, so subsequent changes to the source map do not affect the array. - *

- * Example: - *


-     *     Map<String, Integer> mapOfInts = ImmutableMap.<String, Integer>builder().put("a", 1).build();
-     *     LDValue objectValue = LDValue.Convert.Integer.objectFrom(mapOfInts);
-     * 
- * - * @param map a map with string keys and values of the specified type - * @return a value representing a JSON object, or {@link LDValue#ofNull()} if the parameter was null - * @see LDValue#buildObject() - */ - public LDValue objectFrom(Map map) { - ObjectBuilder ob = LDValue.buildObject(); - for (String key: map.keySet()) { - ob.put(key, fromType(map.get(key))); - } - return ob.build(); - } - } - - /** - * Predefined instances of {@link LDValue.Converter} for commonly used types. - *

- * These are mostly useful for methods that convert {@link LDValue} to or from a collection of - * some type, such as {@link LDValue.Converter#arrayOf(Object...)} and - * {@link LDValue#valuesAs(Converter)}. - * - * @since 4.8.0 - */ - public static abstract class Convert { - private Convert() {} - - /** - * A {@link LDValue.Converter} for booleans. - */ - public static final Converter Boolean = new Converter() { - public LDValue fromType(java.lang.Boolean value) { - return value == null ? LDValue.ofNull() : LDValue.of(value.booleanValue()); - } - public java.lang.Boolean toType(LDValue value) { - return java.lang.Boolean.valueOf(value.booleanValue()); - } - }; - - /** - * A {@link LDValue.Converter} for integers. - */ - public static final Converter Integer = new Converter() { - public LDValue fromType(java.lang.Integer value) { - return value == null ? LDValue.ofNull() : LDValue.of(value.intValue()); - } - public java.lang.Integer toType(LDValue value) { - return java.lang.Integer.valueOf(value.intValue()); - } - }; - - /** - * A {@link LDValue.Converter} for long integers. - *

- * Note that the LaunchDarkly service, and most of the SDKs, represent numeric values internally - * in 64-bit floating-point, which has slightly less precision than a signed 64-bit {@code long}; - * therefore, the full range of {@code long} values cannot be accurately represented. If you need - * to set a user attribute to a numeric value with more significant digits than will fit in a - * {@code double}, it is best to encode it as a string. - */ - public static final Converter Long = new Converter() { - public LDValue fromType(java.lang.Long value) { - return value == null ? LDValue.ofNull() : LDValue.of(value.longValue()); - } - public java.lang.Long toType(LDValue value) { - return java.lang.Long.valueOf(value.longValue()); - } - }; - - /** - * A {@link LDValue.Converter} for floats. - */ - public static final Converter Float = new Converter() { - public LDValue fromType(java.lang.Float value) { - return value == null ? LDValue.ofNull() : LDValue.of(value.floatValue()); - } - public java.lang.Float toType(LDValue value) { - return java.lang.Float.valueOf(value.floatValue()); - } - }; - - /** - * A {@link LDValue.Converter} for doubles. - */ - public static final Converter Double = new Converter() { - public LDValue fromType(java.lang.Double value) { - return value == null ? LDValue.ofNull() : LDValue.of(value.doubleValue()); - } - public java.lang.Double toType(LDValue value) { - return java.lang.Double.valueOf(value.doubleValue()); - } - }; - - /** - * A {@link LDValue.Converter} for strings. - */ - public static final Converter String = new Converter() { - public LDValue fromType(java.lang.String value) { - return LDValue.of(value); - } - public java.lang.String toType(LDValue value) { - return value.stringValue(); - } - }; - } -} diff --git a/src/main/java/com/launchdarkly/client/value/LDValueArray.java b/src/main/java/com/launchdarkly/client/value/LDValueArray.java deleted file mode 100644 index 250863121..000000000 --- a/src/main/java/com/launchdarkly/client/value/LDValueArray.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.launchdarkly.client.value; - -import com.google.common.collect.ImmutableList; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.annotations.JsonAdapter; -import com.google.gson.stream.JsonWriter; - -import java.io.IOException; - -@JsonAdapter(LDValueTypeAdapter.class) -final class LDValueArray extends LDValue { - private static final LDValueArray EMPTY = new LDValueArray(ImmutableList.of()); - private final ImmutableList list; - - static LDValueArray fromList(ImmutableList list) { - return list.isEmpty() ? EMPTY : new LDValueArray(list); - } - - private LDValueArray(ImmutableList list) { - this.list = list; - } - - public LDValueType getType() { - return LDValueType.ARRAY; - } - - @Override - public int size() { - return list.size(); - } - - @Override - public Iterable values() { - return list; - } - - @Override - public LDValue get(int index) { - if (index >= 0 && index < list.size()) { - return list.get(index); - } - return ofNull(); - } - - @Override - void write(JsonWriter writer) throws IOException { - writer.beginArray(); - for (LDValue v: list) { - v.write(writer); - } - writer.endArray(); - } - - @Override - @SuppressWarnings("deprecation") - JsonElement computeJsonElement() { - JsonArray a = new JsonArray(); - for (LDValue item: list) { - a.add(item.asUnsafeJsonElement()); - } - return a; - } -} diff --git a/src/main/java/com/launchdarkly/client/value/LDValueBool.java b/src/main/java/com/launchdarkly/client/value/LDValueBool.java deleted file mode 100644 index 321361353..000000000 --- a/src/main/java/com/launchdarkly/client/value/LDValueBool.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.launchdarkly.client.value; - -import com.google.gson.JsonElement; -import com.google.gson.JsonPrimitive; -import com.google.gson.annotations.JsonAdapter; -import com.google.gson.stream.JsonWriter; - -import java.io.IOException; - -@JsonAdapter(LDValueTypeAdapter.class) -final class LDValueBool extends LDValue { - private static final LDValueBool TRUE = new LDValueBool(true); - private static final LDValueBool FALSE = new LDValueBool(false); - private static final JsonElement JSON_TRUE = new JsonPrimitive(true); - private static final JsonElement JSON_FALSE = new JsonPrimitive(false); - - private final boolean value; - - static LDValueBool fromBoolean(boolean value) { - return value ? TRUE : FALSE; - } - - private LDValueBool(boolean value) { - this.value = value; - } - - public LDValueType getType() { - return LDValueType.BOOLEAN; - } - - @Override - public boolean booleanValue() { - return value; - } - - @Override - public String toJsonString() { - return value ? "true" : "false"; - } - - @Override - void write(JsonWriter writer) throws IOException { - writer.value(value); - } - - @Override - JsonElement computeJsonElement() { - return value ? JSON_TRUE : JSON_FALSE; - } -} diff --git a/src/main/java/com/launchdarkly/client/value/LDValueJsonElement.java b/src/main/java/com/launchdarkly/client/value/LDValueJsonElement.java deleted file mode 100644 index 34f0cb8bb..000000000 --- a/src/main/java/com/launchdarkly/client/value/LDValueJsonElement.java +++ /dev/null @@ -1,206 +0,0 @@ -package com.launchdarkly.client.value; - -import com.google.common.base.Function; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.Iterables; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonPrimitive; -import com.google.gson.annotations.JsonAdapter; -import com.google.gson.stream.JsonWriter; - -import java.io.IOException; -import java.util.Map; -import java.util.Map.Entry; - -@JsonAdapter(LDValueTypeAdapter.class) -final class LDValueJsonElement extends LDValue { - private final JsonElement value; - private final LDValueType type; - - static LDValueJsonElement copyValue(JsonElement value) { - return new LDValueJsonElement(deepCopy(value)); - } - - static LDValueJsonElement wrapUnsafeValue(JsonElement value) { - return new LDValueJsonElement(value); - } - - LDValueJsonElement(JsonElement value) { - this.value = value; - type = typeFromValue(value); - } - - private static LDValueType typeFromValue(JsonElement value) { - if (value != null) { - if (value.isJsonPrimitive()) { - JsonPrimitive p = value.getAsJsonPrimitive(); - if (p.isBoolean()) { - return LDValueType.BOOLEAN; - } else if (p.isNumber()) { - return LDValueType.NUMBER; - } else if (p.isString()) { - return LDValueType.STRING; - } else { - return LDValueType.NULL; - } - } else if (value.isJsonArray()) { - return LDValueType.ARRAY; - } else if (value.isJsonObject()) { - return LDValueType.OBJECT; - } - } - return LDValueType.NULL; - } - - public LDValueType getType() { - return type; - } - - @Override - public boolean isNull() { - return value == null; - } - - @Override - public boolean booleanValue() { - return type == LDValueType.BOOLEAN && value.getAsBoolean(); - } - - @Override - public boolean isNumber() { - return type == LDValueType.NUMBER; - } - - @Override - public boolean isInt() { - return type == LDValueType.NUMBER && isInteger(value.getAsFloat()); - } - - @Override - public int intValue() { - return type == LDValueType.NUMBER ? (int)value.getAsFloat() : 0; // don't rely on their rounding behavior - } - - @Override - public long longValue() { - return type == LDValueType.NUMBER ? (long)value.getAsDouble() : 0; // don't rely on their rounding behavior - } - - @Override - public float floatValue() { - return type == LDValueType.NUMBER ? value.getAsFloat() : 0; - } - - @Override - public double doubleValue() { - return type == LDValueType.NUMBER ? value.getAsDouble() : 0; - } - - @Override - public boolean isString() { - return type == LDValueType.STRING; - } - - @Override - public String stringValue() { - return type == LDValueType.STRING ? value.getAsString() : null; - } - - @Override - public int size() { - switch (type) { - case ARRAY: - return value.getAsJsonArray().size(); - case OBJECT: - return value.getAsJsonObject().size(); - default: - return 0; - } - } - - @Override - public Iterable keys() { - if (type == LDValueType.OBJECT) { - return Iterables.transform(value.getAsJsonObject().entrySet(), new Function, String>() { - public String apply(Map.Entry e) { - return e.getKey(); - } - }); - } - return ImmutableList.of(); - } - - @SuppressWarnings("deprecation") - @Override - public Iterable values() { - switch (type) { - case ARRAY: - return Iterables.transform(value.getAsJsonArray(), new Function() { - public LDValue apply(JsonElement e) { - return unsafeFromJsonElement(e); - } - }); - case OBJECT: - return Iterables.transform(value.getAsJsonObject().entrySet(), new Function, LDValue>() { - public LDValue apply(Map.Entry e) { - return unsafeFromJsonElement(e.getValue()); - } - }); - default: return ImmutableList.of(); - } - } - - @SuppressWarnings("deprecation") - @Override - public LDValue get(int index) { - if (type == LDValueType.ARRAY) { - JsonArray a = value.getAsJsonArray(); - if (index >= 0 && index < a.size()) { - return unsafeFromJsonElement(a.get(index)); - } - } - return ofNull(); - } - - @SuppressWarnings("deprecation") - @Override - public LDValue get(String name) { - if (type == LDValueType.OBJECT) { - return unsafeFromJsonElement(value.getAsJsonObject().get(name)); - } - return ofNull(); - } - - @Override - void write(JsonWriter writer) throws IOException { - gson.toJson(value, writer); - } - - @Override - JsonElement computeJsonElement() { - return value; - } - - static JsonElement deepCopy(JsonElement value) { // deepCopy was added to Gson in 2.8.2 - if (value != null && !value.isJsonPrimitive()) { - if (value.isJsonArray()) { - JsonArray a = value.getAsJsonArray(); - JsonArray ret = new JsonArray(); - for (JsonElement e: a) { - ret.add(deepCopy(e)); - } - return ret; - } else if (value.isJsonObject()) { - JsonObject o = value.getAsJsonObject(); - JsonObject ret = new JsonObject(); - for (Entry e: o.entrySet()) { - ret.add(e.getKey(), deepCopy(e.getValue())); - } - return ret; - } - } - return value; - } -} diff --git a/src/main/java/com/launchdarkly/client/value/LDValueNull.java b/src/main/java/com/launchdarkly/client/value/LDValueNull.java deleted file mode 100644 index 00db72c34..000000000 --- a/src/main/java/com/launchdarkly/client/value/LDValueNull.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.launchdarkly.client.value; - -import com.google.gson.JsonElement; -import com.google.gson.annotations.JsonAdapter; -import com.google.gson.stream.JsonWriter; - -import java.io.IOException; - -@JsonAdapter(LDValueTypeAdapter.class) -final class LDValueNull extends LDValue { - static final LDValueNull INSTANCE = new LDValueNull(); - - public LDValueType getType() { - return LDValueType.NULL; - } - - public boolean isNull() { - return true; - } - - @Override - public String toJsonString() { - return "null"; - } - - @Override - void write(JsonWriter writer) throws IOException { - writer.nullValue(); - } - - @Override - JsonElement computeJsonElement() { - return null; - } -} diff --git a/src/main/java/com/launchdarkly/client/value/LDValueNumber.java b/src/main/java/com/launchdarkly/client/value/LDValueNumber.java deleted file mode 100644 index 6a601c3f0..000000000 --- a/src/main/java/com/launchdarkly/client/value/LDValueNumber.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.launchdarkly.client.value; - -import com.google.gson.JsonElement; -import com.google.gson.JsonPrimitive; -import com.google.gson.annotations.JsonAdapter; -import com.google.gson.stream.JsonWriter; - -import java.io.IOException; - -@JsonAdapter(LDValueTypeAdapter.class) -final class LDValueNumber extends LDValue { - private static final LDValueNumber ZERO = new LDValueNumber(0); - private final double value; - - static LDValueNumber fromDouble(double value) { - return value == 0 ? ZERO : new LDValueNumber(value); - } - - private LDValueNumber(double value) { - this.value = value; - } - - public LDValueType getType() { - return LDValueType.NUMBER; - } - - @Override - public boolean isNumber() { - return true; - } - - @Override - public boolean isInt() { - return isInteger(value); - } - - @Override - public int intValue() { - return (int)value; - } - - @Override - public long longValue() { - return (long)value; - } - - @Override - public float floatValue() { - return (float)value; - } - - @Override - public double doubleValue() { - return value; - } - - @Override - public String toJsonString() { - return isInt() ? String.valueOf(intValue()) : String.valueOf(value); - } - - @Override - void write(JsonWriter writer) throws IOException { - if (isInt()) { - writer.value(intValue()); - } else { - writer.value(value); - } - } - - @Override - JsonElement computeJsonElement() { - return new JsonPrimitive(value); - } -} diff --git a/src/main/java/com/launchdarkly/client/value/LDValueObject.java b/src/main/java/com/launchdarkly/client/value/LDValueObject.java deleted file mode 100644 index eaceb5a7a..000000000 --- a/src/main/java/com/launchdarkly/client/value/LDValueObject.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.launchdarkly.client.value; - -import com.google.common.collect.ImmutableMap; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.annotations.JsonAdapter; -import com.google.gson.stream.JsonWriter; - -import java.io.IOException; -import java.util.Map; - -@JsonAdapter(LDValueTypeAdapter.class) -final class LDValueObject extends LDValue { - private static final LDValueObject EMPTY = new LDValueObject(ImmutableMap.of()); - private final Map map; - - static LDValueObject fromMap(Map map) { - return map.isEmpty() ? EMPTY : new LDValueObject(map); - } - - private LDValueObject(Map map) { - this.map = map; - } - - public LDValueType getType() { - return LDValueType.OBJECT; - } - - @Override - public int size() { - return map.size(); - } - - @Override - public Iterable keys() { - return map.keySet(); - } - - @Override - public Iterable values() { - return map.values(); - } - - @Override - public LDValue get(String name) { - LDValue v = map.get(name); - return v == null ? ofNull() : v; - } - - @Override - void write(JsonWriter writer) throws IOException { - writer.beginObject(); - for (Map.Entry e: map.entrySet()) { - writer.name(e.getKey()); - e.getValue().write(writer); - } - writer.endObject(); - } - - @Override - @SuppressWarnings("deprecation") - JsonElement computeJsonElement() { - JsonObject o = new JsonObject(); - for (String key: map.keySet()) { - o.add(key, map.get(key).asUnsafeJsonElement()); - } - return o; - } -} diff --git a/src/main/java/com/launchdarkly/client/value/LDValueString.java b/src/main/java/com/launchdarkly/client/value/LDValueString.java deleted file mode 100644 index b2ad2c789..000000000 --- a/src/main/java/com/launchdarkly/client/value/LDValueString.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.launchdarkly.client.value; - -import com.google.gson.JsonElement; -import com.google.gson.JsonPrimitive; -import com.google.gson.annotations.JsonAdapter; -import com.google.gson.stream.JsonWriter; - -import java.io.IOException; - -@JsonAdapter(LDValueTypeAdapter.class) -final class LDValueString extends LDValue { - private static final LDValueString EMPTY = new LDValueString(""); - private final String value; - - static LDValueString fromString(String value) { - return value.isEmpty() ? EMPTY : new LDValueString(value); - } - - private LDValueString(String value) { - this.value = value; - } - - public LDValueType getType() { - return LDValueType.STRING; - } - - @Override - public boolean isString() { - return true; - } - - @Override - public String stringValue() { - return value; - } - - @Override - void write(JsonWriter writer) throws IOException { - writer.value(value); - } - - @Override - JsonElement computeJsonElement() { - return new JsonPrimitive(value); - } -} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/value/LDValueType.java b/src/main/java/com/launchdarkly/client/value/LDValueType.java deleted file mode 100644 index d7e3ff7f4..000000000 --- a/src/main/java/com/launchdarkly/client/value/LDValueType.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.launchdarkly.client.value; - -/** - * Describes the type of an {@link LDValue}. These correspond to the standard types in JSON. - * - * @since 4.8.0 - */ -public enum LDValueType { - /** - * The value is null. - */ - NULL, - /** - * The value is a boolean. - */ - BOOLEAN, - /** - * The value is numeric. JSON does not have separate types for integers and floating-point values, - * but you can convert to either. - */ - NUMBER, - /** - * The value is a string. - */ - STRING, - /** - * The value is an array. - */ - ARRAY, - /** - * The value is an object (map). - */ - OBJECT -} diff --git a/src/main/java/com/launchdarkly/client/value/LDValueTypeAdapter.java b/src/main/java/com/launchdarkly/client/value/LDValueTypeAdapter.java deleted file mode 100644 index 72c50b960..000000000 --- a/src/main/java/com/launchdarkly/client/value/LDValueTypeAdapter.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.launchdarkly.client.value; - -import com.google.gson.TypeAdapter; -import com.google.gson.stream.JsonReader; -import com.google.gson.stream.JsonToken; -import com.google.gson.stream.JsonWriter; - -import java.io.IOException; - -final class LDValueTypeAdapter extends TypeAdapter{ - static final LDValueTypeAdapter INSTANCE = new LDValueTypeAdapter(); - - @Override - public LDValue read(JsonReader reader) throws IOException { - JsonToken token = reader.peek(); - switch (token) { - case BEGIN_ARRAY: - ArrayBuilder ab = LDValue.buildArray(); - reader.beginArray(); - while (reader.peek() != JsonToken.END_ARRAY) { - ab.add(read(reader)); - } - reader.endArray(); - return ab.build(); - case BEGIN_OBJECT: - ObjectBuilder ob = LDValue.buildObject(); - reader.beginObject(); - while (reader.peek() != JsonToken.END_OBJECT) { - String key = reader.nextName(); - LDValue value = read(reader); - ob.put(key, value); - } - reader.endObject(); - return ob.build(); - case BOOLEAN: - return LDValue.of(reader.nextBoolean()); - case NULL: - reader.nextNull(); - return LDValue.ofNull(); - case NUMBER: - return LDValue.of(reader.nextDouble()); - case STRING: - return LDValue.of(reader.nextString()); - default: - return null; - } - } - - @Override - public void write(JsonWriter writer, LDValue value) throws IOException { - value.write(writer); - } -} diff --git a/src/main/java/com/launchdarkly/client/value/ObjectBuilder.java b/src/main/java/com/launchdarkly/client/value/ObjectBuilder.java deleted file mode 100644 index 1027652d9..000000000 --- a/src/main/java/com/launchdarkly/client/value/ObjectBuilder.java +++ /dev/null @@ -1,104 +0,0 @@ -package com.launchdarkly.client.value; - -import java.util.HashMap; -import java.util.Map; - -/** - * A builder created by {@link LDValue#buildObject()}. Builder methods are not thread-safe. - * - * @since 4.8.0 - */ -public final class ObjectBuilder { - // Note that we're not using ImmutableMap here because we don't want to duplicate its semantics - // for duplicate keys (rather than overwriting the key *or* throwing an exception when you add it, - // it accepts it but then throws an exception when you call build()). So we have to reimplement - // the copy-on-write behavior. - private volatile Map builder = new HashMap(); - private volatile boolean copyOnWrite = false; - - /** - * Sets a key-value pair in the builder, overwriting any previous value for that key. - * @param key a string key - * @param value a value - * @return the same builder - */ - public ObjectBuilder put(String key, LDValue value) { - if (copyOnWrite) { - builder = new HashMap<>(builder); - copyOnWrite = false; - } - builder.put(key, value); - return this; - } - - /** - * Sets a key-value pair in the builder, overwriting any previous value for that key. - * @param key a string key - * @param value a value - * @return the same builder - */ - public ObjectBuilder put(String key, boolean value) { - return put(key, LDValue.of(value)); - } - - /** - * Sets a key-value pair in the builder, overwriting any previous value for that key. - * @param key a string key - * @param value a value - * @return the same builder - */ - public ObjectBuilder put(String key, int value) { - return put(key, LDValue.of(value)); - } - - /** - * Sets a key-value pair in the builder, overwriting any previous value for that key. - * @param key a string key - * @param value a value - * @return the same builder - */ - public ObjectBuilder put(String key, long value) { - return put(key, LDValue.of(value)); - } - - /** - * Sets a key-value pair in the builder, overwriting any previous value for that key. - * @param key a string key - * @param value a value - * @return the same builder - */ - public ObjectBuilder put(String key, float value) { - return put(key, LDValue.of(value)); - } - - /** - * Sets a key-value pair in the builder, overwriting any previous value for that key. - * @param key a string key - * @param value a value - * @return the same builder - */ - public ObjectBuilder put(String key, double value) { - return put(key, LDValue.of(value)); - } - - /** - * Sets a key-value pair in the builder, overwriting any previous value for that key. - * @param key a string key - * @param value a value - * @return the same builder - */ - public ObjectBuilder put(String key, String value) { - return put(key, LDValue.of(value)); - } - - /** - * Returns an object containing the builder's current elements. Subsequent changes to the builder - * will not affect this value (it uses copy-on-write logic, so the previous values will only be - * copied to a new map if you continue to add elements after calling {@link #build()}. - * @return an {@link LDValue} that is a JSON object - */ - public LDValue build() { - copyOnWrite = true; - return LDValueObject.fromMap(builder); - } -} diff --git a/src/main/java/com/launchdarkly/client/value/package-info.java b/src/main/java/com/launchdarkly/client/value/package-info.java deleted file mode 100644 index 59e453f22..000000000 --- a/src/main/java/com/launchdarkly/client/value/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Provides the {@link com.launchdarkly.client.value.LDValue} abstraction for supported data types. - */ -package com.launchdarkly.client.value; diff --git a/src/main/java/com/launchdarkly/sdk/json/SdkSerializationExtensions.java b/src/main/java/com/launchdarkly/sdk/json/SdkSerializationExtensions.java new file mode 100644 index 000000000..cf3dac109 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/json/SdkSerializationExtensions.java @@ -0,0 +1,14 @@ +package com.launchdarkly.sdk.json; + +import com.google.common.collect.ImmutableList; +import com.launchdarkly.sdk.server.FeatureFlagsState; + +// See JsonSerialization.getDeserializableClasses in java-sdk-common. + +class SdkSerializationExtensions { + public static Iterable> getDeserializableClasses() { + return ImmutableList.>of( + FeatureFlagsState.class + ); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java new file mode 100644 index 000000000..eae023c78 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java @@ -0,0 +1,64 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; + +final class ClientContextImpl implements ClientContext { + private final String sdkKey; + private final HttpConfiguration httpConfiguration; + private final boolean offline; + private final DiagnosticAccumulator diagnosticAccumulator; + private final DiagnosticEvent.Init diagnosticInitEvent; + + ClientContextImpl(String sdkKey, LDConfig configuration, DiagnosticAccumulator diagnosticAccumulator) { + this.sdkKey = sdkKey; + this.httpConfiguration = configuration.httpConfig; + this.offline = configuration.offline; + if (!configuration.diagnosticOptOut && diagnosticAccumulator != null) { + this.diagnosticAccumulator = diagnosticAccumulator; + this.diagnosticInitEvent = new DiagnosticEvent.Init(diagnosticAccumulator.dataSinceDate, diagnosticAccumulator.diagnosticId, configuration); + } else { + this.diagnosticAccumulator = null; + this.diagnosticInitEvent = null; + } + } + + @Override + public String getSdkKey() { + return sdkKey; + } + + @Override + public boolean isOffline() { + return offline; + } + + @Override + public HttpConfiguration getHttpConfiguration() { + return httpConfiguration; + } + + // Note that the following two properties are package-private - they are only used by SDK internal components, + // not any custom components implemented by an application. + DiagnosticAccumulator getDiagnosticAccumulator() { + return diagnosticAccumulator; + } + + DiagnosticEvent.Init getDiagnosticInitEvent() { + return diagnosticInitEvent; + } + + static DiagnosticAccumulator getDiagnosticAccumulator(ClientContext context) { + if (context instanceof ClientContextImpl) { + return ((ClientContextImpl)context).getDiagnosticAccumulator(); + } + return null; + } + + static DiagnosticEvent.Init getDiagnosticInitEvent(ClientContext context) { + if (context instanceof ClientContextImpl) { + return ((ClientContextImpl)context).getDiagnosticInitEvent(); + } + return null; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java new file mode 100644 index 000000000..6ffdd37c7 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -0,0 +1,555 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DiagnosticEvent.ConfigProperty; +import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; +import com.launchdarkly.sdk.server.integrations.HttpConfigurationBuilder; +import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; +import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; +import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; +import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription; +import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.EventProcessor; +import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; +import com.launchdarkly.sdk.server.interfaces.FlagValueChangeListener; +import com.launchdarkly.sdk.server.interfaces.HttpAuthentication; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.URI; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; + +import okhttp3.Credentials; + +/** + * Provides configurable factories for the standard implementations of LaunchDarkly component interfaces. + *

+ * Some of the configuration options in {@link LDConfig.Builder} affect the entire SDK, but others are + * specific to one area of functionality, such as how the SDK receives feature flag updates or processes + * analytics events. For the latter, the standard way to specify a configuration is to call one of the + * static methods in {@link Components} (such as {@link #streamingDataSource()}), apply any desired + * configuration change to the object that that method returns (such as {@link StreamingDataSourceBuilder#initialReconnectDelay(java.time.Duration)}, + * and then use the corresponding method in {@link LDConfig.Builder} (such as {@link LDConfig.Builder#dataSource(DataSourceFactory)}) + * to use that configured component in the SDK. + * + * @since 4.0.0 + */ +public abstract class Components { + /** + * Returns a configuration object for using the default in-memory implementation of a data store. + *

+ * Since it is the default, you do not normally need to call this method, unless you need to create + * a data store instance for testing purposes. + * + * @return a factory object + * @see LDConfig.Builder#dataStore(DataStoreFactory) + * @since 4.12.0 + */ + public static DataStoreFactory inMemoryDataStore() { + return InMemoryDataStoreFactory.INSTANCE; + } + + /** + * Returns a configuration builder for some implementation of a persistent data store. + *

+ * This method is used in conjunction with another factory object provided by specific components + * such as the Redis integration. The latter provides builder methods for options that are specific + * to that integration, while the {@link PersistentDataStoreBuilder} provides options that are + * applicable to any persistent data store (such as caching). For example: + * + *


+   *     LDConfig config = new LDConfig.Builder()
+   *         .dataStore(
+   *             Components.persistentDataStore(
+   *                 Redis.dataStore().url("redis://my-redis-host")
+   *             ).cacheSeconds(15)
+   *         )
+   *         .build();
+   * 
+ * + * See {@link PersistentDataStoreBuilder} for more on how this method is used. + *

+ * For more information on the available persistent data store implementations, see the reference + * guide on Using a persistent feature store. + * + * @param storeFactory the factory/builder for the specific kind of persistent data store + * @return a {@link PersistentDataStoreBuilder} + * @see LDConfig.Builder#dataStore(DataStoreFactory) + * @since 4.12.0 + */ + public static PersistentDataStoreBuilder persistentDataStore(PersistentDataStoreFactory storeFactory) { + return new PersistentDataStoreBuilderImpl(storeFactory); + } + + /** + * Returns a configuration builder for analytics event delivery. + *

+ * The default configuration has events enabled with default settings. If you want to + * customize this behavior, call this method to obtain a builder, change its properties + * with the {@link EventProcessorBuilder} properties, and pass it to {@link LDConfig.Builder#events(EventProcessorFactory)}: + *


+   *     LDConfig config = new LDConfig.Builder()
+   *         .events(Components.sendEvents().capacity(5000).flushIntervalSeconds(2))
+   *         .build();
+   * 
+ * To completely disable sending analytics events, use {@link #noEvents()} instead. + *

+ * Setting {@link LDConfig.Builder#offline(boolean)} to {@code true} will supersede this setting and completely + * disable network requests. + * + * @return a builder for setting streaming connection properties + * @see #noEvents() + * @see LDConfig.Builder#events + * @since 4.12.0 + */ + public static EventProcessorBuilder sendEvents() { + return new EventProcessorBuilderImpl(); + } + + /** + * Returns a configuration object that disables analytics events. + *

+ * Passing this to {@link LDConfig.Builder#events(EventProcessorFactory)} causes the SDK + * to discard all analytics events and not send them to LaunchDarkly, regardless of any other configuration. + *


+   *     LDConfig config = new LDConfig.Builder()
+   *         .events(Components.noEvents())
+   *         .build();
+   * 
+ * + * @return a factory object + * @see #sendEvents() + * @see LDConfig.Builder#events(EventProcessorFactory) + * @since 4.12.0 + */ + public static EventProcessorFactory noEvents() { + return NULL_EVENT_PROCESSOR_FACTORY; + } + + /** + * Returns a configurable factory for using streaming mode to get feature flag data. + *

+ * By default, the SDK uses a streaming connection to receive feature flag data from LaunchDarkly. To use the + * default behavior, you do not need to call this method. However, if you want to customize the behavior of + * the connection, call this method to obtain a builder, change its properties with the + * {@link StreamingDataSourceBuilder} methods, and pass it to {@link LDConfig.Builder#dataSource(DataSourceFactory)}: + *

 
+   *     LDConfig config = new LDConfig.Builder()
+   *         .dataSource(Components.streamingDataSource().initialReconnectDelayMillis(500))
+   *         .build();
+   * 
+ *

+ * Setting {@link LDConfig.Builder#offline(boolean)} to {@code true} will supersede this setting and completely + * disable network requests. + * + * @return a builder for setting streaming connection properties + * @see LDConfig.Builder#dataSource(DataSourceFactory) + * @since 4.12.0 + */ + public static StreamingDataSourceBuilder streamingDataSource() { + return new StreamingDataSourceBuilderImpl(); + } + + /** + * Returns a configurable factory for using polling mode to get feature flag data. + *

+ * This is not the default behavior; by default, the SDK uses a streaming connection to receive feature flag + * data from LaunchDarkly. In polling mode, the SDK instead makes a new HTTP request to LaunchDarkly at regular + * intervals. HTTP caching allows it to avoid redundantly downloading data if there have been no changes, but + * polling is still less efficient than streaming and should only be used on the advice of LaunchDarkly support. + *

+ * To use polling mode, call this method to obtain a builder, change its properties with the + * {@link PollingDataSourceBuilder} methods, and pass it to {@link LDConfig.Builder#dataSource(DataSourceFactory)}: + *


+   *     LDConfig config = new LDConfig.Builder()
+   *         .dataSource(Components.pollingDataSource().pollIntervalMillis(45000))
+   *         .build();
+   * 
+ *

+ * Setting {@link LDConfig.Builder#offline(boolean)} to {@code true} will supersede this setting and completely + * disable network requests. + * + * @return a builder for setting polling properties + * @see LDConfig.Builder#dataSource(DataSourceFactory) + * @since 4.12.0 + */ + public static PollingDataSourceBuilder pollingDataSource() { + return new PollingDataSourceBuilderImpl(); + } + + /** + * Returns a configuration object that disables a direct connection with LaunchDarkly for feature flag updates. + *

+ * Passing this to {@link LDConfig.Builder#dataSource(DataSourceFactory)} causes the SDK + * not to retrieve feature flag data from LaunchDarkly, regardless of any other configuration. + * This is normally done if you are using the Relay Proxy + * in "daemon mode", where an external process-- the Relay Proxy-- connects to LaunchDarkly and populates + * a persistent data store with the feature flag data. The data store could also be populated by + * another process that is running the LaunchDarkly SDK. If there is no external process updating + * the data store, then the SDK will not have any feature flag data and will return application + * default values only. + *


+   *     LDConfig config = new LDConfig.Builder()
+   *         .dataSource(Components.externalUpdatesOnly())
+   *         .dataStore(Components.persistentDataStore(Redis.dataStore())) // assuming the Relay Proxy is using Redis
+   *         .build();
+   * 
+ *

+ * (Note that the interface is still named {@link DataSourceFactory}, but in a future version it + * will be renamed to {@code DataSourceFactory}.) + * + * @return a factory object + * @since 4.12.0 + * @see LDConfig.Builder#dataSource(DataSourceFactory) + */ + public static DataSourceFactory externalUpdatesOnly() { + return NullDataSourceFactory.INSTANCE; + } + + /** + * Returns a configurable factory for the SDK's networking configuration. + *

+ * Passing this to {@link LDConfig.Builder#http(com.launchdarkly.sdk.server.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();
+   * 
+ * + * @return a factory object + * @since 4.13.0 + * @see LDConfig.Builder#http(com.launchdarkly.sdk.server.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); + } + + /** + * Convenience method for creating a {@link FlagChangeListener} that tracks a flag's value for a specific user. + *

+ * This listener instance should only be used with a single {@link LDClient} instance. When you first + * register it by calling {@link LDClientInterface#registerFlagChangeListener(FlagChangeListener)}, it + * immediately evaluates the flag. It then re-evaluates the flag whenever there is an update, and calls + * your {@link FlagValueChangeListener} if and only if the resulting value has changed. + *

+ * See {@link FlagValueChangeListener} for more information and examples. + * + * @param client the same client instance that you will be registering this listener with + * @param flagKey the flag key to be evaluated + * @param user the user properties for evaluation + * @param valueChangeListener an object that you provide which will be notified of changes + * @return a {@link FlagChangeListener} to be passed to {@link LDClientInterface#registerFlagChangeListener(FlagChangeListener)} + * + * @since 5.0.0 + * @see FlagValueChangeListener + * @see FlagChangeListener + */ + public static FlagChangeListener flagValueMonitoringListener(LDClientInterface client, String flagKey, LDUser user, + FlagValueChangeListener valueChangeListener) { + return new FlagValueMonitoringListener(client, flagKey, user, valueChangeListener); + } + + private static final class InMemoryDataStoreFactory implements DataStoreFactory, DiagnosticDescription { + static final DataStoreFactory INSTANCE = new InMemoryDataStoreFactory(); + @Override + public DataStore createDataStore(ClientContext context) { + return new InMemoryDataStore(); + } + + @Override + public LDValue describeConfiguration(LDConfig config) { + return LDValue.of("memory"); + } + } + + private static final EventProcessorFactory NULL_EVENT_PROCESSOR_FACTORY = context -> NullEventProcessor.INSTANCE; + + /** + * Stub implementation of {@link EventProcessor} for when we don't want to send any events. + */ + static final class NullEventProcessor implements EventProcessor { + static final NullEventProcessor INSTANCE = new NullEventProcessor(); + + private NullEventProcessor() {} + + @Override + public void sendEvent(Event e) { + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + } + + private static final class NullDataSourceFactory implements DataSourceFactory, DiagnosticDescription { + static final NullDataSourceFactory INSTANCE = new NullDataSourceFactory(); + + @Override + public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { + if (context.isOffline()) { + // If they have explicitly called offline(true) to disable everything, we'll log this slightly + // more specific message. + LDClient.logger.info("Starting LaunchDarkly client in offline mode"); + } else { + LDClient.logger.info("LaunchDarkly client will not connect to Launchdarkly for feature flag data"); + } + return NullDataSource.INSTANCE; + } + + @Override + public LDValue describeConfiguration(LDConfig config) { + // We can assume that if they don't have a data source, and they *do* have a persistent data store, then + // they're using Relay in daemon mode. + return LDValue.buildObject() + .put(ConfigProperty.CUSTOM_BASE_URI.name, false) + .put(ConfigProperty.CUSTOM_STREAM_URI.name, false) + .put(ConfigProperty.STREAMING_DISABLED.name, false) + .put(ConfigProperty.USING_RELAY_DAEMON.name, + config.dataStoreFactory != null && config.dataStoreFactory != Components.inMemoryDataStore()) + .build(); + } + } + + // Package-private for visibility in tests + static final class NullDataSource implements DataSource { + static final DataSource INSTANCE = new NullDataSource(); + @Override + public Future start() { + return CompletableFuture.completedFuture(null); + } + + @Override + public boolean isInitialized() { + return true; + } + + @Override + public void close() throws IOException {} + } + + private static final class StreamingDataSourceBuilderImpl extends StreamingDataSourceBuilder + implements DiagnosticDescription { + @Override + public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { + // Note, we log startup messages under the LDClient class to keep logs more readable + + if (context.isOffline()) { + return Components.externalUpdatesOnly().createDataSource(context, dataStoreUpdates); + } + + LDClient.logger.info("Enabling streaming API"); + + URI streamUri = baseURI == null ? LDConfig.DEFAULT_STREAM_URI : baseURI; + URI pollUri; + if (pollingBaseURI != null) { + pollUri = pollingBaseURI; + } else { + // If they have set a custom base URI, and they did *not* set a custom polling URI, then we can + // assume they're using Relay in which case both of those values are the same. + pollUri = baseURI == null ? LDConfig.DEFAULT_BASE_URI : baseURI; + } + + DefaultFeatureRequestor requestor = new DefaultFeatureRequestor( + context.getSdkKey(), + context.getHttpConfiguration(), + pollUri, + false + ); + + return new StreamProcessor( + context.getSdkKey(), + context.getHttpConfiguration(), + requestor, + dataStoreUpdates, + null, + ClientContextImpl.getDiagnosticAccumulator(context), + streamUri, + initialReconnectDelay + ); + } + + @Override + public LDValue describeConfiguration(LDConfig config) { + if (config.offline) { + return NullDataSourceFactory.INSTANCE.describeConfiguration(config); + } + return LDValue.buildObject() + .put(ConfigProperty.STREAMING_DISABLED.name, false) + .put(ConfigProperty.CUSTOM_BASE_URI.name, + (pollingBaseURI != null && !pollingBaseURI.equals(LDConfig.DEFAULT_BASE_URI)) || + (pollingBaseURI == null && baseURI != null && !baseURI.equals(LDConfig.DEFAULT_STREAM_URI))) + .put(ConfigProperty.CUSTOM_STREAM_URI.name, + baseURI != null && !baseURI.equals(LDConfig.DEFAULT_STREAM_URI)) + .put(ConfigProperty.RECONNECT_TIME_MILLIS.name, initialReconnectDelay.toMillis()) + .put(ConfigProperty.USING_RELAY_DAEMON.name, false) + .build(); + } + } + + private static final class PollingDataSourceBuilderImpl extends PollingDataSourceBuilder implements DiagnosticDescription { + @Override + public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { + // Note, we log startup messages under the LDClient class to keep logs more readable + + if (context.isOffline()) { + return Components.externalUpdatesOnly().createDataSource(context, dataStoreUpdates); + } + + LDClient.logger.info("Disabling streaming API"); + LDClient.logger.warn("You should only disable the streaming API if instructed to do so by LaunchDarkly support"); + + DefaultFeatureRequestor requestor = new DefaultFeatureRequestor( + context.getSdkKey(), + context.getHttpConfiguration(), + baseURI == null ? LDConfig.DEFAULT_BASE_URI : baseURI, + true + ); + return new PollingProcessor(requestor, dataStoreUpdates, pollInterval); + } + + @Override + public LDValue describeConfiguration(LDConfig config) { + if (config.offline) { + return NullDataSourceFactory.INSTANCE.describeConfiguration(config); + } + return LDValue.buildObject() + .put(ConfigProperty.STREAMING_DISABLED.name, true) + .put(ConfigProperty.CUSTOM_BASE_URI.name, + baseURI != null && !baseURI.equals(LDConfig.DEFAULT_BASE_URI)) + .put(ConfigProperty.CUSTOM_STREAM_URI.name, false) + .put(ConfigProperty.POLLING_INTERVAL_MILLIS.name, pollInterval.toMillis()) + .put(ConfigProperty.USING_RELAY_DAEMON.name, false) + .build(); + } + } + + private static final class EventProcessorBuilderImpl extends EventProcessorBuilder + implements DiagnosticDescription { + @Override + public EventProcessor createEventProcessor(ClientContext context) { + if (context.isOffline()) { + return new NullEventProcessor(); + } + return new DefaultEventProcessor( + context.getSdkKey(), + new EventsConfiguration( + allAttributesPrivate, + capacity, + baseURI == null ? LDConfig.DEFAULT_EVENTS_URI : baseURI, + flushInterval, + inlineUsersInEvents, + privateAttributes, + 0, // deprecated samplingInterval isn't supported in new builder + userKeysCapacity, + userKeysFlushInterval, + diagnosticRecordingInterval + ), + context.getHttpConfiguration(), + ClientContextImpl.getDiagnosticAccumulator(context), + ClientContextImpl.getDiagnosticInitEvent(context) + ); + } + + @Override + public LDValue describeConfiguration(LDConfig config) { + return LDValue.buildObject() + .put(ConfigProperty.ALL_ATTRIBUTES_PRIVATE.name, allAttributesPrivate) + .put(ConfigProperty.CUSTOM_EVENTS_URI.name, baseURI != null && !baseURI.equals(LDConfig.DEFAULT_EVENTS_URI)) + .put(ConfigProperty.DIAGNOSTIC_RECORDING_INTERVAL_MILLIS.name, diagnosticRecordingInterval.toMillis()) + .put(ConfigProperty.EVENTS_CAPACITY.name, capacity) + .put(ConfigProperty.EVENTS_FLUSH_INTERVAL_MILLIS.name, flushInterval.toMillis()) + .put(ConfigProperty.INLINE_USERS_IN_EVENTS.name, inlineUsersInEvents) + .put(ConfigProperty.SAMPLING_INTERVAL.name, 0) + .put(ConfigProperty.USER_KEYS_CAPACITY.name, userKeysCapacity) + .put(ConfigProperty.USER_KEYS_FLUSH_INTERVAL_MILLIS.name, userKeysFlushInterval.toMillis()) + .build(); + } + } + + private static final class HttpConfigurationBuilderImpl extends HttpConfigurationBuilder { + @Override + public HttpConfiguration createHttpConfiguration() { + return new HttpConfigurationImpl( + connectTimeout, + proxyHost == null ? null : new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort)), + proxyAuth, + socketTimeout, + 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); + } + + @Override + public LDValue describeConfiguration(LDConfig config) { + if (persistentDataStoreFactory instanceof DiagnosticDescription) { + return ((DiagnosticDescription)persistentDataStoreFactory).describeConfiguration(config); + } + return LDValue.of("custom"); + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModel.java b/src/main/java/com/launchdarkly/sdk/server/DataModel.java new file mode 100644 index 000000000..f2a907ee3 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/DataModel.java @@ -0,0 +1,502 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableList; +import com.google.gson.annotations.JsonAdapter; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; + +import java.util.Collection; +import java.util.List; +import java.util.Set; + +import static java.util.Collections.emptyList; + +/** + * Contains information about the internal data model for feature flags and user segments. + *

+ * The details of the data model are not public to application code (although of course developers can easily + * look at the code or the data) so that changes to LaunchDarkly SDK implementation details will not be breaking + * changes to the application. Therefore, most of the members of this class are package-private. The public + * members provide a high-level description of model objects so that custom integration code or test code can + * store or serialize them. + */ +public abstract class DataModel { + /** + * The {@link DataKind} instance that describes feature flag data. + *

+ * Applications should not need to reference this object directly. It is public so that custom integrations + * and test code can serialize or deserialize data or inject it into a data store. + */ + public static DataKind FEATURES = new DataKind("features", + DataModel::serializeItem, + s -> deserializeItem(s, FeatureFlag.class)); + + /** + * The {@link DataKind} instance that describes user segment data. + *

+ * Applications should not need to reference this object directly. It is public so that custom integrations + * and test code can serialize or deserialize data or inject it into a data store. + */ + public static DataKind SEGMENTS = new DataKind("segments", + DataModel::serializeItem, + s -> deserializeItem(s, Segment.class)); + + /** + * An enumeration of all supported {@link DataKind} types. + *

+ * Applications should not need to reference this object directly. It is public so that custom data store + * implementations can determine ahead of time what kinds of model objects may need to be stored, if + * necessary. + */ + public static Iterable ALL_DATA_KINDS = ImmutableList.of(FEATURES, SEGMENTS); + + private static ItemDescriptor deserializeItem(String s, Class itemClass) { + VersionedData o = JsonHelpers.deserialize(s, itemClass); + return o.isDeleted() ? ItemDescriptor.deletedItem(o.getVersion()) : new ItemDescriptor(o.getVersion(), o); + } + + private static String serializeItem(ItemDescriptor item) { + Object o = item.getItem(); + if (o != null) { + return JsonHelpers.serialize(o); + } + return "{\"version\":" + item.getVersion() + ",\"deleted\":true}"; + } + + // All of these inner data model classes should have package-private scope. They should have only property + // accessors; the evaluator logic is in Evaluator, EvaluatorBucketing, and EvaluatorOperators. + + /** + * Common interface for FeatureFlag and Segment, for convenience in accessing their common properties. + * @since 3.0.0 + */ + interface VersionedData { + String getKey(); + int getVersion(); + /** + * True if this is a placeholder for a deleted item. + * @return true if deleted + */ + boolean isDeleted(); + } + + @JsonAdapter(JsonHelpers.PostProcessingDeserializableTypeAdapterFactory.class) + static final class FeatureFlag implements VersionedData, JsonHelpers.PostProcessingDeserializable { + private String key; + private int version; + private boolean on; + private List prerequisites; + private String salt; + private List targets; + private List rules; + private VariationOrRollout fallthrough; + private Integer offVariation; //optional + private List variations; + private boolean clientSide; + private boolean trackEvents; + private boolean trackEventsFallthrough; + private Long debugEventsUntilDate; + private boolean deleted; + + // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation + FeatureFlag() {} + + FeatureFlag(String key, int version, boolean on, List prerequisites, String salt, List targets, + List rules, VariationOrRollout fallthrough, Integer offVariation, List variations, + boolean clientSide, boolean trackEvents, boolean trackEventsFallthrough, + Long debugEventsUntilDate, boolean deleted) { + this.key = key; + this.version = version; + this.on = on; + this.prerequisites = prerequisites; + this.salt = salt; + this.targets = targets; + this.rules = rules; + this.fallthrough = fallthrough; + this.offVariation = offVariation; + this.variations = variations; + this.clientSide = clientSide; + this.trackEvents = trackEvents; + this.trackEventsFallthrough = trackEventsFallthrough; + this.debugEventsUntilDate = debugEventsUntilDate; + this.deleted = deleted; + } + + public int getVersion() { + return version; + } + + public String getKey() { + return key; + } + + boolean isTrackEvents() { + return trackEvents; + } + + boolean isTrackEventsFallthrough() { + return trackEventsFallthrough; + } + + Long getDebugEventsUntilDate() { + return debugEventsUntilDate; + } + + public boolean isDeleted() { + return deleted; + } + + boolean isOn() { + return on; + } + + // Guaranteed non-null + List getPrerequisites() { + return prerequisites == null ? emptyList() : prerequisites; + } + + String getSalt() { + return salt; + } + + // Guaranteed non-null + List getTargets() { + return targets == null ? emptyList() : targets; + } + + // Guaranteed non-null + List getRules() { + return rules == null ? emptyList() : rules; + } + + VariationOrRollout getFallthrough() { + return fallthrough; + } + + // Guaranteed non-null + List getVariations() { + return variations == null ? emptyList() : variations; + } + + Integer getOffVariation() { + return offVariation; + } + + boolean isClientSide() { + return clientSide; + } + + // Precompute some invariant values for improved efficiency during evaluations - called from JsonHelpers.PostProcessingDeserializableTypeAdapter + public void afterDeserialized() { + if (prerequisites != null) { + for (Prerequisite p: prerequisites) { + p.setPrerequisiteFailedReason(EvaluationReason.prerequisiteFailed(p.getKey())); + } + } + if (rules != null) { + for (int i = 0; i < rules.size(); i++) { + Rule r = rules.get(i); + r.setRuleMatchReason(EvaluationReason.ruleMatch(i, r.getId())); + } + } + } + } + + static final class Prerequisite { + private String key; + private int variation; + + private transient EvaluationReason prerequisiteFailedReason; + + Prerequisite() {} + + Prerequisite(String key, int variation) { + this.key = key; + this.variation = variation; + } + + String getKey() { + return key; + } + + int getVariation() { + return variation; + } + + // This value is precomputed when we deserialize a FeatureFlag from JSON + EvaluationReason getPrerequisiteFailedReason() { + return prerequisiteFailedReason; + } + + void setPrerequisiteFailedReason(EvaluationReason prerequisiteFailedReason) { + this.prerequisiteFailedReason = prerequisiteFailedReason; + } + } + + static final class Target { + private Set values; + private int variation; + + Target() {} + + Target(Set values, int variation) { + this.values = values; + this.variation = variation; + } + + // Guaranteed non-null + Collection getValues() { + return values == null ? emptyList() : values; + } + + int getVariation() { + return variation; + } + } + + /** + * Expresses a set of AND-ed matching conditions for a user, along with either the fixed variation or percent rollout + * to serve if the conditions match. + * Invariant: one of the variation or rollout must be non-nil. + */ + static final class Rule extends VariationOrRollout { + private String id; + private List clauses; + private boolean trackEvents; + + private transient EvaluationReason ruleMatchReason; + + Rule() { + super(); + } + + Rule(String id, List clauses, Integer variation, Rollout rollout, boolean trackEvents) { + super(variation, rollout); + this.id = id; + this.clauses = clauses; + this.trackEvents = trackEvents; + } + + String getId() { + return id; + } + + // Guaranteed non-null + List getClauses() { + return clauses == null ? emptyList() : clauses; + } + + boolean isTrackEvents() { + return trackEvents; + } + + // This value is precomputed when we deserialize a FeatureFlag from JSON + EvaluationReason getRuleMatchReason() { + return ruleMatchReason; + } + + void setRuleMatchReason(EvaluationReason ruleMatchReason) { + this.ruleMatchReason = ruleMatchReason; + } + } + + static class Clause { + private UserAttribute attribute; + private Operator op; + private List values; //interpreted as an OR of values + private boolean negate; + + Clause() { + } + + Clause(UserAttribute attribute, Operator op, List values, boolean negate) { + this.attribute = attribute; + this.op = op; + this.values = values; + this.negate = negate; + } + + UserAttribute getAttribute() { + return attribute; + } + + Operator getOp() { + return op; + } + + Iterable getValues() { + return values; + } + + boolean isNegate() { + return negate; + } + } + + static final class Rollout { + private List variations; + private UserAttribute bucketBy; + + Rollout() {} + + Rollout(List variations, UserAttribute bucketBy) { + this.variations = variations; + this.bucketBy = bucketBy; + } + + List getVariations() { + return variations; + } + + UserAttribute getBucketBy() { + return bucketBy; + } + } + + /** + * Contains either a fixed variation or percent rollout to serve. + * Invariant: one of the variation or rollout must be non-nil. + */ + static class VariationOrRollout { + private Integer variation; + private Rollout rollout; + + VariationOrRollout() {} + + VariationOrRollout(Integer variation, Rollout rollout) { + this.variation = variation; + this.rollout = rollout; + } + + Integer getVariation() { + return variation; + } + + Rollout getRollout() { + return rollout; + } + } + + static final class WeightedVariation { + private int variation; + private int weight; + + WeightedVariation() {} + + WeightedVariation(int variation, int weight) { + this.variation = variation; + this.weight = weight; + } + + int getVariation() { + return variation; + } + + int getWeight() { + return weight; + } + } + + static final class Segment implements VersionedData { + private String key; + private Set included; + private Set excluded; + private String salt; + private List rules; + private int version; + private boolean deleted; + + Segment() {} + + Segment(String key, Set included, Set excluded, String salt, List rules, int version, boolean deleted) { + this.key = key; + this.included = included; + this.excluded = excluded; + this.salt = salt; + this.rules = rules; + this.version = version; + this.deleted = deleted; + } + + public String getKey() { + return key; + } + + // Guaranteed non-null + Collection getIncluded() { + return included == null ? emptyList() : included; + } + + // Guaranteed non-null + Collection getExcluded() { + return excluded == null ? emptyList() : excluded; + } + + String getSalt() { + return salt; + } + + // Guaranteed non-null + List getRules() { + return rules == null ? emptyList() : rules; + } + + public int getVersion() { + return version; + } + + public boolean isDeleted() { + return deleted; + } + } + + static final class SegmentRule { + private final List clauses; + private final Integer weight; + private final UserAttribute bucketBy; + + SegmentRule(List clauses, Integer weight, UserAttribute bucketBy) { + this.clauses = clauses; + this.weight = weight; + this.bucketBy = bucketBy; + } + + // Guaranteed non-null + List getClauses() { + return clauses == null ? emptyList() : clauses; + } + + Integer getWeight() { + return weight; + } + + UserAttribute getBucketBy() { + return bucketBy; + } + } + + /** + * This enum can be directly deserialized from JSON, avoiding the need for a mapping of strings to + * operators. The implementation of each operator is in EvaluatorOperators. + */ + static enum Operator { + in, + endsWith, + startsWith, + matches, + contains, + lessThan, + lessThanOrEqual, + greaterThan, + greaterThanOrEqual, + before, + after, + semVerEqual, + semVerLessThan, + semVerGreaterThan, + segmentMatch + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java b/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java new file mode 100644 index 000000000..b3a653705 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java @@ -0,0 +1,250 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ImmutableSortedMap; +import com.google.common.collect.Iterables; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel.Operator; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static com.google.common.collect.Iterables.concat; +import static com.google.common.collect.Iterables.isEmpty; +import static com.google.common.collect.Iterables.transform; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static java.util.Collections.emptyList; +import static java.util.Collections.emptySet; + +/** + * Implements a dependency graph ordering for data to be stored in a data store. + *

+ * We use this to order the data that we pass to {@link com.launchdarkly.sdk.server.interfaces.DataStore#init(FullDataSet)}, + * and also to determine which flags are affected by a change if the application is listening for flag change events. + *

+ * Dependencies are defined as follows: there is a dependency from flag F to flag G if F is a prerequisite flag for + * G, or transitively for any of G's prerequisites; there is a dependency from flag F to segment S if F contains a + * rule with a segmentMatch clause that uses S. Therefore, if G or S is modified or deleted then F may be affected, + * and if we must populate the store non-atomically then G and S should be added before F. + * + * @since 4.6.1 + */ +abstract class DataModelDependencies { + static class KindAndKey { + final DataKind kind; + final String key; + + public KindAndKey(DataKind kind, String key) { + this.kind = kind; + this.key = key; + } + + @Override + public boolean equals(Object other) { + if (other instanceof KindAndKey) { + KindAndKey o = (KindAndKey)other; + return kind == o.kind && key.equals(o.key); + } + return false; + } + + @Override + public int hashCode() { + return kind.hashCode() * 31 + key.hashCode(); + } + } + + /** + * Returns the immediate dependencies from the given item. + * + * @param fromKind the item's kind + * @param fromItem the item descriptor + * @return the flags and/or segments that this item depends on + */ + public static Set computeDependenciesFrom(DataKind fromKind, ItemDescriptor fromItem) { + if (fromItem == null || fromItem.getItem() == null) { + return emptySet(); + } + if (fromKind == FEATURES) { + DataModel.FeatureFlag flag = (DataModel.FeatureFlag)fromItem.getItem(); + + Iterable prereqFlagKeys = transform(flag.getPrerequisites(), p -> p.getKey()); + + Iterable segmentKeys = concat( + transform( + flag.getRules(), + rule -> concat( + Iterables.>transform( + rule.getClauses(), + clause -> clause.getOp() == Operator.segmentMatch ? + transform(clause.getValues(), LDValue::stringValue) : + emptyList() + ) + ) + ) + ); + + return ImmutableSet.copyOf( + concat( + transform(prereqFlagKeys, key -> new KindAndKey(FEATURES, key)), + transform(segmentKeys, key -> new KindAndKey(SEGMENTS, key)) + ) + ); + } + return emptySet(); + } + + /** + * Returns a copy of the input data set that guarantees that if you iterate through it the outer list and + * the inner list in the order provided, any object that depends on another object will be updated after it. + * + * @param allData the unordered data set + * @return a map with a defined ordering + */ + public static FullDataSet sortAllCollections(FullDataSet allData) { + ImmutableSortedMap.Builder> builder = + ImmutableSortedMap.orderedBy(dataKindPriorityOrder); + for (Map.Entry> entry: allData.getData()) { + DataKind kind = entry.getKey(); + builder.put(kind, sortCollection(kind, entry.getValue())); + } + return new FullDataSet<>(builder.build().entrySet()); + } + + private static KeyedItems sortCollection(DataKind kind, KeyedItems input) { + if (!isDependencyOrdered(kind) || isEmpty(input.getItems())) { + return input; + } + + Map remainingItems = new HashMap<>(); + for (Map.Entry e: input.getItems()) { + remainingItems.put(e.getKey(), e.getValue()); + } + ImmutableMap.Builder builder = ImmutableMap.builder(); + // Note, ImmutableMap guarantees that the iteration order will be the same as the builder insertion order + + while (!remainingItems.isEmpty()) { + // pick a random item that hasn't been updated yet + for (Map.Entry entry: remainingItems.entrySet()) { + addWithDependenciesFirst(kind, entry.getKey(), entry.getValue(), remainingItems, builder); + break; + } + } + + return new KeyedItems<>(builder.build().entrySet()); + } + + private static void addWithDependenciesFirst(DataKind kind, + String key, + ItemDescriptor item, + Map remainingItems, + ImmutableMap.Builder builder) { + remainingItems.remove(key); // we won't need to visit this item again + for (KindAndKey dependency: computeDependenciesFrom(kind, item)) { + if (dependency.kind == kind) { + ItemDescriptor prereqItem = remainingItems.get(dependency.key); + if (prereqItem != null) { + addWithDependenciesFirst(kind, dependency.key, prereqItem, remainingItems, builder); + } + } + } + builder.put(key, item); + } + + private static boolean isDependencyOrdered(DataKind kind) { + return kind == FEATURES; + } + + private static int getPriority(DataKind kind) { + if (kind == FEATURES) { + return 1; + } else if (kind == SEGMENTS) { + return 0; + } else { + return kind.getName().length() + 2; + } + } + + private static Comparator dataKindPriorityOrder = new Comparator() { + @Override + public int compare(DataKind o1, DataKind o2) { + return getPriority(o1) - getPriority(o2); + } + }; + + /** + * Maintains a bidirectional dependency graph that can be updated whenever an item has changed. + */ + static final class DependencyTracker { + private final Map> dependenciesFrom = new HashMap<>(); + private final Map> dependenciesTo = new HashMap<>(); + + /** + * Updates the dependency graph when an item has changed. + * + * @param fromKind the changed item's kind + * @param fromKey the changed item's key + * @param fromItem the changed item + */ + public void updateDependenciesFrom(DataKind fromKind, String fromKey, ItemDescriptor fromItem) { + KindAndKey fromWhat = new KindAndKey(fromKind, fromKey); + Set updatedDependencies = computeDependenciesFrom(fromKind, fromItem); + + Set oldDependencySet = dependenciesFrom.get(fromWhat); + if (oldDependencySet != null) { + for (KindAndKey oldDep: oldDependencySet) { + Set depsToThisOldDep = dependenciesTo.get(oldDep); + if (depsToThisOldDep != null) { + depsToThisOldDep.remove(fromWhat); + } + } + } + if (updatedDependencies == null) { + dependenciesFrom.remove(fromWhat); + } else { + dependenciesFrom.put(fromWhat, updatedDependencies); + for (KindAndKey newDep: updatedDependencies) { + Set depsToThisNewDep = dependenciesTo.get(newDep); + if (depsToThisNewDep == null) { + depsToThisNewDep = new HashSet<>(); + dependenciesTo.put(newDep, depsToThisNewDep); + } + depsToThisNewDep.add(fromWhat); + } + } + } + + public void reset() { + dependenciesFrom.clear(); + dependenciesTo.clear(); + } + + /** + * Populates the given set with the union of the initial item and all items that directly or indirectly + * depend on it (based on the current state of the dependency graph). + * + * @param itemsOut an existing set to be updated + * @param initialModifiedItem an item that has been modified + */ + public void addAffectedItems(Set itemsOut, KindAndKey initialModifiedItem) { + if (!itemsOut.contains(initialModifiedItem)) { + itemsOut.add(initialModifiedItem); + Set affectedItems = dependenciesTo.get(initialModifiedItem); + if (affectedItems != null) { + for (KindAndKey affectedItem: affectedItems) { + addAffectedItems(itemsOut, affectedItem); + } + } + } + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java new file mode 100644 index 000000000..db63225ee --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java @@ -0,0 +1,36 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; + +// Simple delegator to ensure that LDClient.getDataStoreStatusProvider() never returns null and that +// the application isn't given direct access to the store. +final class DataStoreStatusProviderImpl implements DataStoreStatusProvider { + private final DataStoreStatusProvider delegateTo; + + DataStoreStatusProviderImpl(DataStore store) { + delegateTo = store instanceof DataStoreStatusProvider ? (DataStoreStatusProvider)store : null; + } + + @Override + public Status getStoreStatus() { + return delegateTo == null ? null : delegateTo.getStoreStatus(); + } + + @Override + public boolean addStatusListener(StatusListener listener) { + return delegateTo != null && delegateTo.addStatusListener(listener); + } + + @Override + public void removeStatusListener(StatusListener listener) { + if (delegateTo != null) { + delegateTo.removeStatusListener(listener); + } + } + + @Override + public CacheStats getCacheStats() { + return delegateTo == null ? null : delegateTo.getCacheStats(); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java new file mode 100644 index 000000000..7edc3ef14 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java @@ -0,0 +1,153 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.launchdarkly.sdk.server.DataModelDependencies.KindAndKey; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static com.google.common.collect.Iterables.concat; +import static com.launchdarkly.sdk.server.DataModel.ALL_DATA_KINDS; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static java.util.Collections.emptyMap; + +/** + * The data source will push updates into this component. We then apply any necessary + * transformations before putting them into the data store; currently that just means sorting + * the data set for init(). We also generate flag change events for any updates or deletions. + * + * @since 4.11.0 + */ +final class DataStoreUpdatesImpl implements DataStoreUpdates { + private final DataStore store; + private final FlagChangeEventPublisher flagChangeEventPublisher; + private final DataModelDependencies.DependencyTracker dependencyTracker = new DataModelDependencies.DependencyTracker(); + private final DataStoreStatusProvider dataStoreStatusProvider; + + DataStoreUpdatesImpl(DataStore store, FlagChangeEventPublisher flagChangeEventPublisher) { + this.store = store; + this.flagChangeEventPublisher = flagChangeEventPublisher; + this.dataStoreStatusProvider = new DataStoreStatusProviderImpl(store); + } + + @Override + public void init(FullDataSet allData) { + Map> oldData = null; + + if (hasFlagChangeEventListeners()) { + // Query the existing data if any, so that after the update we can send events for whatever was changed + oldData = new HashMap<>(); + for (DataKind kind: ALL_DATA_KINDS) { + KeyedItems items = store.getAll(kind); + oldData.put(kind, ImmutableMap.copyOf(items.getItems())); + } + } + + store.init(DataModelDependencies.sortAllCollections(allData)); + + // We must always update the dependency graph even if we don't currently have any event listeners, because if + // listeners are added later, we don't want to have to reread the whole data store to compute the graph + updateDependencyTrackerFromFullDataSet(allData); + + // Now, if we previously queried the old data because someone is listening for flag change events, compare + // the versions of all items and generate events for those (and any other items that depend on them) + if (oldData != null) { + sendChangeEvents(computeChangedItemsForFullDataSet(oldData, fullDataSetToMap(allData))); + } + } + + @Override + public void upsert(DataKind kind, String key, ItemDescriptor item) { + boolean successfullyUpdated = store.upsert(kind, key, item); + + if (successfullyUpdated) { + dependencyTracker.updateDependenciesFrom(kind, key, item); + if (hasFlagChangeEventListeners()) { + Set affectedItems = new HashSet<>(); + dependencyTracker.addAffectedItems(affectedItems, new KindAndKey(kind, key)); + sendChangeEvents(affectedItems); + } + } + } + + @Override + public DataStoreStatusProvider getStatusProvider() { + return dataStoreStatusProvider; + } + + private boolean hasFlagChangeEventListeners() { + return flagChangeEventPublisher != null && flagChangeEventPublisher.hasListeners(); + } + + private void sendChangeEvents(Iterable affectedItems) { + if (flagChangeEventPublisher == null) { + return; + } + for (KindAndKey item: affectedItems) { + if (item.kind == FEATURES) { + flagChangeEventPublisher.publishEvent(new FlagChangeEvent(item.key)); + } + } + } + + private void updateDependencyTrackerFromFullDataSet(FullDataSet allData) { + dependencyTracker.reset(); + for (Map.Entry> e0: allData.getData()) { + DataKind kind = e0.getKey(); + for (Map.Entry e1: e0.getValue().getItems()) { + String key = e1.getKey(); + dependencyTracker.updateDependenciesFrom(kind, key, e1.getValue()); + } + } + } + + private Map> fullDataSetToMap(FullDataSet allData) { + Map> ret = new HashMap<>(); + for (Map.Entry> e: allData.getData()) { + ret.put(e.getKey(), ImmutableMap.copyOf(e.getValue().getItems())); + } + return ret; + } + + private Set computeChangedItemsForFullDataSet(Map> oldDataMap, + Map> newDataMap) { + Set affectedItems = new HashSet<>(); + for (DataKind kind: ALL_DATA_KINDS) { + Map oldItems = oldDataMap.get(kind); + Map newItems = newDataMap.get(kind); + if (oldItems == null) { + oldItems = emptyMap(); + } + if (newItems == null) { + newItems = emptyMap(); + } + Set allKeys = ImmutableSet.copyOf(concat(oldItems.keySet(), newItems.keySet())); + for (String key: allKeys) { + ItemDescriptor oldItem = oldItems.get(key); + ItemDescriptor newItem = newItems.get(key); + if (oldItem == null && newItem == null) { // shouldn't be possible due to how we computed allKeys + continue; + } + if (oldItem == null || newItem == null || oldItem.getVersion() < newItem.getVersion()) { + dependencyTracker.addAffectedItems(affectedItems, new KindAndKey(kind, key)); + } + // Note that comparing the version numbers is sufficient; we don't have to compare every detail of the + // flag or segment configuration, because it's a basic underlying assumption of the entire LD data model + // that if an entity's version number hasn't changed, then the entity hasn't changed (and that if two + // version numbers are different, the higher one is the more recent version). + } + } + return affectedItems; + } +} diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java similarity index 87% rename from src/main/java/com/launchdarkly/client/DefaultEventProcessor.java rename to src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java index a82890e78..a814f81ea 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java @@ -1,9 +1,12 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; 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 com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.server.EventSummarizer.EventSummary; +import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.EventProcessor; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,7 +18,6 @@ import java.util.ArrayList; import java.util.Date; import java.util.List; -import java.util.Random; import java.util.UUID; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; @@ -29,11 +31,11 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; -import static com.launchdarkly.client.Util.configureHttpClientBuilder; -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.Util.shutdownHttpClient; +import static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; +import static com.launchdarkly.sdk.server.Util.getHeadersBuilderFor; +import static com.launchdarkly.sdk.server.Util.httpErrorMessage; +import static com.launchdarkly.sdk.server.Util.isHttpErrorRecoverable; +import static com.launchdarkly.sdk.server.Util.shutdownHttpClient; import okhttp3.Headers; import okhttp3.MediaType; @@ -47,6 +49,7 @@ final class DefaultEventProcessor implements EventProcessor { private static final String EVENT_SCHEMA_HEADER = "X-LaunchDarkly-Event-Schema"; private static final String EVENT_SCHEMA_VERSION = "3"; private static final String EVENT_PAYLOAD_ID_HEADER = "X-LaunchDarkly-Payload-ID"; + private static final MediaType JSON_CONTENT_TYPE = MediaType.parse("application/json; charset=utf-8"); @VisibleForTesting final EventDispatcher dispatcher; private final BlockingQueue inbox; @@ -54,8 +57,8 @@ final class DefaultEventProcessor implements EventProcessor { private final AtomicBoolean closed = new AtomicBoolean(false); private volatile boolean inputCapacityExceeded = false; - DefaultEventProcessor(String sdkKey, LDConfig config, EventsConfiguration eventsConfig, HttpConfiguration httpConfig, - DiagnosticAccumulator diagnosticAccumulator) { + DefaultEventProcessor(String sdkKey, EventsConfiguration eventsConfig, HttpConfiguration httpConfig, + DiagnosticAccumulator diagnosticAccumulator, DiagnosticEvent.Init diagnosticInitEvent) { inbox = new ArrayBlockingQueue<>(eventsConfig.capacity); ThreadFactory threadFactory = new ThreadFactoryBuilder() @@ -65,29 +68,24 @@ final class DefaultEventProcessor implements EventProcessor { .build(); scheduler = Executors.newSingleThreadScheduledExecutor(threadFactory); - dispatcher = new EventDispatcher(sdkKey, config, eventsConfig, httpConfig, inbox, threadFactory, closed, diagnosticAccumulator); + dispatcher = new EventDispatcher(sdkKey, eventsConfig, httpConfig, inbox, threadFactory, closed, diagnosticAccumulator, diagnosticInitEvent); - Runnable flusher = new Runnable() { - public void run() { - postMessageAsync(MessageType.FLUSH, null); - } + Runnable flusher = () -> { + postMessageAsync(MessageType.FLUSH, null); }; - this.scheduler.scheduleAtFixedRate(flusher, eventsConfig.flushIntervalSeconds, eventsConfig.flushIntervalSeconds, TimeUnit.SECONDS); - Runnable userKeysFlusher = new Runnable() { - public void run() { - postMessageAsync(MessageType.FLUSH_USERS, null); - } + this.scheduler.scheduleAtFixedRate(flusher, eventsConfig.flushInterval.toMillis(), + eventsConfig.flushInterval.toMillis(), TimeUnit.MILLISECONDS); + Runnable userKeysFlusher = () -> { + postMessageAsync(MessageType.FLUSH_USERS, null); }; - this.scheduler.scheduleAtFixedRate(userKeysFlusher, eventsConfig.userKeysFlushIntervalSeconds, - eventsConfig.userKeysFlushIntervalSeconds, TimeUnit.SECONDS); - if (diagnosticAccumulator != null) { // note that we don't pass a diagnosticAccumulator if diagnosticOptOut was true - Runnable diagnosticsTrigger = new Runnable() { - public void run() { - postMessageAsync(MessageType.DIAGNOSTIC, null); - } + this.scheduler.scheduleAtFixedRate(userKeysFlusher, eventsConfig.userKeysFlushInterval.toMillis(), + eventsConfig.userKeysFlushInterval.toMillis(), TimeUnit.MILLISECONDS); + if (diagnosticAccumulator != null) { + Runnable diagnosticsTrigger = () -> { + postMessageAsync(MessageType.DIAGNOSTIC, null); }; - this.scheduler.scheduleAtFixedRate(diagnosticsTrigger, eventsConfig.diagnosticRecordingIntervalSeconds, - eventsConfig.diagnosticRecordingIntervalSeconds, TimeUnit.SECONDS); + this.scheduler.scheduleAtFixedRate(diagnosticsTrigger, eventsConfig.diagnosticRecordingInterval.toMillis(), + eventsConfig.diagnosticRecordingInterval.toMillis(), TimeUnit.MILLISECONDS); } } @@ -210,20 +208,20 @@ static final class EventDispatcher { private final OkHttpClient httpClient; private final List flushWorkers; private final AtomicInteger busyFlushWorkersCount; - private final Random random = new Random(); private final AtomicLong lastKnownPastTime = new AtomicLong(0); private final AtomicBoolean disabled = new AtomicBoolean(false); - private final DiagnosticAccumulator diagnosticAccumulator; + @VisibleForTesting final DiagnosticAccumulator diagnosticAccumulator; private final ExecutorService diagnosticExecutor; private final SendDiagnosticTaskFactory sendDiagnosticTaskFactory; private long deduplicatedUsers = 0; - private EventDispatcher(String sdkKey, LDConfig config, EventsConfiguration eventsConfig, HttpConfiguration httpConfig, + private EventDispatcher(String sdkKey, EventsConfiguration eventsConfig, HttpConfiguration httpConfig, final BlockingQueue inbox, ThreadFactory threadFactory, final AtomicBoolean closed, - DiagnosticAccumulator diagnosticAccumulator) { + DiagnosticAccumulator diagnosticAccumulator, + DiagnosticEvent.Init diagnosticInitEvent) { this.eventsConfig = eventsConfig; this.diagnosticAccumulator = diagnosticAccumulator; this.busyFlushWorkersCount = new AtomicInteger(0); @@ -236,13 +234,12 @@ private EventDispatcher(String sdkKey, LDConfig config, EventsConfiguration even // picked up by any worker, so if we try to push another one and are refused, it means // all the workers are busy. final BlockingQueue payloadQueue = new ArrayBlockingQueue<>(1); + final EventBuffer outbox = new EventBuffer(eventsConfig.capacity); final SimpleLRUCache userKeys = new SimpleLRUCache(eventsConfig.userKeysCapacity); - - Thread mainThread = threadFactory.newThread(new Runnable() { - public void run() { - runMainLoop(inbox, outbox, userKeys, payloadQueue); - } + + Thread mainThread = threadFactory.newThread(() -> { + runMainLoop(inbox, outbox, userKeys, payloadQueue); }); mainThread.setDaemon(true); @@ -267,11 +264,7 @@ public void uncaughtException(Thread t, Throwable e) { mainThread.start(); flushWorkers = new ArrayList<>(); - EventResponseListener listener = new EventResponseListener() { - public void handleResponse(Response response, Date responseDate) { - EventDispatcher.this.handleResponse(response, responseDate); - } - }; + EventResponseListener listener = this::handleResponse; for (int i = 0; i < MAX_FLUSH_THREADS; i++) { SendEventsTask task = new SendEventsTask(sdkKey, eventsConfig, httpClient, httpConfig, listener, payloadQueue, busyFlushWorkersCount, threadFactory); @@ -282,7 +275,6 @@ public void handleResponse(Response response, Date responseDate) { // Set up diagnostics this.sendDiagnosticTaskFactory = new SendDiagnosticTaskFactory(sdkKey, eventsConfig, httpClient, httpConfig); diagnosticExecutor = Executors.newSingleThreadExecutor(threadFactory); - DiagnosticEvent.Init diagnosticInitEvent = new DiagnosticEvent.Init(diagnosticAccumulator.dataSinceDate, diagnosticAccumulator.diagnosticId, config); diagnosticExecutor.submit(sendDiagnosticTaskFactory.createSendDiagnosticTask(diagnosticInitEvent)); } else { diagnosticExecutor = null; @@ -385,23 +377,22 @@ private void processEvent(Event e, SimpleLRUCache userKeys, Even Event debugEvent = null; if (e instanceof Event.FeatureRequest) { - if (shouldSampleEvent()) { - Event.FeatureRequest fe = (Event.FeatureRequest)e; - addFullEvent = fe.trackEvents; - if (shouldDebugEvent(fe)) { - debugEvent = EventFactory.DEFAULT.newDebugEvent(fe); - } + Event.FeatureRequest fe = (Event.FeatureRequest)e; + addFullEvent = fe.isTrackEvents(); + if (shouldDebugEvent(fe)) { + debugEvent = EventFactory.DEFAULT.newDebugEvent(fe); } } else { - addFullEvent = shouldSampleEvent(); + addFullEvent = true; } // For each user we haven't seen before, we add an index event - unless this is already // an identify event for that user. if (!addFullEvent || !eventsConfig.inlineUsersInEvents) { - if (e.user != null && e.user.getKey() != null) { + LDUser user = e.getUser(); + if (user != null && user.getKey() != null) { boolean isIndexEvent = e instanceof Event.Identify; - boolean alreadySeen = noticeUser(e.user, userKeys); + boolean alreadySeen = noticeUser(user, userKeys); addIndexEvent = !isIndexEvent & !alreadySeen; if (!isIndexEvent & alreadySeen) { deduplicatedUsers++; @@ -410,7 +401,7 @@ private void processEvent(Event e, SimpleLRUCache userKeys, Even } if (addIndexEvent) { - Event.Index ie = new Event.Index(e.creationDate, e.user); + Event.Index ie = new Event.Index(e.getCreationDate(), e.getUser()); outbox.add(ie); } if (addFullEvent) { @@ -426,23 +417,20 @@ private boolean noticeUser(LDUser user, SimpleLRUCache userKeys) if (user == null || user.getKey() == null) { return false; } - String key = user.getKeyAsString(); + String key = user.getKey(); return userKeys.put(key, key) != null; } - private boolean shouldSampleEvent() { - return eventsConfig.samplingInterval <= 0 || random.nextInt(eventsConfig.samplingInterval) == 0; - } - private boolean shouldDebugEvent(Event.FeatureRequest fe) { - if (fe.debugEventsUntilDate != null) { + long debugEventsUntilDate = fe.getDebugEventsUntilDate(); + if (debugEventsUntilDate > 0) { // The "last known past time" comes from the last HTTP response we got from the server. // In case the client's time is set wrong, at least we know that any expiration date // earlier than that point is definitely in the past. If there's any discrepancy, we // want to err on the side of cutting off event debugging sooner. long lastPast = lastKnownPastTime.get(); - if (fe.debugEventsUntilDate > lastPast && - fe.debugEventsUntilDate > System.currentTimeMillis()) { + if (debugEventsUntilDate > lastPast && + debugEventsUntilDate > System.currentTimeMillis()) { return true; } } @@ -500,7 +488,7 @@ private static void postJson(OkHttpClient httpClient, Headers headers, String js Request request = new Request.Builder() .url(uriStr) - .post(RequestBody.create(MediaType.parse("application/json; charset=utf-8"), json)) + .post(RequestBody.create(json, JSON_CONTENT_TYPE)) .headers(headers) .build(); diff --git a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java similarity index 62% rename from src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java rename to src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java index 017bcdc73..efcfd2d7a 100644 --- a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java @@ -1,9 +1,16 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Maps; import com.google.common.io.Files; -import com.launchdarkly.client.interfaces.HttpConfiguration; -import com.launchdarkly.client.interfaces.SerializationException; +import com.launchdarkly.sdk.server.DataModel.VersionedData; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; +import com.launchdarkly.sdk.server.interfaces.SerializationException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -11,14 +18,13 @@ import java.io.File; import java.io.IOException; import java.net.URI; -import java.util.HashMap; import java.util.Map; -import static com.launchdarkly.client.Util.configureHttpClientBuilder; -import static com.launchdarkly.client.Util.getHeadersBuilderFor; -import static com.launchdarkly.client.Util.shutdownHttpClient; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; -import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; +import static com.launchdarkly.sdk.server.Util.getHeadersBuilderFor; +import static com.launchdarkly.sdk.server.Util.shutdownHttpClient; import okhttp3.Cache; import okhttp3.Headers; @@ -64,26 +70,37 @@ public void close() { shutdownHttpClient(httpClient); } - public FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException, SerializationException { + public DataModel.FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException, SerializationException { String body = get(GET_LATEST_FLAGS_PATH + "/" + featureKey); - return JsonHelpers.deserialize(body, FeatureFlag.class); + return JsonHelpers.deserialize(body, DataModel.FeatureFlag.class); } - public Segment getSegment(String segmentKey) throws IOException, HttpErrorException { + public DataModel.Segment getSegment(String segmentKey) throws IOException, HttpErrorException, SerializationException { String body = get(GET_LATEST_SEGMENTS_PATH + "/" + segmentKey); - return JsonHelpers.deserialize(body, Segment.class); + return JsonHelpers.deserialize(body, DataModel.Segment.class); } - public AllData getAllData() throws IOException, HttpErrorException { + public AllData getAllData() throws IOException, HttpErrorException, SerializationException { String body = get(GET_LATEST_ALL_PATH); return JsonHelpers.deserialize(body, AllData.class); } + + static FullDataSet toFullDataSet(AllData allData) { + return new FullDataSet(ImmutableMap.of( + FEATURES, toKeyedItems(allData.flags), + SEGMENTS, toKeyedItems(allData.segments) + ).entrySet()); + } - static Map, Map> toVersionedDataMap(AllData allData) { - Map, Map> ret = new HashMap<>(); - ret.put(FEATURES, allData.flags); - ret.put(SEGMENTS, allData.segments); - return ret; + static KeyedItems toKeyedItems(Map itemsMap) { + if (itemsMap == null) { + return new KeyedItems<>(null); + } + return new KeyedItems<>( + ImmutableList.copyOf( + Maps.transformValues(itemsMap, item -> new ItemDescriptor(item.getVersion(), item)).entrySet() + ) + ); } private String get(String path) throws IOException, HttpErrorException { diff --git a/src/main/java/com/launchdarkly/client/DiagnosticAccumulator.java b/src/main/java/com/launchdarkly/sdk/server/DiagnosticAccumulator.java similarity index 97% rename from src/main/java/com/launchdarkly/client/DiagnosticAccumulator.java rename to src/main/java/com/launchdarkly/sdk/server/DiagnosticAccumulator.java index 22782294f..cea391e90 100644 --- a/src/main/java/com/launchdarkly/client/DiagnosticAccumulator.java +++ b/src/main/java/com/launchdarkly/sdk/server/DiagnosticAccumulator.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import java.util.ArrayList; import java.util.List; diff --git a/src/main/java/com/launchdarkly/client/DiagnosticEvent.java b/src/main/java/com/launchdarkly/sdk/server/DiagnosticEvent.java similarity index 91% rename from src/main/java/com/launchdarkly/client/DiagnosticEvent.java rename to src/main/java/com/launchdarkly/sdk/server/DiagnosticEvent.java index 4439f3261..4f2c8b887 100644 --- a/src/main/java/com/launchdarkly/client/DiagnosticEvent.java +++ b/src/main/java/com/launchdarkly/sdk/server/DiagnosticEvent.java @@ -1,9 +1,9 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.interfaces.DiagnosticDescription; -import com.launchdarkly.client.value.LDValue; -import com.launchdarkly.client.value.LDValueType; -import com.launchdarkly.client.value.ObjectBuilder; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.ObjectBuilder; +import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription; import java.util.List; @@ -86,28 +86,26 @@ static class Init extends DiagnosticEvent { this.configuration = getConfigurationData(config); } - @SuppressWarnings("deprecation") 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.getConnectTimeoutMillis()); - builder.put("socketTimeoutMillis", config.httpConfig.getSocketTimeoutMillis()); + builder.put("connectTimeoutMillis", config.httpConfig.getConnectTimeout().toMillis()); + builder.put("socketTimeoutMillis", config.httpConfig.getSocketTimeout().toMillis()); builder.put("usingProxy", config.httpConfig.getProxy() != null); builder.put("usingProxyAuthenticator", config.httpConfig.getProxyAuthentication() != null); builder.put("offline", config.offline); - builder.put("startWaitMillis", config.startWaitMillis); + builder.put("startWaitMillis", config.startWait.toMillis()); // Allow each pluggable component to describe its own relevant properties. - mergeComponentProperties(builder, config.deprecatedFeatureStore, config, "dataStoreType"); mergeComponentProperties(builder, config.dataStoreFactory == null ? Components.inMemoryDataStore() : config.dataStoreFactory, config, "dataStoreType"); mergeComponentProperties(builder, - config.dataSourceFactory == null ? Components.defaultUpdateProcessor() : config.dataSourceFactory, + config.dataSourceFactory == null ? Components.streamingDataSource() : config.dataSourceFactory, config, null); mergeComponentProperties(builder, - config.eventProcessorFactory == null ? Components.defaultEventProcessor() : config.eventProcessorFactory, + config.eventProcessorFactory == null ? Components.sendEvents() : config.eventProcessorFactory, config, null); return builder.build(); } diff --git a/src/main/java/com/launchdarkly/client/DiagnosticId.java b/src/main/java/com/launchdarkly/sdk/server/DiagnosticId.java similarity index 89% rename from src/main/java/com/launchdarkly/client/DiagnosticId.java rename to src/main/java/com/launchdarkly/sdk/server/DiagnosticId.java index 713aebe33..8601a9780 100644 --- a/src/main/java/com/launchdarkly/client/DiagnosticId.java +++ b/src/main/java/com/launchdarkly/sdk/server/DiagnosticId.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import java.util.UUID; diff --git a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java new file mode 100644 index 000000000..4e3136dd4 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java @@ -0,0 +1,326 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableList; +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.server.interfaces.Event; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; + +/** + * Encapsulates the feature flag evaluation logic. The Evaluator has no knowledge of the rest of the SDK environment; + * if it needs to retrieve flags or segments that are referenced by a flag, it does so through a read-only interface + * that is provided in the constructor. It also produces feature requests as appropriate for any referenced prerequisite + * flags, but does not send them. + */ +class Evaluator { + private final static Logger logger = LoggerFactory.getLogger(Evaluator.class); + + private final Getters getters; + + /** + * An abstraction of getting flags or segments by key. This ensures that Evaluator cannot modify the data store, + * and simplifies testing. + */ + static interface Getters { + DataModel.FeatureFlag getFlag(String key); + DataModel.Segment getSegment(String key); + } + + /** + * Internal container for the results of an evaluation. This consists of the same information that is in an + * {@link EvaluationDetail}, plus a list of any feature request events generated by prerequisite flags. + * + * Unlike all the other simple data containers in the SDK, this is mutable. The reason is that flag evaluations + * may be done very frequently and we would like to minimize the amount of heap churn from intermediate objects, + * and Java does not support multiple return values as Go does, or value types as C# does. + * + * We never expose an EvalResult to application code and we never preserve a reference to it outside of a single + * xxxVariation() or xxxVariationDetail() call, so the risks from mutability are minimal. The only setter method + * that is accessible from outside of the Evaluator class is setValue(), which is exposed so that LDClient can + * replace null values with default values, + */ + static class EvalResult { + private LDValue value = LDValue.ofNull(); + private int variationIndex = NO_VARIATION; + private EvaluationReason reason = null; + private List prerequisiteEvents; + + public EvalResult(LDValue value, int variationIndex, EvaluationReason reason) { + this.value = value; + this.variationIndex = variationIndex; + this.reason = reason; + } + + public static EvalResult error(EvaluationReason.ErrorKind errorKind) { + return new EvalResult(LDValue.ofNull(), NO_VARIATION, EvaluationReason.error(errorKind)); + } + + LDValue getValue() { + return LDValue.normalize(value); + } + + void setValue(LDValue value) { + this.value = value; + } + + int getVariationIndex() { + return variationIndex; + } + + boolean isDefault() { + return variationIndex < 0; + } + + EvaluationReason getReason() { + return reason; + } + + EvaluationDetail getDetails() { + return EvaluationDetail.fromValue(LDValue.normalize(value), variationIndex, reason); + } + + Iterable getPrerequisiteEvents() { + return prerequisiteEvents == null ? ImmutableList.of() : prerequisiteEvents; + } + + private void setPrerequisiteEvents(List prerequisiteEvents) { + this.prerequisiteEvents = prerequisiteEvents; + } + } + + Evaluator(Getters getters) { + this.getters = getters; + } + + /** + * The client's entry point for evaluating a flag. No other Evaluator methods should be exposed. + * + * @param flag an existing feature flag; any other referenced flags or segments will be queried via {@link Getters} + * @param user the user to evaluate against + * @param eventFactory produces feature request events + * @return an {@link EvalResult} + */ + EvalResult evaluate(DataModel.FeatureFlag flag, LDUser user, EventFactory eventFactory) { + if (user == null || user.getKey() == null) { + // this should have been prevented by LDClient.evaluateInternal + logger.warn("Null user or null user key when evaluating flag \"{}\"; returning null", flag.getKey()); + return new EvalResult(null, NO_VARIATION, EvaluationReason.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED)); + } + + // If the flag doesn't have any prerequisites (which most flags don't) then it cannot generate any feature + // request events for prerequisites and we can skip allocating a List. + List prerequisiteEvents = flag.getPrerequisites().isEmpty() ? + null : new ArrayList(); // note, getPrerequisites() is guaranteed non-null + EvalResult result = evaluateInternal(flag, user, eventFactory, prerequisiteEvents); + if (prerequisiteEvents != null) { + result.setPrerequisiteEvents(prerequisiteEvents); + } + return result; + } + + private EvalResult evaluateInternal(DataModel.FeatureFlag flag, LDUser user, EventFactory eventFactory, + List eventsOut) { + if (!flag.isOn()) { + return getOffValue(flag, EvaluationReason.off()); + } + + EvaluationReason prereqFailureReason = checkPrerequisites(flag, user, eventFactory, eventsOut); + if (prereqFailureReason != null) { + return getOffValue(flag, prereqFailureReason); + } + + // Check to see if targets match + for (DataModel.Target target: flag.getTargets()) { // getTargets() and getValues() are guaranteed non-null + if (target.getValues().contains(user.getKey())) { + return getVariation(flag, target.getVariation(), EvaluationReason.targetMatch()); + } + } + // Now walk through the rules and see if any match + List rules = flag.getRules(); // guaranteed non-null + for (int i = 0; i < rules.size(); i++) { + DataModel.Rule rule = rules.get(i); + if (ruleMatchesUser(flag, rule, user)) { + EvaluationReason precomputedReason = rule.getRuleMatchReason(); + EvaluationReason reason = precomputedReason != null ? precomputedReason : EvaluationReason.ruleMatch(i, rule.getId()); + return getValueForVariationOrRollout(flag, rule, user, reason); + } + } + // Walk through the fallthrough and see if it matches + return getValueForVariationOrRollout(flag, flag.getFallthrough(), user, EvaluationReason.fallthrough()); + } + + // Checks prerequisites if any; returns null if successful, or an EvaluationReason if we have to + // short-circuit due to a prerequisite failure. + private EvaluationReason checkPrerequisites(DataModel.FeatureFlag flag, LDUser user, EventFactory eventFactory, + List eventsOut) { + for (DataModel.Prerequisite prereq: flag.getPrerequisites()) { // getPrerequisites() is guaranteed non-null + boolean prereqOk = true; + DataModel.FeatureFlag prereqFeatureFlag = getters.getFlag(prereq.getKey()); + if (prereqFeatureFlag == null) { + logger.error("Could not retrieve prerequisite flag \"{}\" when evaluating \"{}\"", prereq.getKey(), flag.getKey()); + prereqOk = false; + } else { + EvalResult prereqEvalResult = evaluateInternal(prereqFeatureFlag, user, eventFactory, eventsOut); + // Note that if the prerequisite flag is off, we don't consider it a match no matter what its + // off variation was. But we still need to evaluate it in order to generate an event. + if (!prereqFeatureFlag.isOn() || prereqEvalResult == null || prereqEvalResult.getVariationIndex() != prereq.getVariation()) { + prereqOk = false; + } + if (eventsOut != null) { + eventsOut.add(eventFactory.newPrerequisiteFeatureRequestEvent(prereqFeatureFlag, user, prereqEvalResult, flag)); + } + } + if (!prereqOk) { + EvaluationReason precomputedReason = prereq.getPrerequisiteFailedReason(); + return precomputedReason != null ? precomputedReason : EvaluationReason.prerequisiteFailed(prereq.getKey()); + } + } + return null; + } + + private EvalResult getVariation(DataModel.FeatureFlag flag, int variation, EvaluationReason reason) { + List variations = flag.getVariations(); + if (variation < 0 || variation >= variations.size()) { + logger.error("Data inconsistency in feature flag \"{}\": invalid variation index", flag.getKey()); + return EvalResult.error(EvaluationReason.ErrorKind.MALFORMED_FLAG); + } else { + return new EvalResult(variations.get(variation), variation, reason); + } + } + + private EvalResult getOffValue(DataModel.FeatureFlag flag, EvaluationReason reason) { + Integer offVariation = flag.getOffVariation(); + if (offVariation == null) { // off variation unspecified - return default value + return new EvalResult(null, NO_VARIATION, reason); + } else { + return getVariation(flag, offVariation, reason); + } + } + + private EvalResult getValueForVariationOrRollout(DataModel.FeatureFlag flag, DataModel.VariationOrRollout vr, LDUser user, EvaluationReason reason) { + Integer index = EvaluatorBucketing.variationIndexForUser(vr, user, flag.getKey(), flag.getSalt()); + if (index == null) { + logger.error("Data inconsistency in feature flag \"{}\": variation/rollout object with no variation or rollout", flag.getKey()); + return EvalResult.error(EvaluationReason.ErrorKind.MALFORMED_FLAG); + } else { + return getVariation(flag, index, reason); + } + } + + private boolean ruleMatchesUser(DataModel.FeatureFlag flag, DataModel.Rule rule, LDUser user) { + for (DataModel.Clause clause: rule.getClauses()) { // getClauses() is guaranteed non-null + if (!clauseMatchesUser(clause, user)) { + return false; + } + } + return true; + } + + private boolean clauseMatchesUser(DataModel.Clause clause, LDUser user) { + // In the case of a segment match operator, we check if the user is in any of the segments, + // and possibly negate + if (clause.getOp() == DataModel.Operator.segmentMatch) { + for (LDValue j: clause.getValues()) { + if (j.isString()) { + DataModel.Segment segment = getters.getSegment(j.stringValue()); + if (segment != null) { + if (segmentMatchesUser(segment, user)) { + return maybeNegate(clause, true); + } + } + } + } + return maybeNegate(clause, false); + } + + return clauseMatchesUserNoSegments(clause, user); + } + + private boolean clauseMatchesUserNoSegments(DataModel.Clause clause, LDUser user) { + LDValue userValue = user.getAttribute(clause.getAttribute()); + if (userValue.isNull()) { + return false; + } + + if (userValue.getType() == LDValueType.ARRAY) { + for (LDValue value: userValue.values()) { + if (value.getType() == LDValueType.ARRAY || value.getType() == LDValueType.OBJECT) { + logger.error("Invalid custom attribute value in user object for user key \"{}\": {}", user.getKey(), value); + return false; + } + if (clauseMatchAny(clause, value)) { + return maybeNegate(clause, true); + } + } + return maybeNegate(clause, false); + } else if (userValue.getType() != LDValueType.OBJECT) { + return maybeNegate(clause, clauseMatchAny(clause, userValue)); + } + logger.warn("Got unexpected user attribute type \"{}\" for user key \"{}\" and attribute \"{}\"", + userValue.getType(), user.getKey(), clause.getAttribute()); + return false; + } + + private boolean clauseMatchAny(DataModel.Clause clause, LDValue userValue) { + DataModel.Operator op = clause.getOp(); + if (op != null) { + for (LDValue v : clause.getValues()) { + if (EvaluatorOperators.apply(op, userValue, v)) { + return true; + } + } + } + return false; + } + + private boolean maybeNegate(DataModel.Clause clause, boolean b) { + return clause.isNegate() ? !b : b; + } + + private boolean segmentMatchesUser(DataModel.Segment segment, LDUser user) { + String userKey = user.getKey(); + if (userKey == null) { + return false; + } + if (segment.getIncluded().contains(userKey)) { // getIncluded(), getExcluded(), and getRules() are guaranteed non-null + return true; + } + if (segment.getExcluded().contains(userKey)) { + return false; + } + for (DataModel.SegmentRule rule: segment.getRules()) { + if (segmentRuleMatchesUser(rule, user, segment.getKey(), segment.getSalt())) { + return true; + } + } + return false; + } + + private boolean segmentRuleMatchesUser(DataModel.SegmentRule segmentRule, LDUser user, String segmentKey, String salt) { + for (DataModel.Clause c: segmentRule.getClauses()) { + if (!clauseMatchesUserNoSegments(c, user)) { + return false; + } + } + + // If the Weight is absent, this rule matches + if (segmentRule.getWeight() == null) { + return true; + } + + // All of the clauses are met. See if the user buckets in + double bucket = EvaluatorBucketing.bucketUser(user, segmentKey, segmentRule.getBucketBy(), salt); + double weight = (double)segmentRule.getWeight() / 100000.0; + return bucket < weight; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java new file mode 100644 index 000000000..f45425e2b --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java @@ -0,0 +1,67 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; + +import org.apache.commons.codec.digest.DigestUtils; + +/** + * Encapsulates the logic for percentage rollouts. + */ +abstract class EvaluatorBucketing { + private static final float LONG_SCALE = (float) 0xFFFFFFFFFFFFFFFL; + + // Attempt to determine the variation index for a given user. Returns null if no index can be computed + // due to internal inconsistency of the data (i.e. a malformed flag). + static Integer variationIndexForUser(DataModel.VariationOrRollout vr, LDUser user, String key, String salt) { + Integer variation = vr.getVariation(); + if (variation != null) { + return variation; + } else { + DataModel.Rollout rollout = vr.getRollout(); + if (rollout != null && rollout.getVariations() != null && !rollout.getVariations().isEmpty()) { + float bucket = bucketUser(user, key, rollout.getBucketBy(), salt); + float sum = 0F; + for (DataModel.WeightedVariation wv : rollout.getVariations()) { + sum += (float) wv.getWeight() / 100000F; + if (bucket < sum) { + return wv.getVariation(); + } + } + // The user's bucket value was greater than or equal to the end of the last bucket. This could happen due + // to a rounding error, or due to the fact that we are scaling to 100000 rather than 99999, or the flag + // data could contain buckets that don't actually add up to 100000. Rather than returning an error in + // this case (or changing the scaling, which would potentially change the results for *all* users), we + // will simply put the user in the last bucket. + return rollout.getVariations().get(rollout.getVariations().size() - 1).getVariation(); + } + } + return null; + } + + static float bucketUser(LDUser user, String key, UserAttribute attr, String salt) { + LDValue userValue = user.getAttribute(attr == null ? UserAttribute.KEY : attr); + String idHash = getBucketableStringValue(userValue); + if (idHash != null) { + if (user.getSecondary() != null) { + idHash = idHash + "." + user.getSecondary(); + } + String hash = DigestUtils.sha1Hex(key + "." + salt + "." + idHash).substring(0, 15); + long longVal = Long.parseLong(hash, 16); + return (float) longVal / LONG_SCALE; + } + return 0F; + } + + private static String getBucketableStringValue(LDValue userValue) { + switch (userValue.getType()) { + case STRING: + return userValue.stringValue(); + case NUMBER: + return userValue.isInt() ? String.valueOf(userValue.intValue()) : null; + default: + return null; + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/EvaluatorOperators.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorOperators.java new file mode 100644 index 000000000..75d02ae52 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorOperators.java @@ -0,0 +1,143 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.LDValue; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.regex.Pattern; + +/** + * Defines the behavior of all operators that can be used in feature flag rules and segment rules. + */ +abstract class EvaluatorOperators { + private static enum ComparisonOp { + EQ, + LT, + LTE, + GT, + GTE; + + boolean test(int delta) { + switch (this) { + case EQ: + return delta == 0; + case LT: + return delta < 0; + case LTE: + return delta <= 0; + case GT: + return delta > 0; + case GTE: + return delta >= 0; + } + return false; + } + } + + static boolean apply(DataModel.Operator op, LDValue userValue, LDValue clauseValue) { + switch (op) { + case in: + return userValue.equals(clauseValue); + + case endsWith: + return userValue.isString() && clauseValue.isString() && userValue.stringValue().endsWith(clauseValue.stringValue()); + + case startsWith: + return userValue.isString() && clauseValue.isString() && userValue.stringValue().startsWith(clauseValue.stringValue()); + + case matches: + return userValue.isString() && clauseValue.isString() && + Pattern.compile(clauseValue.stringValue()).matcher(userValue.stringValue()).find(); + + case contains: + return userValue.isString() && clauseValue.isString() && userValue.stringValue().contains(clauseValue.stringValue()); + + case lessThan: + return compareNumeric(ComparisonOp.LT, userValue, clauseValue); + + case lessThanOrEqual: + return compareNumeric(ComparisonOp.LTE, userValue, clauseValue); + + case greaterThan: + return compareNumeric(ComparisonOp.GT, userValue, clauseValue); + + case greaterThanOrEqual: + return compareNumeric(ComparisonOp.GTE, userValue, clauseValue); + + case before: + return compareDate(ComparisonOp.LT, userValue, clauseValue); + + case after: + return compareDate(ComparisonOp.GT, userValue, clauseValue); + + case semVerEqual: + return compareSemVer(ComparisonOp.EQ, userValue, clauseValue); + + case semVerLessThan: + return compareSemVer(ComparisonOp.LT, userValue, clauseValue); + + case semVerGreaterThan: + return compareSemVer(ComparisonOp.GT, userValue, clauseValue); + + case segmentMatch: + // We shouldn't call apply() for this operator, because it is really implemented in + // Evaluator.clauseMatchesUser(). + return false; + }; + return false; + } + + private static boolean compareNumeric(ComparisonOp op, LDValue userValue, LDValue clauseValue) { + if (!userValue.isNumber() || !clauseValue.isNumber()) { + return false; + } + double n1 = userValue.doubleValue(); + double n2 = clauseValue.doubleValue(); + int compare = n1 == n2 ? 0 : (n1 < n2 ? -1 : 1); + return op.test(compare); + } + + private static boolean compareDate(ComparisonOp op, LDValue userValue, LDValue clauseValue) { + ZonedDateTime dt1 = valueToDateTime(userValue); + ZonedDateTime dt2 = valueToDateTime(clauseValue); + if (dt1 == null || dt2 == null) { + return false; + } + return op.test(dt1.compareTo(dt2)); + } + + private static boolean compareSemVer(ComparisonOp op, LDValue userValue, LDValue clauseValue) { + SemanticVersion sv1 = valueToSemVer(userValue); + SemanticVersion sv2 = valueToSemVer(clauseValue); + if (sv1 == null || sv2 == null) { + return false; + } + return op.test(sv1.compareTo(sv2)); + } + + private static ZonedDateTime valueToDateTime(LDValue value) { + if (value.isNumber()) { + return ZonedDateTime.ofInstant(Instant.ofEpochMilli(value.longValue()), ZoneOffset.UTC); + } else if (value.isString()) { + try { + return ZonedDateTime.parse(value.stringValue()); + } catch (Throwable t) { + return null; + } + } else { + return null; + } + } + + private static SemanticVersion valueToSemVer(LDValue value) { + if (!value.isString()) { + return null; + } + try { + return SemanticVersion.parse(value.stringValue(), true); + } catch (SemanticVersion.InvalidVersionException e) { + return null; + } + } +} diff --git a/src/main/java/com/launchdarkly/client/EventFactory.java b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java similarity index 52% rename from src/main/java/com/launchdarkly/client/EventFactory.java rename to src/main/java/com/launchdarkly/sdk/server/EventFactory.java index 4afc7240f..39a9e1346 100644 --- a/src/main/java/com/launchdarkly/client/EventFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java @@ -1,6 +1,9 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.Event; abstract class EventFactory { public static final EventFactory DEFAULT = new DefaultEventFactory(false); @@ -9,8 +12,8 @@ abstract class EventFactory { protected abstract long getTimestamp(); protected abstract boolean isIncludeReasons(); - public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user, LDValue value, - Integer variationIndex, EvaluationReason reason, LDValue defaultValue, String prereqOf) { + public Event.FeatureRequest newFeatureRequestEvent(DataModel.FeatureFlag flag, LDUser user, LDValue value, + int variationIndex, EvaluationReason reason, LDValue defaultValue, String prereqOf) { boolean requireExperimentData = isExperiment(flag, reason); return new Event.FeatureRequest( getTimestamp(), @@ -23,40 +26,70 @@ public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user (requireExperimentData || isIncludeReasons()) ? reason : null, prereqOf, requireExperimentData || flag.isTrackEvents(), - flag.getDebugEventsUntilDate(), + flag.getDebugEventsUntilDate() == null ? 0 : flag.getDebugEventsUntilDate().longValue(), false ); } - public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user, EvaluationDetail result, LDValue defaultVal) { + public Event.FeatureRequest newFeatureRequestEvent(DataModel.FeatureFlag flag, LDUser user, Evaluator.EvalResult result, LDValue defaultVal) { return newFeatureRequestEvent(flag, user, result == null ? null : result.getValue(), - result == null ? null : result.getVariationIndex(), result == null ? null : result.getReason(), + result == null ? -1 : result.getVariationIndex(), result == null ? null : result.getReason(), defaultVal, null); } - public Event.FeatureRequest newDefaultFeatureRequestEvent(FeatureFlag flag, LDUser user, LDValue defaultValue, + public Event.FeatureRequest newDefaultFeatureRequestEvent(DataModel.FeatureFlag flag, LDUser user, LDValue defaultValue, EvaluationReason.ErrorKind errorKind) { - return new Event.FeatureRequest(getTimestamp(), flag.getKey(), user, flag.getVersion(), - null, defaultValue, defaultValue, isIncludeReasons() ? EvaluationReason.error(errorKind) : null, - null, flag.isTrackEvents(), flag.getDebugEventsUntilDate(), false); + return new Event.FeatureRequest( + getTimestamp(), + flag.getKey(), + user, + flag.getVersion(), + -1, + defaultValue, + defaultValue, + isIncludeReasons() ? EvaluationReason.error(errorKind) : null, + null, + flag.isTrackEvents(), + flag.getDebugEventsUntilDate() == null ? 0 : flag.getDebugEventsUntilDate().longValue(), + false + ); } public Event.FeatureRequest newUnknownFeatureRequestEvent(String key, LDUser user, LDValue defaultValue, EvaluationReason.ErrorKind errorKind) { - return new Event.FeatureRequest(getTimestamp(), key, user, null, null, defaultValue, defaultValue, - isIncludeReasons() ? EvaluationReason.error(errorKind) : null, null, false, null, false); + return new Event.FeatureRequest( + getTimestamp(), + key, + user, + -1, + -1, + defaultValue, + defaultValue, + isIncludeReasons() ? EvaluationReason.error(errorKind) : null, + null, + false, + 0, + false + ); } - public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(FeatureFlag prereqFlag, LDUser user, EvaluationDetail result, - FeatureFlag prereqOf) { - return newFeatureRequestEvent(prereqFlag, user, result == null ? null : result.getValue(), - result == null ? null : result.getVariationIndex(), result == null ? null : result.getReason(), - LDValue.ofNull(), prereqOf.getKey()); + public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(DataModel.FeatureFlag prereqFlag, LDUser user, + Evaluator.EvalResult details, DataModel.FeatureFlag prereqOf) { + return newFeatureRequestEvent( + prereqFlag, + user, + details == null ? null : details.getValue(), + details == null ? -1 : details.getVariationIndex(), + details == null ? null : details.getReason(), + LDValue.ofNull(), + prereqOf.getKey() + ); } public Event.FeatureRequest newDebugEvent(Event.FeatureRequest from) { - return new Event.FeatureRequest(from.creationDate, from.key, from.user, from.version, from.variation, from.value, - from.defaultVal, from.reason, from.prereqOf, from.trackEvents, from.debugEventsUntilDate, true); + return new Event.FeatureRequest( + from.getCreationDate(), from.getKey(), from.getUser(), from.getVersion(), from.getVariation(), from.getValue(), + from.getDefaultVal(), from.getReason(), from.getPrereqOf(), from.isTrackEvents(), from.getDebugEventsUntilDate(), true); } public Event.Custom newCustomEvent(String key, LDUser user, LDValue data, Double metricValue) { @@ -67,7 +100,7 @@ public Event.Identify newIdentifyEvent(LDUser user) { return new Event.Identify(getTimestamp(), user); } - private boolean isExperiment(FeatureFlag flag, EvaluationReason reason) { + private boolean isExperiment(DataModel.FeatureFlag flag, EvaluationReason reason) { if (reason == null) { // doesn't happen in real life, but possible in testing return false; @@ -76,17 +109,12 @@ private boolean isExperiment(FeatureFlag flag, EvaluationReason reason) { case FALLTHROUGH: return flag.isTrackEventsFallthrough(); case RULE_MATCH: - if (!(reason instanceof EvaluationReason.RuleMatch)) { - // shouldn't be possible - return false; - } - EvaluationReason.RuleMatch rm = (EvaluationReason.RuleMatch)reason; - int ruleIndex = rm.getRuleIndex(); + int ruleIndex = reason.getRuleIndex(); // Note, it is OK to rely on the rule index rather than the unique ID in this context, because the // FeatureFlag that is passed to us here *is* necessarily the same version of the flag that was just // evaluated, so we cannot be out of sync with its rule list. if (ruleIndex >= 0 && ruleIndex < flag.getRules().size()) { - Rule rule = flag.getRules().get(ruleIndex); + DataModel.Rule rule = flag.getRules().get(ruleIndex); return rule.isTrackEvents(); } return false; diff --git a/src/main/java/com/launchdarkly/client/EventOutputFormatter.java b/src/main/java/com/launchdarkly/sdk/server/EventOutputFormatter.java similarity index 80% rename from src/main/java/com/launchdarkly/client/EventOutputFormatter.java rename to src/main/java/com/launchdarkly/sdk/server/EventOutputFormatter.java index 03ba5a12c..5984cb2a0 100644 --- a/src/main/java/com/launchdarkly/client/EventOutputFormatter.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventOutputFormatter.java @@ -1,10 +1,13 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.gson.Gson; import com.google.gson.stream.JsonWriter; -import com.launchdarkly.client.EventSummarizer.CounterKey; -import com.launchdarkly.client.EventSummarizer.CounterValue; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.EventSummarizer.CounterKey; +import com.launchdarkly.sdk.server.EventSummarizer.CounterValue; +import com.launchdarkly.sdk.server.interfaces.Event; import java.io.IOException; import java.io.Writer; @@ -44,41 +47,41 @@ int writeOutputEvents(Event[] events, EventSummarizer.EventSummary summary, Writ private boolean writeOutputEvent(Event event, JsonWriter jw) throws IOException { if (event instanceof Event.FeatureRequest) { Event.FeatureRequest fe = (Event.FeatureRequest)event; - startEvent(fe, fe.debug ? "debug" : "feature", fe.key, jw); - writeUserOrKey(fe, fe.debug, jw); - if (fe.version != null) { + startEvent(fe, fe.isDebug() ? "debug" : "feature", fe.getKey(), jw); + writeUserOrKey(fe, fe.isDebug(), jw); + if (fe.getVersion() >= 0) { jw.name("version"); - jw.value(fe.version); + jw.value(fe.getVersion()); } - if (fe.variation != null) { + if (fe.getVariation() >= 0) { jw.name("variation"); - jw.value(fe.variation); + jw.value(fe.getVariation()); } - writeLDValue("value", fe.value, jw); - writeLDValue("default", fe.defaultVal, jw); - if (fe.prereqOf != null) { + writeLDValue("value", fe.getValue(), jw); + writeLDValue("default", fe.getDefaultVal(), jw); + if (fe.getPrereqOf() != null) { jw.name("prereqOf"); - jw.value(fe.prereqOf); + jw.value(fe.getPrereqOf()); } - writeEvaluationReason("reason", fe.reason, jw); + writeEvaluationReason("reason", fe.getReason(), jw); jw.endObject(); } else if (event instanceof Event.Identify) { - startEvent(event, "identify", event.user == null ? null : event.user.getKeyAsString(), jw); - writeUser(event.user, jw); + startEvent(event, "identify", event.getUser() == null ? null : event.getUser().getKey(), jw); + writeUser(event.getUser(), jw); jw.endObject(); } else if (event instanceof Event.Custom) { Event.Custom ce = (Event.Custom)event; - startEvent(event, "custom", ce.key, jw); + startEvent(event, "custom", ce.getKey(), jw); writeUserOrKey(ce, false, jw); - writeLDValue("data", ce.data, jw); - if (ce.metricValue != null) { + writeLDValue("data", ce.getData(), jw); + if (ce.getMetricValue() != null) { jw.name("metricValue"); - jw.value(ce.metricValue); + jw.value(ce.getMetricValue()); } jw.endObject(); } else if (event instanceof Event.Index) { startEvent(event, "index", null, jw); - writeUser(event.user, jw); + writeUser(event.getUser(), jw); jw.endObject(); } else { return false; @@ -127,11 +130,11 @@ private void writeSummaryEvent(EventSummarizer.EventSummary summary, JsonWriter jw.beginObject(); - if (keyForThisFlag.variation != null) { + if (keyForThisFlag.variation >= 0) { jw.name("variation"); jw.value(keyForThisFlag.variation); } - if (keyForThisFlag.version != null) { + if (keyForThisFlag.version >= 0) { jw.name("version"); jw.value(keyForThisFlag.version); } else { @@ -160,7 +163,7 @@ private void startEvent(Event event, String kind, String key, JsonWriter jw) thr jw.name("kind"); jw.value(kind); jw.name("creationDate"); - jw.value(event.creationDate); + jw.value(event.getCreationDate()); if (key != null) { jw.name("key"); jw.value(key); @@ -168,13 +171,13 @@ private void startEvent(Event event, String kind, String key, JsonWriter jw) thr } private void writeUserOrKey(Event event, boolean forceInline, JsonWriter jw) throws IOException { - LDUser user = event.user; + LDUser user = event.getUser(); if (user != null) { if (config.inlineUsersInEvents || forceInline) { writeUser(user, jw); } else { jw.name("userKey"); - jw.value(user.getKeyAsString()); + jw.value(user.getKey()); } } } diff --git a/src/main/java/com/launchdarkly/client/EventSummarizer.java b/src/main/java/com/launchdarkly/sdk/server/EventSummarizer.java similarity index 83% rename from src/main/java/com/launchdarkly/client/EventSummarizer.java rename to src/main/java/com/launchdarkly/sdk/server/EventSummarizer.java index b21c0d870..eaa3bd583 100644 --- a/src/main/java/com/launchdarkly/client/EventSummarizer.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventSummarizer.java @@ -1,6 +1,7 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.Event; import java.util.HashMap; import java.util.Map; @@ -25,8 +26,8 @@ final class EventSummarizer { void summarizeEvent(Event event) { if (event instanceof Event.FeatureRequest) { Event.FeatureRequest fe = (Event.FeatureRequest)event; - eventsState.incrementCounter(fe.key, fe.variation, fe.version, fe.value, fe.defaultVal); - eventsState.noteTimestamp(fe.creationDate); + eventsState.incrementCounter(fe.getKey(), fe.getVariation(), fe.getVersion(), fe.getValue(), fe.getDefaultVal()); + eventsState.noteTimestamp(fe.getCreationDate()); } } @@ -64,7 +65,7 @@ boolean isEmpty() { return counters.isEmpty(); } - void incrementCounter(String flagKey, Integer variation, Integer version, LDValue flagValue, LDValue defaultVal) { + void incrementCounter(String flagKey, int variation, int version, LDValue flagValue, LDValue defaultVal) { CounterKey key = new CounterKey(flagKey, variation, version); CounterValue value = counters.get(key); @@ -101,10 +102,10 @@ public int hashCode() { static final class CounterKey { final String key; - final Integer variation; - final Integer version; + final int variation; + final int version; - CounterKey(String key, Integer variation, Integer version) { + CounterKey(String key, int variation, int version) { this.key = key; this.variation = variation; this.version = version; @@ -114,15 +115,15 @@ static final class CounterKey { public boolean equals(Object other) { if (other instanceof CounterKey) { CounterKey o = (CounterKey)other; - return o.key.equals(this.key) && Objects.equals(o.variation, this.variation) && - Objects.equals(o.version, this.version); + return o.key.equals(this.key) && o.variation == this.variation && + o.version == this.version; } return false; } @Override public int hashCode() { - return key.hashCode() + 31 * (Objects.hashCode(variation) + 31 * Objects.hashCode(version)); + return key.hashCode() + 31 * (variation + 31 * version); } @Override diff --git a/src/main/java/com/launchdarkly/sdk/server/EventUserSerialization.java b/src/main/java/com/launchdarkly/sdk/server/EventUserSerialization.java new file mode 100644 index 000000000..5b49a5b47 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/EventUserSerialization.java @@ -0,0 +1,111 @@ +package com.launchdarkly.sdk.server; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; + +import java.io.IOException; +import java.util.Set; +import java.util.TreeSet; + +class EventUserSerialization { + + // Used internally when including users in analytics events, to ensure that private attributes are stripped out. + static class UserAdapterWithPrivateAttributeBehavior extends TypeAdapter { + private static final UserAttribute[] BUILT_IN_OPTIONAL_STRING_ATTRIBUTES = new UserAttribute[] { + UserAttribute.SECONDARY_KEY, + UserAttribute.IP, + UserAttribute.EMAIL, + UserAttribute.NAME, + UserAttribute.AVATAR, + UserAttribute.FIRST_NAME, + UserAttribute.LAST_NAME, + UserAttribute.COUNTRY + }; + + private final EventsConfiguration config; + + public UserAdapterWithPrivateAttributeBehavior(EventsConfiguration config) { + this.config = config; + } + + @Override + public void write(JsonWriter out, LDUser user) throws IOException { + if (user == null) { + out.value((String)null); + return; + } + + // Collect the private attribute names (use TreeSet to make ordering predictable for tests) + Set privateAttributeNames = new TreeSet(); + + out.beginObject(); + // The key can never be private + out.name("key").value(user.getKey()); + + for (UserAttribute attr: BUILT_IN_OPTIONAL_STRING_ATTRIBUTES) { + LDValue value = user.getAttribute(attr); + if (!value.isNull()) { + if (!checkAndAddPrivate(attr, user, privateAttributeNames)) { + out.name(attr.getName()).value(value.stringValue()); + } + } + } + if (!user.getAttribute(UserAttribute.ANONYMOUS).isNull()) { + out.name("anonymous").value(user.isAnonymous()); + } + writeCustomAttrs(out, user, privateAttributeNames); + writePrivateAttrNames(out, privateAttributeNames); + + out.endObject(); + } + + private void writePrivateAttrNames(JsonWriter out, Set names) throws IOException { + if (names.isEmpty()) { + return; + } + out.name("privateAttrs"); + out.beginArray(); + for (String name : names) { + out.value(name); + } + out.endArray(); + } + + private boolean checkAndAddPrivate(UserAttribute attribute, LDUser user, Set privateAttrs) { + boolean result = config.allAttributesPrivate || config.privateAttributes.contains(attribute) || user.isAttributePrivate(attribute); + if (result) { + privateAttrs.add(attribute.getName()); + } + return result; + } + + private void writeCustomAttrs(JsonWriter out, LDUser user, Set privateAttributeNames) throws IOException { + boolean beganObject = false; + for (UserAttribute attribute: user.getCustomAttributes()) { + if (!checkAndAddPrivate(attribute, user, privateAttributeNames)) { + if (!beganObject) { + out.name("custom"); + out.beginObject(); + beganObject = true; + } + out.name(attribute.getName()); + LDValue value = user.getAttribute(attribute); + JsonHelpers.gsonInstance().toJson(value, LDValue.class, out); + } + } + if (beganObject) { + out.endObject(); + } + } + + @Override + public LDUser read(JsonReader in) throws IOException { + // We never need to unmarshal user objects, so there's no need to implement this + return null; + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/EventsConfiguration.java b/src/main/java/com/launchdarkly/sdk/server/EventsConfiguration.java new file mode 100644 index 000000000..92acfb4b0 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/EventsConfiguration.java @@ -0,0 +1,38 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableSet; +import com.launchdarkly.sdk.UserAttribute; + +import java.net.URI; +import java.time.Duration; +import java.util.Set; + +// Used internally to encapsulate the various config/builder properties for events. +final class EventsConfiguration { + final boolean allAttributesPrivate; + final int capacity; + final URI eventsUri; + final Duration flushInterval; + final boolean inlineUsersInEvents; + final ImmutableSet privateAttributes; + final int samplingInterval; + final int userKeysCapacity; + final Duration userKeysFlushInterval; + final Duration diagnosticRecordingInterval; + + EventsConfiguration(boolean allAttributesPrivate, int capacity, URI eventsUri, Duration flushInterval, + boolean inlineUsersInEvents, Set privateAttributes, int samplingInterval, + int userKeysCapacity, Duration userKeysFlushInterval, Duration diagnosticRecordingInterval) { + super(); + this.allAttributesPrivate = allAttributesPrivate; + this.capacity = capacity; + this.eventsUri = eventsUri == null ? LDConfig.DEFAULT_EVENTS_URI : eventsUri; + this.flushInterval = flushInterval; + this.inlineUsersInEvents = inlineUsersInEvents; + this.privateAttributes = privateAttributes == null ? ImmutableSet.of() : ImmutableSet.copyOf(privateAttributes); + this.samplingInterval = samplingInterval; + this.userKeysCapacity = userKeysCapacity; + this.userKeysFlushInterval = userKeysFlushInterval; + this.diagnosticRecordingInterval = diagnosticRecordingInterval; + } +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java similarity index 71% rename from src/main/java/com/launchdarkly/client/FeatureFlagsState.java rename to src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java index 0b9577496..af9c4ccb5 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java @@ -1,33 +1,41 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.google.common.base.Objects; -import com.google.gson.Gson; -import com.google.gson.JsonElement; import com.google.gson.TypeAdapter; import com.google.gson.annotations.JsonAdapter; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.json.JsonSerializable; import java.io.IOException; import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.Objects; + +import static com.launchdarkly.sdk.server.JsonHelpers.gsonInstance; /** * A snapshot of the state of all feature flags with regard to a specific user, generated by - * calling {@link LDClientInterface#allFlagsState(LDUser, FlagsStateOption...)}. + * calling {@link LDClientInterface#allFlagsState(com.launchdarkly.sdk.LDUser, FlagsStateOption...)}. *

- * Serializing this object to JSON using Gson will produce the appropriate data structure for - * bootstrapping the LaunchDarkly JavaScript client. + * LaunchDarkly defines a standard JSON encoding for this object, suitable for + * bootstrapping + * the LaunchDarkly JavaScript browser SDK. You can convert it to JSON in any of these ways: + *

    + *
  1. With {@link com.launchdarkly.sdk.json.JsonSerialization}. + *
  2. With Gson, if and only if you configure your {@code Gson} instance with + * {@link com.launchdarkly.sdk.json.LDGson}. + *
  3. With Jackson, if and only if you configure your {@code ObjectMapper} instance with + * {@link com.launchdarkly.sdk.json.LDJackson}. + *
* * @since 4.3.0 */ @JsonAdapter(FeatureFlagsState.JsonSerialization.class) -public class FeatureFlagsState { - private static final Gson gson = new Gson(); - - private final Map flagValues; +public class FeatureFlagsState implements JsonSerializable { + private final Map flagValues; private final Map flagMetadata; private final boolean valid; @@ -51,21 +59,21 @@ static class FlagMetadata { public boolean equals(Object other) { if (other instanceof FlagMetadata) { FlagMetadata o = (FlagMetadata)other; - return Objects.equal(variation, o.variation) && - Objects.equal(version, o.version) && - Objects.equal(trackEvents, o.trackEvents) && - Objects.equal(debugEventsUntilDate, o.debugEventsUntilDate); + return Objects.equals(variation, o.variation) && + Objects.equals(version, o.version) && + Objects.equals(trackEvents, o.trackEvents) && + Objects.equals(debugEventsUntilDate, o.debugEventsUntilDate); } return false; } @Override public int hashCode() { - return Objects.hashCode(variation, version, trackEvents, debugEventsUntilDate); + return Objects.hash(variation, version, trackEvents, debugEventsUntilDate); } } - private FeatureFlagsState(Map flagValues, + private FeatureFlagsState(Map flagValues, Map flagMetadata, boolean valid) { this.flagValues = Collections.unmodifiableMap(flagValues); this.flagMetadata = Collections.unmodifiableMap(flagMetadata); @@ -86,7 +94,7 @@ public boolean isValid() { * @param key the feature flag key * @return the flag's JSON value; null if the flag returned the default value, or if there was no such flag */ - public JsonElement getFlagValue(String key) { + public LDValue getFlagValue(String key) { return flagValues.get(key); } @@ -108,7 +116,7 @@ public EvaluationReason getFlagReason(String key) { * Instead, serialize the FeatureFlagsState object to JSON using {@code Gson.toJson()} or {@code Gson.toJsonTree()}. * @return an immutable map of flag keys to JSON values */ - public Map toValuesMap() { + public Map toValuesMap() { return flagValues; } @@ -125,11 +133,11 @@ public boolean equals(Object other) { @Override public int hashCode() { - return Objects.hashCode(flagValues, flagMetadata, valid); + return Objects.hash(flagValues, flagMetadata, valid); } static class Builder { - private Map flagValues = new HashMap<>(); + private Map flagValues = new HashMap<>(); private Map flagMetadata = new HashMap<>(); private final boolean saveReasons; private final boolean detailsOnlyForTrackedFlags; @@ -145,13 +153,13 @@ Builder valid(boolean valid) { return this; } - @SuppressWarnings("deprecation") - Builder addFlag(FeatureFlag flag, EvaluationDetail eval) { - flagValues.put(flag.getKey(), eval.getValue().asUnsafeJsonElement()); + Builder addFlag(DataModel.FeatureFlag flag, Evaluator.EvalResult eval) { + flagValues.put(flag.getKey(), eval.getValue()); final boolean flagIsTracked = flag.isTrackEvents() || (flag.getDebugEventsUntilDate() != null && flag.getDebugEventsUntilDate() > System.currentTimeMillis()); final boolean wantDetails = !detailsOnlyForTrackedFlags || flagIsTracked; - FlagMetadata data = new FlagMetadata(eval.getVariationIndex(), + FlagMetadata data = new FlagMetadata( + eval.isDefault() ? null : eval.getVariationIndex(), (saveReasons && wantDetails) ? eval.getReason() : null, wantDetails ? flag.getVersion() : null, flag.isTrackEvents(), @@ -169,12 +177,12 @@ static class JsonSerialization extends TypeAdapter { @Override public void write(JsonWriter out, FeatureFlagsState state) throws IOException { out.beginObject(); - for (Map.Entry entry: state.flagValues.entrySet()) { + for (Map.Entry entry: state.flagValues.entrySet()) { out.name(entry.getKey()); - gson.toJson(entry.getValue(), out); + gsonInstance().toJson(entry.getValue(), LDValue.class, out); } out.name("$flagsState"); - gson.toJson(state.flagMetadata, Map.class, out); + gsonInstance().toJson(state.flagMetadata, Map.class, out); out.name("$valid"); out.value(state.valid); out.endObject(); @@ -183,7 +191,7 @@ public void write(JsonWriter out, FeatureFlagsState state) throws IOException { // There isn't really a use case for deserializing this, but we have to implement it @Override public FeatureFlagsState read(JsonReader in) throws IOException { - Map flagValues = new HashMap<>(); + Map flagValues = new HashMap<>(); Map flagMetadata = new HashMap<>(); boolean valid = true; in.beginObject(); @@ -193,14 +201,14 @@ public FeatureFlagsState read(JsonReader in) throws IOException { in.beginObject(); while (in.hasNext()) { String metaName = in.nextName(); - FlagMetadata meta = gson.fromJson(in, FlagMetadata.class); + FlagMetadata meta = gsonInstance().fromJson(in, FlagMetadata.class); flagMetadata.put(metaName, meta); } in.endObject(); } else if (name.equals("$valid")) { valid = in.nextBoolean(); } else { - JsonElement value = gson.fromJson(in, JsonElement.class); + LDValue value = gsonInstance().fromJson(in, LDValue.class); flagValues.put(name, value); } } diff --git a/src/main/java/com/launchdarkly/sdk/server/FeatureRequestor.java b/src/main/java/com/launchdarkly/sdk/server/FeatureRequestor.java new file mode 100644 index 000000000..e1e5b3003 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/FeatureRequestor.java @@ -0,0 +1,53 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.launchdarkly.sdk.server.DataModel.VersionedData; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; + +import java.io.Closeable; +import java.io.IOException; +import java.util.AbstractMap; +import java.util.Map; + +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; + +interface FeatureRequestor extends Closeable { + DataModel.FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException; + + DataModel.Segment getSegment(String segmentKey) throws IOException, HttpErrorException; + + AllData getAllData() throws IOException, HttpErrorException; + + static class AllData { + final Map flags; + final Map segments; + + AllData(Map flags, Map segments) { + this.flags = flags; + this.segments = segments; + } + + FullDataSet toFullDataSet() { + return new FullDataSet(ImmutableMap.of( + FEATURES, toKeyedItems(FEATURES, flags), + SEGMENTS, toKeyedItems(SEGMENTS, segments) + ).entrySet()); + } + + static KeyedItems toKeyedItems(DataKind kind, Map itemsMap) { + ImmutableList.Builder> builder = ImmutableList.builder(); + if (itemsMap != null) { + for (Map.Entry e: itemsMap.entrySet()) { + ItemDescriptor item = new ItemDescriptor(e.getValue().getVersion(), e.getValue()); + builder.add(new AbstractMap.SimpleEntry<>(e.getKey(), item)); + } + } + return new KeyedItems<>(builder.build()); + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java b/src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java new file mode 100644 index 000000000..217174b32 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java @@ -0,0 +1,58 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; + +import java.io.Closeable; +import java.io.IOException; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; + +final class FlagChangeEventPublisher implements Closeable { + private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); + private volatile ExecutorService executor = null; + + public void register(FlagChangeListener listener) { + listeners.add(listener); + synchronized (this) { + if (executor == null) { + executor = createExecutorService(); + } + } + } + + public void unregister(FlagChangeListener listener) { + listeners.remove(listener); + } + + public boolean hasListeners() { + return !listeners.isEmpty(); + } + + public void publishEvent(FlagChangeEvent event) { + for (FlagChangeListener l: listeners) { + executor.execute(() -> { + l.onFlagChange(event); + }); + } + } + + @Override + public void close() throws IOException { + if (executor != null) { + executor.shutdown(); + } + } + + private ExecutorService createExecutorService() { + ThreadFactory threadFactory = new ThreadFactoryBuilder() + .setDaemon(true) + .setNameFormat("LaunchDarkly-FlagChangeEventPublisher-%d") + .setPriority(Thread.MIN_PRIORITY) + .build(); + return Executors.newCachedThreadPool(threadFactory); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/FlagValueMonitoringListener.java b/src/main/java/com/launchdarkly/sdk/server/FlagValueMonitoringListener.java new file mode 100644 index 000000000..0d756bdae --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/FlagValueMonitoringListener.java @@ -0,0 +1,42 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; +import com.launchdarkly.sdk.server.interfaces.FlagValueChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagValueChangeListener; + +import java.util.concurrent.atomic.AtomicReference; + +/** + * Implementation of the flag change listener wrapper provided by + * {@link Components#flagValueMonitoringListener(LDClientInterface, String, com.launchdarkly.sdk.LDUser, com.launchdarkly.sdk.server.interfaces.FlagValueChangeListener)}. + * This class is deliberately not public, it is an implementation detail. + */ +final class FlagValueMonitoringListener implements FlagChangeListener { + private final LDClientInterface client; + private final AtomicReference currentValue = new AtomicReference<>(LDValue.ofNull()); + private final String flagKey; + private final LDUser user; + private final FlagValueChangeListener valueChangeListener; + + public FlagValueMonitoringListener(LDClientInterface client, String flagKey, LDUser user, FlagValueChangeListener valueChangeListener) { + this.client = client; + this.flagKey = flagKey; + this.user = user; + this.valueChangeListener = valueChangeListener; + currentValue.set(client.jsonValueVariation(flagKey, user, LDValue.ofNull())); + } + + @Override + public void onFlagChange(FlagChangeEvent event) { + if (event.getKey().equals(flagKey)) { + LDValue newValue = client.jsonValueVariation(flagKey, user, LDValue.ofNull()); + LDValue previousValue = currentValue.getAndSet(newValue); + if (!newValue.equals(previousValue)) { + valueChangeListener.onFlagValueChange(new FlagValueChangeEvent(flagKey, previousValue, newValue)); + } + } + } +} diff --git a/src/main/java/com/launchdarkly/client/FlagsStateOption.java b/src/main/java/com/launchdarkly/sdk/server/FlagsStateOption.java similarity index 90% rename from src/main/java/com/launchdarkly/client/FlagsStateOption.java rename to src/main/java/com/launchdarkly/sdk/server/FlagsStateOption.java index 71cb14829..79359fefd 100644 --- a/src/main/java/com/launchdarkly/client/FlagsStateOption.java +++ b/src/main/java/com/launchdarkly/sdk/server/FlagsStateOption.java @@ -1,7 +1,9 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.EvaluationReason; /** - * Optional parameters that can be passed to {@link LDClientInterface#allFlagsState(LDUser, FlagsStateOption...)}. + * Optional parameters that can be passed to {@link LDClientInterface#allFlagsState(com.launchdarkly.sdk.LDUser, FlagsStateOption...)}. * @since 4.3.0 */ public final class FlagsStateOption { diff --git a/src/main/java/com/launchdarkly/client/HttpConfigurationImpl.java b/src/main/java/com/launchdarkly/sdk/server/HttpConfigurationImpl.java similarity index 59% rename from src/main/java/com/launchdarkly/client/HttpConfigurationImpl.java rename to src/main/java/com/launchdarkly/sdk/server/HttpConfigurationImpl.java index c1e9b3e7c..7a616c58a 100644 --- a/src/main/java/com/launchdarkly/client/HttpConfigurationImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/HttpConfigurationImpl.java @@ -1,37 +1,38 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.interfaces.HttpAuthentication; -import com.launchdarkly.client.interfaces.HttpConfiguration; +import com.launchdarkly.sdk.server.interfaces.HttpAuthentication; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import java.net.Proxy; +import java.time.Duration; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.X509TrustManager; final class HttpConfigurationImpl implements HttpConfiguration { - final int connectTimeoutMillis; + final Duration connectTimeout; final Proxy proxy; final HttpAuthentication proxyAuth; - final int socketTimeoutMillis; + final Duration socketTimeout; final SSLSocketFactory sslSocketFactory; final X509TrustManager trustManager; final String wrapper; - HttpConfigurationImpl(int connectTimeoutMillis, Proxy proxy, HttpAuthentication proxyAuth, - int socketTimeoutMillis, SSLSocketFactory sslSocketFactory, X509TrustManager trustManager, + HttpConfigurationImpl(Duration connectTimeout, Proxy proxy, HttpAuthentication proxyAuth, + Duration socketTimeout, SSLSocketFactory sslSocketFactory, X509TrustManager trustManager, String wrapper) { - this.connectTimeoutMillis = connectTimeoutMillis; + this.connectTimeout = connectTimeout; this.proxy = proxy; this.proxyAuth = proxyAuth; - this.socketTimeoutMillis = socketTimeoutMillis; + this.socketTimeout = socketTimeout; this.sslSocketFactory = sslSocketFactory; this.trustManager = trustManager; this.wrapper = wrapper; } @Override - public int getConnectTimeoutMillis() { - return connectTimeoutMillis; + public Duration getConnectTimeout() { + return connectTimeout; } @Override @@ -45,8 +46,8 @@ public HttpAuthentication getProxyAuthentication() { } @Override - public int getSocketTimeoutMillis() { - return socketTimeoutMillis; + public Duration getSocketTimeout() { + return socketTimeout; } @Override diff --git a/src/main/java/com/launchdarkly/client/HttpErrorException.java b/src/main/java/com/launchdarkly/sdk/server/HttpErrorException.java similarity index 88% rename from src/main/java/com/launchdarkly/client/HttpErrorException.java rename to src/main/java/com/launchdarkly/sdk/server/HttpErrorException.java index 8450e260f..30b10f3ff 100644 --- a/src/main/java/com/launchdarkly/client/HttpErrorException.java +++ b/src/main/java/com/launchdarkly/sdk/server/HttpErrorException.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; @SuppressWarnings("serial") final class HttpErrorException extends Exception { diff --git a/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java b/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java new file mode 100644 index 000000000..aea263cbc --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java @@ -0,0 +1,117 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/** + * A thread-safe, versioned store for feature flags and related data based on a + * {@link HashMap}. This is the default implementation of {@link DataStore}. + * + * As of version 5.0.0, this is package-private; applications must use the factory method + * {@link Components#inMemoryDataStore()}. + */ +class InMemoryDataStore implements DataStore, DiagnosticDescription { + private volatile ImmutableMap> allData = ImmutableMap.of(); + private volatile boolean initialized = false; + private Object writeLock = new Object(); + + @Override + public void init(FullDataSet allData) { + synchronized (writeLock) { + ImmutableMap.Builder> newData = ImmutableMap.builder(); + for (Map.Entry> entry: allData.getData()) { + newData.put(entry.getKey(), ImmutableMap.copyOf(entry.getValue().getItems())); + } + this.allData = newData.build(); // replaces the entire map atomically + this.initialized = true; + } + } + + @Override + public ItemDescriptor get(DataKind kind, String key) { + Map items = allData.get(kind); + if (items == null) { + return null; + } + return items.get(key); + } + + @Override + public KeyedItems getAll(DataKind kind) { + Map items = allData.get(kind); + if (items == null) { + return new KeyedItems<>(null); + } + return new KeyedItems<>(ImmutableList.copyOf(items.entrySet())); + } + + @Override + public boolean upsert(DataKind kind, String key, ItemDescriptor item) { + synchronized (writeLock) { + Map existingItems = this.allData.get(kind); + ItemDescriptor oldItem = null; + if (existingItems != null) { + oldItem = existingItems.get(key); + if (oldItem != null && oldItem.getVersion() >= item.getVersion()) { + return false; + } + } + // The following logic is necessary because ImmutableMap.Builder doesn't support overwriting an existing key + ImmutableMap.Builder> newData = ImmutableMap.builder(); + for (Map.Entry> e: this.allData.entrySet()) { + if (!e.getKey().equals(kind)) { + newData.put(e.getKey(), e.getValue()); + } + } + if (existingItems == null) { + newData.put(kind, ImmutableMap.of(key, item)); + } else { + ImmutableMap.Builder itemsBuilder = ImmutableMap.builder(); + if (oldItem == null) { + itemsBuilder.putAll(existingItems); + } else { + for (Map.Entry e: existingItems.entrySet()) { + if (!e.getKey().equals(key)) { + itemsBuilder.put(e.getKey(), e.getValue()); + } + } + } + itemsBuilder.put(key, item); + newData.put(kind, itemsBuilder.build()); + } + this.allData = newData.build(); // replaces the entire map atomically + return true; + } + } + + @Override + public boolean isInitialized() { + return initialized; + } + + /** + * Does nothing; this class does not have any resources to release + * + * @throws IOException will never happen + */ + @Override + public void close() throws IOException { + return; + } + + @Override + public LDValue describeConfiguration(LDConfig config) { + return LDValue.of("memory"); + } +} diff --git a/src/main/java/com/launchdarkly/client/JsonHelpers.java b/src/main/java/com/launchdarkly/sdk/server/JsonHelpers.java similarity index 86% rename from src/main/java/com/launchdarkly/client/JsonHelpers.java rename to src/main/java/com/launchdarkly/sdk/server/JsonHelpers.java index 7fb1c0095..d5464e8e3 100644 --- a/src/main/java/com/launchdarkly/client/JsonHelpers.java +++ b/src/main/java/com/launchdarkly/sdk/server/JsonHelpers.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -9,12 +9,17 @@ 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 com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.DataModel.VersionedData; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.SerializationException; import java.io.IOException; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; -import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; abstract class JsonHelpers { private static final Gson gson = new Gson(); @@ -34,7 +39,7 @@ static Gson gsonInstance() { */ static Gson gsonInstanceForEventsSerialization(EventsConfiguration config) { return new GsonBuilder() - .registerTypeAdapter(LDUser.class, new LDUser.UserAdapterWithPrivateAttributeBehavior(config)) + .registerTypeAdapter(LDUser.class, new EventUserSerialization.UserAdapterWithPrivateAttributeBehavior(config)) .create(); } @@ -82,7 +87,7 @@ static String serialize(Object o) { * @param parsedJson the parsed JSON * @return the deserialized item */ - static VersionedData deserializeFromParsedJson(VersionedDataKind kind, JsonElement parsedJson) throws SerializationException { + static VersionedData deserializeFromParsedJson(DataKind kind, JsonElement parsedJson) throws SerializationException { VersionedData item; try { if (kind == FEATURES) { diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java similarity index 53% rename from src/main/java/com/launchdarkly/client/LDClient.java rename to src/main/java/com/launchdarkly/sdk/server/LDClient.java index cfe55e61d..dc1b53b18 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -1,8 +1,22 @@ -package com.launchdarkly.client; - -import com.google.gson.JsonElement; -import com.launchdarkly.client.Components.NullUpdateProcessor; -import com.launchdarkly.client.value.LDValue; +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.EventProcessor; +import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; import org.apache.commons.codec.binary.Hex; import org.slf4j.Logger; @@ -24,7 +38,9 @@ import javax.crypto.spec.SecretKeySpec; import static com.google.common.base.Preconditions.checkNotNull; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; /** * A client for the LaunchDarkly API. Client instances are thread-safe. Applications should instantiate @@ -37,12 +53,14 @@ public final class LDClient implements LDClientInterface { private static final String HMAC_ALGORITHM = "HmacSHA256"; static final String CLIENT_VERSION = getClientVersion(); - private final LDConfig config; private final String sdkKey; + private final Evaluator evaluator; + private final FlagChangeEventPublisher flagChangeEventPublisher; final EventProcessor eventProcessor; - final UpdateProcessor updateProcessor; - final FeatureStore featureStore; - final boolean shouldCloseFeatureStore; + final DataSource dataSource; + final DataStore dataStore; + private final DataStoreStatusProvider dataStoreStatusProvider; + private final boolean offline; /** * Creates a new client instance that connects to LaunchDarkly with the default configuration. In most @@ -54,6 +72,16 @@ public LDClient(String sdkKey) { this(sdkKey, LDConfig.DEFAULT); } + private static final DataModel.FeatureFlag getFlag(DataStore store, String key) { + ItemDescriptor item = store.get(FEATURES, key); + return item == null ? null : (DataModel.FeatureFlag)item.getItem(); + } + + private static final DataModel.Segment getSegment(DataStore store, String key) { + ItemDescriptor item = store.get(SEGMENTS, key); + return item == null ? null : (DataModel.Segment)item.getItem(); + } + /** * Creates a new client to connect to LaunchDarkly with a custom configuration. This constructor * can be used to configure advanced client features, such as customizing the LaunchDarkly base URL. @@ -62,8 +90,12 @@ public LDClient(String sdkKey) { * @param config a client configuration object */ public LDClient(String sdkKey, LDConfig config) { - this.config = new LDConfig(checkNotNull(config, "config must not be null")); + checkNotNull(config, "config must not be null"); this.sdkKey = checkNotNull(sdkKey, "sdkKey must not be null"); + this.offline = config.offline; + + final EventProcessorFactory epFactory = config.eventProcessorFactory == null ? + Components.sendEvents() : config.eventProcessorFactory; if (config.httpConfig.getProxy() != null) { if (config.httpConfig.getProxyAuthentication() != null) { @@ -73,65 +105,50 @@ public LDClient(String sdkKey, LDConfig config) { } } - FeatureStore store; - if (this.config.deprecatedFeatureStore != null) { - store = this.config.deprecatedFeatureStore; - // The following line is for backward compatibility with the obsolete mechanism by which the - // caller could pass in a FeatureStore implementation instance that we did not create. We - // were not disposing of that instance when the client was closed, so we should continue not - // doing so until the next major version eliminates that mechanism. We will always dispose - // of instances that we created ourselves from a factory. - this.shouldCloseFeatureStore = false; - } else { - FeatureStoreFactory factory = config.dataStoreFactory == null ? - Components.inMemoryDataStore() : config.dataStoreFactory; - store = factory.createFeatureStore(); - this.shouldCloseFeatureStore = true; - } - this.featureStore = new FeatureStoreClientWrapper(store); - - @SuppressWarnings("deprecation") // defaultEventProcessor() will be replaced by sendEvents() once the deprecated config properties are removed - EventProcessorFactory epFactory = this.config.eventProcessorFactory == null ? - Components.defaultEventProcessor() : this.config.eventProcessorFactory; - - DiagnosticAccumulator diagnosticAccumulator = null; - // Do not create accumulator if config has specified is opted out, or if epFactory doesn't support diagnostics - if (!this.config.diagnosticOptOut && epFactory instanceof EventProcessorFactoryWithDiagnostics) { - diagnosticAccumulator = new DiagnosticAccumulator(new DiagnosticId(sdkKey)); - } + // Do not create diagnostic accumulator if config has specified is opted out, or if we're not using the + // standard event processor + final boolean useDiagnostics = !config.diagnosticOptOut && epFactory instanceof EventProcessorBuilder; + final ClientContextImpl context = new ClientContextImpl(sdkKey, config, + useDiagnostics ? new DiagnosticAccumulator(new DiagnosticId(sdkKey)) : null); - if (epFactory instanceof EventProcessorFactoryWithDiagnostics) { - EventProcessorFactoryWithDiagnostics epwdFactory = ((EventProcessorFactoryWithDiagnostics) epFactory); - this.eventProcessor = epwdFactory.createEventProcessor(sdkKey, this.config, diagnosticAccumulator); - } else { - this.eventProcessor = epFactory.createEventProcessor(sdkKey, this.config); - } + this.eventProcessor = epFactory.createEventProcessor(context); - @SuppressWarnings("deprecation") // defaultUpdateProcessor() will be replaced by streamingDataSource() once the deprecated config.stream is removed - UpdateProcessorFactory upFactory = config.dataSourceFactory == null ? - Components.defaultUpdateProcessor() : config.dataSourceFactory; + DataStoreFactory factory = config.dataStoreFactory == null ? + Components.inMemoryDataStore() : config.dataStoreFactory; + this.dataStore = factory.createDataStore(context); + this.dataStoreStatusProvider = new DataStoreStatusProviderImpl(this.dataStore); - if (upFactory instanceof UpdateProcessorFactoryWithDiagnostics) { - UpdateProcessorFactoryWithDiagnostics upwdFactory = ((UpdateProcessorFactoryWithDiagnostics) upFactory); - this.updateProcessor = upwdFactory.createUpdateProcessor(sdkKey, this.config, featureStore, diagnosticAccumulator); - } else { - this.updateProcessor = upFactory.createUpdateProcessor(sdkKey, this.config, featureStore); - } + this.evaluator = new Evaluator(new Evaluator.Getters() { + public DataModel.FeatureFlag getFlag(String key) { + return LDClient.getFlag(LDClient.this.dataStore, key); + } - Future startFuture = updateProcessor.start(); - if (this.config.startWaitMillis > 0L) { - if (!(updateProcessor instanceof NullUpdateProcessor)) { - logger.info("Waiting up to " + this.config.startWaitMillis + " milliseconds for LaunchDarkly client to start..."); + public DataModel.Segment getSegment(String key) { + return LDClient.getSegment(LDClient.this.dataStore, key); + } + }); + + this.flagChangeEventPublisher = new FlagChangeEventPublisher(); + + DataSourceFactory dataSourceFactory = config.dataSourceFactory == null ? + Components.streamingDataSource() : config.dataSourceFactory; + DataStoreUpdates dataStoreUpdates = new DataStoreUpdatesImpl(dataStore, flagChangeEventPublisher); + this.dataSource = dataSourceFactory.createDataSource(context, dataStoreUpdates); + + Future startFuture = dataSource.start(); + if (!config.startWait.isZero() && !config.startWait.isNegative()) { + if (!(dataSource instanceof Components.NullDataSource)) { + logger.info("Waiting up to " + config.startWait.toMillis() + " milliseconds for LaunchDarkly client to start..."); } try { - startFuture.get(this.config.startWaitMillis, TimeUnit.MILLISECONDS); + startFuture.get(config.startWait.toMillis(), TimeUnit.MILLISECONDS); } catch (TimeoutException e) { logger.error("Timeout encountered waiting for LaunchDarkly client initialization"); } catch (Exception e) { logger.error("Exception encountered waiting for LaunchDarkly client initialization: {}", e.toString()); logger.debug(e.toString(), e); } - if (!updateProcessor.initialized()) { + if (!dataSource.isInitialized()) { logger.warn("LaunchDarkly client was not successfully initialized"); } } @@ -139,7 +156,7 @@ public LDClient(String sdkKey, LDConfig config) { @Override public boolean initialized() { - return updateProcessor.initialized(); + return dataSource.isInitialized(); } @Override @@ -149,28 +166,16 @@ public void track(String eventName, LDUser user) { @Override public void trackData(String eventName, LDUser user, LDValue data) { - if (user == null || user.getKeyAsString() == null) { + if (user == null || user.getKey() == null) { logger.warn("Track called with null user or null user key!"); } else { eventProcessor.sendEvent(EventFactory.DEFAULT.newCustomEvent(eventName, user, data, null)); } } - @SuppressWarnings("deprecation") - @Override - public void track(String eventName, LDUser user, JsonElement data) { - trackData(eventName, user, LDValue.unsafeFromJsonElement(data)); - } - - @SuppressWarnings("deprecation") - @Override - public void track(String eventName, LDUser user, JsonElement data, double metricValue) { - trackMetric(eventName, user, LDValue.unsafeFromJsonElement(data), metricValue); - } - @Override public void trackMetric(String eventName, LDUser user, LDValue data, double metricValue) { - if (user == null || user.getKeyAsString() == null) { + if (user == null || user.getKey() == null) { logger.warn("Track called with null user or null user key!"); } else { eventProcessor.sendEvent(EventFactory.DEFAULT.newCustomEvent(eventName, user, data, metricValue)); @@ -179,7 +184,7 @@ public void trackMetric(String eventName, LDUser user, LDValue data, double metr @Override public void identify(LDUser user) { - if (user == null || user.getKeyAsString() == null) { + if (user == null || user.getKey() == null) { logger.warn("Identify called with null user or null user key!"); } else { eventProcessor.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(user)); @@ -188,16 +193,6 @@ public void identify(LDUser user) { private void sendFlagRequestEvent(Event.FeatureRequest event) { eventProcessor.sendEvent(event); - NewRelicReflector.annotateTransaction(event.key, String.valueOf(event.value)); - } - - @Override - public Map allFlags(LDUser user) { - FeatureFlagsState state = allFlagsState(user); - if (!state.isValid()) { - return null; - } - return state.toValuesMap(); } @Override @@ -209,33 +204,36 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) } if (!initialized()) { - if (featureStore.initialized()) { - logger.warn("allFlagsState() was called before client initialized; using last known values from feature store"); + if (dataStore.isInitialized()) { + logger.warn("allFlagsState() was called before client initialized; using last known values from data store"); } else { - logger.warn("allFlagsState() was called before client initialized; feature store unavailable, returning no data"); + logger.warn("allFlagsState() was called before client initialized; data store unavailable, returning no data"); return builder.valid(false).build(); } } - if (user == null || user.getKeyAsString() == null) { + if (user == null || user.getKey() == null) { logger.warn("allFlagsState() was called with null user or null user key! returning no data"); return builder.valid(false).build(); } boolean clientSideOnly = FlagsStateOption.hasOption(options, FlagsStateOption.CLIENT_SIDE_ONLY); - Map flags = featureStore.all(FEATURES); - for (Map.Entry entry : flags.entrySet()) { - FeatureFlag flag = entry.getValue(); + KeyedItems flags = dataStore.getAll(FEATURES); + for (Map.Entry entry : flags.getItems()) { + if (entry.getValue().getItem() == null) { + continue; // deleted flag placeholder + } + DataModel.FeatureFlag flag = (DataModel.FeatureFlag)entry.getValue().getItem(); if (clientSideOnly && !flag.isClientSide()) { continue; } try { - EvaluationDetail result = flag.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails(); + Evaluator.EvalResult result = evaluator.evaluate(flag, user, EventFactory.DEFAULT); builder.addFlag(flag, result); } catch (Exception e) { logger.error("Exception caught for feature flag \"{}\" when evaluating all flags: {}", entry.getKey(), e.toString()); logger.debug(e.toString(), e); - builder.addFlag(entry.getValue(), EvaluationDetail.fromValue(LDValue.ofNull(), null, EvaluationReason.exception(e))); + builder.addFlag(flag, new Evaluator.EvalResult(LDValue.ofNull(), NO_VARIATION, EvaluationReason.exception(e))); } } return builder.build(); @@ -261,76 +259,62 @@ public String stringVariation(String featureKey, LDUser user, String defaultValu return evaluate(featureKey, user, LDValue.of(defaultValue), true).stringValue(); } - @SuppressWarnings("deprecation") - @Override - public JsonElement jsonVariation(String featureKey, LDUser user, JsonElement defaultValue) { - return evaluate(featureKey, user, LDValue.unsafeFromJsonElement(defaultValue), false).asUnsafeJsonElement(); - } - @Override public LDValue jsonValueVariation(String featureKey, LDUser user, LDValue defaultValue) { - return evaluate(featureKey, user, defaultValue == null ? LDValue.ofNull() : defaultValue, false); + return evaluate(featureKey, user, LDValue.normalize(defaultValue), false); } @Override public EvaluationDetail boolVariationDetail(String featureKey, LDUser user, boolean defaultValue) { - EvaluationDetail details = evaluateDetail(featureKey, user, LDValue.of(defaultValue), true, + Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), true, EventFactory.DEFAULT_WITH_REASONS); - return EvaluationDetail.fromValue(details.getValue().booleanValue(), - details.getVariationIndex(), details.getReason()); + return EvaluationDetail.fromValue(result.getValue().booleanValue(), + result.getVariationIndex(), result.getReason()); } @Override public EvaluationDetail intVariationDetail(String featureKey, LDUser user, int defaultValue) { - EvaluationDetail details = evaluateDetail(featureKey, user, LDValue.of(defaultValue), true, + Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), true, EventFactory.DEFAULT_WITH_REASONS); - return EvaluationDetail.fromValue(details.getValue().intValue(), - details.getVariationIndex(), details.getReason()); + return EvaluationDetail.fromValue(result.getValue().intValue(), + result.getVariationIndex(), result.getReason()); } @Override public EvaluationDetail doubleVariationDetail(String featureKey, LDUser user, double defaultValue) { - EvaluationDetail details = evaluateDetail(featureKey, user, LDValue.of(defaultValue), true, + Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), true, EventFactory.DEFAULT_WITH_REASONS); - return EvaluationDetail.fromValue(details.getValue().doubleValue(), - details.getVariationIndex(), details.getReason()); + return EvaluationDetail.fromValue(result.getValue().doubleValue(), + result.getVariationIndex(), result.getReason()); } @Override public EvaluationDetail stringVariationDetail(String featureKey, LDUser user, String defaultValue) { - EvaluationDetail details = evaluateDetail(featureKey, user, LDValue.of(defaultValue), true, - EventFactory.DEFAULT_WITH_REASONS); - return EvaluationDetail.fromValue(details.getValue().stringValue(), - details.getVariationIndex(), details.getReason()); - } - - @SuppressWarnings("deprecation") - @Override - public EvaluationDetail jsonVariationDetail(String featureKey, LDUser user, JsonElement defaultValue) { - EvaluationDetail details = evaluateDetail(featureKey, user, LDValue.unsafeFromJsonElement(defaultValue), false, + Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), true, EventFactory.DEFAULT_WITH_REASONS); - return EvaluationDetail.fromValue(details.getValue().asUnsafeJsonElement(), - details.getVariationIndex(), details.getReason()); + return EvaluationDetail.fromValue(result.getValue().stringValue(), + result.getVariationIndex(), result.getReason()); } @Override public EvaluationDetail jsonValueVariationDetail(String featureKey, LDUser user, LDValue defaultValue) { - return evaluateDetail(featureKey, user, defaultValue == null ? LDValue.ofNull() : defaultValue, false, EventFactory.DEFAULT_WITH_REASONS); + Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.normalize(defaultValue), false, EventFactory.DEFAULT_WITH_REASONS); + return EvaluationDetail.fromValue(result.getValue(), result.getVariationIndex(), result.getReason()); } @Override public boolean isFlagKnown(String featureKey) { if (!initialized()) { - if (featureStore.initialized()) { - logger.warn("isFlagKnown called before client initialized for feature flag \"{}\"; using last known values from feature store", featureKey); + if (dataStore.isInitialized()) { + logger.warn("isFlagKnown called before client initialized for feature flag \"{}\"; using last known values from data store", featureKey); } else { - logger.warn("isFlagKnown called before client initialized for feature flag \"{}\"; feature store unavailable, returning false", featureKey); + logger.warn("isFlagKnown called before client initialized for feature flag \"{}\"; data store unavailable, returning false", featureKey); return false; } } try { - if (featureStore.get(FEATURES, featureKey) != null) { + if (getFlag(dataStore, featureKey) != null) { return true; } } catch (Exception e) { @@ -342,61 +326,61 @@ public boolean isFlagKnown(String featureKey) { } private LDValue evaluate(String featureKey, LDUser user, LDValue defaultValue, boolean checkType) { - return evaluateDetail(featureKey, user, defaultValue, checkType, EventFactory.DEFAULT).getValue(); + return evaluateInternal(featureKey, user, defaultValue, checkType, EventFactory.DEFAULT).getValue(); } - private EvaluationDetail evaluateDetail(String featureKey, LDUser user, LDValue defaultValue, - boolean checkType, EventFactory eventFactory) { - EvaluationDetail details = evaluateInternal(featureKey, user, defaultValue, eventFactory); - if (details.getValue() != null && checkType) { - if (defaultValue.getType() != details.getValue().getType()) { - logger.error("Feature flag evaluation expected result as {}, but got {}", defaultValue.getType(), details.getValue().getType()); - return EvaluationDetail.error(EvaluationReason.ErrorKind.WRONG_TYPE, defaultValue); - } - } - return details; + private Evaluator.EvalResult errorResult(EvaluationReason.ErrorKind errorKind, final LDValue defaultValue) { + return new Evaluator.EvalResult(defaultValue, NO_VARIATION, EvaluationReason.error(errorKind)); } - private EvaluationDetail evaluateInternal(String featureKey, LDUser user, LDValue defaultValue, EventFactory eventFactory) { + private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LDValue defaultValue, boolean checkType, + EventFactory eventFactory) { if (!initialized()) { - if (featureStore.initialized()) { - logger.warn("Evaluation called before client initialized for feature flag \"{}\"; using last known values from feature store", featureKey); + if (dataStore.isInitialized()) { + logger.warn("Evaluation called before client initialized for feature flag \"{}\"; using last known values from data store", featureKey); } else { - logger.warn("Evaluation called before client initialized for feature flag \"{}\"; feature store unavailable, returning default value", featureKey); + logger.warn("Evaluation called before client initialized for feature flag \"{}\"; data store unavailable, returning default value", featureKey); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, EvaluationReason.ErrorKind.CLIENT_NOT_READY)); - return EvaluationDetail.error(EvaluationReason.ErrorKind.CLIENT_NOT_READY, defaultValue); + return errorResult(EvaluationReason.ErrorKind.CLIENT_NOT_READY, defaultValue); } } - FeatureFlag featureFlag = null; + DataModel.FeatureFlag featureFlag = null; try { - featureFlag = featureStore.get(FEATURES, featureKey); + featureFlag = getFlag(dataStore, featureKey); if (featureFlag == null) { logger.info("Unknown feature flag \"{}\"; returning default value", featureKey); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, EvaluationReason.ErrorKind.FLAG_NOT_FOUND)); - return EvaluationDetail.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND, defaultValue); + return errorResult(EvaluationReason.ErrorKind.FLAG_NOT_FOUND, defaultValue); } - if (user == null || user.getKeyAsString() == null) { + if (user == null || user.getKey() == null) { logger.warn("Null user or null user key when evaluating flag \"{}\"; returning default value", featureKey); sendFlagRequestEvent(eventFactory.newDefaultFeatureRequestEvent(featureFlag, user, defaultValue, EvaluationReason.ErrorKind.USER_NOT_SPECIFIED)); - return EvaluationDetail.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, defaultValue); + return errorResult(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, defaultValue); } - if (user.getKeyAsString().isEmpty()) { + if (user.getKey().isEmpty()) { logger.warn("User key is blank. Flag evaluation will proceed, but the user will not be stored in LaunchDarkly"); } - FeatureFlag.EvalResult evalResult = featureFlag.evaluate(user, featureStore, eventFactory); + Evaluator.EvalResult evalResult = evaluator.evaluate(featureFlag, user, eventFactory); for (Event.FeatureRequest event : evalResult.getPrerequisiteEvents()) { eventProcessor.sendEvent(event); } - EvaluationDetail details = evalResult.getDetails(); - if (details.isDefaultValue()) { - details = EvaluationDetail.fromValue(defaultValue, null, details.getReason()); + if (evalResult.isDefault()) { + evalResult.setValue(defaultValue); + } else { + LDValue value = evalResult.getValue(); // guaranteed not to be an actual Java null, but can be LDValue.ofNull() + if (checkType && !value.isNull() && !defaultValue.isNull() && defaultValue.getType() != value.getType()) { + logger.error("Feature flag evaluation expected result as {}, but got {}", defaultValue.getType(), value.getType()); + sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, + EvaluationReason.ErrorKind.WRONG_TYPE)); + return errorResult(EvaluationReason.ErrorKind.WRONG_TYPE, defaultValue); + } } - sendFlagRequestEvent(eventFactory.newFeatureRequestEvent(featureFlag, user, details, defaultValue)); - return details; + sendFlagRequestEvent(eventFactory.newFeatureRequestEvent(featureFlag, user, evalResult, defaultValue)); + return evalResult; } catch (Exception e) { logger.error("Encountered exception while evaluating feature flag \"{}\": {}", featureKey, e.toString()); logger.debug(e.toString(), e); @@ -407,18 +391,32 @@ private EvaluationDetail evaluateInternal(String featureKey, LDUser use sendFlagRequestEvent(eventFactory.newDefaultFeatureRequestEvent(featureFlag, user, defaultValue, EvaluationReason.ErrorKind.EXCEPTION)); } - return EvaluationDetail.fromValue(defaultValue, null, EvaluationReason.exception(e)); + return new Evaluator.EvalResult(defaultValue, NO_VARIATION, EvaluationReason.exception(e)); } } + @Override + public void registerFlagChangeListener(FlagChangeListener listener) { + flagChangeEventPublisher.register(listener); + } + + @Override + public void unregisterFlagChangeListener(FlagChangeListener listener) { + flagChangeEventPublisher.unregister(listener); + } + + @Override + public DataStoreStatusProvider getDataStoreStatusProvider() { + return dataStoreStatusProvider; + } + @Override public void close() throws IOException { logger.info("Closing LaunchDarkly Client"); - if (shouldCloseFeatureStore) { // see comment in constructor about this variable - this.featureStore.close(); - } + this.dataStore.close(); this.eventProcessor.close(); - this.updateProcessor.close(); + this.dataSource.close(); + this.flagChangeEventPublisher.close(); } @Override @@ -428,18 +426,18 @@ public void flush() { @Override public boolean isOffline() { - return config.offline; + return offline; } @Override public String secureModeHash(LDUser user) { - if (user == null || user.getKeyAsString() == null) { + if (user == null || user.getKey() == null) { return null; } try { Mac mac = Mac.getInstance(HMAC_ALGORITHM); mac.init(new SecretKeySpec(sdkKey.getBytes(), HMAC_ALGORITHM)); - return Hex.encodeHexString(mac.doFinal(user.getKeyAsString().getBytes("UTF8"))); + return Hex.encodeHexString(mac.doFinal(user.getKey().getBytes("UTF8"))); } catch (InvalidKeyException | UnsupportedEncodingException | NoSuchAlgorithmException e) { logger.error("Could not generate secure mode hash: {}", e.toString()); logger.debug(e.toString(), e); diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java similarity index 70% rename from src/main/java/com/launchdarkly/client/LDClientInterface.java rename to src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java index 80db0168b..20220e432 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java @@ -1,11 +1,13 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.google.gson.JsonElement; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; import java.io.Closeable; import java.io.IOException; -import java.util.Map; /** * This interface defines the public methods of {@link LDClient}. @@ -27,17 +29,6 @@ public interface LDClientInterface extends Closeable { */ void track(String eventName, LDUser user); - /** - * Tracks that a user performed an event, and provides additional custom data. - * - * @param eventName the name of the event - * @param user the user that performed the event - * @param data a JSON object containing additional data associated with the event; may be null - * @deprecated Use {@link #trackData(String, LDUser, LDValue)}. - */ - @Deprecated - void track(String eventName, LDUser user, JsonElement data); - /** * Tracks that a user performed an event, and provides additional custom data. * @@ -48,26 +39,6 @@ public interface LDClientInterface extends Closeable { */ void trackData(String eventName, LDUser user, LDValue data); - /** - * Tracks that a user performed an event, and provides an additional numeric value for custom metrics. - *

- * As of this version’s release date, the LaunchDarkly service does not support the {@code metricValue} - * parameter. As a result, calling this overload of {@code track} will not yet produce any different - * behavior from calling {@link #track(String, LDUser, JsonElement)} without a {@code metricValue}. - * Refer to the SDK reference guide for the latest status. - * - * @param eventName the name of the event - * @param user the user that performed the event - * @param data a JSON object containing additional data associated with the event; may be null - * @param metricValue a numeric value used by the LaunchDarkly experimentation feature in numeric custom - * metrics. Can be omitted if this event is used by only non-numeric metrics. This field will also be - * returned as part of the custom event for Data Export. - * @since 4.8.0 - * @deprecated Use {@link #trackMetric(String, LDUser, LDValue, double)}. - */ - @Deprecated - void track(String eventName, LDUser user, JsonElement data, double metricValue); - /** * Tracks that a user performed an event, and provides an additional numeric value for custom metrics. *

@@ -94,23 +65,6 @@ public interface LDClientInterface extends Closeable { */ void identify(LDUser user); - /** - * Returns a map from feature flag keys to {@code JsonElement} feature flag values for a given user. - * If the result of a flag's evaluation would have returned the default variation, it will have a null entry - * in the map. If the client is offline, has not been initialized, or a null user or user with null/empty user key a {@code null} map will be returned. - * This method will not send analytics events back to LaunchDarkly. - *

- * The most common use case for this method is to bootstrap a set of client-side feature flags from a back-end service. - * - * @param user the end user requesting the feature flags - * @return a map from feature flag keys to {@code JsonElement} for the specified user - * - * @deprecated Use {@link #allFlagsState} instead. Current versions of the client-side SDK will not - * generate analytics events correctly if you pass the result of {@code allFlags()}. - */ - @Deprecated - Map allFlags(LDUser user); - /** * Returns an object that encapsulates the state of all feature flags for a given user, including the flag * values and also metadata that can be used on the front end. This method does not send analytics events @@ -168,19 +122,6 @@ public interface LDClientInterface extends Closeable { */ String stringVariation(String featureKey, LDUser user, String defaultValue); - /** - * Calculates the {@link JsonElement} value of a feature flag for a given user. - * - * @param featureKey the unique key for the feature flag - * @param user the end user requesting the flag - * @param defaultValue the default value of the flag - * @return the variation for the given user, or {@code defaultValue} if the flag is disabled in the LaunchDarkly control panel - * @deprecated Use {@link #jsonValueVariation(String, LDUser, LDValue)}. Gson types may be removed - * from the public API in the future. - */ - @Deprecated - JsonElement jsonVariation(String featureKey, LDUser user, JsonElement defaultValue); - /** * Calculates the {@link LDValue} value of a feature flag for a given user. * @@ -245,21 +186,6 @@ public interface LDClientInterface extends Closeable { */ EvaluationDetail stringVariationDetail(String featureKey, LDUser user, String defaultValue); - /** - * Calculates the value of a feature flag for a given user, and returns an object that describes the - * way the value was determined. The {@code reason} property in the result will also be included in - * analytics events, if you are capturing detailed event data for this flag. - * @param featureKey the unique key for the feature flag - * @param user the end user requesting the flag - * @param defaultValue the default value of the flag - * @return an {@link EvaluationDetail} object - * @since 2.3.0 - * @deprecated Use {@link #jsonValueVariationDetail(String, LDUser, LDValue)}. Gson types may be removed - * from the public API in the future. - */ - @Deprecated - EvaluationDetail jsonVariationDetail(String featureKey, LDUser user, JsonElement defaultValue); - /** * Calculates the {@link LDValue} value of a feature flag for a given user. * @@ -299,6 +225,58 @@ public interface LDClientInterface extends Closeable { */ boolean isOffline(); + /** + * Registers a listener to be notified of feature flag changes. + *

+ * The listener will be notified whenever the SDK receives any change to any feature flag's configuration, + * or to a user segment that is referenced by a feature flag. If the updated flag is used as a prerequisite + * for other flags, the SDK assumes that those flags may now behave differently and sends events for them + * as well. + *

+ * Note that this does not necessarily mean the flag's value has changed for any particular user, only that + * some part of the flag configuration was changed so that it may return a different value than it + * previously returned for some user. + *

+ * Change events only work if the SDK is actually connecting to LaunchDarkly (or using the file data source). + * If the SDK is only reading flags from a database ({@link Components#externalUpdatesOnly()}) then it cannot + * know when there is a change, because flags are read on an as-needed basis. + *

+ * The listener will be called from a worker thread. + *

+ * Calling this method for an already-registered listener has no effect. + * + * @param listener the event listener to register + * @see #unregisterFlagChangeListener(FlagChangeListener) + * @see FlagChangeListener + * @since 5.0.0 + */ + void registerFlagChangeListener(FlagChangeListener listener); + + /** + * Unregisters a listener so that it will no longer be notified of feature flag changes. + *

+ * Calling this method for a listener that was not previously registered has no effect. + * + * @param listener the event listener to unregister + * @see #registerFlagChangeListener(FlagChangeListener) + * @see FlagChangeListener + * @since 5.0.0 + */ + void unregisterFlagChangeListener(FlagChangeListener listener); + + /** + * Returns an interface for tracking the status of a persistent data store. + *

+ * The {@link DataStoreStatusProvider} has methods for checking whether the data store is (as far as the + * SDK knows) currently operational, tracking changes in this status, and getting cache statistics. These + * are only relevant for a persistent data store; if you are using an in-memory data store, then this + * method will return a stub object that provides no information. + * + * @return a {@link DataStoreStatusProvider} + * @since 5.0.0 + */ + DataStoreStatusProvider getDataStoreStatusProvider(); + /** * For more info: https://github.com/launchdarkly/js-client#secure-mode * @param user the user to be hashed along with the SDK key diff --git a/src/main/java/com/launchdarkly/sdk/server/LDConfig.java b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java new file mode 100644 index 000000000..235ce9196 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java @@ -0,0 +1,206 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.EventProcessor; +import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; +import com.launchdarkly.sdk.server.interfaces.HttpConfigurationFactory; + +import java.net.URI; +import java.time.Duration; + +/** + * This class exposes advanced configuration options for the {@link LDClient}. Instances of this class must be constructed with a {@link com.launchdarkly.sdk.server.LDConfig.Builder}. + */ +public final class LDConfig { + 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 Duration DEFAULT_START_WAIT = Duration.ofSeconds(5); + + protected static final LDConfig DEFAULT = new Builder().build(); + + final DataSourceFactory dataSourceFactory; + final DataStoreFactory dataStoreFactory; + final boolean diagnosticOptOut; + final EventProcessorFactory eventProcessorFactory; + final HttpConfiguration httpConfig; + final boolean offline; + final Duration startWait; + + protected LDConfig(Builder builder) { + this.dataStoreFactory = builder.dataStoreFactory; + this.eventProcessorFactory = builder.eventProcessorFactory; + this.dataSourceFactory = builder.dataSourceFactory; + this.diagnosticOptOut = builder.diagnosticOptOut; + this.httpConfig = builder.httpConfigFactory == null ? + Components.httpConfiguration().createHttpConfiguration() : + builder.httpConfigFactory.createHttpConfiguration(); + this.offline = builder.offline; + this.startWait = builder.startWait; + } + + LDConfig(LDConfig config) { + this.dataSourceFactory = config.dataSourceFactory; + this.dataStoreFactory = config.dataStoreFactory; + this.diagnosticOptOut = config.diagnosticOptOut; + this.eventProcessorFactory = config.eventProcessorFactory; + this.httpConfig = config.httpConfig; + this.offline = config.offline; + this.startWait = config.startWait; + } + + /** + * A builder that helps construct + * {@link com.launchdarkly.sdk.server.LDConfig} objects. Builder calls can be chained, enabling the + * following pattern: + *

+   * LDConfig config = new LDConfig.Builder()
+   *      .connectTimeoutMillis(3)
+   *      .socketTimeoutMillis(3)
+   *      .build()
+   * 
+ */ + public static class Builder { + private DataSourceFactory dataSourceFactory = null; + private DataStoreFactory dataStoreFactory = null; + private boolean diagnosticOptOut = false; + private EventProcessorFactory eventProcessorFactory = null; + private HttpConfigurationFactory httpConfigFactory = null; + private boolean offline = false; + private Duration startWait = DEFAULT_START_WAIT; + + /** + * Creates a builder with all configuration parameters set to the default + */ + public Builder() { + } + + /** + * Sets the implementation of the component that receives feature flag data from LaunchDarkly, + * using a factory object. Depending on the implementation, the factory may be a builder that + * allows you to set other configuration options as well. + *

+ * The default is {@link Components#streamingDataSource()}. You may instead use + * {@link Components#pollingDataSource()}, or a test fixture such as + * {@link com.launchdarkly.sdk.server.integrations.FileData#dataSource()}. See those methods + * for details on how to configure them. + * + * @param factory the factory object + * @return the builder + * @since 4.12.0 + */ + public Builder dataSource(DataSourceFactory factory) { + this.dataSourceFactory = factory; + return this; + } + + /** + * Sets the implementation of the data store to be used for holding feature flags and + * related data received from LaunchDarkly, using a factory object. The default is + * {@link Components#inMemoryDataStore()}; for database integrations, use + * {@link Components#persistentDataStore(com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory)}. + * + * @param factory the factory object + * @return the builder + * @since 4.12.0 + */ + public Builder dataStore(DataStoreFactory factory) { + this.dataStoreFactory = factory; + return this; + } + + /** + * Set to true to opt out of sending diagnostics data. + *

+ * Unless {@code diagnosticOptOut} is set to true, the client will send some diagnostics data to the + * LaunchDarkly servers in order to assist in the development of future SDK improvements. These diagnostics + * consist of an initial payload containing some details of SDK in use, the SDK's configuration, and the platform + * the SDK is being run on; as well as payloads sent periodically with information on irregular occurrences such + * as dropped events. + * + * @see com.launchdarkly.sdk.server.integrations.EventProcessorBuilder#diagnosticRecordingInterval(Duration) + * + * @param diagnosticOptOut true if you want to opt out of sending any diagnostics data + * @return the builder + * @since 4.12.0 + */ + public Builder diagnosticOptOut(boolean diagnosticOptOut) { + this.diagnosticOptOut = diagnosticOptOut; + return this; + } + + /** + * Sets the implementation of {@link EventProcessor} to be used for processing analytics events. + *

+ * The default is {@link Components#sendEvents()}, but you may choose to use a custom implementation + * (for instance, a test fixture), or disable events with {@link Components#noEvents()}. + * + * @param factory a builder/factory object for event configuration + * @return the builder + * @since 4.12.0 + */ + public Builder events(EventProcessorFactory factory) { + this.eventProcessorFactory = factory; + return this; + } + + /** + * 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; + } + + /** + * Set whether this client is offline. + *

+ * In offline mode, the SDK will not make network connections to LaunchDarkly for any purpose. Feature + * flag data will only be available if it already exists in the data store, and analytics events will + * not be sent. + *

+ * This is equivalent to calling {@code dataSource(Components.externalUpdatesOnly())} and + * {@code events(Components.noEvents())}. It overrides any other values you may have set for + * {@link #dataSource(DataSourceFactory)} or {@link #events(EventProcessorFactory)}. + * + * @param offline when set to true no calls to LaunchDarkly will be made + * @return the builder + */ + public Builder offline(boolean offline) { + this.offline = offline; + return this; + } + + /** + * Set how long the constructor will block awaiting a successful connection to LaunchDarkly. + * Setting this to a zero or negative duration will not block and cause the constructor to return immediately. + * Default value: 5000 + * + * @param startWait maximum time to wait; null to use the default + * @return the builder + */ + public Builder startWait(Duration startWait) { + this.startWait = startWait == null ? DEFAULT_START_WAIT : startWait; + return this; + } + + /** + * Builds the configured {@link com.launchdarkly.sdk.server.LDConfig} object. + * + * @return the {@link com.launchdarkly.sdk.server.LDConfig} configured by this builder + */ + public LDConfig build() { + return new LDConfig(this); + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java new file mode 100644 index 000000000..6bb689245 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java @@ -0,0 +1,88 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.SerializationException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static com.launchdarkly.sdk.server.Util.httpErrorMessage; +import static com.launchdarkly.sdk.server.Util.isHttpErrorRecoverable; + +final class PollingProcessor implements DataSource { + private static final Logger logger = LoggerFactory.getLogger(PollingProcessor.class); + + @VisibleForTesting final FeatureRequestor requestor; + private final DataStoreUpdates dataStoreUpdates; + @VisibleForTesting final Duration pollInterval; + private AtomicBoolean initialized = new AtomicBoolean(false); + private ScheduledExecutorService scheduler = null; + + PollingProcessor(FeatureRequestor requestor, DataStoreUpdates dataStoreUpdates, Duration pollInterval) { + this.requestor = requestor; // note that HTTP configuration is applied to the requestor when it is created + this.dataStoreUpdates = dataStoreUpdates; + this.pollInterval = pollInterval; + } + + @Override + public boolean isInitialized() { + return initialized.get(); + } + + @Override + public void close() throws IOException { + logger.info("Closing LaunchDarkly PollingProcessor"); + if (scheduler != null) { + scheduler.shutdown(); + } + requestor.close(); + } + + @Override + public Future start() { + logger.info("Starting LaunchDarkly polling client with interval: " + + pollInterval.toMillis() + " milliseconds"); + final CompletableFuture initFuture = new CompletableFuture<>(); + ThreadFactory threadFactory = new ThreadFactoryBuilder() + .setNameFormat("LaunchDarkly-PollingProcessor-%d") + .build(); + scheduler = Executors.newScheduledThreadPool(1, threadFactory); + + scheduler.scheduleAtFixedRate(() -> { + try { + FeatureRequestor.AllData allData = requestor.getAllData(); + dataStoreUpdates.init(allData.toFullDataSet()); + if (!initialized.getAndSet(true)) { + logger.info("Initialized LaunchDarkly client."); + initFuture.complete(null); + } + } catch (HttpErrorException e) { + logger.error(httpErrorMessage(e.getStatus(), "polling request", "will retry")); + if (!isHttpErrorRecoverable(e.getStatus())) { + scheduler.shutdown(); + initFuture.complete(null); // if client is initializing, make it stop waiting; has no effect if already inited + } + } 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, pollInterval.toMillis(), TimeUnit.MILLISECONDS); + + return initFuture; + } +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/SemanticVersion.java b/src/main/java/com/launchdarkly/sdk/server/SemanticVersion.java similarity index 99% rename from src/main/java/com/launchdarkly/client/SemanticVersion.java rename to src/main/java/com/launchdarkly/sdk/server/SemanticVersion.java index 7e0ef034c..cb5a152cd 100644 --- a/src/main/java/com/launchdarkly/client/SemanticVersion.java +++ b/src/main/java/com/launchdarkly/sdk/server/SemanticVersion.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import java.util.regex.Matcher; import java.util.regex.Pattern; diff --git a/src/main/java/com/launchdarkly/client/SimpleLRUCache.java b/src/main/java/com/launchdarkly/sdk/server/SimpleLRUCache.java similarity index 94% rename from src/main/java/com/launchdarkly/client/SimpleLRUCache.java rename to src/main/java/com/launchdarkly/sdk/server/SimpleLRUCache.java index a048e9a06..cc79f6cf1 100644 --- a/src/main/java/com/launchdarkly/client/SimpleLRUCache.java +++ b/src/main/java/com/launchdarkly/sdk/server/SimpleLRUCache.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import java.util.LinkedHashMap; import java.util.Map; diff --git a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java new file mode 100644 index 000000000..83b376abf --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java @@ -0,0 +1,481 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.annotations.VisibleForTesting; +import com.google.gson.JsonElement; +import com.launchdarkly.eventsource.ConnectionErrorHandler; +import com.launchdarkly.eventsource.ConnectionErrorHandler.Action; +import com.launchdarkly.eventsource.EventHandler; +import com.launchdarkly.eventsource.EventSource; +import com.launchdarkly.eventsource.MessageEvent; +import com.launchdarkly.eventsource.UnsuccessfulResponseException; +import com.launchdarkly.sdk.server.DataModel.VersionedData; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; +import com.launchdarkly.sdk.server.interfaces.SerializationException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.AbstractMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; + +import static com.launchdarkly.sdk.server.DataModel.ALL_DATA_KINDS; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; +import static com.launchdarkly.sdk.server.Util.getHeadersBuilderFor; +import static com.launchdarkly.sdk.server.Util.httpErrorMessage; +import static com.launchdarkly.sdk.server.Util.isHttpErrorRecoverable; + +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. + * 2a. If the data store supports status notifications (which all persistent stores normally do), then we can + * assume it has entered a failed state and will notify us once it is working again. If and when it recovers, then + * it will tell us whether we need to restart the stream (to ensure that we haven't missed any updates), or + * whether it has already persisted all of the stream updates we received during the outage. + * 2b. If the data store doesn't support status notifications (which is normally only true of the in-memory store) + * then we don't know the significance of the error, but we must assume that updates have been lost, so we'll + * restart the stream. + * 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 DataSource { + private static final String PUT = "put"; + private static final String PATCH = "patch"; + private static final String DELETE = "delete"; + private static final String INDIRECT_PUT = "indirect/put"; + private static final String INDIRECT_PATCH = "indirect/patch"; + private static final Logger logger = LoggerFactory.getLogger(StreamProcessor.class); + private static final Duration DEAD_CONNECTION_INTERVAL = Duration.ofSeconds(300); + + private final DataStoreUpdates dataStoreUpdates; + private final HttpConfiguration httpConfig; + private final Headers headers; + @VisibleForTesting final URI streamUri; + @VisibleForTesting final Duration initialReconnectDelay; + @VisibleForTesting final FeatureRequestor requestor; + private final DiagnosticAccumulator diagnosticAccumulator; + private final EventSourceCreator eventSourceCreator; + private final DataStoreStatusProvider.StatusListener statusListener; + 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 + + static final class EventSourceParams { + final EventHandler handler; + final URI streamUri; + final Duration initialReconnectDelay; + final ConnectionErrorHandler errorHandler; + final Headers headers; + final HttpConfiguration httpConfig; + + EventSourceParams(EventHandler handler, URI streamUri, Duration initialReconnectDelay, + ConnectionErrorHandler errorHandler, Headers headers, HttpConfiguration httpConfig) { + this.handler = handler; + this.streamUri = streamUri; + this.initialReconnectDelay = initialReconnectDelay; + this.errorHandler = errorHandler; + this.headers = headers; + this.httpConfig = httpConfig; + } + } + + @FunctionalInterface + static interface EventSourceCreator { + EventSource createEventSource(EventSourceParams params); + } + + StreamProcessor( + String sdkKey, + HttpConfiguration httpConfig, + FeatureRequestor requestor, + DataStoreUpdates dataStoreUpdates, + EventSourceCreator eventSourceCreator, + DiagnosticAccumulator diagnosticAccumulator, + URI streamUri, + Duration initialReconnectDelay + ) { + this.dataStoreUpdates = dataStoreUpdates; + this.httpConfig = httpConfig; + this.requestor = requestor; + this.diagnosticAccumulator = diagnosticAccumulator; + this.eventSourceCreator = eventSourceCreator != null ? eventSourceCreator : StreamProcessor::defaultEventSourceCreator; + this.streamUri = streamUri; + this.initialReconnectDelay = initialReconnectDelay; + + this.headers = getHeadersBuilderFor(sdkKey, httpConfig) + .add("Accept", "text/event-stream") + .build(); + + DataStoreStatusProvider.StatusListener statusListener = this::onStoreStatusChanged; + if (dataStoreUpdates.getStatusProvider().addStatusListener(statusListener)) { + this.statusListener = statusListener; + } else { + this.statusListener = null; + } + } + + private void onStoreStatusChanged(DataStoreStatusProvider.Status newStatus) { + if (newStatus.isAvailable()) { + if (newStatus.isRefreshNeeded()) { + // The store has just transitioned from unavailable to available, and we can't guarantee that + // all of the latest data got cached, so let's restart the stream to refresh all the data. + EventSource stream = es; + if (stream != null) { + logger.warn("Restarting stream to refresh data after data store outage"); + stream.restart(); + } + } + } + } + + private ConnectionErrorHandler createDefaultConnectionErrorHandler() { + return (Throwable t) -> { + recordStreamInit(true); + if (t instanceof UnsuccessfulResponseException) { + int status = ((UnsuccessfulResponseException)t).getCode(); + logger.error(httpErrorMessage(status, "streaming connection", "will retry")); + if (!isHttpErrorRecoverable(status)) { + return Action.SHUTDOWN; + } + esStarted = System.currentTimeMillis(); + return Action.PROCEED; + } + return Action.PROCEED; + }; + } + + @Override + public Future start() { + final CompletableFuture initFuture = new CompletableFuture<>(); + + ConnectionErrorHandler wrappedConnectionErrorHandler = (Throwable t) -> { + Action result = connectionErrorHandler.onConnectionError(t); + if (result == Action.SHUTDOWN) { + initFuture.complete(null); // if client is initializing, make it stop waiting; has no effect if already inited + } + return result; + }; + + EventHandler handler = new StreamEventHandler(initFuture); + + es = eventSourceCreator.createEventSource(new EventSourceParams(handler, + URI.create(streamUri.toASCIIString() + "/all"), + initialReconnectDelay, + wrappedConnectionErrorHandler, + headers, + httpConfig)); + esStarted = System.currentTimeMillis(); + es.start(); + return initFuture; + } + + private void recordStreamInit(boolean failed) { + if (diagnosticAccumulator != null && esStarted != 0) { + diagnosticAccumulator.recordStreamInit(esStarted, System.currentTimeMillis() - esStarted, failed); + } + } + + @Override + public void close() throws IOException { + logger.info("Closing LaunchDarkly StreamProcessor"); + if (statusListener != null) { + dataStoreUpdates.getStatusProvider().removeStatusListener(statusListener); + } + if (es != null) { + es.close(); + } + requestor.close(); + } + + @Override + public boolean isInitialized() { + return initialized.get(); + } + + private class StreamEventHandler implements EventHandler { + private final CompletableFuture initFuture; + + StreamEventHandler(CompletableFuture initFuture) { + this.initFuture = initFuture; + } + + @Override + public void onOpen() throws Exception { + } + + @Override + public void onClosed() throws Exception { + } + + @Override + public void onMessage(String name, MessageEvent event) throws Exception { + try { + switch (name) { + case PUT: + handlePut(event.getData()); + break; + + case PATCH: + handlePatch(event.getData()); + break; + + case DELETE: + handleDelete(event.getData()); + break; + + case INDIRECT_PUT: + handleIndirectPut(); + break; + + case INDIRECT_PATCH: + handleIndirectPatch(event.getData()); + break; + + default: + logger.warn("Unexpected event found in stream: " + name); + break; + } + lastStoreUpdateFailed = false; + } 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) { + // See item 2 in error handling comments at top of class + if (!lastStoreUpdateFailed) { + logger.error("Unexpected data store failure when storing updates from stream: {}", + e.getCause().toString()); + logger.debug(e.getCause().toString(), e.getCause()); + } + if (statusListener == null) { + if (!lastStoreUpdateFailed) { + logger.warn("Restarting stream to ensure that we have the latest data"); + } + es.restart(); + } + lastStoreUpdateFailed = true; + } catch (Exception e) { + logger.warn("Unexpected error from stream processor: {}", e.toString()); + logger.debug(e.toString(), e); + } + } + + private void handlePut(String eventData) throws StreamInputException, StreamStoreException { + recordStreamInit(false); + esStarted = 0; + PutData putData = parseStreamJson(PutData.class, eventData); + FullDataSet allData = putData.data.toFullDataSet(); + try { + dataStoreUpdates.init(allData); + } catch (Exception e) { + throw new StreamStoreException(e); + } + if (!initialized.getAndSet(true)) { + initFuture.complete(null); + logger.info("Initialized LaunchDarkly client."); + } + } + + private void handlePatch(String eventData) throws StreamInputException, StreamStoreException { + PatchData data = parseStreamJson(PatchData.class, eventData); + Map.Entry kindAndKey = getKindAndKeyFromStreamApiPath(data.path); + if (kindAndKey == null) { + return; + } + DataKind kind = kindAndKey.getKey(); + String key = kindAndKey.getValue(); + VersionedData item = deserializeFromParsedJson(kind, data.data); + try { + dataStoreUpdates.upsert(kind, key, new ItemDescriptor(item.getVersion(), item)); + } catch (Exception e) { + throw new StreamStoreException(e); + } + } + + private void handleDelete(String eventData) throws StreamInputException, StreamStoreException { + DeleteData data = parseStreamJson(DeleteData.class, eventData); + Map.Entry kindAndKey = getKindAndKeyFromStreamApiPath(data.path); + if (kindAndKey == null) { + return; + } + DataKind kind = kindAndKey.getKey(); + String key = kindAndKey.getValue(); + ItemDescriptor placeholder = new ItemDescriptor(data.version, null); + try { + dataStoreUpdates.upsert(kind, key, placeholder); + } catch (Exception e) { + throw new StreamStoreException(e); + } + } + + private void handleIndirectPut() throws StreamInputException, StreamStoreException { + FeatureRequestor.AllData putData; + try { + putData = requestor.getAllData(); + } catch (Exception e) { + throw new StreamInputException(e); + } + FullDataSet allData = putData.toFullDataSet(); + try { + dataStoreUpdates.init(allData); + } catch (Exception e) { + throw new StreamStoreException(e); + } + if (!initialized.getAndSet(true)) { + initFuture.complete(null); + logger.info("Initialized LaunchDarkly client."); + } + } + + private void handleIndirectPatch(String path) throws StreamInputException, StreamStoreException { + Map.Entry kindAndKey = getKindAndKeyFromStreamApiPath(path); + DataKind kind = kindAndKey.getKey(); + String key = kindAndKey.getValue(); + VersionedData item; + try { + item = kind == SEGMENTS ? requestor.getSegment(key) : requestor.getFlag(key); + } catch (Exception e) { + throw new StreamInputException(e); + // In this case, StreamInputException doesn't necessarily represent malformed data from the service - it + // could be that the request to the polling endpoint failed in some other way. But either way, we must + // assume that we did not get valid data from LD so we have missed an update. + } + try { + dataStoreUpdates.upsert(kind, key, new ItemDescriptor(item.getVersion(), item)); + } catch (Exception e) { + throw new StreamStoreException(e); + } + } + + @Override + public void onComment(String comment) { + logger.debug("Received a heartbeat"); + } + + @Override + public void onError(Throwable throwable) { + logger.warn("Encountered EventSource error: {}", throwable.toString()); + logger.debug(throwable.toString(), throwable); + } + } + + private static EventSource defaultEventSourceCreator(EventSourceParams params) { + EventSource.Builder builder = new EventSource.Builder(params.handler, params.streamUri) + .clientBuilderActions(new EventSource.Builder.ClientConfigurer() { + public void configure(OkHttpClient.Builder builder) { + configureHttpClientBuilder(params.httpConfig, builder); + } + }) + .connectionErrorHandler(params.errorHandler) + .headers(params.headers) + .reconnectTime(params.initialReconnectDelay) + .readTimeout(DEAD_CONNECTION_INTERVAL); + // Note that this is not the same read timeout that can be set in LDConfig. We default to a smaller one + // there because we don't expect long delays within any *non*-streaming response that the LD client gets. + // A read timeout on the stream will result in the connection being cycled, so we set this to be slightly + // more than the expected interval between heartbeat signals. + + return builder.build(); + } + + private static Map.Entry getKindAndKeyFromStreamApiPath(String path) throws StreamInputException { + if (path == null) { + throw new StreamInputException("missing item path"); + } + for (DataKind kind: ALL_DATA_KINDS) { + String prefix = (kind == SEGMENTS) ? "/segments/" : "/flags/"; + if (path.startsWith(prefix)) { + return new AbstractMap.SimpleEntry(kind, 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(DataKind 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; + + @SuppressWarnings("unused") // used by Gson + public PutData() { } + } + + private static final class PatchData { + String path; + JsonElement data; + + @SuppressWarnings("unused") // used by Gson + public PatchData() { } + } + + private static final class DeleteData { + String path; + int version; + + @SuppressWarnings("unused") // used by Gson + public DeleteData() { } + } +} diff --git a/src/main/java/com/launchdarkly/client/Util.java b/src/main/java/com/launchdarkly/sdk/server/Util.java similarity index 73% rename from src/main/java/com/launchdarkly/client/Util.java rename to src/main/java/com/launchdarkly/sdk/server/Util.java index 24ada3497..b77811c61 100644 --- a/src/main/java/com/launchdarkly/client/Util.java +++ b/src/main/java/com/launchdarkly/sdk/server/Util.java @@ -1,13 +1,7 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -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 com.launchdarkly.sdk.server.interfaces.HttpAuthentication; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import java.io.IOException; import java.util.concurrent.TimeUnit; @@ -23,25 +17,6 @@ import okhttp3.Route; class Util { - /** - * Converts either a unix epoch millis number or RFC3339/ISO8601 timestamp as {@link JsonPrimitive} to a {@link DateTime} object. - * @param maybeDate wraps either a number or a string that may contain a valid timestamp. - * @return null if input is not a valid format. - */ - static DateTime jsonPrimitiveToDateTime(LDValue maybeDate) { - if (maybeDate.isNumber()) { - return new DateTime((long)maybeDate.doubleValue()); - } else if (maybeDate.isString()) { - try { - return new DateTime(maybeDate.stringValue(), DateTimeZone.UTC); - } catch (Throwable t) { - return null; - } - } else { - return null; - } - } - static Headers.Builder getHeadersBuilderFor(String sdkKey, HttpConfiguration config) { Headers.Builder builder = new Headers.Builder() .add("Authorization", sdkKey) @@ -56,9 +31,9 @@ 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.getConnectTimeoutMillis(), TimeUnit.MILLISECONDS) - .readTimeout(config.getSocketTimeoutMillis(), TimeUnit.MILLISECONDS) - .writeTimeout(config.getSocketTimeoutMillis(), TimeUnit.MILLISECONDS) + .connectTimeout(config.getConnectTimeout()) + .readTimeout(config.getSocketTimeout()) + .writeTimeout(config.getSocketTimeout()) .retryOnConnectionFailure(false); // we will implement our own retry logic if (config.getSslSocketFactory() != null) { @@ -85,11 +60,7 @@ public Request authenticate(Route route, Response response) throws IOException { 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()); - } - }); + c -> new HttpAuthentication.Challenge(c.scheme(), c.realm())); String credential = strategy.provideAuthorization(challenges); return response.request().newBuilder() .header(responseHeaderName, credential) diff --git a/src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilder.java similarity index 57% rename from src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilder.java index fb229fc61..80274006e 100644 --- a/src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilder.java @@ -1,9 +1,11 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.Components; -import com.launchdarkly.client.EventProcessorFactory; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; import java.net.URI; +import java.time.Duration; import java.util.Arrays; import java.util.HashSet; import java.util.Set; @@ -13,16 +15,13 @@ *

* The SDK normally buffers analytics events and sends them to LaunchDarkly at intervals. If you want * to customize this behavior, create a builder with {@link Components#sendEvents()}, change its - * properties with the methods of this class, and pass it to {@link com.launchdarkly.client.LDConfig.Builder#events(EventProcessorFactory)}: + * properties with the methods of this class, and pass it to {@link com.launchdarkly.sdk.server.LDConfig.Builder#events(EventProcessorFactory)}: *


  *     LDConfig config = new LDConfig.Builder()
  *         .events(Components.sendEvents().capacity(5000).flushIntervalSeconds(2))
  *         .build();
  * 
*

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

* Note that this class is abstract; the actual implementation is created by calling {@link Components#sendEvents()}. * * @since 4.12.0 @@ -34,14 +33,14 @@ public abstract class EventProcessorBuilder implements EventProcessorFactory { public static final int DEFAULT_CAPACITY = 10000; /** - * The default value for {@link #diagnosticRecordingIntervalSeconds(int)}. + * The default value for {@link #diagnosticRecordingInterval(Duration)}: 15 minutes. */ - public static final int DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS = 60 * 15; + public static final Duration DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL = Duration.ofMinutes(15); /** - * The default value for {@link #flushIntervalSeconds(int)}. + * The default value for {@link #flushInterval(Duration)}: 5 seconds. */ - public static final int DEFAULT_FLUSH_INTERVAL_SECONDS = 5; + public static final Duration DEFAULT_FLUSH_INTERVAL = Duration.ofSeconds(5); /** * The default value for {@link #userKeysCapacity(int)}. @@ -49,36 +48,36 @@ public abstract class EventProcessorBuilder implements EventProcessorFactory { public static final int DEFAULT_USER_KEYS_CAPACITY = 1000; /** - * The default value for {@link #userKeysFlushIntervalSeconds(int)}. + * The default value for {@link #userKeysFlushInterval(Duration)}: 5 minutes. */ - public static final int DEFAULT_USER_KEYS_FLUSH_INTERVAL_SECONDS = 60 * 5; + public static final Duration DEFAULT_USER_KEYS_FLUSH_INTERVAL = Duration.ofMinutes(5); /** - * The minimum value for {@link #diagnosticRecordingIntervalSeconds(int)}. + * The minimum value for {@link #diagnosticRecordingInterval(Duration)}: 60 seconds. */ - public static final int MIN_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS = 60; + public static final Duration MIN_DIAGNOSTIC_RECORDING_INTERVAL = Duration.ofSeconds(60); protected boolean allAttributesPrivate = false; protected URI baseURI; protected int capacity = DEFAULT_CAPACITY; - protected int diagnosticRecordingIntervalSeconds = DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS; - protected int flushIntervalSeconds = DEFAULT_FLUSH_INTERVAL_SECONDS; + protected Duration diagnosticRecordingInterval = DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL; + protected Duration flushInterval = DEFAULT_FLUSH_INTERVAL; protected boolean inlineUsersInEvents = false; - protected Set privateAttrNames; + protected Set privateAttributes; protected int userKeysCapacity = DEFAULT_USER_KEYS_CAPACITY; - protected int userKeysFlushIntervalSeconds = DEFAULT_USER_KEYS_FLUSH_INTERVAL_SECONDS; + protected Duration userKeysFlushInterval = DEFAULT_USER_KEYS_FLUSH_INTERVAL; /** * Sets whether or not all optional user attributes should be hidden from LaunchDarkly. *

* If this is {@code true}, all user attribute values (other than the key) will be private, not just * the attributes specified in {@link #privateAttributeNames(String...)} or on a per-user basis with - * {@link com.launchdarkly.client.LDUser.Builder} methods. By default, it is {@code false}. + * {@link com.launchdarkly.sdk.LDUser.Builder} methods. By default, it is {@code false}. * * @param allAttributesPrivate true if all user attributes should be private * @return the builder * @see #privateAttributeNames(String...) - * @see com.launchdarkly.client.LDUser.Builder + * @see com.launchdarkly.sdk.LDUser.Builder */ public EventProcessorBuilder allAttributesPrivate(boolean allAttributesPrivate) { this.allAttributesPrivate = allAttributesPrivate; @@ -107,7 +106,7 @@ public EventProcessorBuilder baseURI(URI baseURI) { * Set the capacity of the events buffer. *

* The client buffers up to this many events in memory before flushing. If the capacity is exceeded before - * the buffer is flushed (see {@link #flushIntervalSeconds(int)}, events will be discarded. Increasing the + * the buffer is flushed (see {@link #flushInterval(Duration)}, events will be discarded. Increasing the * capacity means that events are less likely to be discarded, at the cost of consuming more memory. *

* The default value is {@link #DEFAULT_CAPACITY}. @@ -123,18 +122,22 @@ public EventProcessorBuilder capacity(int capacity) { /** * Sets the interval at which periodic diagnostic data is sent. *

- * The default value is {@link #DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS}; the minimum value is - * {@link #MIN_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS}. This property is ignored if - * {@link com.launchdarkly.client.LDConfig.Builder#diagnosticOptOut(boolean)} is set to {@code true}. + * The default value is {@link #DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL}; the minimum value is + * {@link #MIN_DIAGNOSTIC_RECORDING_INTERVAL}. This property is ignored if + * {@link com.launchdarkly.sdk.server.LDConfig.Builder#diagnosticOptOut(boolean)} is set to {@code true}. * - * @see com.launchdarkly.client.LDConfig.Builder#diagnosticOptOut(boolean) + * @see com.launchdarkly.sdk.server.LDConfig.Builder#diagnosticOptOut(boolean) * - * @param diagnosticRecordingIntervalSeconds the diagnostics interval in seconds + * @param diagnosticRecordingInterval the diagnostics interval; null to use the default * @return the builder */ - public EventProcessorBuilder diagnosticRecordingIntervalSeconds(int diagnosticRecordingIntervalSeconds) { - this.diagnosticRecordingIntervalSeconds = diagnosticRecordingIntervalSeconds < MIN_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS ? - MIN_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS : diagnosticRecordingIntervalSeconds; + public EventProcessorBuilder diagnosticRecordingInterval(Duration diagnosticRecordingInterval) { + if (diagnosticRecordingInterval == null) { + this.diagnosticRecordingInterval = DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL; + } else { + this.diagnosticRecordingInterval = diagnosticRecordingInterval.compareTo(MIN_DIAGNOSTIC_RECORDING_INTERVAL) < 0 ? + MIN_DIAGNOSTIC_RECORDING_INTERVAL : diagnosticRecordingInterval; + } return this; } @@ -143,13 +146,13 @@ public EventProcessorBuilder diagnosticRecordingIntervalSeconds(int diagnosticRe *

* Decreasing the flush interval means that the event buffer is less likely to reach capacity. *

- * The default value is {@link #DEFAULT_FLUSH_INTERVAL_SECONDS}. + * The default value is {@link #DEFAULT_FLUSH_INTERVAL}. * - * @param flushIntervalSeconds the flush interval in seconds + * @param flushInterval the flush interval; null to use the default * @return the builder */ - public EventProcessorBuilder flushIntervalSeconds(int flushIntervalSeconds) { - this.flushIntervalSeconds = flushIntervalSeconds; + public EventProcessorBuilder flushInterval(Duration flushInterval) { + this.flushInterval = flushInterval == null ? DEFAULT_FLUSH_INTERVAL : flushInterval; return this; } @@ -172,15 +175,39 @@ public EventProcessorBuilder inlineUsersInEvents(boolean inlineUsersInEvents) { *

* Any users sent to LaunchDarkly with this configuration active will have attributes with these * names removed. This is in addition to any attributes that were marked as private for an - * individual user with {@link com.launchdarkly.client.LDUser.Builder} methods. + * individual user with {@link com.launchdarkly.sdk.LDUser.Builder} methods. + *

+ * Using {@link #privateAttributes(UserAttribute...)} is preferable to avoid the possibility of + * misspelling a built-in attribute. * * @param attributeNames a set of names that will be removed from user data set to LaunchDarkly * @return the builder * @see #allAttributesPrivate(boolean) - * @see com.launchdarkly.client.LDUser.Builder + * @see com.launchdarkly.sdk.LDUser.Builder */ public EventProcessorBuilder privateAttributeNames(String... attributeNames) { - this.privateAttrNames = new HashSet<>(Arrays.asList(attributeNames)); + privateAttributes = new HashSet<>(); + for (String a: attributeNames) { + privateAttributes.add(UserAttribute.forName(a)); + } + return this; + } + + /** + * Marks a set of attribute names as private. + *

+ * Any users sent to LaunchDarkly with this configuration active will have attributes with these + * names removed. This is in addition to any attributes that were marked as private for an + * individual user with {@link com.launchdarkly.sdk.LDUser.Builder} methods. + * + * @param attributes a set of attributes that will be removed from user data set to LaunchDarkly + * @return the builder + * @see #allAttributesPrivate(boolean) + * @see com.launchdarkly.sdk.LDUser.Builder + * @see #privateAttributeNames + */ + public EventProcessorBuilder privateAttributes(UserAttribute... attributes) { + privateAttributes = new HashSet<>(Arrays.asList(attributes)); return this; } @@ -188,7 +215,7 @@ public EventProcessorBuilder privateAttributeNames(String... attributeNames) { * Sets the number of user keys that the event processor can remember at any one time. *

* To avoid sending duplicate user details in analytics events, the SDK maintains a cache of - * recently seen user keys, expiring at an interval set by {@link #userKeysFlushIntervalSeconds(int)}. + * recently seen user keys, expiring at an interval set by {@link #userKeysFlushInterval(Duration)}. *

* The default value is {@link #DEFAULT_USER_KEYS_CAPACITY}. * @@ -203,13 +230,13 @@ public EventProcessorBuilder userKeysCapacity(int userKeysCapacity) { /** * Sets the interval at which the event processor will reset its cache of known user keys. *

- * The default value is {@link #DEFAULT_USER_KEYS_FLUSH_INTERVAL_SECONDS}. + * The default value is {@link #DEFAULT_USER_KEYS_FLUSH_INTERVAL}. * - * @param userKeysFlushIntervalSeconds the flush interval in seconds + * @param userKeysFlushInterval the flush interval; null to use the default * @return the builder */ - public EventProcessorBuilder userKeysFlushIntervalSeconds(int userKeysFlushIntervalSeconds) { - this.userKeysFlushIntervalSeconds = userKeysFlushIntervalSeconds; + public EventProcessorBuilder userKeysFlushInterval(Duration userKeysFlushInterval) { + this.userKeysFlushInterval = userKeysFlushInterval == null ? DEFAULT_USER_KEYS_FLUSH_INTERVAL : userKeysFlushInterval; return this; } } diff --git a/src/main/java/com/launchdarkly/client/integrations/FileData.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java similarity index 93% rename from src/main/java/com/launchdarkly/client/integrations/FileData.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java index 9771db552..f38722cf1 100644 --- a/src/main/java/com/launchdarkly/client/integrations/FileData.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; /** * Integration between the LaunchDarkly SDK and file data. @@ -17,7 +17,7 @@ public abstract class FileData { *

* This object can be modified with {@link FileDataSourceBuilder} methods for any desired * custom settings, before including it in the SDK configuration with - * {@link com.launchdarkly.client.LDConfig.Builder#dataSource(com.launchdarkly.client.UpdateProcessorFactory)}. + * {@link com.launchdarkly.sdk.server.LDConfig.Builder#dataSource(com.launchdarkly.sdk.server.interfaces.DataSourceFactory)}. *

* At a minimum, you will want to call {@link FileDataSourceBuilder#filePaths(String...)} to specify * your data file(s); you can also use {@link FileDataSourceBuilder#autoUpdate(boolean)} to @@ -34,8 +34,8 @@ public abstract class FileData { *

* This will cause the client not to connect to LaunchDarkly to get feature flags. The * client may still make network connections to send analytics events, unless you have disabled - * this with {@link com.launchdarkly.client.LDConfig.Builder#sendEvents(boolean)} or - * {@link com.launchdarkly.client.LDConfig.Builder#offline(boolean)}. + * this with {@link com.launchdarkly.sdk.server.Components#noEvents()} or + * {@link com.launchdarkly.sdk.server.LDConfig.Builder#offline(boolean)}. *

* Flag data files can be either JSON or YAML. They contain an object with three possible * properties: diff --git a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java similarity index 80% rename from src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java index 49df9e3de..429f27382 100644 --- a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java @@ -1,9 +1,9 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.FeatureStore; -import com.launchdarkly.client.LDConfig; -import com.launchdarkly.client.UpdateProcessor; -import com.launchdarkly.client.UpdateProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import java.nio.file.InvalidPathException; import java.nio.file.Path; @@ -14,13 +14,13 @@ /** * To use the file data source, obtain a new instance of this class with {@link FileData#dataSource()}, * call the builder method {@link #filePaths(String...)} to specify file path(s), - * then pass the resulting object to {@link com.launchdarkly.client.LDConfig.Builder#dataSource(UpdateProcessorFactory)}. + * then pass the resulting object to {@link com.launchdarkly.sdk.server.LDConfig.Builder#dataSource(DataSourceFactory)}. *

* For more details, see {@link FileData}. * * @since 4.12.0 */ -public final class FileDataSourceBuilder implements UpdateProcessorFactory { +public final class FileDataSourceBuilder implements DataSourceFactory { private final List sources = new ArrayList<>(); private boolean autoUpdate = false; @@ -77,7 +77,7 @@ public FileDataSourceBuilder autoUpdate(boolean autoUpdate) { * Used internally by the LaunchDarkly client. */ @Override - public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { - return new FileDataSourceImpl(featureStore, sources, autoUpdate); + public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { + return new FileDataSourceImpl(dataStoreUpdates, sources, autoUpdate); } } \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java similarity index 69% rename from src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java index cd2244564..f4d93ffcb 100644 --- a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java @@ -1,15 +1,17 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.google.common.util.concurrent.Futures; -import com.google.gson.JsonElement; -import com.launchdarkly.client.FeatureStore; -import com.launchdarkly.client.UpdateProcessor; -import com.launchdarkly.client.VersionedData; -import com.launchdarkly.client.VersionedDataKind; -import com.launchdarkly.client.integrations.FileDataSourceParsing.FileDataException; -import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFactory; -import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFileParser; -import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFileRep; +import com.google.common.collect.ImmutableList; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FileDataException; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFactory; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFileParser; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFileRep; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,15 +26,19 @@ import java.nio.file.WatchKey; import java.nio.file.WatchService; import java.nio.file.Watchable; +import java.util.AbstractMap; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE; import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE; import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY; @@ -41,16 +47,16 @@ * Implements taking flag data from files and putting it into the data store, at startup time and * optionally whenever files change. */ -final class FileDataSourceImpl implements UpdateProcessor { +final class FileDataSourceImpl implements DataSource { private static final Logger logger = LoggerFactory.getLogger(FileDataSourceImpl.class); - private final FeatureStore store; + private final DataStoreUpdates dataStoreUpdates; private final DataLoader dataLoader; private final AtomicBoolean inited = new AtomicBoolean(false); private final FileWatcher fileWatcher; - FileDataSourceImpl(FeatureStore store, List sources, boolean autoUpdate) { - this.store = store; + FileDataSourceImpl(DataStoreUpdates dataStoreUpdates, List sources, boolean autoUpdate) { + this.dataStoreUpdates = dataStoreUpdates; this.dataLoader = new DataLoader(sources); FileWatcher fw = null; @@ -67,7 +73,7 @@ final class FileDataSourceImpl implements UpdateProcessor { @Override public Future start() { - final Future initFuture = Futures.immediateFuture(null); + final Future initFuture = CompletableFuture.completedFuture(null); reload(); @@ -76,10 +82,8 @@ public Future start() { // if we are told to reload by the file watcher. if (fileWatcher != null) { - fileWatcher.start(new Runnable() { - public void run() { - FileDataSourceImpl.this.reload(); - } + fileWatcher.start(() -> { + FileDataSourceImpl.this.reload(); }); } @@ -94,13 +98,13 @@ private boolean reload() { logger.error(e.getDescription()); return false; } - store.init(builder.build()); + dataStoreUpdates.init(builder.build()); inited.set(true); return true; } @Override - public boolean initialized() { + public boolean isInitialized() { return inited.get(); } @@ -214,18 +218,18 @@ public void load(DataBuilder builder) throws FileDataException FlagFileParser parser = FlagFileParser.selectForContent(data); FlagFileRep fileContents = parser.parse(new ByteArrayInputStream(data)); if (fileContents.flags != null) { - for (Map.Entry e: fileContents.flags.entrySet()) { - builder.add(VersionedDataKind.FEATURES, FlagFactory.flagFromJson(e.getValue())); + for (Map.Entry e: fileContents.flags.entrySet()) { + builder.add(FEATURES, e.getKey(), FlagFactory.flagFromJson(e.getValue())); } } if (fileContents.flagValues != null) { - for (Map.Entry e: fileContents.flagValues.entrySet()) { - builder.add(VersionedDataKind.FEATURES, FlagFactory.flagWithValue(e.getKey(), e.getValue())); + for (Map.Entry e: fileContents.flagValues.entrySet()) { + builder.add(FEATURES, e.getKey(), FlagFactory.flagWithValue(e.getKey(), e.getValue())); } } if (fileContents.segments != null) { - for (Map.Entry e: fileContents.segments.entrySet()) { - builder.add(VersionedDataKind.SEGMENTS, FlagFactory.segmentFromJson(e.getValue())); + for (Map.Entry e: fileContents.segments.entrySet()) { + builder.add(SEGMENTS, e.getKey(), FlagFactory.segmentFromJson(e.getValue())); } } } catch (FileDataException e) { @@ -242,23 +246,26 @@ public void load(DataBuilder builder) throws FileDataException * expects. Will throw an exception if we try to add the same flag or segment key more than once. */ static final class DataBuilder { - private final Map, Map> allData = new HashMap<>(); + private final Map> allData = new HashMap<>(); - public Map, Map> build() { - return allData; + public FullDataSet build() { + ImmutableList.Builder>> allBuilder = ImmutableList.builder(); + for (Map.Entry> e0: allData.entrySet()) { + allBuilder.add(new AbstractMap.SimpleEntry<>(e0.getKey(), new KeyedItems<>(e0.getValue().entrySet()))); + } + return new FullDataSet<>(allBuilder.build()); } - public void add(VersionedDataKind kind, VersionedData item) throws FileDataException { - @SuppressWarnings("unchecked") - Map items = (Map)allData.get(kind); + public void add(DataKind kind, String key, ItemDescriptor item) throws FileDataException { + Map items = allData.get(kind); if (items == null) { - items = new HashMap(); + items = new HashMap(); allData.put(kind, items); } - if (items.containsKey(item.getKey())) { - throw new FileDataException("in " + kind.getNamespace() + ", key \"" + item.getKey() + "\" was already defined", null, null); + if (items.containsKey(key)) { + throw new FileDataException("in " + kind.getName() + ", key \"" + key + "\" was already defined", null, null); } - items.put(item.getKey(), item); + items.put(key, item); } } } diff --git a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java similarity index 82% rename from src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java index 08083e4d9..74d3d46e3 100644 --- a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java @@ -1,12 +1,10 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; import com.google.gson.Gson; -import com.google.gson.JsonArray; import com.google.gson.JsonElement; -import com.google.gson.JsonObject; import com.google.gson.JsonSyntaxException; -import com.launchdarkly.client.VersionedData; -import com.launchdarkly.client.VersionedDataKind; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.error.YAMLException; @@ -19,6 +17,9 @@ import java.nio.file.Path; import java.util.Map; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; + abstract class FileDataSourceParsing { /** * Indicates that the file processor encountered an error in one of the input files. This exception is @@ -65,13 +66,13 @@ public String getDescription() { * parse the flags or segments at this level; that will be done by {@link FlagFactory}. */ static final class FlagFileRep { - Map flags; - Map flagValues; - Map segments; + Map flags; + Map flagValues; + Map segments; FlagFileRep() {} - FlagFileRep(Map flags, Map flagValues, Map segments) { + FlagFileRep(Map flags, Map flagValues, Map segments) { this.flags = flags; this.flagValues = flagValues; this.segments = segments; @@ -182,42 +183,36 @@ public FlagFileRep parse(InputStream input) throws FileDataException, IOExceptio * build some JSON and then parse that. */ static final class FlagFactory { - private static final Gson gson = new Gson(); - - static VersionedData flagFromJson(String jsonString) { - return flagFromJson(gson.fromJson(jsonString, JsonElement.class)); + static ItemDescriptor flagFromJson(String jsonString) { + return FEATURES.deserialize(jsonString); } - static VersionedData flagFromJson(JsonElement jsonTree) { - return gson.fromJson(jsonTree, VersionedDataKind.FEATURES.getItemClass()); + static ItemDescriptor flagFromJson(LDValue jsonTree) { + return flagFromJson(jsonTree.toJsonString()); } /** * Constructs a flag that always returns the same value. This is done by giving it a single * variation and setting the fallthrough variation to that. */ - static VersionedData flagWithValue(String key, JsonElement value) { - JsonElement jsonValue = gson.toJsonTree(value); - JsonObject o = new JsonObject(); - o.addProperty("key", key); - o.addProperty("on", true); - JsonArray vs = new JsonArray(); - vs.add(jsonValue); - o.add("variations", vs); + static ItemDescriptor flagWithValue(String key, LDValue jsonValue) { + LDValue o = LDValue.buildObject() + .put("key", key) + .put("on", true) + .put("variations", LDValue.buildArray().add(jsonValue).build()) + .put("fallthrough", LDValue.buildObject().put("variation", 0).build()) + .build(); // Note that LaunchDarkly normally prevents you from creating a flag with just one variation, // but it's the application that validates that; the SDK doesn't care. - JsonObject ft = new JsonObject(); - ft.addProperty("variation", 0); - o.add("fallthrough", ft); - return flagFromJson(o); + return FEATURES.deserialize(o.toJsonString()); } - static VersionedData segmentFromJson(String jsonString) { - return segmentFromJson(gson.fromJson(jsonString, JsonElement.class)); + static ItemDescriptor segmentFromJson(String jsonString) { + return SEGMENTS.deserialize(jsonString); } - static VersionedData segmentFromJson(JsonElement jsonTree) { - return gson.fromJson(jsonTree, VersionedDataKind.SEGMENTS.getItemClass()); + static ItemDescriptor segmentFromJson(LDValue jsonTree) { + return segmentFromJson(jsonTree.toJsonString()); } } } diff --git a/src/main/java/com/launchdarkly/client/integrations/HttpConfigurationBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilder.java similarity index 70% rename from src/main/java/com/launchdarkly/client/integrations/HttpConfigurationBuilder.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilder.java index 3392f0e9f..7fa33d889 100644 --- a/src/main/java/com/launchdarkly/client/integrations/HttpConfigurationBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilder.java @@ -1,8 +1,10 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.Components; -import com.launchdarkly.client.interfaces.HttpAuthentication; -import com.launchdarkly.client.interfaces.HttpConfigurationFactory; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.HttpAuthentication; +import com.launchdarkly.sdk.server.interfaces.HttpConfigurationFactory; + +import java.time.Duration; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.X509TrustManager; @@ -12,7 +14,7 @@ *

* 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)}: + * and pass it to {@link com.launchdarkly.sdk.server.LDConfig.Builder#http(HttpConfigurationFactory)}: *


  *     LDConfig config = new LDConfig.Builder()
  *         .http(
@@ -23,29 +25,26 @@
  *         .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)}. + * The default value for {@link #connectTimeout(Duration)}: two seconds. */ - public static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = 2000; + public static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(2); /** - * The default value for {@link #socketTimeoutMillis(int)}. + * The default value for {@link #socketTimeout(Duration)}: 10 seconds. */ - public static final int DEFAULT_SOCKET_TIMEOUT_MILLIS = 10000; + public static final Duration DEFAULT_SOCKET_TIMEOUT = Duration.ofSeconds(10); - protected int connectTimeoutMillis = DEFAULT_CONNECT_TIMEOUT_MILLIS; + protected Duration connectTimeout = DEFAULT_CONNECT_TIMEOUT; protected HttpAuthentication proxyAuth; protected String proxyHost; protected int proxyPort; - protected int socketTimeoutMillis = DEFAULT_SOCKET_TIMEOUT_MILLIS; + protected Duration socketTimeout = DEFAULT_SOCKET_TIMEOUT; protected SSLSocketFactory sslSocketFactory; protected X509TrustManager trustManager; protected String wrapperName; @@ -55,13 +54,13 @@ public abstract class HttpConfigurationBuilder implements HttpConfigurationFacto * 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}. + * The default is {@link #DEFAULT_CONNECT_TIMEOUT}. * - * @param connectTimeoutMillis the connection timeout, in milliseconds + * @param connectTimeout the connection timeout; null to use the default * @return the builder */ - public HttpConfigurationBuilder connectTimeoutMillis(int connectTimeoutMillis) { - this.connectTimeoutMillis = connectTimeoutMillis; + public HttpConfigurationBuilder connectTimeout(Duration connectTimeout) { + this.connectTimeout = connectTimeout == null ? DEFAULT_CONNECT_TIMEOUT : connectTimeout; return this; } @@ -93,16 +92,16 @@ public HttpConfigurationBuilder proxyAuth(HttpAuthentication strategy) { /** * 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 + * used by {@link com.launchdarkly.sdk.server.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}. + * The default is {@link #DEFAULT_SOCKET_TIMEOUT}. * - * @param socketTimeoutMillis the socket timeout, in milliseconds + * @param socketTimeout the socket timeout; null to use the default * @return the builder */ - public HttpConfigurationBuilder socketTimeoutMillis(int socketTimeoutMillis) { - this.socketTimeoutMillis = socketTimeoutMillis; + public HttpConfigurationBuilder socketTimeout(Duration socketTimeout) { + this.socketTimeout = socketTimeout == null ? DEFAULT_SOCKET_TIMEOUT : socketTimeout; return this; } diff --git a/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilder.java similarity index 70% rename from src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilder.java index 21c0f1177..d80e825e6 100644 --- a/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilder.java @@ -1,11 +1,14 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.Components; -import com.launchdarkly.client.FeatureStoreCacheConfig; -import com.launchdarkly.client.FeatureStoreFactory; -import com.launchdarkly.client.interfaces.DiagnosticDescription; -import com.launchdarkly.client.interfaces.PersistentDataStoreFactory; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; +import java.time.Duration; import java.util.concurrent.TimeUnit; /** @@ -16,7 +19,7 @@ * There is also universal behavior that the SDK provides for all persistent data stores, such as caching; * the {@link PersistentDataStoreBuilder} adds this. *

- * After configuring this object, pass it to {@link com.launchdarkly.client.LDConfig.Builder#dataStore(FeatureStoreFactory)} + * After configuring this object, pass it to {@link com.launchdarkly.sdk.server.LDConfig.Builder#dataStore(DataStoreFactory)} * to use it in the SDK configuration. For example, using the Redis integration: * *


@@ -36,16 +39,16 @@
  * {@link Components#persistentDataStore(PersistentDataStoreFactory)}.
  * @since 4.12.0
  */
-@SuppressWarnings("deprecation")
-public abstract class PersistentDataStoreBuilder implements FeatureStoreFactory, DiagnosticDescription {
+public abstract class PersistentDataStoreBuilder implements DataStoreFactory {
   /**
    * The default value for the cache TTL.
    */
-  public static final int DEFAULT_CACHE_TTL_SECONDS = 15;
+  public static final Duration DEFAULT_CACHE_TTL = Duration.ofSeconds(15);
 
-  protected final PersistentDataStoreFactory persistentDataStoreFactory;
-  protected FeatureStoreCacheConfig caching = FeatureStoreCacheConfig.DEFAULT;
-  protected CacheMonitor cacheMonitor = null;
+  protected final PersistentDataStoreFactory persistentDataStoreFactory; // see Components for why this is not private
+  private Duration cacheTime = DEFAULT_CACHE_TTL;
+  private StaleValuesPolicy staleValuesPolicy = StaleValuesPolicy.EVICT;
+  private boolean recordCacheStats = false;
 
   /**
    * Possible values for {@link #staleValuesPolicy(StaleValuesPolicy)}.
@@ -108,7 +111,7 @@ protected PersistentDataStoreBuilder(PersistentDataStoreFactory persistentDataSt
    * @return the builder
    */
   public PersistentDataStoreBuilder noCaching() {
-    return cacheTime(0, TimeUnit.MILLISECONDS);
+    return cacheTime(Duration.ZERO);
   }
   
   /**
@@ -119,33 +122,32 @@ public PersistentDataStoreBuilder noCaching() {
    * 

* If the value is negative, data is cached forever (equivalent to {@link #cacheForever()}). * - * @param cacheTime the cache TTL in whatever units you wish - * @param cacheTimeUnit the time unit + * @param cacheTime the cache TTL; null to use the default * @return the builder */ - public PersistentDataStoreBuilder cacheTime(long cacheTime, TimeUnit cacheTimeUnit) { - caching = caching.ttl(cacheTime, cacheTimeUnit); + public PersistentDataStoreBuilder cacheTime(Duration cacheTime) { + this.cacheTime = cacheTime == null ? DEFAULT_CACHE_TTL : cacheTime; return this; } /** - * Shortcut for calling {@link #cacheTime(long, TimeUnit)} with {@link TimeUnit#MILLISECONDS}. + * Shortcut for calling {@link #cacheTime(Duration)} with a duration in milliseconds. * * @param millis the cache TTL in milliseconds * @return the builder */ public PersistentDataStoreBuilder cacheMillis(long millis) { - return cacheTime(millis, TimeUnit.MILLISECONDS); + return cacheTime(Duration.ofMillis(millis)); } /** - * Shortcut for calling {@link #cacheTime(long, TimeUnit)} with {@link TimeUnit#SECONDS}. + * Shortcut for calling {@link #cacheTime(Duration)} with a duration in seconds. * * @param seconds the cache TTL in seconds * @return the builder */ public PersistentDataStoreBuilder cacheSeconds(long seconds) { - return cacheTime(seconds, TimeUnit.SECONDS); + return cacheTime(Duration.ofSeconds(seconds)); } /** @@ -161,7 +163,7 @@ public PersistentDataStoreBuilder cacheSeconds(long seconds) { * @return the builder */ public PersistentDataStoreBuilder cacheForever() { - return cacheTime(-1, TimeUnit.MILLISECONDS); + return cacheTime(Duration.ofMillis(-1)); } /** @@ -172,37 +174,35 @@ public PersistentDataStoreBuilder cacheForever() { * @return the builder */ public PersistentDataStoreBuilder staleValuesPolicy(StaleValuesPolicy staleValuesPolicy) { - caching = caching.staleValuesPolicy(FeatureStoreCacheConfig.StaleValuesPolicy.fromNewEnum(staleValuesPolicy)); + this.staleValuesPolicy = staleValuesPolicy == null ? StaleValuesPolicy.EVICT : staleValuesPolicy; return this; } /** - * Provides a conduit for an application to monitor the effectiveness of the in-memory cache. + * Enables monitoring of the in-memory cache. *

- * Create an instance of {@link CacheMonitor}; retain a reference to it, and also pass it to this - * method when you are configuring the persistent data store. The store will use - * {@link CacheMonitor#setSource(java.util.concurrent.Callable)} to make the caching - * statistics available through that {@link CacheMonitor} instance. - *

- * Note that turning on cache monitoring may slightly decrease performance, due to the need to - * record statistics for each cache operation. - *

- * Example usage: + * If set to true, this makes caching statistics available through the {@link DataStoreStatusProvider} + * that you can obtain from the client instance. This may slightly decrease performance, due to the + * need to record statistics for each cache operation. + *

+ * By default, it is false: statistics will not be recorded and the {@link DataStoreStatusProvider#getCacheStats()} + * method will return null. * - *

-   *     CacheMonitor cacheMonitor = new CacheMonitor();
-   *     LDConfig config = new LDConfig.Builder()
-   *         .dataStore(Components.persistentDataStore(Redis.dataStore()).cacheMonitor(cacheMonitor))
-   *         .build();
-   *     // later...
-   *     CacheMonitor.CacheStats stats = cacheMonitor.getCacheStats();
-   * 
- * - * @param cacheMonitor an instance of {@link CacheMonitor} + * @param recordCacheStats true to record caching statiistics * @return the builder + * @since 5.0.0 */ - public PersistentDataStoreBuilder cacheMonitor(CacheMonitor cacheMonitor) { - this.cacheMonitor = cacheMonitor; + public PersistentDataStoreBuilder recordCacheStats(boolean recordCacheStats) { + this.recordCacheStats = recordCacheStats; return this; } + + /** + * Called by the SDK to create the data store instance. + */ + @Override + public DataStore createDataStore(ClientContext context) { + PersistentDataStore core = persistentDataStoreFactory.createPersistentDataStore(context); + return new PersistentDataStoreWrapper(core, cacheTime, staleValuesPolicy, recordCacheStats); + } } diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java new file mode 100644 index 000000000..a59528420 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java @@ -0,0 +1,138 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.Status; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.StatusListener; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; + +/** + * Used internally to encapsulate the data store status broadcasting mechanism for PersistentDataStoreWrapper. + *

+ * This is currently only used by PersistentDataStoreWrapper, but encapsulating it in its own class helps with + * clarity and also lets us reuse this logic in tests. + */ +final class PersistentDataStoreStatusManager { + private static final Logger logger = LoggerFactory.getLogger(PersistentDataStoreStatusManager.class); + static final int POLL_INTERVAL_MS = 500; // visible for testing + + private final List listeners = new ArrayList<>(); + private final ScheduledExecutorService scheduler; + private final Callable statusPollFn; + private final boolean refreshOnRecovery; + private volatile boolean lastAvailable; + private volatile ScheduledFuture pollerFuture; + + PersistentDataStoreStatusManager(boolean refreshOnRecovery, boolean availableNow, Callable statusPollFn) { + this.refreshOnRecovery = refreshOnRecovery; + this.lastAvailable = availableNow; + this.statusPollFn = statusPollFn; + + ThreadFactory threadFactory = new ThreadFactoryBuilder() + .setNameFormat("LaunchDarkly-DataStoreStatusManager-%d") + .build(); + scheduler = Executors.newSingleThreadScheduledExecutor(threadFactory); + // Using newSingleThreadScheduledExecutor avoids ambiguity about execution order if we might have + // have a StatusNotificationTask happening soon after another one. + } + + synchronized void addStatusListener(StatusListener listener) { + listeners.add(listener); + } + + synchronized void removeStatusListener(StatusListener listener) { + listeners.remove(listener); + } + + void updateAvailability(boolean available) { + StatusListener[] copyOfListeners = null; + synchronized (this) { + if (lastAvailable == available) { + return; + } + lastAvailable = available; + copyOfListeners = listeners.toArray(new StatusListener[listeners.size()]); + } + + Status status = new Status(available, available && refreshOnRecovery); + + if (available) { + logger.warn("Persistent store is available again"); + } + + // Notify all the subscribers (on a worker thread, so we can't be blocked by a slow listener). + if (copyOfListeners.length > 0) { + scheduler.schedule(new StatusNotificationTask(status, copyOfListeners), 0, TimeUnit.MILLISECONDS); + } + + // If the store has just become unavailable, start a poller to detect when it comes back. If it has + // become available, stop any polling we are currently doing. + if (available) { + synchronized (this) { + if (pollerFuture != null) { + pollerFuture.cancel(false); + pollerFuture = null; + } + } + } else { + logger.warn("Detected persistent store unavailability; updates will be cached until it recovers"); + + // Start polling until the store starts working again + Runnable pollerTask = new Runnable() { + public void run() { + try { + if (statusPollFn.call()) { + updateAvailability(true); + } + } catch (Exception e) { + logger.error("Unexpected error from data store status function: {0}", e); + } + } + }; + synchronized (this) { + if (pollerFuture == null) { + pollerFuture = scheduler.scheduleAtFixedRate(pollerTask, POLL_INTERVAL_MS, POLL_INTERVAL_MS, TimeUnit.MILLISECONDS); + } + } + } + } + + synchronized boolean isAvailable() { + return lastAvailable; + } + + void close() { + scheduler.shutdown(); + } + + private static final class StatusNotificationTask implements Runnable { + private final Status status; + private final StatusListener[] listeners; + + StatusNotificationTask(Status status, StatusListener[] listeners) { + this.status = status; + this.listeners = listeners; + } + + public void run() { + for (StatusListener listener: listeners) { + try { + listener.dataStoreStatusChanged(status); + } catch (Exception e) { + logger.error("Unexpected error from StatusListener: {0}", e); + } + } + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java new file mode 100644 index 000000000..f5868ee3a --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java @@ -0,0 +1,479 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.google.common.base.Optional; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import com.google.common.collect.ImmutableList; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.google.common.util.concurrent.UncheckedExecutionException; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.time.Duration; +import java.util.AbstractMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicBoolean; + +import static com.google.common.collect.Iterables.concat; +import static com.google.common.collect.Iterables.filter; +import static com.google.common.collect.Iterables.isEmpty; + +/** + * Package-private implementation of {@link DataStore} that delegates the basic functionality to an + * instance of {@link PersistentDataStore}. It provides optional caching behavior and other logic that + * would otherwise be repeated in every data store implementation. This makes it easier to create new + * database integrations by implementing only the database-specific logic. + *

+ * This class is only constructed by {@link PersistentDataStoreBuilder}. + */ +final class PersistentDataStoreWrapper implements DataStore, DataStoreStatusProvider { + private static final Logger logger = LoggerFactory.getLogger(PersistentDataStoreWrapper.class); + private static final String CACHE_REFRESH_THREAD_POOL_NAME_FORMAT = "CachingStoreWrapper-refresher-pool-%d"; + + private final PersistentDataStore core; + private final LoadingCache> itemCache; + private final LoadingCache> allCache; + private final LoadingCache initCache; + private final PersistentDataStoreStatusManager statusManager; + private final boolean cacheIndefinitely; + private final Set cachedDataKinds = new HashSet<>(); // this map is used in pollForAvailability() + private final AtomicBoolean inited = new AtomicBoolean(false); + private final ListeningExecutorService executorService; + + PersistentDataStoreWrapper( + final PersistentDataStore core, + Duration cacheTtl, + PersistentDataStoreBuilder.StaleValuesPolicy staleValuesPolicy, + boolean recordCacheStats + ) { + this.core = core; + + if (cacheTtl == null || cacheTtl.isZero()) { + itemCache = null; + allCache = null; + initCache = null; + executorService = null; + cacheIndefinitely = false; + } else { + cacheIndefinitely = cacheTtl.isNegative(); + CacheLoader> itemLoader = new CacheLoader>() { + @Override + public Optional load(CacheKey key) throws Exception { + return Optional.fromNullable(getAndDeserializeItem(key.kind, key.key)); + } + }; + CacheLoader> allLoader = new CacheLoader>() { + @Override + public KeyedItems load(DataKind kind) throws Exception { + return getAllAndDeserialize(kind); + } + }; + CacheLoader initLoader = new CacheLoader() { + @Override + public Boolean load(String key) throws Exception { + return core.isInitialized(); + } + }; + + if (staleValuesPolicy == PersistentDataStoreBuilder.StaleValuesPolicy.REFRESH_ASYNC) { + ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat(CACHE_REFRESH_THREAD_POOL_NAME_FORMAT).setDaemon(true).build(); + ExecutorService parentExecutor = Executors.newSingleThreadExecutor(threadFactory); + executorService = MoreExecutors.listeningDecorator(parentExecutor); + + // Note that the REFRESH_ASYNC mode is only used for itemCache, not allCache, since retrieving all flags is + // less frequently needed and we don't want to incur the extra overhead. + itemLoader = CacheLoader.asyncReloading(itemLoader, executorService); + } else { + executorService = null; + } + + itemCache = newCacheBuilder(cacheTtl, staleValuesPolicy, recordCacheStats).build(itemLoader); + allCache = newCacheBuilder(cacheTtl, staleValuesPolicy, recordCacheStats).build(allLoader); + initCache = newCacheBuilder(cacheTtl, staleValuesPolicy, recordCacheStats).build(initLoader); + } + statusManager = new PersistentDataStoreStatusManager(!cacheIndefinitely, true, this::pollAvailabilityAfterOutage); + } + + private static CacheBuilder newCacheBuilder( + Duration cacheTtl, + PersistentDataStoreBuilder.StaleValuesPolicy staleValuesPolicy, + boolean recordCacheStats + ) { + CacheBuilder builder = CacheBuilder.newBuilder(); + boolean isInfiniteTtl = cacheTtl.isNegative(); + if (!isInfiniteTtl) { + if (staleValuesPolicy == PersistentDataStoreBuilder.StaleValuesPolicy.EVICT) { + // We are using an "expire after write" cache. This will evict stale values and block while loading the latest + // from the underlying data store. + builder = builder.expireAfterWrite(cacheTtl); + } else { + // We are using a "refresh after write" cache. This will not automatically evict stale values, allowing them + // to be returned if failures occur when updating them. + builder = builder.refreshAfterWrite(cacheTtl); + } + } + if (recordCacheStats) { + builder = builder.recordStats(); + } + return builder; + } + + @Override + public void close() throws IOException { + if (executorService != null) { + executorService.shutdownNow(); + } + statusManager.close(); + core.close(); + } + + @Override + public boolean isInitialized() { + if (inited.get()) { + return true; + } + boolean result; + if (initCache != null) { + try { + result = initCache.get(""); + } catch (ExecutionException e) { + result = false; + } + } else { + result = core.isInitialized(); + } + if (result) { + inited.set(true); + } + return result; + } + + @Override + public void init(FullDataSet allData) { + synchronized (cachedDataKinds) { + cachedDataKinds.clear(); + for (Map.Entry> e: allData.getData()) { + cachedDataKinds.add(e.getKey()); + } + } + ImmutableList.Builder>> allBuilder = ImmutableList.builder(); + for (Map.Entry> e0: allData.getData()) { + DataKind kind = e0.getKey(); + KeyedItems items = serializeAll(kind, e0.getValue()); + allBuilder.add(new AbstractMap.SimpleEntry<>(kind, items)); + } + RuntimeException failure = initCore(new FullDataSet<>(allBuilder.build())); + if (itemCache != null && allCache != null) { + itemCache.invalidateAll(); + allCache.invalidateAll(); + if (failure != null && !cacheIndefinitely) { + // Normally, if the underlying store failed to do the update, we do not want to update the cache - + // the idea being that it's better to stay in a consistent state of having old data than to act + // like we have new data but then suddenly fall back to old data when the cache expires. However, + // if the cache TTL is infinite, then it makes sense to update the cache always. + throw failure; + } + for (Map.Entry> e0: allData.getData()) { + DataKind kind = e0.getKey(); + KeyedItems immutableItems = new KeyedItems<>(ImmutableList.copyOf(e0.getValue().getItems())); + allCache.put(kind, immutableItems); + for (Map.Entry e1: e0.getValue().getItems()) { + itemCache.put(CacheKey.forItem(kind, e1.getKey()), Optional.of(e1.getValue())); + } + } + } + if (failure == null || cacheIndefinitely) { + inited.set(true); + } + if (failure != null) { + throw failure; + } + } + + private RuntimeException initCore(FullDataSet allData) { + try { + core.init(allData); + processError(null); + return null; + } catch (RuntimeException e) { + processError(e); + return e; + } + } + + @Override + public ItemDescriptor get(DataKind kind, String key) { + try { + ItemDescriptor ret = itemCache != null ? itemCache.get(CacheKey.forItem(kind, key)).orNull() : + getAndDeserializeItem(kind, key); + processError(null); + return ret; + } catch (Exception e) { + processError(e); + throw getAsRuntimeException(e); + } + } + + @Override + public KeyedItems getAll(DataKind kind) { + try { + KeyedItems ret; + ret = allCache != null ? allCache.get(kind) : getAllAndDeserialize(kind); + processError(null); + return ret; + } catch (Exception e) { + processError(e); + throw getAsRuntimeException(e); + } + } + + private static RuntimeException getAsRuntimeException(Exception e) { + Throwable t = (e instanceof ExecutionException || e instanceof UncheckedExecutionException) + ? e.getCause() // this is a wrapped exception thrown by a cache + : e; + return t instanceof RuntimeException ? (RuntimeException)t : new RuntimeException(t); + } + + @Override + public boolean upsert(DataKind kind, String key, ItemDescriptor item) { + synchronized (cachedDataKinds) { + cachedDataKinds.add(kind); + } + SerializedItemDescriptor serializedItem = serialize(kind, item); + boolean updated = false; + RuntimeException failure = null; + try { + updated = core.upsert(kind, key, serializedItem); + processError(null); + } catch (RuntimeException e) { + // Normally, if the underlying store failed to do the update, we do not want to update the cache - + // the idea being that it's better to stay in a consistent state of having old data than to act + // like we have new data but then suddenly fall back to old data when the cache expires. However, + // if the cache TTL is infinite, then it makes sense to update the cache always. + processError(e); + if (!cacheIndefinitely) + { + throw e; + } + failure = e; + } + if (itemCache != null) { + CacheKey cacheKey = CacheKey.forItem(kind, key); + if (failure == null) { + if (updated) { + itemCache.put(cacheKey, Optional.of(item)); + } else { + // there was a concurrent modification elsewhere - update the cache to get the new state + itemCache.refresh(cacheKey); + } + } else { + Optional oldItem = itemCache.getIfPresent(cacheKey); + if (oldItem == null || !oldItem.isPresent() || oldItem.get().getVersion() < item.getVersion()) { + itemCache.put(cacheKey, Optional.of(item)); + } + } + } + if (allCache != null) { + // If the cache has a finite TTL, then we should remove the "all items" cache entry to force + // a reread the next time All is called. However, if it's an infinite TTL, we need to just + // update the item within the existing "all items" entry (since we want things to still work + // even if the underlying store is unavailable). + if (cacheIndefinitely) { + KeyedItems cachedAll = allCache.getIfPresent(kind); + allCache.put(kind, updateSingleItem(cachedAll, key, item)); + } else { + allCache.invalidate(kind); + } + } + if (failure != null) { + throw failure; + } + return updated; + } + + @Override + public Status getStoreStatus() { + return new Status(statusManager.isAvailable(), false); + } + + @Override + public boolean addStatusListener(StatusListener listener) { + statusManager.addStatusListener(listener); + return true; + } + + @Override + public void removeStatusListener(StatusListener listener) { + statusManager.removeStatusListener(listener); + } + + @Override + public CacheStats getCacheStats() { + if (itemCache == null || allCache == null) { + return null; + } + com.google.common.cache.CacheStats itemStats = itemCache.stats(); + com.google.common.cache.CacheStats allStats = allCache.stats(); + return new CacheStats( + itemStats.hitCount() + allStats.hitCount(), + itemStats.missCount() + allStats.missCount(), + itemStats.loadSuccessCount() + allStats.loadSuccessCount(), + itemStats.loadExceptionCount() + allStats.loadExceptionCount(), + itemStats.totalLoadTime() + allStats.totalLoadTime(), + itemStats.evictionCount() + allStats.evictionCount()); + } + + /** + * Return the underlying implementation object. + * + * @return the underlying implementation object + */ + public PersistentDataStore getCore() { + return core; + } + + private ItemDescriptor getAndDeserializeItem(DataKind kind, String key) { + SerializedItemDescriptor maybeSerializedItem = core.get(kind, key); + return maybeSerializedItem == null ? null : deserialize(kind, maybeSerializedItem); + } + + private KeyedItems getAllAndDeserialize(DataKind kind) { + KeyedItems allItems = core.getAll(kind); + if (isEmpty(allItems.getItems())) { + return new KeyedItems(null); + } + ImmutableList.Builder> b = ImmutableList.builder(); + for (Map.Entry e: allItems.getItems()) { + b.add(new AbstractMap.SimpleEntry<>(e.getKey(), deserialize(kind, e.getValue()))); + } + return new KeyedItems<>(b.build()); + } + + private SerializedItemDescriptor serialize(DataKind kind, ItemDescriptor itemDesc) { + boolean isDeleted = itemDesc.getItem() == null; + return new SerializedItemDescriptor(itemDesc.getVersion(), isDeleted, kind.serialize(itemDesc)); + } + + private KeyedItems serializeAll(DataKind kind, KeyedItems items) { + ImmutableList.Builder> itemsBuilder = ImmutableList.builder(); + for (Map.Entry e: items.getItems()) { + itemsBuilder.add(new AbstractMap.SimpleEntry<>(e.getKey(), serialize(kind, e.getValue()))); + } + return new KeyedItems<>(itemsBuilder.build()); + } + + private ItemDescriptor deserialize(DataKind kind, SerializedItemDescriptor serializedItemDesc) { + if (serializedItemDesc.isDeleted() || serializedItemDesc.getSerializedItem() == null) { + return ItemDescriptor.deletedItem(serializedItemDesc.getVersion()); + } + ItemDescriptor deserializedItem = kind.deserialize(serializedItemDesc.getSerializedItem()); + if (serializedItemDesc.getVersion() == 0 || serializedItemDesc.getVersion() == deserializedItem.getVersion() + || deserializedItem.getItem() == null) { + return deserializedItem; + } + // If the store gave us a version number that isn't what was encoded in the object, trust it + return new ItemDescriptor(serializedItemDesc.getVersion(), deserializedItem.getItem()); + } + + private KeyedItems updateSingleItem(KeyedItems items, String key, ItemDescriptor item) { + // This is somewhat inefficient but it's preferable to use immutable data structures in the cache. + return new KeyedItems<>( + ImmutableList.copyOf(concat( + items == null ? ImmutableList.of() : filter(items.getItems(), e -> !e.getKey().equals(key)), + ImmutableList.>of(new AbstractMap.SimpleEntry<>(key, item)) + ) + )); + } + + private void processError(Throwable error) { + if (error == null) { + // If we're waiting to recover after a failure, we'll let the polling routine take care + // of signaling success. Even if we could signal success a little earlier based on the + // success of whatever operation we just did, we'd rather avoid the overhead of acquiring + // w.statusLock every time we do anything. So we'll just do nothing here. + return; + } + statusManager.updateAvailability(false); + } + + private boolean pollAvailabilityAfterOutage() { + if (!core.isStoreAvailable()) { + return false; + } + + if (cacheIndefinitely && allCache != null) { + // If we're in infinite cache mode, then we can assume the cache has a full set of current + // flag data (since presumably the data source has still been running) and we can just + // write the contents of the cache to the underlying data store. + DataKind[] allKinds; + synchronized (cachedDataKinds) { + allKinds = cachedDataKinds.toArray(new DataKind[cachedDataKinds.size()]); + } + ImmutableList.Builder>> builder = ImmutableList.builder(); + for (DataKind kind: allKinds) { + KeyedItems items = allCache.getIfPresent(kind); + if (items != null) { + builder.add(new AbstractMap.SimpleEntry<>(kind, serializeAll(kind, items))); + } + } + RuntimeException e = initCore(new FullDataSet<>(builder.build())); + if (e == null) { + logger.warn("Successfully updated persistent store from cached data"); + } else { + // We failed to write the cached data to the underlying store. In this case, we should not + // return to a recovered state, but just try this all again next time the poll task runs. + logger.error("Tried to write cached data to persistent store after a store outage, but failed: {}", e); + return false; + } + } + + return true; + } + + private static final class CacheKey { + final DataKind kind; + final String key; + + public static CacheKey forItem(DataKind kind, String key) { + return new CacheKey(kind, key); + } + + private CacheKey(DataKind kind, String key) { + this.kind = kind; + this.key = key; + } + + @Override + public boolean equals(Object other) { + if (other instanceof CacheKey) { + CacheKey o = (CacheKey) other; + return o.kind.getName().equals(this.kind.getName()) && o.key.equals(this.key); + } + return false; + } + + @Override + public int hashCode() { + return kind.getName().hashCode() * 31 + key.hashCode(); + } + } +} diff --git a/src/main/java/com/launchdarkly/client/integrations/PollingDataSourceBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/PollingDataSourceBuilder.java similarity index 64% rename from src/main/java/com/launchdarkly/client/integrations/PollingDataSourceBuilder.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/PollingDataSourceBuilder.java index 72a461cbd..43f1fa38a 100644 --- a/src/main/java/com/launchdarkly/client/integrations/PollingDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/PollingDataSourceBuilder.java @@ -1,9 +1,10 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.Components; -import com.launchdarkly.client.UpdateProcessorFactory; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; import java.net.URI; +import java.time.Duration; /** * Contains methods for configuring the polling data source. @@ -14,28 +15,25 @@ * polling is still less efficient than streaming and should only be used on the advice of LaunchDarkly support. *

* To use polling mode, create a builder with {@link Components#pollingDataSource()}, - * change its properties with the methods of this class, and pass it to {@link com.launchdarkly.client.LDConfig.Builder#dataSource(UpdateProcessorFactory)}: + * change its properties with the methods of this class, and pass it to {@link com.launchdarkly.sdk.server.LDConfig.Builder#dataSource(DataSourceFactory)}: *


  *     LDConfig config = new LDConfig.Builder()
  *         .dataSource(Components.pollingDataSource().pollIntervalMillis(45000))
  *         .build();
  * 
*

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

* Note that this class is abstract; the actual implementation is created by calling {@link Components#pollingDataSource()}. * * @since 4.12.0 */ -public abstract class PollingDataSourceBuilder implements UpdateProcessorFactory { +public abstract class PollingDataSourceBuilder implements DataSourceFactory { /** - * The default and minimum value for {@link #pollIntervalMillis(long)}. + * The default and minimum value for {@link #pollInterval(Duration)}: 30 seconds. */ - public static final long DEFAULT_POLL_INTERVAL_MILLIS = 30000L; + public static final Duration DEFAULT_POLL_INTERVAL = Duration.ofSeconds(30); protected URI baseURI; - protected long pollIntervalMillis = DEFAULT_POLL_INTERVAL_MILLIS; + protected Duration pollInterval = DEFAULT_POLL_INTERVAL; /** * Sets a custom base URI for the polling service. @@ -58,16 +56,18 @@ public PollingDataSourceBuilder baseURI(URI baseURI) { /** * Sets the interval at which the SDK will poll for feature flag updates. *

- * The default and minimum value is {@link #DEFAULT_POLL_INTERVAL_MILLIS}. Values less than this will be + * The default and minimum value is {@link #DEFAULT_POLL_INTERVAL}. Values less than this will be * set to the default. * - * @param pollIntervalMillis the polling interval in milliseconds + * @param pollInterval the polling interval; null to use the default * @return the builder */ - public PollingDataSourceBuilder pollIntervalMillis(long pollIntervalMillis) { - this.pollIntervalMillis = pollIntervalMillis < DEFAULT_POLL_INTERVAL_MILLIS ? - DEFAULT_POLL_INTERVAL_MILLIS : - pollIntervalMillis; + public PollingDataSourceBuilder pollInterval(Duration pollInterval) { + if (pollInterval == null) { + this.pollInterval = DEFAULT_POLL_INTERVAL; + } else { + this.pollInterval = pollInterval.compareTo(DEFAULT_POLL_INTERVAL) < 0 ? DEFAULT_POLL_INTERVAL : pollInterval; + } return this; } } diff --git a/src/main/java/com/launchdarkly/client/integrations/StreamingDataSourceBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilder.java similarity index 72% rename from src/main/java/com/launchdarkly/client/integrations/StreamingDataSourceBuilder.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilder.java index 62e891d3d..4943858d2 100644 --- a/src/main/java/com/launchdarkly/client/integrations/StreamingDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilder.java @@ -1,38 +1,36 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.Components; -import com.launchdarkly.client.UpdateProcessorFactory; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; import java.net.URI; +import java.time.Duration; /** * Contains methods for configuring the streaming data source. *

* By default, the SDK uses a streaming connection to receive feature flag data from LaunchDarkly. If you want * to customize the behavior of the connection, create a builder with {@link Components#streamingDataSource()}, - * change its properties with the methods of this class, and pass it to {@link com.launchdarkly.client.LDConfig.Builder#dataSource(UpdateProcessorFactory)}: + * change its properties with the methods of this class, and pass it to {@link com.launchdarkly.sdk.server.LDConfig.Builder#dataSource(DataSourceFactory)}: *


  *     LDConfig config = new LDConfig.Builder()
  *         .dataSource(Components.streamingDataSource().initialReconnectDelayMillis(500))
  *         .build();
  * 
*

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

* Note that this class is abstract; the actual implementation is created by calling {@link Components#streamingDataSource()}. * * @since 4.12.0 */ -public abstract class StreamingDataSourceBuilder implements UpdateProcessorFactory { +public abstract class StreamingDataSourceBuilder implements DataSourceFactory { /** - * The default value for {@link #initialReconnectDelayMillis(long)}. + * The default value for {@link #initialReconnectDelay(Duration)}: 1000 milliseconds. */ - public static final long DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS = 1000; + public static final Duration DEFAULT_INITIAL_RECONNECT_DELAY = Duration.ofMillis(1000); protected URI baseURI; protected URI pollingBaseURI; - protected long initialReconnectDelayMillis = DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS; + protected Duration initialReconnectDelay = DEFAULT_INITIAL_RECONNECT_DELAY; /** * Sets a custom base URI for the streaming service. @@ -59,14 +57,14 @@ public StreamingDataSourceBuilder baseURI(URI baseURI) { * to be reestablished. The delay for the first reconnection will start near this value, and then * increase exponentially for any subsequent connection failures. *

- * The default value is {@link #DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS}. + * The default value is {@link #DEFAULT_INITIAL_RECONNECT_DELAY}. * - * @param initialReconnectDelayMillis the reconnect time base value in milliseconds + * @param initialReconnectDelay the reconnect time base value; null to use the default * @return the builder */ - public StreamingDataSourceBuilder initialReconnectDelayMillis(long initialReconnectDelayMillis) { - this.initialReconnectDelayMillis = initialReconnectDelayMillis; + public StreamingDataSourceBuilder initialReconnectDelay(Duration initialReconnectDelay) { + this.initialReconnectDelay = initialReconnectDelay == null ? DEFAULT_INITIAL_RECONNECT_DELAY : initialReconnectDelay; return this; } diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/package-info.java b/src/main/java/com/launchdarkly/sdk/server/integrations/package-info.java new file mode 100644 index 000000000..7c5d27cb6 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/package-info.java @@ -0,0 +1,11 @@ +/** + * This package contains integration tools for connecting the SDK to other software components, or + * configuring how it connects to LaunchDarkly. + *

+ * In the current main LaunchDarkly Java SDK library, this package contains the configuration builders + * for the standard SDK components such as {@link com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder}, + * the {@link com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder} builder for use with + * database integrations (the specific database integrations themselves are provided by add-on libraries), + * and {@link com.launchdarkly.sdk.server.integrations.FileData} (for reading flags from a file in testing). + */ +package com.launchdarkly.sdk.server.integrations; diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java new file mode 100644 index 000000000..6c43e21c1 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java @@ -0,0 +1,31 @@ +package com.launchdarkly.sdk.server.interfaces; + +/** + * Context information provided by the {@link com.launchdarkly.sdk.server.LDClient} when creating components. + *

+ * This is passed as a parameter to {@link DataStoreFactory#createDataStore(ClientContext)}, etc. The + * actual implementation class may contain other properties that are only relevant to the built-in SDK + * components and are therefore not part of the public interface; this allows the SDK to add its own + * context information as needed without disturbing the public API. + * + * @since 5.0.0 + */ +public interface ClientContext { + /** + * The current {@link com.launchdarkly.sdk.server.LDClient} instance's SDK key. + * @return the SDK key + */ + public String getSdkKey(); + + /** + * True if {@link com.launchdarkly.sdk.server.LDConfig.Builder#offline(boolean)} was set to true. + * @return the offline status + */ + public boolean isOffline(); + + /** + * The configured networking properties that apply to all components. + * @return the HTTP configuration + */ + public HttpConfiguration getHttpConfiguration(); +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSource.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSource.java new file mode 100644 index 000000000..848420cb3 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSource.java @@ -0,0 +1,30 @@ +package com.launchdarkly.sdk.server.interfaces; + +import java.io.Closeable; +import java.io.IOException; +import java.util.concurrent.Future; + +/** + * Interface for an object that receives updates to feature flags, user segments, and anything + * else that might come from LaunchDarkly, and passes them to a {@link DataStore}. + * @since 5.0.0 + */ +public interface DataSource extends Closeable { + /** + * Starts the client. + * @return {@link Future}'s completion status indicates the client has been initialized. + */ + Future start(); + + /** + * Returns true once the client has been initialized and will never return false again. + * @return true if the client has been initialized + */ + boolean isInitialized(); + + /** + * Tells the component to shut down and release any resources it is using. + * @throws IOException if there is an error while closing + */ + void close() throws IOException; +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceFactory.java new file mode 100644 index 000000000..87f9c1482 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceFactory.java @@ -0,0 +1,19 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.Components; + +/** + * Interface for a factory that creates some implementation of {@link DataSource}. + * @see Components + * @since 4.11.0 + */ +public interface DataSourceFactory { + /** + * Creates an implementation instance. + * + * @param context allows access to the client configuration + * @param dataStoreUpdates the component pushes data into the SDK via this interface + * @return an {@link DataSource} + */ + public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates); +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStore.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStore.java new file mode 100644 index 000000000..83f8d34cd --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStore.java @@ -0,0 +1,81 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; + +import java.io.Closeable; + +/** + * Interface for a data store that holds feature flags and related data received by the SDK. + *

+ * Ordinarily, the only implementations of this interface are the default in-memory implementation, + * which holds references to actual SDK data model objects, and the persistent data store + * implementation that delegates to a {@link PersistentDataStore}. + *

+ * All implementations must permit concurrent access and updates. + * + * @since 5.0.0 + */ +public interface DataStore extends Closeable { + /** + * Overwrites the store's contents with a set of items for each collection. + *

+ * All previous data should be discarded, regardless of versioning. + *

+ * The update should be done atomically. If it cannot be done atomically, then the store + * must first add or update each item in the same order that they are given in the input + * data, and then delete any previously stored items that were not in the input data. + * + * @param allData a list of {@link DataStoreTypes.DataKind} instances and their corresponding data sets + */ + void init(FullDataSet allData); + + /** + * Retrieves an item from the specified collection, if available. + *

+ * If the item has been deleted and the store contains a placeholder, it should + * return that placeholder rather than null. + * + * @param kind specifies which collection to use + * @param key the unique key of the item within that collection + * @return a versioned item that contains the stored data (or placeholder for deleted data); + * null if the key is unknown + */ + ItemDescriptor get(DataKind kind, String key); + + /** + * Retrieves all items from the specified collection. + *

+ * If the store contains placeholders for deleted items, it should include them in + * the results, not filter them out. + * + * @param kind specifies which collection to use + * @return a collection of key-value pairs; the ordering is not significant + */ + KeyedItems getAll(DataKind kind); + + /** + * Updates or inserts an item in the specified collection. For updates, the object will only be + * updated if the existing version is less than the new version. + *

+ * The SDK may pass an {@link ItemDescriptor} that contains a null, to represent a placeholder + * for a deleted item. In that case, assuming the version is greater than any existing version of + * that item, the store should retain that placeholder rather than simply not storing anything. + * + * @param kind specifies which collection to use + * @param key the unique key for the item within that collection + * @param item the item to insert or update + * @return true if the item was updated; false if it was not updated because the store contains + * an equal or greater version + */ + boolean upsert(DataKind kind, String key, ItemDescriptor item); + + /** + * Checks whether this store has been initialized with any data yet. + * + * @return true if the store contains data + */ + boolean isInitialized(); +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreFactory.java new file mode 100644 index 000000000..0ed2456ad --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreFactory.java @@ -0,0 +1,18 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.Components; + +/** + * Interface for a factory that creates some implementation of {@link DataStore}. + * @see Components + * @since 4.11.0 + */ +public interface DataStoreFactory { + /** + * Creates an implementation instance. + * + * @param context allows access to the client configuration + * @return a {@link DataStore} + */ + DataStore createDataStore(ClientContext context); +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java new file mode 100644 index 000000000..25b43b08b --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java @@ -0,0 +1,230 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; + +import java.util.Objects; + +/** + * An interface for querying the status of a persistent data store. + *

+ * An implementation of this interface is returned by {@link com.launchdarkly.sdk.server.LDClientInterface#getDataStoreStatusProvider}. + * If the data store is a persistent data store, then these methods are implemented by the SDK; if it is a custom + * class that implements this interface, then these methods delegate to the corresponding methods of the class; + * if it is the default in-memory data store, then these methods do nothing and return null values. + * + * @since 5.0.0 + */ +public interface DataStoreStatusProvider { + /** + * Returns the current status of the store. + * + * @return the latest status, or null if not available + */ + public Status getStoreStatus(); + + /** + * Subscribes for notifications of status changes. + *

+ * Applications may wish to know if there is an outage in a persistent data store, since that could mean that + * flag evaluations are unable to get the flag data from the store (unless it is currently cached) and therefore + * might return default values. + *

+ * If the SDK receives an exception while trying to query or update the data store, then it notifies listeners + * that the store appears to be offline ({@link Status#isAvailable()} is false) and begins polling the store + * at intervals until a query succeeds. Once it succeeds, it notifies listeners again with {@link Status#isAvailable()} + * set to true. + *

+ * This method has no effect if the data store implementation does not support status tracking, such as if you + * are using the default in-memory store rather than a persistent store. + * + * @param listener the listener to add + * @return true if the listener was added, or was already registered; false if the data store does not support + * status tracking + */ + public boolean addStatusListener(StatusListener listener); + + /** + * Unsubscribes from notifications of status changes. + *

+ * This method has no effect if the data store implementation does not support status tracking, such as if you + * are using the default in-memory store rather than a persistent store. + * + * @param listener the listener to remove; if no such listener was added, this does nothing + */ + public void removeStatusListener(StatusListener listener); + + /** + * Queries the current cache statistics, if this is a persistent store with caching enabled. + *

+ * This method returns null if the data store implementation does not support cache statistics because it is + * not a persistent store, or because you did not enable cache monitoring with + * {@link PersistentDataStoreBuilder#recordCacheStats(boolean)}. + * + * @return a {@link CacheStats} instance; null if not applicable + */ + public CacheStats getCacheStats(); + + /** + * Information about a status change. + */ + public static final class Status { + private final boolean available; + private final boolean refreshNeeded; + + /** + * Creates an instance. + * @param available see {@link #isAvailable()} + * @param refreshNeeded see {@link #isRefreshNeeded()} + */ + public Status(boolean available, boolean refreshNeeded) { + this.available = available; + this.refreshNeeded = refreshNeeded; + } + + /** + * Returns true if the SDK believes the data store is now available. + *

+ * This property is normally true. If the SDK receives an exception while trying to query or update the data + * store, then it sets this property to false (notifying listeners, if any) and polls the store at intervals + * until a query succeeds. Once it succeeds, it sets the property back to true (again notifying listeners). + * + * @return true if store is available + */ + public boolean isAvailable() { + return available; + } + + /** + * Returns true if the store may be out of date due to a previous outage, so the SDK should attempt to refresh + * all feature flag data and rewrite it to the store. + *

+ * This property is not meaningful to application code. + * + * @return true if data should be rewritten + */ + public boolean isRefreshNeeded() { + return refreshNeeded; + } + } + + /** + * Interface for receiving status change notifications. + */ + public static interface StatusListener { + /** + * Called when the store status has changed. + * @param newStatus the new status + */ + public void dataStoreStatusChanged(Status newStatus); + } + + /** + * A snapshot of cache statistics. The statistics are cumulative across the lifetime of the data store. + *

+ * This is based on the data provided by Guava's caching framework. The SDK currently uses Guava + * internally, but is not guaranteed to always do so, and to avoid embedding Guava API details in + * the SDK API this is provided as a separate class. + * + * @see DataStoreStatusProvider#getCacheStats() + * @see PersistentDataStoreBuilder#recordCacheStats(boolean) + * @since 4.12.0 + */ + public static final class CacheStats { + private final long hitCount; + private final long missCount; + private final long loadSuccessCount; + private final long loadExceptionCount; + private final long totalLoadTime; + private final long evictionCount; + + /** + * Constructs a new instance. + * + * @param hitCount number of queries that produced a cache hit + * @param missCount number of queries that produced a cache miss + * @param loadSuccessCount number of cache misses that loaded a value without an exception + * @param loadExceptionCount number of cache misses that tried to load a value but got an exception + * @param totalLoadTime number of nanoseconds spent loading new values + * @param evictionCount number of cache entries that have been evicted + */ + public CacheStats(long hitCount, long missCount, long loadSuccessCount, long loadExceptionCount, + long totalLoadTime, long evictionCount) { + this.hitCount = hitCount; + this.missCount = missCount; + this.loadSuccessCount = loadSuccessCount; + this.loadExceptionCount = loadExceptionCount; + this.totalLoadTime = totalLoadTime; + this.evictionCount = evictionCount; + } + + /** + * The number of data queries that received cached data instead of going to the underlying data store. + * @return the number of cache hits + */ + public long getHitCount() { + return hitCount; + } + + /** + * The number of data queries that did not find cached data and went to the underlying data store. + * @return the number of cache misses + */ + public long getMissCount() { + return missCount; + } + + /** + * The number of times a cache miss resulted in successfully loading a data store item (or finding + * that it did not exist in the store). + * @return the number of successful loads + */ + public long getLoadSuccessCount() { + return loadSuccessCount; + } + + /** + * The number of times that an error occurred while querying the underlying data store. + * @return the number of failed loads + */ + public long getLoadExceptionCount() { + return loadExceptionCount; + } + + /** + * The total number of nanoseconds that the cache has spent loading new values. + * @return total time spent for all cache loads + */ + public long getTotalLoadTime() { + return totalLoadTime; + } + + /** + * The number of times cache entries have been evicted. + * @return the number of evictions + */ + public long getEvictionCount() { + return evictionCount; + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof CacheStats)) { + return false; + } + CacheStats o = (CacheStats)other; + return hitCount == o.hitCount && missCount == o.missCount && loadSuccessCount == o.loadSuccessCount && + loadExceptionCount == o.loadExceptionCount && totalLoadTime == o.totalLoadTime && evictionCount == o.evictionCount; + } + + @Override + public int hashCode() { + return Objects.hash(hitCount, missCount, loadSuccessCount, loadExceptionCount, totalLoadTime, evictionCount); + } + + @Override + public String toString() { + return "{hit=" + hitCount + ", miss=" + missCount + ", loadSuccess=" + loadSuccessCount + + ", loadException=" + loadExceptionCount + ", totalLoadTime=" + totalLoadTime + ", evictionCount=" + evictionCount + "}"; + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java new file mode 100644 index 000000000..04fda02f9 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java @@ -0,0 +1,296 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.google.common.collect.ImmutableList; + +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; + +/** + * Types that are used by the {@link DataStore} interface. + * + * @since 5.0.0 + */ +public abstract class DataStoreTypes { + /** + * Represents a separately namespaced collection of storable data items. + *

+ * The SDK passes instances of this type to the data store to specify whether it is referring to + * a feature flag, a user segment, etc. The data store implementation should not look for a + * specific data kind (such as feature flags), but should treat all data kinds generically. + */ + public static final class DataKind { + private final String name; + private final Function serializer; + private final Function deserializer; + + /** + * A case-sensitive alphabetic string that uniquely identifies this data kind. + *

+ * This is in effect a namespace for a collection of items of the same kind. Item keys must be + * unique within that namespace. Persistent data store implementations could use this string + * as part of a composite key or table name. + * + * @return the namespace string + */ + public String getName() { + return name; + } + + /** + * Returns a serialized representation of an item of this kind. + *

+ * The SDK uses this function to generate the data that is stored by a {@link PersistentDataStore}. + * Store implementations normally do not need to call it, except in a special case described in the + * documentation for {@link PersistentDataStore} regarding deleted item placeholders. + * + * @param item an {@link ItemDescriptor} describing the object to be serialized + * @return the serialized representation + * @exception ClassCastException if the object is of the wrong class + */ + public String serialize(ItemDescriptor item) { + return serializer.apply(item); + } + + /** + * Creates an item of this kind from its serialized representation. + *

+ * The SDK uses this function to translate data that is returned by a {@link PersistentDataStore}. + * Store implementations do not normally need to call it, but there is a special case described in + * the documentation for {@link PersistentDataStore}, regarding updates. + *

+ * The returned {@link ItemDescriptor} has two properties: {@link ItemDescriptor#getItem()}, which + * is the deserialized object or a {@code null} value for a deleted item placeholder, and + * {@link ItemDescriptor#getVersion()}, which provides the object's version number regardless of + * whether it is deleted or not. + * + * @param s the serialized representation + * @return an {@link ItemDescriptor} describing the deserialized object + */ + public ItemDescriptor deserialize(String s) { + return deserializer.apply(s); + } + + /** + * Constructs a DataKind instance. + * + * @param name the value for {@link #getName()} + * @param serializer the function to use for {@link #serialize(DataStoreTypes.ItemDescriptor)} + * @param deserializer the function to use for {@link #deserialize(String)} + */ + public DataKind(String name, Function serializer, Function deserializer) { + this.name = name; + this.serializer = serializer; + this.deserializer = deserializer; + } + + @Override + public String toString() { + return "DataKind(" + name + ")"; + } + } + + /** + * A versioned item (or placeholder) storable in a {@link DataStore}. + *

+ * This is used for data stores that directly store objects as-is, as the default in-memory + * store does. Items are typed as {@code Object}; the store should not know or care what the + * actual object is. + *

+ * For any given key within a {@link DataKind}, there can be either an existing item with a + * version, or a "tombstone" placeholder representing a deleted item (also with a version). + * Deleted item placeholders are used so that if an item is first updated with version N and + * then deleted with version N+1, but the SDK receives those changes out of order, version N + * will not overwrite the deletion. + *

+ * Persistent data stores use {@link SerializedItemDescriptor} instead. + */ + public static final class ItemDescriptor { + private final int version; + private final Object item; + + /** + * Returns the version number of this data, provided by the SDK. + * + * @return the version number + */ + public int getVersion() { + return version; + } + + /** + * Returns the data item, or null if this is a placeholder for a deleted item. + * + * @return an object or null + */ + public Object getItem() { + return item; + } + + /** + * Constructs a new instance. + * + * @param version the version number + * @param item an object or null + */ + public ItemDescriptor(int version, Object item) { + this.version = version; + this.item = item; + } + + /** + * Convenience method for constructing a deleted item placeholder. + * + * @param version the version number + * @return an ItemDescriptor + */ + public static ItemDescriptor deletedItem(int version) { + return new ItemDescriptor(version, null); + } + + @Override + public boolean equals(Object o) { + if (o instanceof ItemDescriptor) { + ItemDescriptor other = (ItemDescriptor)o; + return version == other.version && Objects.equals(item, other.item); + } + return false; + } + + @Override + public String toString() { + return "ItemDescriptor(" + version + "," + item + ")"; + } + } + + /** + * A versioned item (or placeholder) storable in a {@link PersistentDataStore}. + *

+ * This is equivalent to {@link ItemDescriptor}, but is used for persistent data stores. The + * SDK will convert each data item to and from its serialized string form; the persistent data + * store deals only with the serialized form. + */ + public static final class SerializedItemDescriptor { + private final int version; + private final boolean deleted; + private final String serializedItem; + + /** + * Returns the version number of this data, provided by the SDK. + * @return the version number + */ + public int getVersion() { + return version; + } + + /** + * Returns true if this is a placeholder (tombstone) for a deleted item. If so, + * {@link #getSerializedItem()} will still contain a string representing the deleted item, but + * the persistent store implementation has the option of not storing it if it can represent the + * placeholder in a more efficient way. + * + * @return true if this is a deleted item placeholder + */ + public boolean isDeleted() { + return deleted; + } + + /** + * Returns the data item's serialized representation. This will never be null; for a deleted item + * placeholder, it will contain a special value that can be stored if necessary (see {@link #isDeleted()}). + * + * @return the serialized data or null + */ + public String getSerializedItem() { + return serializedItem; + } + + /** + * Constructs a new instance. + * + * @param version the version number + * @param deleted true if this is a deleted item placeholder + * @param serializedItem the serialized data (will not be null) + */ + public SerializedItemDescriptor(int version, boolean deleted, String serializedItem) { + this.version = version; + this.deleted = deleted; + this.serializedItem = serializedItem; + } + + @Override + public boolean equals(Object o) { + if (o instanceof SerializedItemDescriptor) { + SerializedItemDescriptor other = (SerializedItemDescriptor)o; + return version == other.version && deleted == other.deleted && + Objects.equals(serializedItem, other.serializedItem); + } + return false; + } + + @Override + public String toString() { + return "SerializedItemDescriptor(" + version + "," + deleted + "," + serializedItem + ")"; + } + } + + /** + * Wrapper for a set of storable items being passed to a data store. + *

+ * Since the generic type signature for the data set is somewhat complicated (it is an ordered + * list of key-value pairs where each key is a {@link DataKind}, and each value is another ordered + * list of key-value pairs for the individual data items), this type simplifies the declaration of + * data store methods and makes it easier to see what the type represents. + * + * @param will be {@link ItemDescriptor} or {@link SerializedItemDescriptor} + */ + public static final class FullDataSet { + private final Iterable>> data; + + /** + * Returns the wrapped data set. + * + * @return an enumeration of key-value pairs; may be empty, but will not be null + */ + public Iterable>> getData() { + return data; + } + + /** + * Constructs a new instance. + * + * @param data the data set + */ + public FullDataSet(Iterable>> data) { + this.data = data == null ? ImmutableList.of(): data; + } + } + + /** + * Wrapper for a set of storable items being passed to a data store, within a single + * {@link DataKind}. + * + * @param will be {@link ItemDescriptor} or {@link SerializedItemDescriptor} + */ + public static final class KeyedItems { + private final Iterable> items; + + /** + * Returns the wrapped data set. + * + * @return an enumeration of key-value pairs; may be empty, but will not be null + */ + public Iterable> getItems() { + return items; + } + + /** + * Constructs a new instance. + * + * @param items the data set + */ + public KeyedItems(Iterable> items) { + this.items = items == null ? ImmutableList.of() : items; + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreUpdates.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreUpdates.java new file mode 100644 index 000000000..409369833 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreUpdates.java @@ -0,0 +1,55 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; + +/** + * Interface that a data source implementation will use to push data into the underlying + * data store. + *

+ * This layer of indirection allows the SDK to perform any other necessary operations that must + * happen when data is updated, by providing its own implementation of {@link DataStoreUpdates}. + * + * @since 5.0.0 + */ +public interface DataStoreUpdates { + /** + * Overwrites the store's contents with a set of items for each collection. + *

+ * All previous data should be discarded, regardless of versioning. + *

+ * The update should be done atomically. If it cannot be done atomically, then the store + * must first add or update each item in the same order that they are given in the input + * data, and then delete any previously stored items that were not in the input data. + * + * @param allData a list of {@link DataStoreTypes.DataKind} instances and their corresponding data sets + */ + void init(FullDataSet allData); + + /** + * Updates or inserts an item in the specified collection. For updates, the object will only be + * updated if the existing version is less than the new version. + *

+ * The SDK may pass an {@link ItemDescriptor} that contains a null, to represent a placeholder + * for a deleted item. In that case, assuming the version is greater than any existing version of + * that item, the store should retain that placeholder rather than simply not storing anything. + * + * @param kind specifies which collection to use + * @param key the unique key for the item within that collection + * @param item the item to insert or update + */ + void upsert(DataKind kind, String key, ItemDescriptor item); + + /** + * Returns an object that provides status tracking for the data store, if applicable. + *

+ * For data stores that do not support status tracking (the in-memory store, or a custom implementation + * that is not based on the SDK's usual persistent data store mechanism), it returns a stub + * implementation that returns null from {@link DataStoreStatusProvider#getStoreStatus()} and + * false from {@link DataStoreStatusProvider#addStatusListener(com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.StatusListener)}. + * + * @return a {@link DataStoreStatusProvider} + */ + DataStoreStatusProvider getStatusProvider(); +} diff --git a/src/main/java/com/launchdarkly/client/interfaces/DiagnosticDescription.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DiagnosticDescription.java similarity index 70% rename from src/main/java/com/launchdarkly/client/interfaces/DiagnosticDescription.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/DiagnosticDescription.java index 5c8034163..5cbc0c832 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/DiagnosticDescription.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DiagnosticDescription.java @@ -1,15 +1,15 @@ -package com.launchdarkly.client.interfaces; +package com.launchdarkly.sdk.server.interfaces; -import com.launchdarkly.client.LDConfig; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.LDConfig; /** * Optional interface for components to describe their own configuration. *

* The SDK uses a simplified JSON representation of its configuration when recording diagnostics data. - * Any class that implements {@link com.launchdarkly.client.FeatureStoreFactory}, - * {@link com.launchdarkly.client.UpdateProcessorFactory}, {@link com.launchdarkly.client.EventProcessorFactory}, - * or {@link com.launchdarkly.client.interfaces.PersistentDataStoreFactory} may choose to contribute + * Any class that implements {@link com.launchdarkly.sdk.server.interfaces.DataStoreFactory}, + * {@link com.launchdarkly.sdk.server.interfaces.DataSourceFactory}, {@link com.launchdarkly.sdk.server.interfaces.EventProcessorFactory}, + * or {@link com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory} may choose to contribute * values to this representation, although the SDK may or may not use them. For components that do not * implement this interface, the SDK may instead describe them using {@code getClass().getSimpleName()}. *

diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/Event.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/Event.java new file mode 100644 index 000000000..0cba86ac8 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/Event.java @@ -0,0 +1,248 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.LDClientInterface; + +/** + * Base class for all analytics events that are generated by the client. Also defines all of its own subclasses. + * + * Applications do not need to reference these types directly. They are used internally in analytics event + * processing, and are visible only to support writing a custom implementation of {@link EventProcessor} if + * desired. + */ +public class Event { + private final long creationDate; + private final LDUser user; + + /** + * Base event constructor. + * @param creationDate the timestamp in milliseconds + * @param user the user associated with the event + */ + public Event(long creationDate, LDUser user) { + this.creationDate = creationDate; + this.user = user; + } + + /** + * The event timestamp. + * @return the timestamp in milliseconds + */ + public long getCreationDate() { + return creationDate; + } + + /** + * The user associated with the event. + * @return the user object + */ + public LDUser getUser() { + return user; + } + + /** + * A custom event created with {@link LDClientInterface#track(String, LDUser)} or one of its overloads. + */ + public static final class Custom extends Event { + private final String key; + private final LDValue data; + private final Double metricValue; + + /** + * Constructs a custom event. + * @param timestamp the timestamp in milliseconds + * @param key the event key + * @param user the user associated with the event + * @param data custom data if any (null is the same as {@link LDValue#ofNull()}) + * @param metricValue custom metric value if any + * @since 4.8.0 + */ + public Custom(long timestamp, String key, LDUser user, LDValue data, Double metricValue) { + super(timestamp, user); + this.key = key; + this.data = data == null ? LDValue.ofNull() : data; + this.metricValue = metricValue; + } + + /** + * The custom event key. + * @return the event key + */ + public String getKey() { + return key; + } + + /** + * The custom data associated with the event, if any. + * @return the event data (null is equivalent to {@link LDValue#ofNull()}) + */ + public LDValue getData() { + return data; + } + + /** + * The numeric metric value associated with the event, if any. + * @return the metric value or null + */ + public Double getMetricValue() { + return metricValue; + } + } + + /** + * An event created with {@link LDClientInterface#identify(LDUser)}. + */ + public static final class Identify extends Event { + /** + * Constructs an identify event. + * @param timestamp the timestamp in milliseconds + * @param user the user associated with the event + */ + public Identify(long timestamp, LDUser user) { + super(timestamp, user); + } + } + + /** + * An event created internally by the SDK to hold user data that may be referenced by multiple events. + */ + public static final class Index extends Event { + /** + * Constructs an index event. + * @param timestamp the timestamp in milliseconds + * @param user the user associated with the event + */ + public Index(long timestamp, LDUser user) { + super(timestamp, user); + } + } + + /** + * An event generated by a feature flag evaluation. + */ + public static final class FeatureRequest extends Event { + private final String key; + private final int variation; + private final LDValue value; + private final LDValue defaultVal; + private final int version; + private final String prereqOf; + private final boolean trackEvents; + private final long debugEventsUntilDate; + private final EvaluationReason reason; + private final boolean debug; + + /** + * Constructs a feature request event. + * @param timestamp the timestamp in milliseconds + * @param key the flag key + * @param user the user associated with the event + * @param version the flag version, or -1 if the flag was not found + * @param variation the result variation, or -1 if there was an error + * @param value the result value + * @param defaultVal the default value passed by the application + * @param reason the evaluation reason, if it is to be included in the event + * @param prereqOf if this flag was evaluated as a prerequisite, this is the key of the flag that referenced it + * @param trackEvents true if full event tracking is turned on for this flag + * @param debugEventsUntilDate if non-null, the time until which event debugging should be enabled + * @param debug true if this is a debugging event + * @since 4.8.0 + */ + public FeatureRequest(long timestamp, String key, LDUser user, int version, int variation, LDValue value, + LDValue defaultVal, EvaluationReason reason, String prereqOf, boolean trackEvents, long debugEventsUntilDate, boolean debug) { + super(timestamp, user); + this.key = key; + this.version = version; + this.variation = variation; + this.value = value; + this.defaultVal = defaultVal; + this.prereqOf = prereqOf; + this.trackEvents = trackEvents; + this.debugEventsUntilDate = debugEventsUntilDate; + this.reason = reason; + this.debug = debug; + } + + /** + * The key of the feature flag that was evaluated. + * @return the flag key + */ + public String getKey() { + return key; + } + + /** + * The index of the selected flag variation, or -1 if the application default value was used. + * @return zero-based index of the variation, or -1 + */ + public int getVariation() { + return variation; + } + + /** + * The value of the selected flag variation. + * @return the value + */ + public LDValue getValue() { + return value; + } + + /** + * The application default value used in the evaluation. + * @return the application default + */ + public LDValue getDefaultVal() { + return defaultVal; + } + + /** + * The version of the feature flag that was evaluated, or -1 if the flag was not found. + * @return the flag version or null + */ + public int getVersion() { + return version; + } + + /** + * If this flag was evaluated as a prerequisite for another flag, the key of the other flag. + * @return a flag key or null + */ + public String getPrereqOf() { + return prereqOf; + } + + /** + * True if full event tracking is enabled for this flag. + * @return true if full event tracking is on + */ + public boolean isTrackEvents() { + return trackEvents; + } + + /** + * If debugging is enabled for this flag, the Unix millisecond time at which to stop debugging. + * @return a timestamp or zero + */ + public long getDebugEventsUntilDate() { + return debugEventsUntilDate; + } + + /** + * The {@link EvaluationReason} for this evaluation, or null if the reason was not requested for this evaluation. + * @return a reason object or null + */ + public EvaluationReason getReason() { + return reason; + } + + /** + * True if this event was generated due to debugging being enabled. + * @return true if this is a debug event + */ + public boolean isDebug() { + return debug; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/EventProcessor.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/EventProcessor.java similarity index 51% rename from src/main/java/com/launchdarkly/client/EventProcessor.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/EventProcessor.java index 2b756cda6..656964257 100644 --- a/src/main/java/com/launchdarkly/client/EventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/EventProcessor.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server.interfaces; import java.io.Closeable; @@ -20,26 +20,4 @@ public interface EventProcessor extends Closeable { * any events that were not yet delivered prior to shutting down. */ void flush(); - - /** - * Stub implementation of {@link EventProcessor} for when we don't want to send any events. - * - * @deprecated Use {@link Components#noEvents()}. - */ - // This was exposed because everything in an interface is public. The SDK itself no longer refers to this class; - // instead it uses Components.NullEventProcessor. - @Deprecated - static final class NullEventProcessor implements EventProcessor { - @Override - public void sendEvent(Event e) { - } - - @Override - public void flush() { - } - - @Override - public void close() { - } - } } diff --git a/src/main/java/com/launchdarkly/client/EventProcessorFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/EventProcessorFactory.java similarity index 54% rename from src/main/java/com/launchdarkly/client/EventProcessorFactory.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/EventProcessorFactory.java index 3d76b5aad..afc247770 100644 --- a/src/main/java/com/launchdarkly/client/EventProcessorFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/EventProcessorFactory.java @@ -1,4 +1,6 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.Components; /** * Interface for a factory that creates some implementation of {@link EventProcessor}. @@ -8,9 +10,9 @@ public interface EventProcessorFactory { /** * Creates an implementation instance. - * @param sdkKey the SDK key for your LaunchDarkly environment - * @param config the LaunchDarkly configuration + * + * @param context allows access to the client configuration * @return an {@link EventProcessor} */ - EventProcessor createEventProcessor(String sdkKey, LDConfig config); + EventProcessor createEventProcessor(ClientContext context); } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeEvent.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeEvent.java new file mode 100644 index 000000000..b72a49c4f --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeEvent.java @@ -0,0 +1,38 @@ +package com.launchdarkly.sdk.server.interfaces; + +/** + * Parameter class used with {@link FlagChangeListener}. + *

+ * This is not an analytics event to be sent to LaunchDarkly; it is a notification to the application. + * + * @since 5.0.0 + * @see FlagChangeListener + * @see FlagValueChangeEvent + * @see com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener) + * @see com.launchdarkly.sdk.server.LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) + */ +public class FlagChangeEvent { + private final String key; + + /** + * Constructs a new instance. + * + * @param key the feature flag key + */ + public FlagChangeEvent(String key) { + this.key = key; + } + + /** + * Returns the key of the feature flag whose configuration has changed. + *

+ * The specified flag may have been modified directly, or this may be an indirect change due to a change + * in some other flag that is a prerequisite for this flag, or a user segment that is referenced in the + * flag's rules. + * + * @return the flag key + */ + public String getKey() { + return key; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeListener.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeListener.java new file mode 100644 index 000000000..42f8093cd --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeListener.java @@ -0,0 +1,29 @@ +package com.launchdarkly.sdk.server.interfaces; + +/** + * An event listener that is notified when a feature flag's configuration has changed. + *

+ * As described in {@link com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener)}, + * this notification does not mean that the flag now returns a different value for any particular user, + * only that it may do so. LaunchDarkly feature flags can be configured to return a single value + * for all users, or to have complex targeting behavior. To know what effect the change would have for + * any given set of user properties, you would need to re-evaluate the flag by calling one of the + * {@code variation} methods on the client. + *

+ * In simple use cases where you know that the flag configuration does not vary per user, or where you + * know ahead of time what user properties you will evaluate the flag with, it may be more convenient + * to use {@link FlagValueChangeListener}. + * + * @since 5.0.0 + * @see FlagValueChangeListener + * @see com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener) + * @see com.launchdarkly.sdk.server.LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) + */ +public interface FlagChangeListener { + /** + * The SDK calls this method when a feature flag's configuration has changed in some way. + * + * @param event the event parameters + */ + void onFlagChange(FlagChangeEvent event); +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeEvent.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeEvent.java new file mode 100644 index 000000000..81767de46 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeEvent.java @@ -0,0 +1,64 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.LDValue; + +/** + * Parameter class used with {@link FlagValueChangeListener}. + *

+ * This is not an analytics event to be sent to LaunchDarkly; it is a notification to the application. + * + * @since 5.0.0 + * @see FlagValueChangeListener + * @see com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener) + * @see com.launchdarkly.sdk.server.LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) + * @see com.launchdarkly.sdk.server.Components#flagValueMonitoringListener(com.launchdarkly.sdk.server.LDClientInterface, String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener) + */ +public class FlagValueChangeEvent extends FlagChangeEvent { + private final LDValue oldValue; + private final LDValue newValue; + + /** + * Constructs a new instance. + * + * @param key the feature flag key + * @param oldValue the previous flag value + * @param newValue the new flag value + */ + public FlagValueChangeEvent(String key, LDValue oldValue, LDValue newValue) { + super(key); + this.oldValue = LDValue.normalize(oldValue); + this.newValue = LDValue.normalize(newValue); + } + + /** + * Returns the last known value of the flag for the specified user prior to the update. + *

+ * Since flag values can be of any JSON data type, this is represented as {@link LDValue}. That class + * has methods for converting to a primitive Java type such {@link LDValue#booleanValue()}. + *

+ * If the flag did not exist before or could not be evaluated, this will be {@link LDValue#ofNull()}. + * Note that there is no application default value parameter as there is for the {@code variation} + * methods; it is up to your code to substitute whatever fallback value is appropriate. + * + * @return the previous flag value + */ + public LDValue getOldValue() { + return oldValue; + } + + /** + * Returns the new value of the flag for the specified user. + *

+ * Since flag values can be of any JSON data type, this is represented as {@link LDValue}. That class + * has methods for converting to a primitive Java type such {@link LDValue#booleanValue()}. + *

+ * If the flag was deleted or could not be evaluated, this will be {@link LDValue#ofNull()}. + * Note that there is no application default value parameter as there is for the {@code variation} + * methods; it is up to your code to substitute whatever fallback value is appropriate. + * + * @return the new flag value + */ + public LDValue getNewValue() { + return newValue; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java new file mode 100644 index 000000000..d5390828c --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java @@ -0,0 +1,42 @@ +package com.launchdarkly.sdk.server.interfaces; + +/** + * An event listener that is notified when a feature flag's value has changed for a specific user. + *

+ * Use this in conjunction with + * {@link com.launchdarkly.sdk.server.Components#flagValueMonitoringListener(com.launchdarkly.sdk.server.LDClientInterface, String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener)} + * if you want the client to re-evaluate a flag for a specific set of user properties whenever + * the flag's configuration has changed, and notify you only if the new value is different from the old + * value. The listener will not be notified if the flag's configuration is changed in some way that does + * not affect its value for that user. + * + *


+ *     String flagKey = "my-important-flag";
+ *     LDUser userForFlagEvaluation = new LDUser("user-key-for-global-flag-state");
+ *     FlagValueChangeListener listenForNewValue = event -> {
+ *         if (event.getKey().equals(flagKey)) {
+ *             doSomethingWithNewValue(event.getNewValue().booleanValue());
+ *         }
+ *     };
+ *     client.registerFlagChangeListener(Components.flagValueMonitoringListener(
+ *         client, flagKey, userForFlagEvaluation, listenForNewValue));
+ * 
+ * + * In the above example, the value provided in {@code event.getNewValue()} is the result of calling + * {@code client.jsonValueVariation(flagKey, userForFlagEvaluation, LDValue.ofNull())} after the flag + * has changed. + * + * @since 5.0.0 + * @see FlagChangeListener + * @see com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener) + * @see com.launchdarkly.sdk.server.LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) + * @see com.launchdarkly.sdk.server.Components#flagValueMonitoringListener(com.launchdarkly.sdk.server.LDClientInterface, String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener) + */ +public interface FlagValueChangeListener { + /** + * The SDK calls this method when a feature flag's value has changed with regard to the specified user. + * + * @param event the event parameters + */ + void onFlagValueChange(FlagValueChangeEvent event); +} diff --git a/src/main/java/com/launchdarkly/client/interfaces/HttpAuthentication.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpAuthentication.java similarity index 96% rename from src/main/java/com/launchdarkly/client/interfaces/HttpAuthentication.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/HttpAuthentication.java index 879a201ec..f0bf34d3a 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/HttpAuthentication.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpAuthentication.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.interfaces; +package com.launchdarkly.sdk.server.interfaces; /** * Represents a supported method of HTTP authentication, including proxy authentication. diff --git a/src/main/java/com/launchdarkly/client/interfaces/HttpConfiguration.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfiguration.java similarity index 82% rename from src/main/java/com/launchdarkly/client/interfaces/HttpConfiguration.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfiguration.java index 9d0cb31df..abda924f5 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/HttpConfiguration.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfiguration.java @@ -1,8 +1,9 @@ -package com.launchdarkly.client.interfaces; +package com.launchdarkly.sdk.server.interfaces; -import com.launchdarkly.client.integrations.HttpConfigurationBuilder; +import com.launchdarkly.sdk.server.integrations.HttpConfigurationBuilder; import java.net.Proxy; +import java.time.Duration; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.X509TrustManager; @@ -19,9 +20,9 @@ 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 + * @return the connection timeout; must not be null */ - int getConnectTimeoutMillis(); + Duration getConnectTimeout(); /** * The proxy configuration, if any. @@ -40,12 +41,12 @@ public interface HttpConfiguration { /** * 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 + * used by {@link com.launchdarkly.sdk.server.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 + * @return the socket timeout; must not be null */ - int getSocketTimeoutMillis(); + Duration getSocketTimeout(); /** * The configured socket factory for secure connections. diff --git a/src/main/java/com/launchdarkly/client/interfaces/HttpConfigurationFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfigurationFactory.java similarity index 59% rename from src/main/java/com/launchdarkly/client/interfaces/HttpConfigurationFactory.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfigurationFactory.java index ade4a5d48..58e912a61 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/HttpConfigurationFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfigurationFactory.java @@ -1,10 +1,10 @@ -package com.launchdarkly.client.interfaces; +package com.launchdarkly.sdk.server.interfaces; /** * Interface for a factory that creates an {@link HttpConfiguration}. * - * @see com.launchdarkly.client.Components#httpConfiguration() - * @see com.launchdarkly.client.LDConfig.Builder#http(HttpConfigurationFactory) + * @see com.launchdarkly.sdk.server.Components#httpConfiguration() + * @see com.launchdarkly.sdk.server.LDConfig.Builder#http(HttpConfigurationFactory) * @since 4.13.0 */ public interface HttpConfigurationFactory { diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStore.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStore.java new file mode 100644 index 000000000..410baa062 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStore.java @@ -0,0 +1,138 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; + +import java.io.Closeable; + +/** + * Interface for a data store that holds feature flags and related data in a serialized form. + *

+ * This interface should be used for database integrations, or any other data store + * implementation that stores data in some external service. The SDK will take care of + * converting between its own internal data model and a serialized string form; the data + * store interacts only with the serialized form. The SDK will also provide its own caching + * layer on top of the persistent data store; the data store implementation should not + * provide caching, but simply do every query or update that the SDK tells it to do. + *

+ * Implementations must be thread-safe. + *

+ * Conceptually, each item in the store is a {@link SerializedItemDescriptor} which always has + * a version number, and can represent either a serialized object or a placeholder (tombstone) + * for a deleted item. There are two approaches a persistent store implementation can use for + * persisting this data: + * + * 1. Preferably, it should store the version number and the {@link SerializedItemDescriptor#isDeleted()} + * state separately so that the object does not need to be fully deserialized to read them. In + * this case, deleted item placeholders can ignore the value of {@link SerializedItemDescriptor#getSerializedItem()} + * on writes and can set it to null on reads. The store should never call {@link DataKind#deserialize(String)} + * or {@link DataKind#serialize(DataStoreTypes.ItemDescriptor)}. + * + * 2. If that isn't possible, then the store should simply persist the exact string from + * {@link SerializedItemDescriptor#getSerializedItem()} on writes, and return the persisted + * string on reads (returning zero for the version and false for {@link SerializedItemDescriptor#isDeleted()}). + * The string is guaranteed to provide the SDK with enough information to infer the version and + * the deleted state. On updates, the store must call {@link DataKind#deserialize(String)} in + * order to inspect the version number of the existing item if any. + * + * @since 5.0.0 + */ +public interface PersistentDataStore extends Closeable { + /** + * Overwrites the store's contents with a set of items for each collection. + *

+ * All previous data should be discarded, regardless of versioning. + *

+ * The update should be done atomically. If it cannot be done atomically, then the store + * must first add or update each item in the same order that they are given in the input + * data, and then delete any previously stored items that were not in the input data. + * + * @param allData a list of {@link DataStoreTypes.DataKind} instances and their corresponding data sets + */ + void init(FullDataSet allData); + + /** + * Retrieves an item from the specified collection, if available. + *

+ * If the key is not known at all, the method should return null. Otherwise, it should return + * a {@link SerializedItemDescriptor} as follows: + *

+ * 1. If the version number and deletion state can be determined without fully deserializing + * the item, then the store should set those properties in the {@link SerializedItemDescriptor} + * (and can set {@link SerializedItemDescriptor#getSerializedItem()} to null for deleted items). + *

+ * 2. Otherwise, it should simply set {@link SerializedItemDescriptor#getSerializedItem()} to + * the exact string that was persisted, and can leave the other properties as zero/false. See + * comments on {@link PersistentDataStore} for more about this. + * + * @param kind specifies which collection to use + * @param key the unique key of the item within that collection + * @return a versioned item that contains the stored data (or placeholder for deleted data); + * null if the key is unknown + */ + SerializedItemDescriptor get(DataKind kind, String key); + + /** + * Retrieves all items from the specified collection. + *

+ * If the store contains placeholders for deleted items, it should include them in the results, + * not filter them out. See {@link #get(DataStoreTypes.DataKind, String)} for how to set the properties of the + * {@link SerializedItemDescriptor} for each item. + * + * @param kind specifies which collection to use + * @return a collection of key-value pairs; the ordering is not significant + */ + KeyedItems getAll(DataKind kind); + + /** + * Updates or inserts an item in the specified collection. + *

+ * If the given key already exists in that collection, the store must check the version number + * of the existing item (even if it is a deleted item placeholder); if that version is greater + * than or equal to the version of the new item, the update fails and the method returns false. + * If the store is not able to determine the version number of an existing item without fully + * deserializing the existing item, then it is allowed to call {@link DataKind#deserialize(String)} + * for that purpose. + *

+ * If the item's {@link SerializedItemDescriptor#isDeleted()} method returns true, this is a + * deleted item placeholder. The store must persist this, rather than simply removing the key + * from the store. The SDK will provide a string in {@link SerializedItemDescriptor#getSerializedItem()} + * which the store can persist for this purpose; or, if the store is capable of persisting the + * version number and deleted state without storing anything else, it should do so. + * + * @param kind specifies which collection to use + * @param key the unique key for the item within that collection + * @param item the item to insert or update + * @return true if the item was updated; false if it was not updated because the store contains + * an equal or greater version + */ + boolean upsert(DataKind kind, String key, SerializedItemDescriptor item); + + /** + * Returns true if this store has been initialized. + *

+ * In a shared data store, the implementation should be able to detect this state even if + * {@link #init} was called in a different process, i.e. it must query the underlying + * data store in some way. The method does not need to worry about caching this value; the SDK + * will call it rarely. + * + * @return true if the store has been initialized + */ + boolean isInitialized(); + + /** + * Tests whether the data store seems to be functioning normally. + *

+ * This should not be a detailed test of different kinds of operations, but just the smallest possible + * operation to determine whether (for instance) we can reach the database. + *

+ * Whenever one of the store's other methods throws an exception, the SDK will assume that it may have + * become unavailable (e.g. the database connection was lost). The SDK will then call + * {@link #isStoreAvailable()} at intervals until it returns true. + * + * @return true if the underlying data store is reachable + */ + public boolean isStoreAvailable(); +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStoreFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStoreFactory.java new file mode 100644 index 000000000..f86b7b788 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStoreFactory.java @@ -0,0 +1,23 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; + +/** + * Interface for a factory that creates some implementation of a persistent data store. + *

+ * This interface is implemented by database integrations. Usage is described in + * {@link com.launchdarkly.sdk.server.Components#persistentDataStore}. + * + * @see com.launchdarkly.sdk.server.Components + * @since 4.12.0 + */ +public interface PersistentDataStoreFactory { + /** + * Called internally from {@link PersistentDataStoreBuilder} to create the implementation object + * for the specific type of data store. + * + * @param context allows access to the client configuration + * @return the implementation object + */ + PersistentDataStore createPersistentDataStore(ClientContext context); +} diff --git a/src/main/java/com/launchdarkly/client/interfaces/SerializationException.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/SerializationException.java similarity index 94% rename from src/main/java/com/launchdarkly/client/interfaces/SerializationException.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/SerializationException.java index 0473c991f..89256a6fd 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/SerializationException.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/SerializationException.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.interfaces; +package com.launchdarkly.sdk.server.interfaces; /** * General exception class for all errors in serializing or deserializing JSON. diff --git a/src/main/java/com/launchdarkly/client/interfaces/package-info.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java similarity index 83% rename from src/main/java/com/launchdarkly/client/interfaces/package-info.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java index d798dc8f0..5d38c8803 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/package-info.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java @@ -4,4 +4,4 @@ * You will not need to refer to these types in your code unless you are creating a * plug-in component, such as a database integration. */ -package com.launchdarkly.client.interfaces; +package com.launchdarkly.sdk.server.interfaces; diff --git a/src/main/java/com/launchdarkly/sdk/server/package-info.java b/src/main/java/com/launchdarkly/sdk/server/package-info.java new file mode 100644 index 000000000..501216981 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/package-info.java @@ -0,0 +1,10 @@ +/** + * Main package for the LaunchDarkly Server-Side Java SDK, containing the client and configuration classes. + *

+ * You will most often use {@link com.launchdarkly.sdk.server.LDClient} (the SDK client) and + * {@link com.launchdarkly.sdk.server.LDConfig} (configuration options for the client). + *

+ * Other commonly used types such as {@link com.launchdarkly.sdk.LDUser} are in the {@code com.launchdarkly.sdk} + * package, since those are not server-side-specific and are shared with the LaunchDarkly Android SDK. + */ +package com.launchdarkly.sdk.server; diff --git a/src/test/java/com/launchdarkly/client/DataStoreTestTypes.java b/src/test/java/com/launchdarkly/client/DataStoreTestTypes.java deleted file mode 100644 index bd0595def..000000000 --- a/src/test/java/com/launchdarkly/client/DataStoreTestTypes.java +++ /dev/null @@ -1,117 +0,0 @@ -package com.launchdarkly.client; - -import com.google.common.base.Objects; - -@SuppressWarnings("javadoc") -public class DataStoreTestTypes { - public static class TestItem implements VersionedData { - public final String name; - public final String key; - public final int version; - public final boolean deleted; - - public TestItem(String name, String key, int version) { - this(name, key, version, false); - } - - public TestItem(String name, String key, int version, boolean deleted) { - this.name = name; - this.key = key; - this.version = version; - this.deleted = deleted; - } - - @Override - public String getKey() { - return key; - } - - @Override - public int getVersion() { - return version; - } - - @Override - public boolean isDeleted() { - return deleted; - } - - public TestItem withName(String newName) { - return new TestItem(newName, key, version, deleted); - } - - public TestItem withVersion(int newVersion) { - return new TestItem(name, key, newVersion, deleted); - } - - public TestItem withDeleted(boolean newDeleted) { - return new TestItem(name, key, version, newDeleted); - } - - @Override - public boolean equals(Object other) { - if (other instanceof TestItem) { - TestItem o = (TestItem)other; - return Objects.equal(name, o.name) && - Objects.equal(key, o.key) && - version == o.version && - deleted == o.deleted; - } - return false; - } - - @Override - public int hashCode() { - return Objects.hashCode(name, key, version, deleted); - } - - @Override - public String toString() { - return "TestItem(" + name + "," + key + "," + version + "," + deleted + ")"; - } - } - - public static final VersionedDataKind TEST_ITEMS = new VersionedDataKind() { - @Override - public String getNamespace() { - return "test-items"; - } - - @Override - public Class getItemClass() { - return TestItem.class; - } - - @Override - public String getStreamApiPath() { - return null; - } - - @Override - public TestItem makeDeletedItem(String key, int version) { - return new TestItem(null, key, version, true); - } - }; - - public static final VersionedDataKind OTHER_TEST_ITEMS = new VersionedDataKind() { - @Override - public String getNamespace() { - return "other-test-items"; - } - - @Override - public Class getItemClass() { - return TestItem.class; - } - - @Override - public String getStreamApiPath() { - return null; - } - - @Override - public TestItem makeDeletedItem(String key, int version) { - return new TestItem(null, key, version, true); - } - }; -} diff --git a/src/test/java/com/launchdarkly/client/DeprecatedRedisFeatureStoreTest.java b/src/test/java/com/launchdarkly/client/DeprecatedRedisFeatureStoreTest.java deleted file mode 100644 index 058b252d0..000000000 --- a/src/test/java/com/launchdarkly/client/DeprecatedRedisFeatureStoreTest.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.launchdarkly.client; - -import com.google.common.cache.CacheStats; - -import org.junit.BeforeClass; -import org.junit.Test; - -import java.net.URI; - -import static com.launchdarkly.client.VersionedDataKind.FEATURES; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.is; -import static org.junit.Assume.assumeThat; -import static org.junit.Assume.assumeTrue; - -import redis.clients.jedis.Jedis; - -@SuppressWarnings({ "javadoc", "deprecation" }) -public class DeprecatedRedisFeatureStoreTest extends FeatureStoreDatabaseTestBase { - - private static final URI REDIS_URI = URI.create("redis://localhost:6379"); - - public DeprecatedRedisFeatureStoreTest(boolean cached) { - super(cached); - } - - @BeforeClass - public static void maybeSkipDatabaseTests() { - String skipParam = System.getenv("LD_SKIP_DATABASE_TESTS"); - assumeTrue(skipParam == null || skipParam.equals("")); - } - - @Override - protected RedisFeatureStore makeStore() { - RedisFeatureStoreBuilder builder = new RedisFeatureStoreBuilder(REDIS_URI); - builder.caching(cached ? FeatureStoreCacheConfig.enabled().ttlSeconds(30) : FeatureStoreCacheConfig.disabled()); - return builder.build(); - } - - @Override - protected RedisFeatureStore makeStoreWithPrefix(String prefix) { - return new RedisFeatureStoreBuilder(REDIS_URI).caching(FeatureStoreCacheConfig.disabled()).prefix(prefix).build(); - } - - @Override - protected void clearAllData() { - try (Jedis client = new Jedis("localhost")) { - client.flushDB(); - } - } - - @Test - public void canGetCacheStats() { - assumeThat(cached, is(true)); - - CacheStats stats = store.getCacheStats(); - - assertThat(stats, equalTo(new CacheStats(0, 0, 0, 0, 0, 0))); - - // Cause a cache miss - store.get(FEATURES, "key1"); - stats = store.getCacheStats(); - assertThat(stats.hitCount(), equalTo(0L)); - assertThat(stats.missCount(), equalTo(1L)); - assertThat(stats.loadSuccessCount(), equalTo(1L)); // even though it's a miss, it's a "success" because there was no exception - assertThat(stats.loadExceptionCount(), equalTo(0L)); - - // Cause a cache hit - store.upsert(FEATURES, new FeatureFlagBuilder("key2").version(1).build()); // inserting the item also caches it - store.get(FEATURES, "key2"); // now it's a cache hit - stats = store.getCacheStats(); - assertThat(stats.hitCount(), equalTo(1L)); - assertThat(stats.missCount(), equalTo(1L)); - assertThat(stats.loadSuccessCount(), equalTo(1L)); - assertThat(stats.loadExceptionCount(), equalTo(0L)); - - // We have no way to force a load exception with a real Redis store - } -} diff --git a/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java b/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java deleted file mode 100644 index f1b409294..000000000 --- a/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java +++ /dev/null @@ -1,200 +0,0 @@ -package com.launchdarkly.client; - -import com.google.gson.Gson; -import com.google.gson.JsonElement; - -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(); - String json = "{\"kind\":\"OFF\"}"; - assertJsonEqual(json, gson.toJson(reason)); - assertEquals("OFF", reason.toString()); - } - - @Test - public void testFallthroughSerialization() { - EvaluationReason reason = EvaluationReason.fallthrough(); - String json = "{\"kind\":\"FALLTHROUGH\"}"; - assertJsonEqual(json, gson.toJson(reason)); - assertEquals("FALLTHROUGH", reason.toString()); - } - - @Test - public void testTargetMatchSerialization() { - EvaluationReason reason = EvaluationReason.targetMatch(); - String json = "{\"kind\":\"TARGET_MATCH\"}"; - assertJsonEqual(json, gson.toJson(reason)); - assertEquals("TARGET_MATCH", reason.toString()); - } - - @Test - public void testRuleMatchSerialization() { - EvaluationReason reason = EvaluationReason.ruleMatch(1, "id"); - String json = "{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":\"id\"}"; - assertJsonEqual(json, gson.toJson(reason)); - assertEquals("RULE_MATCH(1,id)", reason.toString()); - } - - @Test - public void testPrerequisiteFailedSerialization() { - EvaluationReason reason = EvaluationReason.prerequisiteFailed("key"); - String json = "{\"kind\":\"PREREQUISITE_FAILED\",\"prerequisiteKey\":\"key\"}"; - assertJsonEqual(json, gson.toJson(reason)); - assertEquals("PREREQUISITE_FAILED(key)", reason.toString()); - } - - @Test - public void testErrorSerialization() { - EvaluationReason reason = EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND); - String json = "{\"kind\":\"ERROR\",\"errorKind\":\"FLAG_NOT_FOUND\"}"; - assertJsonEqual(json, gson.toJson(reason)); - assertEquals("ERROR(FLAG_NOT_FOUND)", reason.toString()); - } - - @Test - public void testErrorSerializationWithException() { - // We do *not* want the JSON representation to include the exception, because that is used in events, and - // the LD event service won't know what to do with that field (which will also contain a big stacktrace). - EvaluationReason reason = EvaluationReason.exception(new Exception("something happened")); - String json = "{\"kind\":\"ERROR\",\"errorKind\":\"EXCEPTION\"}"; - assertJsonEqual(json, gson.toJson(reason)); - assertEquals("ERROR(EXCEPTION,java.lang.Exception: something happened)", reason.toString()); - } - - @Test - public void errorInstancesAreReused() { - for (EvaluationReason.ErrorKind errorKind: EvaluationReason.ErrorKind.values()) { - EvaluationReason r0 = EvaluationReason.error(errorKind); - assertEquals(errorKind, r0.getErrorKind()); - EvaluationReason r1 = EvaluationReason.error(errorKind); - assertSame(r0, r1); - } - } - - private void assertJsonEqual(String expectedString, String actualString) { - JsonElement expected = gson.fromJson(expectedString, JsonElement.class); - JsonElement actual = gson.fromJson(actualString, JsonElement.class); - assertEquals(expected, actual); - } -} diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java deleted file mode 100644 index c8c072605..000000000 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ /dev/null @@ -1,679 +0,0 @@ -package com.launchdarkly.client; - -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableSet; -import com.google.gson.Gson; -import com.google.gson.JsonElement; -import com.launchdarkly.client.value.LDValue; - -import org.junit.Before; -import org.junit.Test; - -import java.util.Arrays; - -import static com.launchdarkly.client.EvaluationDetail.fromValue; -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; -import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; -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 FeatureFlagTest { - - private static LDUser BASE_USER = new LDUser.Builder("x").build(); - - private FeatureStore featureStore; - - @Before - public void before() { - featureStore = new InMemoryFeatureStore(); - } - - @Test - public void flagReturnsOffVariationIfFlagIsOff() throws Exception { - FeatureFlag f = new FeatureFlagBuilder("feature") - .on(false) - .offVariation(1) - .fallthrough(fallthroughVariation(0)) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .build(); - FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - - assertEquals(fromValue(LDValue.of("off"), 1, EvaluationReason.off()), result.getDetails()); - assertEquals(0, result.getPrerequisiteEvents().size()); - } - - @Test - public void flagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() throws Exception { - FeatureFlag f = new FeatureFlagBuilder("feature") - .on(false) - .fallthrough(fallthroughVariation(0)) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .build(); - FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - - assertEquals(fromValue(LDValue.ofNull(), null, EvaluationReason.off()), result.getDetails()); - assertEquals(0, result.getPrerequisiteEvents().size()); - } - - @Test - public void flagReturnsErrorIfFlagIsOffAndOffVariationIsTooHigh() throws Exception { - FeatureFlag f = new FeatureFlagBuilder("feature") - .on(false) - .offVariation(999) - .fallthrough(fallthroughVariation(0)) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .build(); - FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - - assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); - assertEquals(0, result.getPrerequisiteEvents().size()); - } - - @Test - public void flagReturnsErrorIfFlagIsOffAndOffVariationIsNegative() throws Exception { - FeatureFlag f = new FeatureFlagBuilder("feature") - .on(false) - .offVariation(-1) - .fallthrough(fallthroughVariation(0)) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .build(); - FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - - assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); - assertEquals(0, result.getPrerequisiteEvents().size()); - } - - @Test - public void flagReturnsFallthroughIfFlagIsOnAndThereAreNoRules() throws Exception { - FeatureFlag f = new FeatureFlagBuilder("feature") - .on(true) - .offVariation(1) - .fallthrough(fallthroughVariation(0)) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .build(); - FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - - assertEquals(fromValue(LDValue.of("fall"), 0, EvaluationReason.fallthrough()), result.getDetails()); - assertEquals(0, result.getPrerequisiteEvents().size()); - } - - @Test - public void flagReturnsErrorIfFallthroughHasTooHighVariation() throws Exception { - FeatureFlag f = new FeatureFlagBuilder("feature") - .on(true) - .offVariation(1) - .fallthrough(fallthroughVariation(999)) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .build(); - FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - - assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); - assertEquals(0, result.getPrerequisiteEvents().size()); - } - - @Test - public void flagReturnsErrorIfFallthroughHasNegativeVariation() throws Exception { - FeatureFlag f = new FeatureFlagBuilder("feature") - .on(true) - .offVariation(1) - .fallthrough(fallthroughVariation(-1)) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .build(); - FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - - assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); - assertEquals(0, result.getPrerequisiteEvents().size()); - } - - @Test - public void flagReturnsErrorIfFallthroughHasNeitherVariationNorRollout() throws Exception { - FeatureFlag f = new FeatureFlagBuilder("feature") - .on(true) - .offVariation(1) - .fallthrough(new VariationOrRollout(null, null)) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .build(); - FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - - assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); - assertEquals(0, result.getPrerequisiteEvents().size()); - } - - @Test - public void flagReturnsErrorIfFallthroughHasEmptyRolloutVariationList() throws Exception { - FeatureFlag f = new FeatureFlagBuilder("feature") - .on(true) - .offVariation(1) - .fallthrough(new VariationOrRollout(null, - new VariationOrRollout.Rollout(ImmutableList.of(), null))) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .build(); - FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - - assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); - assertEquals(0, result.getPrerequisiteEvents().size()); - } - - @Test - public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { - FeatureFlag f0 = new FeatureFlagBuilder("feature0") - .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .build(); - FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - - EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); - assertEquals(fromValue(LDValue.of("off"), 1, expectedReason), result.getDetails()); - assertEquals(0, result.getPrerequisiteEvents().size()); - } - - @Test - public void flagReturnsOffVariationAndEventIfPrerequisiteIsOff() throws Exception { - FeatureFlag f0 = new FeatureFlagBuilder("feature0") - .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .version(1) - .build(); - FeatureFlag f1 = new FeatureFlagBuilder("feature1") - .on(false) - .offVariation(1) - // note that even though it returns the desired variation, it is still off and therefore not a match - .fallthrough(fallthroughVariation(0)) - .variations(LDValue.of("nogo"), LDValue.of("go")) - .version(2) - .build(); - featureStore.upsert(FEATURES, f1); - FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - - EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); - assertEquals(fromValue(LDValue.of("off"), 1, expectedReason), result.getDetails()); - - assertEquals(1, result.getPrerequisiteEvents().size()); - Event.FeatureRequest event = result.getPrerequisiteEvents().get(0); - assertEquals(f1.getKey(), event.key); - assertEquals(LDValue.of("go"), event.value); - assertEquals(f1.getVersion(), event.version.intValue()); - assertEquals(f0.getKey(), event.prereqOf); - } - - @Test - public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Exception { - FeatureFlag f0 = new FeatureFlagBuilder("feature0") - .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .version(1) - .build(); - FeatureFlag f1 = new FeatureFlagBuilder("feature1") - .on(true) - .fallthrough(fallthroughVariation(0)) - .variations(LDValue.of("nogo"), LDValue.of("go")) - .version(2) - .build(); - featureStore.upsert(FEATURES, f1); - FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - - EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); - assertEquals(fromValue(LDValue.of("off"), 1, expectedReason), result.getDetails()); - - assertEquals(1, result.getPrerequisiteEvents().size()); - Event.FeatureRequest event = result.getPrerequisiteEvents().get(0); - assertEquals(f1.getKey(), event.key); - assertEquals(LDValue.of("nogo"), event.value); - assertEquals(f1.getVersion(), event.version.intValue()); - assertEquals(f0.getKey(), event.prereqOf); - } - - @Test - public void prerequisiteFailedReasonInstanceIsReusedForSamePrerequisite() throws Exception { - FeatureFlag f0 = new FeatureFlagBuilder("feature0") - .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .build(); - FeatureFlag.EvalResult result0 = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - FeatureFlag.EvalResult result1 = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - - EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); - assertEquals(expectedReason, result0.getDetails().getReason()); - assertSame(result0.getDetails().getReason(), result1.getDetails().getReason()); - } - - @Test - public void flagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAreNoRules() throws Exception { - FeatureFlag f0 = new FeatureFlagBuilder("feature0") - .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .version(1) - .build(); - FeatureFlag f1 = new FeatureFlagBuilder("feature1") - .on(true) - .fallthrough(fallthroughVariation(1)) - .variations(LDValue.of("nogo"), LDValue.of("go")) - .version(2) - .build(); - featureStore.upsert(FEATURES, f1); - FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - - assertEquals(fromValue(LDValue.of("fall"), 0, EvaluationReason.fallthrough()), result.getDetails()); - assertEquals(1, result.getPrerequisiteEvents().size()); - - Event.FeatureRequest event = result.getPrerequisiteEvents().get(0); - assertEquals(f1.getKey(), event.key); - assertEquals(LDValue.of("go"), event.value); - assertEquals(f1.getVersion(), event.version.intValue()); - assertEquals(f0.getKey(), event.prereqOf); - } - - @Test - public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exception { - FeatureFlag f0 = new FeatureFlagBuilder("feature0") - .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .version(1) - .build(); - FeatureFlag f1 = new FeatureFlagBuilder("feature1") - .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature2", 1))) - .fallthrough(fallthroughVariation(1)) - .variations(LDValue.of("nogo"), LDValue.of("go")) - .version(2) - .build(); - FeatureFlag f2 = new FeatureFlagBuilder("feature2") - .on(true) - .fallthrough(fallthroughVariation(1)) - .variations(LDValue.of("nogo"), LDValue.of("go")) - .version(3) - .build(); - featureStore.upsert(FEATURES, f1); - featureStore.upsert(FEATURES, f2); - FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - - assertEquals(fromValue(LDValue.of("fall"), 0, EvaluationReason.fallthrough()), result.getDetails()); - assertEquals(2, result.getPrerequisiteEvents().size()); - - Event.FeatureRequest event0 = result.getPrerequisiteEvents().get(0); - assertEquals(f2.getKey(), event0.key); - assertEquals(LDValue.of("go"), event0.value); - assertEquals(f2.getVersion(), event0.version.intValue()); - assertEquals(f1.getKey(), event0.prereqOf); - - Event.FeatureRequest event1 = result.getPrerequisiteEvents().get(1); - assertEquals(f1.getKey(), event1.key); - assertEquals(LDValue.of("go"), event1.value); - assertEquals(f1.getVersion(), event1.version.intValue()); - assertEquals(f0.getKey(), event1.prereqOf); - } - - @Test - public void flagMatchesUserFromTargets() throws Exception { - FeatureFlag f = new FeatureFlagBuilder("feature") - .on(true) - .targets(Arrays.asList(new Target(ImmutableSet.of("whoever", "userkey"), 2))) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .build(); - LDUser user = new LDUser.Builder("userkey").build(); - FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); - - assertEquals(fromValue(LDValue.of("on"), 2, EvaluationReason.targetMatch()), result.getDetails()); - assertEquals(0, result.getPrerequisiteEvents().size()); - } - - @Test - public void flagMatchesUserFromRules() { - Clause clause0 = new Clause("key", Operator.in, Arrays.asList(LDValue.of("wrongkey")), false); - Clause clause1 = new Clause("key", Operator.in, Arrays.asList(LDValue.of("userkey")), false); - Rule rule0 = new Rule("ruleid0", Arrays.asList(clause0), 2, null); - Rule rule1 = new Rule("ruleid1", Arrays.asList(clause1), 2, null); - FeatureFlag f = featureFlagWithRules("feature", rule0, rule1); - LDUser user = new LDUser.Builder("userkey").build(); - FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); - - assertEquals(fromValue(LDValue.of("on"), 2, EvaluationReason.ruleMatch(1, "ruleid1")), result.getDetails()); - assertEquals(0, result.getPrerequisiteEvents().size()); - } - - @Test - public void ruleMatchReasonInstanceIsReusedForSameRule() { - Clause clause0 = new Clause("key", Operator.in, Arrays.asList(LDValue.of("wrongkey")), false); - Clause clause1 = new Clause("key", Operator.in, Arrays.asList(LDValue.of("userkey")), false); - Rule rule0 = new Rule("ruleid0", Arrays.asList(clause0), 2, null); - Rule rule1 = new Rule("ruleid1", Arrays.asList(clause1), 2, null); - FeatureFlag f = featureFlagWithRules("feature", rule0, rule1); - LDUser user = new LDUser.Builder("userkey").build(); - LDUser otherUser = new LDUser.Builder("wrongkey").build(); - - FeatureFlag.EvalResult sameResult0 = f.evaluate(user, featureStore, EventFactory.DEFAULT); - FeatureFlag.EvalResult sameResult1 = f.evaluate(user, featureStore, EventFactory.DEFAULT); - FeatureFlag.EvalResult otherResult = f.evaluate(otherUser, featureStore, EventFactory.DEFAULT); - - assertEquals(EvaluationReason.ruleMatch(1, "ruleid1"), sameResult0.getDetails().getReason()); - assertSame(sameResult0.getDetails().getReason(), sameResult1.getDetails().getReason()); - - assertEquals(EvaluationReason.ruleMatch(0, "ruleid0"), otherResult.getDetails().getReason()); - } - - @Test - public void ruleWithTooHighVariationReturnsMalformedFlagError() { - Clause clause = new Clause("key", Operator.in, Arrays.asList(LDValue.of("userkey")), false); - Rule rule = new Rule("ruleid", Arrays.asList(clause), 999, null); - FeatureFlag f = featureFlagWithRules("feature", rule); - LDUser user = new LDUser.Builder("userkey").build(); - FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); - - assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); - assertEquals(0, result.getPrerequisiteEvents().size()); - } - - @Test - public void ruleWithNegativeVariationReturnsMalformedFlagError() { - Clause clause = new Clause("key", Operator.in, Arrays.asList(LDValue.of("userkey")), false); - Rule rule = new Rule("ruleid", Arrays.asList(clause), -1, null); - FeatureFlag f = featureFlagWithRules("feature", rule); - LDUser user = new LDUser.Builder("userkey").build(); - FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); - - assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); - assertEquals(0, result.getPrerequisiteEvents().size()); - } - - @Test - public void ruleWithNoVariationOrRolloutReturnsMalformedFlagError() { - Clause clause = new Clause("key", Operator.in, Arrays.asList(LDValue.of("userkey")), false); - Rule rule = new Rule("ruleid", Arrays.asList(clause), null, null); - FeatureFlag f = featureFlagWithRules("feature", rule); - LDUser user = new LDUser.Builder("userkey").build(); - FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); - - assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); - assertEquals(0, result.getPrerequisiteEvents().size()); - } - - @Test - public void ruleWithRolloutWithEmptyVariationsListReturnsMalformedFlagError() { - Clause clause = new Clause("key", Operator.in, Arrays.asList(LDValue.of("userkey")), false); - Rule rule = new Rule("ruleid", Arrays.asList(clause), null, - new VariationOrRollout.Rollout(ImmutableList.of(), null)); - FeatureFlag f = featureFlagWithRules("feature", rule); - LDUser user = new LDUser.Builder("userkey").build(); - FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); - - assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); - assertEquals(0, result.getPrerequisiteEvents().size()); - } - - @Test - public void clauseCanMatchBuiltInAttribute() throws Exception { - Clause clause = new Clause("name", Operator.in, Arrays.asList(LDValue.of("Bob")), false); - FeatureFlag f = booleanFlagWithClauses("flag", clause); - LDUser user = new LDUser.Builder("key").name("Bob").build(); - - assertEquals(LDValue.of(true), f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().getValue()); - } - - @Test - public void clauseCanMatchCustomAttribute() throws Exception { - Clause clause = new Clause("legs", Operator.in, Arrays.asList(LDValue.of(4)), false); - FeatureFlag f = booleanFlagWithClauses("flag", clause); - LDUser user = new LDUser.Builder("key").custom("legs", 4).build(); - - assertEquals(LDValue.of(true), f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().getValue()); - } - - @Test - public void clauseReturnsFalseForMissingAttribute() throws Exception { - Clause clause = new Clause("legs", Operator.in, Arrays.asList(LDValue.of(4)), false); - FeatureFlag f = booleanFlagWithClauses("flag", clause); - LDUser user = new LDUser.Builder("key").name("Bob").build(); - - assertEquals(LDValue.of(false), f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().getValue()); - } - - @Test - public void clauseCanBeNegated() throws Exception { - Clause clause = new Clause("name", Operator.in, Arrays.asList(LDValue.of("Bob")), true); - FeatureFlag f = booleanFlagWithClauses("flag", clause); - LDUser user = new LDUser.Builder("key").name("Bob").build(); - - assertEquals(LDValue.of(false), f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().getValue()); - } - - @Test - public void clauseWithUnsupportedOperatorStringIsUnmarshalledWithNullOperator() throws Exception { - // This just verifies that GSON will give us a null in this case instead of throwing an exception, - // so we fail as gracefully as possible if a new operator type has been added in the application - // and the SDK hasn't been upgraded yet. - String badClauseJson = "{\"attribute\":\"name\",\"operator\":\"doesSomethingUnsupported\",\"values\":[\"x\"]}"; - Gson gson = new Gson(); - Clause clause = gson.fromJson(badClauseJson, Clause.class); - assertNotNull(clause); - - JsonElement json = gson.toJsonTree(clause); - String expectedJson = "{\"attribute\":\"name\",\"values\":[\"x\"],\"negate\":false}"; - assertEquals(gson.fromJson(expectedJson, JsonElement.class), json); - } - - @Test - public void clauseWithNullOperatorDoesNotMatch() throws Exception { - Clause badClause = new Clause("name", null, Arrays.asList(LDValue.of("Bob")), false); - FeatureFlag f = booleanFlagWithClauses("flag", badClause); - LDUser user = new LDUser.Builder("key").name("Bob").build(); - - assertEquals(LDValue.of(false), f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().getValue()); - } - - @Test - public void clauseWithNullOperatorDoesNotStopSubsequentRuleFromMatching() throws Exception { - Clause badClause = new Clause("name", null, Arrays.asList(LDValue.of("Bob")), false); - Rule badRule = new Rule("rule1", Arrays.asList(badClause), 1, null); - Clause goodClause = new Clause("name", Operator.in, Arrays.asList(LDValue.of("Bob")), false); - Rule goodRule = new Rule("rule2", Arrays.asList(goodClause), 1, null); - FeatureFlag f = new FeatureFlagBuilder("feature") - .on(true) - .rules(Arrays.asList(badRule, goodRule)) - .fallthrough(fallthroughVariation(0)) - .offVariation(0) - .variations(LDValue.of(false), LDValue.of(true)) - .build(); - LDUser user = new LDUser.Builder("key").name("Bob").build(); - - EvaluationDetail details = f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails(); - assertEquals(fromValue(LDValue.of(true), 1, EvaluationReason.ruleMatch(1, "rule2")), details); - } - - @Test - public void testSegmentMatchClauseRetrievesSegmentFromStore() throws Exception { - Segment segment = new Segment.Builder("segkey") - .included(Arrays.asList("foo")) - .version(1) - .build(); - featureStore.upsert(SEGMENTS, segment); - - FeatureFlag flag = segmentMatchBooleanFlag("segkey"); - LDUser user = new LDUser.Builder("foo").build(); - - FeatureFlag.EvalResult result = flag.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(LDValue.of(true), result.getDetails().getValue()); - } - - @Test - public void testSegmentMatchClauseFallsThroughIfSegmentNotFound() throws Exception { - FeatureFlag flag = segmentMatchBooleanFlag("segkey"); - LDUser user = new LDUser.Builder("foo").build(); - - FeatureFlag.EvalResult result = flag.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(LDValue.of(false), result.getDetails().getValue()); - } - - @Test - public void flagIsDeserializedWithAllProperties() { - String json = flagWithAllPropertiesJson().toJsonString(); - FeatureFlag flag0 = TEST_GSON_INSTANCE.fromJson(json, FeatureFlag.class); - assertFlagHasAllProperties(flag0); - - 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 = TEST_GSON_INSTANCE.fromJson(json, FeatureFlag.class); - assertEquals("flag-key", flag.getKey()); - assertEquals(99, flag.getVersion()); - assertFalse(flag.isOn()); - assertNull(flag.getSalt()); - assertNull(flag.getTargets()); - assertNull(flag.getRules()); - assertNull(flag.getFallthrough()); - assertNull(flag.getOffVariation()); - assertNull(flag.getVariations()); - assertFalse(flag.isClientSide()); - assertFalse(flag.isTrackEvents()); - assertFalse(flag.isTrackEventsFallthrough()); - assertNull(flag.getDebugEventsUntilDate()); - } - - private FeatureFlag featureFlagWithRules(String flagKey, Rule... rules) { - return new FeatureFlagBuilder(flagKey) - .on(true) - .rules(Arrays.asList(rules)) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .build(); - } - - private FeatureFlag segmentMatchBooleanFlag(String segmentKey) { - Clause clause = new Clause("", Operator.segmentMatch, Arrays.asList(LDValue.of(segmentKey)), false); - return booleanFlagWithClauses("flag", clause); - } - - private LDValue flagWithAllPropertiesJson() { - return LDValue.buildObject() - .put("key", "flag-key") - .put("version", 99) - .put("on", true) - .put("prerequisites", LDValue.buildArray() - .build()) - .put("salt", "123") - .put("targets", LDValue.buildArray() - .add(LDValue.buildObject() - .put("variation", 1) - .put("values", LDValue.buildArray().add("key1").add("key2").build()) - .build()) - .build()) - .put("rules", LDValue.buildArray() - .add(LDValue.buildObject() - .put("id", "id0") - .put("trackEvents", true) - .put("variation", 2) - .put("clauses", LDValue.buildArray() - .add(LDValue.buildObject() - .put("attribute", "name") - .put("op", "in") - .put("values", LDValue.buildArray().add("Lucy").build()) - .put("negate", true) - .build()) - .build()) - .build()) - .add(LDValue.buildObject() - .put("id", "id1") - .put("rollout", LDValue.buildObject() - .put("variations", LDValue.buildArray() - .add(LDValue.buildObject() - .put("variation", 2) - .put("weight", 100000) - .build()) - .build()) - .put("bucketBy", "email") - .build()) - .build()) - .build()) - .put("fallthrough", LDValue.buildObject() - .put("variation", 1) - .build()) - .put("offVariation", 2) - .put("variations", LDValue.buildArray().add("a").add("b").add("c").build()) - .put("clientSide", true) - .put("trackEvents", true) - .put("trackEventsFallthrough", true) - .put("debugEventsUntilDate", 1000) - .build(); - } - - private void assertFlagHasAllProperties(FeatureFlag flag) { - assertEquals("flag-key", flag.getKey()); - assertEquals(99, flag.getVersion()); - assertTrue(flag.isOn()); - assertEquals("123", flag.getSalt()); - - assertNotNull(flag.getTargets()); - assertEquals(1, flag.getTargets().size()); - Target t0 = flag.getTargets().get(0); - assertEquals(1, t0.getVariation()); - assertEquals(ImmutableSet.of("key1", "key2"), t0.getValues()); - - assertNotNull(flag.getRules()); - assertEquals(2, flag.getRules().size()); - Rule r0 = flag.getRules().get(0); - assertEquals("id0", r0.getId()); - assertTrue(r0.isTrackEvents()); - assertEquals(new Integer(2), r0.getVariation()); - assertNull(r0.getRollout()); - - assertNotNull(r0.getClauses()); - Clause c0 = r0.getClauses().get(0); - assertEquals("name", c0.getAttribute()); - assertEquals(Operator.in, c0.getOp()); - assertEquals(ImmutableList.of(LDValue.of("Lucy")), c0.getValues()); - assertTrue(c0.isNegate()); - - Rule r1 = flag.getRules().get(1); - assertEquals("id1", r1.getId()); - assertFalse(r1.isTrackEvents()); - assertNull(r1.getVariation()); - assertNotNull(r1.getRollout()); - assertNotNull(r1.getRollout().getVariations()); - assertEquals(1, r1.getRollout().getVariations().size()); - assertEquals(2, r1.getRollout().getVariations().get(0).getVariation()); - assertEquals(100000, r1.getRollout().getVariations().get(0).getWeight()); - assertEquals("email", r1.getRollout().getBucketBy()); - - assertNotNull(flag.getFallthrough()); - assertEquals(new Integer(1), flag.getFallthrough().getVariation()); - assertNull(flag.getFallthrough().getRollout()); - assertEquals(new Integer(2), flag.getOffVariation()); - assertEquals(ImmutableList.of(LDValue.of("a"), LDValue.of("b"), LDValue.of("c")), flag.getVariations()); - assertTrue(flag.isClientSide()); - assertTrue(flag.isTrackEvents()); - assertTrue(flag.isTrackEventsFallthrough()); - assertEquals(new Long(1000), flag.getDebugEventsUntilDate()); - } -} diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java deleted file mode 100644 index 92e4cfc0d..000000000 --- a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java +++ /dev/null @@ -1,119 +0,0 @@ -package com.launchdarkly.client; - -import com.google.common.collect.ImmutableMap; -import com.google.gson.Gson; -import com.google.gson.JsonElement; -import com.launchdarkly.client.value.LDValue; - -import org.junit.Test; - -import static com.launchdarkly.client.EvaluationDetail.fromValue; -import static com.launchdarkly.client.TestUtil.js; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; - -@SuppressWarnings("javadoc") -public class FeatureFlagsStateTest { - private static final Gson gson = new Gson(); - - @Test - public void canGetFlagValue() { - EvaluationDetail eval = fromValue(LDValue.of("value"), 1, EvaluationReason.off()); - FeatureFlag flag = new FeatureFlagBuilder("key").build(); - FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); - - assertEquals(js("value"), state.getFlagValue("key")); - } - - @Test - public void unknownFlagReturnsNullValue() { - FeatureFlagsState state = new FeatureFlagsState.Builder().build(); - - assertNull(state.getFlagValue("key")); - } - - @Test - public void canGetFlagReason() { - EvaluationDetail eval = fromValue(LDValue.of("value1"), 1, EvaluationReason.off()); - FeatureFlag flag = new FeatureFlagBuilder("key").build(); - FeatureFlagsState state = new FeatureFlagsState.Builder(FlagsStateOption.WITH_REASONS) - .addFlag(flag, eval).build(); - - assertEquals(EvaluationReason.off(), state.getFlagReason("key")); - } - - @Test - public void unknownFlagReturnsNullReason() { - FeatureFlagsState state = new FeatureFlagsState.Builder().build(); - - assertNull(state.getFlagReason("key")); - } - - @Test - public void reasonIsNullIfReasonsWereNotRecorded() { - EvaluationDetail eval = fromValue(LDValue.of("value1"), 1, EvaluationReason.off()); - FeatureFlag flag = new FeatureFlagBuilder("key").build(); - FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); - - assertNull(state.getFlagReason("key")); - } - - @Test - public void flagCanHaveNullValue() { - EvaluationDetail eval = fromValue(LDValue.ofNull(), 1, null); - FeatureFlag flag = new FeatureFlagBuilder("key").build(); - FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); - - assertNull(state.getFlagValue("key")); - } - - @Test - public void canConvertToValuesMap() { - EvaluationDetail eval1 = fromValue(LDValue.of("value1"), 0, EvaluationReason.off()); - FeatureFlag flag1 = new FeatureFlagBuilder("key1").build(); - EvaluationDetail eval2 = fromValue(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); - FeatureFlag flag2 = new FeatureFlagBuilder("key2").build(); - FeatureFlagsState state = new FeatureFlagsState.Builder() - .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); - - ImmutableMap expected = ImmutableMap.of("key1", js("value1"), "key2", js("value2")); - assertEquals(expected, state.toValuesMap()); - } - - @Test - public void canConvertToJson() { - EvaluationDetail eval1 = fromValue(LDValue.of("value1"), 0, EvaluationReason.off()); - FeatureFlag flag1 = new FeatureFlagBuilder("key1").version(100).trackEvents(false).build(); - EvaluationDetail eval2 = fromValue(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); - FeatureFlag flag2 = new FeatureFlagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); - FeatureFlagsState state = new FeatureFlagsState.Builder(FlagsStateOption.WITH_REASONS) - .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); - - String json = "{\"key1\":\"value1\",\"key2\":\"value2\"," + - "\"$flagsState\":{" + - "\"key1\":{" + - "\"variation\":0,\"version\":100,\"reason\":{\"kind\":\"OFF\"}" + // note, "trackEvents: false" is omitted - "},\"key2\":{" + - "\"variation\":1,\"version\":200,\"reason\":{\"kind\":\"FALLTHROUGH\"},\"trackEvents\":true,\"debugEventsUntilDate\":1000" + - "}" + - "}," + - "\"$valid\":true" + - "}"; - JsonElement expected = gson.fromJson(json, JsonElement.class); - assertEquals(expected, gson.toJsonTree(state)); - } - - @Test - public void canConvertFromJson() { - EvaluationDetail eval1 = fromValue(LDValue.of("value1"), 0, EvaluationReason.off()); - FeatureFlag flag1 = new FeatureFlagBuilder("key1").version(100).trackEvents(false).build(); - EvaluationDetail eval2 = fromValue(LDValue.of("value2"), 1, EvaluationReason.off()); - FeatureFlag flag2 = new FeatureFlagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); - FeatureFlagsState state = new FeatureFlagsState.Builder() - .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); - - String json = gson.toJson(state); - FeatureFlagsState state1 = gson.fromJson(json, FeatureFlagsState.class); - assertEquals(state, state1); - } -} diff --git a/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java b/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java deleted file mode 100644 index 2d90cd4a6..000000000 --- a/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java +++ /dev/null @@ -1,125 +0,0 @@ -package com.launchdarkly.client; - -import org.junit.Test; - -import java.util.concurrent.TimeUnit; - -import static com.launchdarkly.client.FeatureStoreCacheConfig.StaleValuesPolicy.EVICT; -import static com.launchdarkly.client.FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH; -import static com.launchdarkly.client.FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH_ASYNC; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; - -@SuppressWarnings({ "deprecation", "javadoc" }) -public class FeatureStoreCachingTest { - @Test - public void disabledHasExpectedProperties() { - FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.disabled(); - assertThat(fsc.getCacheTime(), equalTo(0L)); - assertThat(fsc.isEnabled(), equalTo(false)); - assertThat(fsc.isInfiniteTtl(), equalTo(false)); - assertThat(fsc.getStaleValuesPolicy(), equalTo(EVICT)); - } - - @Test - public void enabledHasExpectedProperties() { - FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled(); - assertThat(fsc.getCacheTime(), equalTo(FeatureStoreCacheConfig.DEFAULT_TIME_SECONDS)); - assertThat(fsc.getCacheTimeUnit(), equalTo(TimeUnit.SECONDS)); - assertThat(fsc.isEnabled(), equalTo(true)); - assertThat(fsc.isInfiniteTtl(), equalTo(false)); - assertThat(fsc.getStaleValuesPolicy(), equalTo(EVICT)); - } - - @Test - public void defaultIsEnabled() { - FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.DEFAULT; - assertThat(fsc.getCacheTime(), equalTo(FeatureStoreCacheConfig.DEFAULT_TIME_SECONDS)); - assertThat(fsc.getCacheTimeUnit(), equalTo(TimeUnit.SECONDS)); - assertThat(fsc.isEnabled(), equalTo(true)); - assertThat(fsc.getStaleValuesPolicy(), equalTo(EVICT)); - } - - @Test - public void canSetTtl() { - FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled() - .staleValuesPolicy(REFRESH) - .ttl(3, TimeUnit.DAYS); - assertThat(fsc.getCacheTime(), equalTo(3L)); - assertThat(fsc.getCacheTimeUnit(), equalTo(TimeUnit.DAYS)); - assertThat(fsc.getStaleValuesPolicy(), equalTo(REFRESH)); - } - - @Test - public void canSetTtlInMillis() { - FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled() - .staleValuesPolicy(REFRESH) - .ttlMillis(3); - assertThat(fsc.getCacheTime(), equalTo(3L)); - assertThat(fsc.getCacheTimeUnit(), equalTo(TimeUnit.MILLISECONDS)); - assertThat(fsc.getStaleValuesPolicy(), equalTo(REFRESH)); - } - - @Test - public void canSetTtlInSeconds() { - FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled() - .staleValuesPolicy(REFRESH) - .ttlSeconds(3); - assertThat(fsc.getCacheTime(), equalTo(3L)); - assertThat(fsc.getCacheTimeUnit(), equalTo(TimeUnit.SECONDS)); - assertThat(fsc.getStaleValuesPolicy(), equalTo(REFRESH)); - } - - @Test - public void zeroTtlMeansDisabled() { - FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled() - .ttl(0, TimeUnit.SECONDS); - assertThat(fsc.isEnabled(), equalTo(false)); - assertThat(fsc.isInfiniteTtl(), equalTo(false)); - } - - @Test - public void negativeTtlMeansEnabledAndInfinite() { - FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled() - .ttl(-1, TimeUnit.SECONDS); - assertThat(fsc.isEnabled(), equalTo(true)); - assertThat(fsc.isInfiniteTtl(), equalTo(true)); - } - - @Test - public void canSetStaleValuesPolicy() { - FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled() - .ttlMillis(3) - .staleValuesPolicy(REFRESH_ASYNC); - assertThat(fsc.getStaleValuesPolicy(), equalTo(REFRESH_ASYNC)); - assertThat(fsc.getCacheTime(), equalTo(3L)); - assertThat(fsc.getCacheTimeUnit(), equalTo(TimeUnit.MILLISECONDS)); - } - - @Test - public void equalityUsesTime() { - FeatureStoreCacheConfig fsc1 = FeatureStoreCacheConfig.enabled().ttlMillis(3); - FeatureStoreCacheConfig fsc2 = FeatureStoreCacheConfig.enabled().ttlMillis(3); - FeatureStoreCacheConfig fsc3 = FeatureStoreCacheConfig.enabled().ttlMillis(4); - assertThat(fsc1.equals(fsc2), equalTo(true)); - assertThat(fsc1.equals(fsc3), equalTo(false)); - } - - @Test - public void equalityUsesTimeUnit() { - FeatureStoreCacheConfig fsc1 = FeatureStoreCacheConfig.enabled().ttlMillis(3); - FeatureStoreCacheConfig fsc2 = FeatureStoreCacheConfig.enabled().ttlMillis(3); - FeatureStoreCacheConfig fsc3 = FeatureStoreCacheConfig.enabled().ttlSeconds(3); - assertThat(fsc1.equals(fsc2), equalTo(true)); - assertThat(fsc1.equals(fsc3), equalTo(false)); - } - - @Test - public void equalityUsesStaleValuesPolicy() { - FeatureStoreCacheConfig fsc1 = FeatureStoreCacheConfig.enabled().staleValuesPolicy(EVICT); - FeatureStoreCacheConfig fsc2 = FeatureStoreCacheConfig.enabled().staleValuesPolicy(EVICT); - FeatureStoreCacheConfig fsc3 = FeatureStoreCacheConfig.enabled().staleValuesPolicy(REFRESH); - assertThat(fsc1.equals(fsc2), equalTo(true)); - assertThat(fsc1.equals(fsc3), equalTo(false)); - } -} diff --git a/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java b/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java deleted file mode 100644 index 83565442e..000000000 --- a/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java +++ /dev/null @@ -1,238 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.DataStoreTestTypes.TestItem; -import com.launchdarkly.client.TestUtil.DataBuilder; - -import org.junit.After; -import org.junit.Assume; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; -import org.junit.runners.Parameterized.Parameters; - -import java.util.Arrays; -import java.util.Map; - -import static com.launchdarkly.client.DataStoreTestTypes.TEST_ITEMS; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.junit.Assume.assumeFalse; -import static org.junit.Assume.assumeTrue; - -/** - * Extends FeatureStoreTestBase with tests for feature stores where multiple store instances can - * use the same underlying data store (i.e. database implementations in general). - */ -@RunWith(Parameterized.class) -@SuppressWarnings("javadoc") -public abstract class FeatureStoreDatabaseTestBase extends FeatureStoreTestBase { - - @Parameters(name="cached={0}") - public static Iterable data() { - return Arrays.asList(new Boolean[] { false, true }); - } - - public FeatureStoreDatabaseTestBase(boolean cached) { - super(cached); - } - - /** - * Test subclasses should override this method if the feature store class supports a key prefix option - * for keeping data sets distinct within the same database. - */ - protected T makeStoreWithPrefix(String prefix) { - return null; - } - - /** - * Test classes should override this to return false if the feature store class does not have a local - * caching option (e.g. the in-memory store). - * @return - */ - protected boolean isCachingSupported() { - return true; - } - - /** - * Test classes should override this to clear all data from the underlying database, if it is - * possible for data to exist there before the feature store is created (i.e. if - * isUnderlyingDataSharedByAllInstances() returns true). - */ - protected void clearAllData() { - } - - /** - * Test classes should override this (and return true) if it is possible to instrument the feature - * store to execute the specified Runnable during an upsert operation, for concurrent modification tests. - */ - protected boolean setUpdateHook(T storeUnderTest, Runnable hook) { - return false; - } - - @Before - public void setup() { - assumeTrue(isCachingSupported() || !cached); - super.setup(); - } - - @After - public void teardown() throws Exception { - store.close(); - } - - @Test - public void storeNotInitializedBeforeInit() { - clearAllData(); - assertFalse(store.initialized()); - } - - @Test - public void storeInitializedAfterInit() { - store.init(new DataBuilder().build()); - assertTrue(store.initialized()); - } - - @Test - public void oneInstanceCanDetectIfAnotherInstanceHasInitializedTheStore() { - assumeFalse(cached); // caching would cause the inited state to only be detected after the cache has expired - - clearAllData(); - T store2 = makeStore(); - - assertFalse(store.initialized()); - - store2.init(new DataBuilder().add(TEST_ITEMS, item1).build()); - - assertTrue(store.initialized()); - } - - @Test - public void oneInstanceCanDetectIfAnotherInstanceHasInitializedTheStoreEvenIfEmpty() { - assumeFalse(cached); // caching would cause the inited state to only be detected after the cache has expired - - clearAllData(); - T store2 = makeStore(); - - assertFalse(store.initialized()); - - store2.init(new DataBuilder().build()); - - assertTrue(store.initialized()); - } - - // The following two tests verify that the update version checking logic works correctly when - // another client instance is modifying the same data. They will run only if the test class - // supports setUpdateHook(). - - @Test - public void handlesUpsertRaceConditionAgainstExternalClientWithLowerVersion() throws Exception { - final T store2 = makeStore(); - - int startVersion = 1; - final int store2VersionStart = 2; - final int store2VersionEnd = 4; - int store1VersionEnd = 10; - - final TestItem startItem = new TestItem("me", "foo", startVersion); - - Runnable concurrentModifier = new Runnable() { - int versionCounter = store2VersionStart; - public void run() { - if (versionCounter <= store2VersionEnd) { - store2.upsert(TEST_ITEMS, startItem.withVersion(versionCounter)); - versionCounter++; - } - } - }; - - try { - assumeTrue(setUpdateHook(store, concurrentModifier)); - - store.init(new DataBuilder().add(TEST_ITEMS, startItem).build()); - - TestItem store1End = startItem.withVersion(store1VersionEnd); - store.upsert(TEST_ITEMS, store1End); - - VersionedData result = store.get(TEST_ITEMS, startItem.key); - assertEquals(store1VersionEnd, result.getVersion()); - } finally { - store2.close(); - } - } - - @Test - public void handlesUpsertRaceConditionAgainstExternalClientWithHigherVersion() throws Exception { - final T store2 = makeStore(); - - int startVersion = 1; - final int store2Version = 3; - int store1VersionEnd = 2; - - final TestItem startItem = new TestItem("me", "foo", startVersion); - - Runnable concurrentModifier = new Runnable() { - public void run() { - store2.upsert(TEST_ITEMS, startItem.withVersion(store2Version)); - } - }; - - try { - assumeTrue(setUpdateHook(store, concurrentModifier)); - - store.init(new DataBuilder().add(TEST_ITEMS, startItem).build()); - - TestItem store1End = startItem.withVersion(store1VersionEnd); - store.upsert(TEST_ITEMS, store1End); - - VersionedData result = store.get(TEST_ITEMS, startItem.key); - assertEquals(store2Version, result.getVersion()); - } finally { - store2.close(); - } - } - - @Test - public void storesWithDifferentPrefixAreIndependent() throws Exception { - T store1 = makeStoreWithPrefix("aaa"); - Assume.assumeNotNull(store1); - T store2 = makeStoreWithPrefix("bbb"); - clearAllData(); - - try { - assertFalse(store1.initialized()); - assertFalse(store2.initialized()); - - TestItem item1a = new TestItem("a1", "flag-a", 1); - TestItem item1b = new TestItem("b", "flag-b", 1); - TestItem item2a = new TestItem("a2", "flag-a", 2); - TestItem item2c = new TestItem("c", "flag-c", 2); - - store1.init(new DataBuilder().add(TEST_ITEMS, item1a, item1b).build()); - assertTrue(store1.initialized()); - assertFalse(store2.initialized()); - - store2.init(new DataBuilder().add(TEST_ITEMS, item2a, item2c).build()); - assertTrue(store1.initialized()); - assertTrue(store2.initialized()); - - Map items1 = store1.all(TEST_ITEMS); - Map items2 = store2.all(TEST_ITEMS); - assertEquals(2, items1.size()); - assertEquals(2, items2.size()); - assertEquals(item1a, items1.get(item1a.key)); - assertEquals(item1b, items1.get(item1b.key)); - assertEquals(item2a, items2.get(item2a.key)); - assertEquals(item2c, items2.get(item2c.key)); - - assertEquals(item1a, store1.get(TEST_ITEMS, item1a.key)); - assertEquals(item1b, store1.get(TEST_ITEMS, item1b.key)); - assertEquals(item2a, store2.get(TEST_ITEMS, item2a.key)); - assertEquals(item2c, store2.get(TEST_ITEMS, item2c.key)); - } finally { - store1.close(); - store2.close(); - } - } -} diff --git a/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java b/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java deleted file mode 100644 index d97917b58..000000000 --- a/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java +++ /dev/null @@ -1,180 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.DataStoreTestTypes.TestItem; -import com.launchdarkly.client.TestUtil.DataBuilder; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; - -import java.util.Map; - -import static com.launchdarkly.client.DataStoreTestTypes.OTHER_TEST_ITEMS; -import static com.launchdarkly.client.DataStoreTestTypes.TEST_ITEMS; -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.assertTrue; - -/** - * Basic tests for FeatureStore implementations. For database implementations, use the more - * comprehensive FeatureStoreDatabaseTestBase. - */ -@SuppressWarnings("javadoc") -public abstract class FeatureStoreTestBase { - - protected T store; - protected boolean cached; - - protected TestItem item1 = new TestItem("first", "key1", 10); - - protected TestItem item2 = new TestItem("second", "key2", 10); - - protected TestItem otherItem1 = new TestItem("other-first", "key1", 11); - - public FeatureStoreTestBase() { - this(false); - } - - public FeatureStoreTestBase(boolean cached) { - this.cached = cached; - } - - /** - * Test subclasses must override this method to create an instance of the feature store class, with - * caching either enabled or disabled depending on the "cached" property. - * @return - */ - protected abstract T makeStore(); - - /** - * Test classes should override this to clear all data from the underlying database, if it is - * possible for data to exist there before the feature store is created (i.e. if - * isUnderlyingDataSharedByAllInstances() returns true). - */ - protected void clearAllData() { - } - - @Before - public void setup() { - store = makeStore(); - } - - @After - public void teardown() throws Exception { - store.close(); - } - - @Test - public void storeNotInitializedBeforeInit() { - clearAllData(); - assertFalse(store.initialized()); - } - - @Test - public void storeInitializedAfterInit() { - store.init(new DataBuilder().build()); - assertTrue(store.initialized()); - } - - @Test - public void initCompletelyReplacesPreviousData() { - clearAllData(); - - Map, Map> allData = - new DataBuilder().add(TEST_ITEMS, item1, item2).add(OTHER_TEST_ITEMS, otherItem1).build(); - store.init(allData); - - TestItem item2v2 = item2.withVersion(item2.version + 1); - allData = new DataBuilder().add(TEST_ITEMS, item2v2).add(OTHER_TEST_ITEMS).build(); - store.init(allData); - - assertNull(store.get(TEST_ITEMS, item1.key)); - assertEquals(item2v2, store.get(TEST_ITEMS, item2.key)); - assertNull(store.get(OTHER_TEST_ITEMS, otherItem1.key)); - } - - @Test - public void getExistingItem() { - store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); - assertEquals(item1, store.get(TEST_ITEMS, item1.key)); - } - - @Test - public void getNonexistingItem() { - store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); - assertNull(store.get(TEST_ITEMS, "biz")); - } - - @Test - public void getAll() { - store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).add(OTHER_TEST_ITEMS, otherItem1).build()); - Map items = store.all(TEST_ITEMS); - assertEquals(2, items.size()); - assertEquals(item1, items.get(item1.key)); - assertEquals(item2, items.get(item2.key)); - } - - @Test - public void getAllWithDeletedItem() { - store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); - store.delete(TEST_ITEMS, item1.key, item1.getVersion() + 1); - Map items = store.all(TEST_ITEMS); - assertEquals(1, items.size()); - assertEquals(item2, items.get(item2.key)); - } - - @Test - public void upsertWithNewerVersion() { - store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); - TestItem newVer = item1.withVersion(item1.version + 1); - store.upsert(TEST_ITEMS, newVer); - assertEquals(newVer, store.get(TEST_ITEMS, item1.key)); - } - - @Test - public void upsertWithOlderVersion() { - store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); - TestItem oldVer = item1.withVersion(item1.version - 1); - store.upsert(TEST_ITEMS, oldVer); - assertEquals(item1, store.get(TEST_ITEMS, item1.key)); - } - - @Test - public void upsertNewItem() { - store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); - TestItem newItem = new TestItem("new-name", "new-key", 99); - store.upsert(TEST_ITEMS, newItem); - assertEquals(newItem, store.get(TEST_ITEMS, newItem.key)); - } - - @Test - public void deleteWithNewerVersion() { - store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); - store.delete(TEST_ITEMS, item1.key, item1.version + 1); - assertNull(store.get(TEST_ITEMS, item1.key)); - } - - @Test - public void deleteWithOlderVersion() { - store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); - store.delete(TEST_ITEMS, item1.key, item1.version - 1); - assertNotNull(store.get(TEST_ITEMS, item1.key)); - } - - @Test - public void deleteUnknownItem() { - store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); - store.delete(TEST_ITEMS, "biz", 11); - assertNull(store.get(TEST_ITEMS, "biz")); - } - - @Test - public void upsertOlderVersionAfterDelete() { - store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); - store.delete(TEST_ITEMS, item1.key, item1.version + 1); - store.upsert(TEST_ITEMS, item1); - assertNull(store.get(TEST_ITEMS, item1.key)); - } -} diff --git a/src/test/java/com/launchdarkly/client/InMemoryFeatureStoreTest.java b/src/test/java/com/launchdarkly/client/InMemoryFeatureStoreTest.java deleted file mode 100644 index fec2aab7c..000000000 --- a/src/test/java/com/launchdarkly/client/InMemoryFeatureStoreTest.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.launchdarkly.client; - -public class InMemoryFeatureStoreTest extends FeatureStoreTestBase { - - @Override - protected InMemoryFeatureStore makeStore() { - return new InMemoryFeatureStore(); - } -} diff --git a/src/test/java/com/launchdarkly/client/LDClientExternalUpdatesOnlyTest.java b/src/test/java/com/launchdarkly/client/LDClientExternalUpdatesOnlyTest.java deleted file mode 100644 index 414fd628a..000000000 --- a/src/test/java/com/launchdarkly/client/LDClientExternalUpdatesOnlyTest.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.value.LDValue; - -import org.junit.Test; - -import java.io.IOException; - -import static com.launchdarkly.client.TestUtil.flagWithValue; -import static com.launchdarkly.client.TestUtil.initedFeatureStore; -import static com.launchdarkly.client.TestUtil.specificFeatureStore; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -@SuppressWarnings("javadoc") -public class LDClientExternalUpdatesOnlyTest { - @Test - public void externalUpdatesOnlyClientHasNullUpdateProcessor() throws Exception { - LDConfig config = new LDConfig.Builder() - .dataSource(Components.externalUpdatesOnly()) - .build(); - try (LDClient client = new LDClient("SDK_KEY", config)) { - assertEquals(Components.NullUpdateProcessor.class, client.updateProcessor.getClass()); - } - } - - @Test - public void externalUpdatesOnlyClientHasDefaultEventProcessor() throws Exception { - LDConfig config = new LDConfig.Builder() - .dataSource(Components.externalUpdatesOnly()) - .build(); - try (LDClient client = new LDClient("SDK_KEY", config)) { - assertEquals(DefaultEventProcessor.class, client.eventProcessor.getClass()); - } - } - - @Test - public void externalUpdatesOnlyClientIsInitialized() throws Exception { - LDConfig config = new LDConfig.Builder() - .dataSource(Components.externalUpdatesOnly()) - .build(); - try (LDClient client = new LDClient("SDK_KEY", config)) { - assertTrue(client.initialized()); - } - } - - @Test - public void externalUpdatesOnlyClientGetsFlagFromFeatureStore() throws IOException { - FeatureStore testFeatureStore = initedFeatureStore(); - LDConfig config = new LDConfig.Builder() - .dataSource(Components.externalUpdatesOnly()) - .dataStore(specificFeatureStore(testFeatureStore)) - .build(); - FeatureFlag flag = flagWithValue("key", LDValue.of(true)); - testFeatureStore.upsert(FEATURES, flag); - try (LDClient client = new LDClient("SDK_KEY", config)) { - assertTrue(client.boolVariation("key", new LDUser("user"), false)); - } - } - - @SuppressWarnings("deprecation") - @Test - public void lddModeClientHasNullUpdateProcessor() throws IOException { - LDConfig config = new LDConfig.Builder() - .useLdd(true) - .build(); - try (LDClient client = new LDClient("SDK_KEY", config)) { - assertEquals(Components.NullUpdateProcessor.class, client.updateProcessor.getClass()); - } - } - - @Test - public void lddModeClientHasDefaultEventProcessor() throws IOException { - @SuppressWarnings("deprecation") - LDConfig config = new LDConfig.Builder() - .useLdd(true) - .build(); - try (LDClient client = new LDClient("SDK_KEY", config)) { - assertEquals(DefaultEventProcessor.class, client.eventProcessor.getClass()); - } - } - - @Test - public void lddModeClientIsInitialized() throws IOException { - @SuppressWarnings("deprecation") - LDConfig config = new LDConfig.Builder() - .useLdd(true) - .build(); - try (LDClient client = new LDClient("SDK_KEY", config)) { - assertTrue(client.initialized()); - } - } - - @Test - public void lddModeClientGetsFlagFromFeatureStore() throws IOException { - FeatureStore testFeatureStore = initedFeatureStore(); - @SuppressWarnings("deprecation") - LDConfig config = new LDConfig.Builder() - .useLdd(true) - .dataStore(specificFeatureStore(testFeatureStore)) - .build(); - FeatureFlag flag = flagWithValue("key", LDValue.of(true)); - testFeatureStore.upsert(FEATURES, flag); - try (LDClient client = new LDClient("SDK_KEY", config)) { - assertTrue(client.boolVariation("key", new LDUser("user"), false)); - } - } -} \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java deleted file mode 100644 index caa1c37a0..000000000 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ /dev/null @@ -1,475 +0,0 @@ -package com.launchdarkly.client; - -import com.google.common.base.Function; -import com.google.common.base.Joiner; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.Iterables; -import com.launchdarkly.client.value.LDValue; - -import org.easymock.Capture; -import org.easymock.EasyMock; -import org.easymock.EasyMockSupport; -import org.junit.Before; -import org.junit.Test; - -import java.io.IOException; -import java.net.URI; -import java.util.List; -import java.util.Map; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; - -import static com.launchdarkly.client.TestUtil.failedUpdateProcessor; -import static com.launchdarkly.client.TestUtil.flagWithValue; -import static com.launchdarkly.client.TestUtil.initedFeatureStore; -import static com.launchdarkly.client.TestUtil.specificFeatureStore; -import static com.launchdarkly.client.TestUtil.updateProcessorWithData; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; -import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; -import static org.easymock.EasyMock.anyObject; -import static org.easymock.EasyMock.capture; -import static org.easymock.EasyMock.eq; -import static org.easymock.EasyMock.expect; -import static org.easymock.EasyMock.expectLastCall; -import static org.easymock.EasyMock.isA; -import static org.easymock.EasyMock.isNull; -import static org.easymock.EasyMock.replay; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -import junit.framework.AssertionFailedError; - -/** - * See also LDClientEvaluationTest, etc. This file contains mostly tests for the startup logic. - */ -@SuppressWarnings("javadoc") -public class LDClientTest extends EasyMockSupport { - private final static String SDK_KEY = "SDK_KEY"; - - private UpdateProcessor updateProcessor; - private EventProcessor eventProcessor; - private Future initFuture; - private LDClientInterface client; - - @SuppressWarnings("unchecked") - @Before - public void before() { - updateProcessor = createStrictMock(UpdateProcessor.class); - eventProcessor = createStrictMock(EventProcessor.class); - initFuture = createStrictMock(Future.class); - } - - @Test - public void constructorThrowsExceptionForNullSdkKey() throws Exception { - try (LDClient client = new LDClient(null)) { - fail("expected exception"); - } catch (NullPointerException e) { - assertEquals("sdkKey must not be null", e.getMessage()); - } - } - - @Test - public void constructorWithConfigThrowsExceptionForNullSdkKey() throws Exception { - try (LDClient client = new LDClient(null, new LDConfig.Builder().build())) { - fail("expected exception"); - } catch (NullPointerException e) { - assertEquals("sdkKey must not be null", e.getMessage()); - } - } - - @Test - public void constructorThrowsExceptionForNullConfig() throws Exception { - try (LDClient client = new LDClient(SDK_KEY, null)) { - fail("expected exception"); - } catch (NullPointerException e) { - assertEquals("config must not be null", e.getMessage()); - } - } - - @Test - public void clientHasDefaultEventProcessorWithDefaultConfig() throws Exception { - LDConfig config = new LDConfig.Builder() - .dataSource(Components.externalUpdatesOnly()) - .startWaitMillis(0) - .build(); - try (LDClient client = new LDClient("SDK_KEY", config)) { - assertEquals(DefaultEventProcessor.class, client.eventProcessor.getClass()); - } - } - - @Test - public void clientHasDefaultEventProcessorWithSendEvents() throws Exception { - LDConfig config = new LDConfig.Builder() - .dataSource(Components.externalUpdatesOnly()) - .events(Components.sendEvents()) - .startWaitMillis(0) - .build(); - try (LDClient client = new LDClient("SDK_KEY", config)) { - assertEquals(DefaultEventProcessor.class, client.eventProcessor.getClass()); - } - } - - @Test - public void clientHasNullEventProcessorWithNoEvents() throws Exception { - LDConfig config = new LDConfig.Builder() - .dataSource(Components.externalUpdatesOnly()) - .events(Components.noEvents()) - .startWaitMillis(0) - .build(); - try (LDClient client = new LDClient("SDK_KEY", config)) { - assertEquals(Components.NullEventProcessor.class, client.eventProcessor.getClass()); - } - } - - @SuppressWarnings("deprecation") - @Test - public void clientHasDefaultEventProcessorIfSendEventsIsTrue() throws Exception { - LDConfig config = new LDConfig.Builder() - .dataSource(Components.externalUpdatesOnly()) - .startWaitMillis(0) - .sendEvents(true) - .build(); - try (LDClient client = new LDClient(SDK_KEY, config)) { - assertEquals(DefaultEventProcessor.class, client.eventProcessor.getClass()); - } - } - - @SuppressWarnings("deprecation") - @Test - public void clientHasNullEventProcessorIfSendEventsIsFalse() throws IOException { - LDConfig config = new LDConfig.Builder() - .dataSource(Components.externalUpdatesOnly()) - .startWaitMillis(0) - .sendEvents(false) - .build(); - try (LDClient client = new LDClient(SDK_KEY, config)) { - assertEquals(Components.NullEventProcessor.class, client.eventProcessor.getClass()); - } - } - - @Test - public void streamingClientHasStreamProcessor() throws Exception { - LDConfig config = new LDConfig.Builder() - .dataSource(Components.streamingDataSource().baseURI(URI.create("http://fake"))) - .startWaitMillis(0) - .build(); - try (LDClient client = new LDClient(SDK_KEY, config)) { - assertEquals(StreamProcessor.class, client.updateProcessor.getClass()); - } - } - - @Test - public void pollingClientHasPollingProcessor() throws IOException { - LDConfig config = new LDConfig.Builder() - .dataSource(Components.pollingDataSource().baseURI(URI.create("http://fake"))) - .startWaitMillis(0) - .build(); - try (LDClient client = new LDClient(SDK_KEY, config)) { - assertEquals(PollingProcessor.class, client.updateProcessor.getClass()); - } - } - - @Test - public void sameDiagnosticAccumulatorPassedToFactoriesWhenSupported() throws IOException { - EventProcessorFactoryWithDiagnostics mockEventProcessorFactory = createStrictMock(EventProcessorFactoryWithDiagnostics.class); - UpdateProcessorFactoryWithDiagnostics mockUpdateProcessorFactory = createStrictMock(UpdateProcessorFactoryWithDiagnostics.class); - - LDConfig config = new LDConfig.Builder() - .startWaitMillis(0) - .events(mockEventProcessorFactory) - .dataSource(mockUpdateProcessorFactory) - .build(); - - Capture capturedEventAccumulator = Capture.newInstance(); - Capture capturedUpdateAccumulator = Capture.newInstance(); - expect(mockEventProcessorFactory.createEventProcessor(eq(SDK_KEY), isA(LDConfig.class), capture(capturedEventAccumulator))).andReturn(niceMock(EventProcessor.class)); - expect(mockUpdateProcessorFactory.createUpdateProcessor(eq(SDK_KEY), isA(LDConfig.class), isA(FeatureStore.class), capture(capturedUpdateAccumulator))).andReturn(failedUpdateProcessor()); - - replayAll(); - - try (LDClient client = new LDClient(SDK_KEY, config)) { - verifyAll(); - assertNotNull(capturedEventAccumulator.getValue()); - assertEquals(capturedEventAccumulator.getValue(), capturedUpdateAccumulator.getValue()); - } - } - - @Test - public void nullDiagnosticAccumulatorPassedToFactoriesWhenOptedOut() throws IOException { - EventProcessorFactoryWithDiagnostics mockEventProcessorFactory = createStrictMock(EventProcessorFactoryWithDiagnostics.class); - UpdateProcessorFactoryWithDiagnostics mockUpdateProcessorFactory = createStrictMock(UpdateProcessorFactoryWithDiagnostics.class); - - LDConfig config = new LDConfig.Builder() - .startWaitMillis(0) - .events(mockEventProcessorFactory) - .dataSource(mockUpdateProcessorFactory) - .diagnosticOptOut(true) - .build(); - - expect(mockEventProcessorFactory.createEventProcessor(eq(SDK_KEY), isA(LDConfig.class), isNull(DiagnosticAccumulator.class))).andReturn(niceMock(EventProcessor.class)); - expect(mockUpdateProcessorFactory.createUpdateProcessor(eq(SDK_KEY), isA(LDConfig.class), isA(FeatureStore.class), isNull(DiagnosticAccumulator.class))).andReturn(failedUpdateProcessor()); - - replayAll(); - - try (LDClient client = new LDClient(SDK_KEY, config)) { - verifyAll(); - } - } - - @Test - public void nullDiagnosticAccumulatorPassedToUpdateFactoryWhenEventProcessorDoesNotSupportDiagnostics() throws IOException { - EventProcessorFactory mockEventProcessorFactory = createStrictMock(EventProcessorFactory.class); - UpdateProcessorFactoryWithDiagnostics mockUpdateProcessorFactory = createStrictMock(UpdateProcessorFactoryWithDiagnostics.class); - - LDConfig config = new LDConfig.Builder() - .startWaitMillis(0) - .events(mockEventProcessorFactory) - .dataSource(mockUpdateProcessorFactory) - .build(); - - expect(mockEventProcessorFactory.createEventProcessor(eq(SDK_KEY), isA(LDConfig.class))).andReturn(niceMock(EventProcessor.class)); - expect(mockUpdateProcessorFactory.createUpdateProcessor(eq(SDK_KEY), isA(LDConfig.class), isA(FeatureStore.class), isNull(DiagnosticAccumulator.class))).andReturn(failedUpdateProcessor()); - - replayAll(); - - try (LDClient client = new LDClient(SDK_KEY, config)) { - verifyAll(); - } - } - - @Test - public void noWaitForUpdateProcessorIfWaitMillisIsZero() throws Exception { - LDConfig.Builder config = new LDConfig.Builder() - .startWaitMillis(0L); - - expect(updateProcessor.start()).andReturn(initFuture); - expect(updateProcessor.initialized()).andReturn(false); - replayAll(); - - client = createMockClient(config); - assertFalse(client.initialized()); - - verifyAll(); - } - - @Test - public void willWaitForUpdateProcessorIfWaitMillisIsNonZero() throws Exception { - LDConfig.Builder config = new LDConfig.Builder() - .startWaitMillis(10L); - - expect(updateProcessor.start()).andReturn(initFuture); - expect(initFuture.get(10L, TimeUnit.MILLISECONDS)).andReturn(null); - expect(updateProcessor.initialized()).andReturn(false).anyTimes(); - replayAll(); - - client = createMockClient(config); - assertFalse(client.initialized()); - - verifyAll(); - } - - @Test - public void updateProcessorCanTimeOut() throws Exception { - LDConfig.Builder config = new LDConfig.Builder() - .startWaitMillis(10L); - - expect(updateProcessor.start()).andReturn(initFuture); - expect(initFuture.get(10L, TimeUnit.MILLISECONDS)).andThrow(new TimeoutException()); - expect(updateProcessor.initialized()).andReturn(false).anyTimes(); - replayAll(); - - client = createMockClient(config); - assertFalse(client.initialized()); - - verifyAll(); - } - - @Test - public void clientCatchesRuntimeExceptionFromUpdateProcessor() throws Exception { - LDConfig.Builder config = new LDConfig.Builder() - .startWaitMillis(10L); - - expect(updateProcessor.start()).andReturn(initFuture); - expect(initFuture.get(10L, TimeUnit.MILLISECONDS)).andThrow(new RuntimeException()); - expect(updateProcessor.initialized()).andReturn(false).anyTimes(); - replayAll(); - - client = createMockClient(config); - assertFalse(client.initialized()); - - verifyAll(); - } - - @Test - public void isFlagKnownReturnsTrueForExistingFlag() throws Exception { - FeatureStore testFeatureStore = initedFeatureStore(); - LDConfig.Builder config = new LDConfig.Builder() - .startWaitMillis(0) - .dataStore(specificFeatureStore(testFeatureStore)); - expect(updateProcessor.start()).andReturn(initFuture); - expect(updateProcessor.initialized()).andReturn(true).times(1); - replayAll(); - - client = createMockClient(config); - - testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(1))); - assertTrue(client.isFlagKnown("key")); - verifyAll(); - } - - @Test - public void isFlagKnownReturnsFalseForUnknownFlag() throws Exception { - FeatureStore testFeatureStore = initedFeatureStore(); - LDConfig.Builder config = new LDConfig.Builder() - .startWaitMillis(0) - .dataStore(specificFeatureStore(testFeatureStore)); - expect(updateProcessor.start()).andReturn(initFuture); - expect(updateProcessor.initialized()).andReturn(true).times(1); - replayAll(); - - client = createMockClient(config); - - assertFalse(client.isFlagKnown("key")); - verifyAll(); - } - - @Test - public void isFlagKnownReturnsFalseIfStoreAndClientAreNotInitialized() throws Exception { - FeatureStore testFeatureStore = new InMemoryFeatureStore(); - LDConfig.Builder config = new LDConfig.Builder() - .startWaitMillis(0) - .dataStore(specificFeatureStore(testFeatureStore)); - expect(updateProcessor.start()).andReturn(initFuture); - expect(updateProcessor.initialized()).andReturn(false).times(1); - replayAll(); - - client = createMockClient(config); - - testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(1))); - assertFalse(client.isFlagKnown("key")); - verifyAll(); - } - - @Test - public void isFlagKnownUsesStoreIfStoreIsInitializedButClientIsNot() throws Exception { - FeatureStore testFeatureStore = initedFeatureStore(); - LDConfig.Builder config = new LDConfig.Builder() - .startWaitMillis(0) - .dataStore(specificFeatureStore(testFeatureStore)); - expect(updateProcessor.start()).andReturn(initFuture); - expect(updateProcessor.initialized()).andReturn(false).times(1); - replayAll(); - - client = createMockClient(config); - - testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(1))); - assertTrue(client.isFlagKnown("key")); - verifyAll(); - } - - @Test - public void evaluationUsesStoreIfStoreIsInitializedButClientIsNot() throws Exception { - FeatureStore testFeatureStore = initedFeatureStore(); - LDConfig.Builder config = new LDConfig.Builder() - .dataStore(specificFeatureStore(testFeatureStore)) - .startWaitMillis(0L); - expect(updateProcessor.start()).andReturn(initFuture); - expect(updateProcessor.initialized()).andReturn(false); - expectEventsSent(1); - replayAll(); - - client = createMockClient(config); - - testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(1))); - assertEquals(new Integer(1), client.intVariation("key", new LDUser("user"), 0)); - - verifyAll(); - } - - @Test - public void dataSetIsPassedToFeatureStoreInCorrectOrder() throws Exception { - // This verifies that the client is using FeatureStoreClientWrapper and that it is applying the - // correct ordering for flag prerequisites, etc. This should work regardless of what kind of - // UpdateProcessor we're using. - - Capture, Map>> captureData = Capture.newInstance(); - FeatureStore store = createStrictMock(FeatureStore.class); - store.init(EasyMock.capture(captureData)); - replay(store); - - LDConfig.Builder config = new LDConfig.Builder() - .dataSource(updateProcessorWithData(DEPENDENCY_ORDERING_TEST_DATA)) - .dataStore(specificFeatureStore(store)) - .events(Components.noEvents()); - client = new LDClient(SDK_KEY, config.build()); - - Map, Map> dataMap = captureData.getValue(); - assertEquals(2, dataMap.size()); - - // Segments should always come first - assertEquals(SEGMENTS, Iterables.get(dataMap.keySet(), 0)); - assertEquals(DEPENDENCY_ORDERING_TEST_DATA.get(SEGMENTS).size(), Iterables.get(dataMap.values(), 0).size()); - - // Features should be ordered so that a flag always appears after its prerequisites, if any - assertEquals(FEATURES, Iterables.get(dataMap.keySet(), 1)); - Map map1 = Iterables.get(dataMap.values(), 1); - List list1 = ImmutableList.copyOf(map1.values()); - assertEquals(DEPENDENCY_ORDERING_TEST_DATA.get(FEATURES).size(), map1.size()); - for (int itemIndex = 0; itemIndex < list1.size(); itemIndex++) { - FeatureFlag item = (FeatureFlag)list1.get(itemIndex); - for (Prerequisite prereq: item.getPrerequisites()) { - FeatureFlag depFlag = (FeatureFlag)map1.get(prereq.getKey()); - int depIndex = list1.indexOf(depFlag); - if (depIndex > itemIndex) { - Iterable allKeys = Iterables.transform(list1, new Function() { - public String apply(VersionedData d) { - return d.getKey(); - } - }); - fail(String.format("%s depends on %s, but %s was listed first; keys in order are [%s]", - item.getKey(), prereq.getKey(), item.getKey(), - Joiner.on(", ").join(allKeys))); - } - } - } - } - - private void expectEventsSent(int count) { - eventProcessor.sendEvent(anyObject(Event.class)); - if (count > 0) { - expectLastCall().times(count); - } else { - expectLastCall().andThrow(new AssertionFailedError("should not have queued an event")).anyTimes(); - } - } - - private LDClientInterface createMockClient(LDConfig.Builder config) { - config.dataSource(TestUtil.specificUpdateProcessor(updateProcessor)); - config.events(TestUtil.specificEventProcessor(eventProcessor)); - return new LDClient(SDK_KEY, config.build()); - } - - private static Map, Map> DEPENDENCY_ORDERING_TEST_DATA = - ImmutableMap., Map>of( - FEATURES, - ImmutableMap.builder() - .put("a", new FeatureFlagBuilder("a") - .prerequisites(ImmutableList.of(new Prerequisite("b", 0), new Prerequisite("c", 0))).build()) - .put("b", new FeatureFlagBuilder("b") - .prerequisites(ImmutableList.of(new Prerequisite("c", 0), new Prerequisite("e", 0))).build()) - .put("c", new FeatureFlagBuilder("c").build()) - .put("d", new FeatureFlagBuilder("d").build()) - .put("e", new FeatureFlagBuilder("e").build()) - .put("f", new FeatureFlagBuilder("f").build()) - .build(), - SEGMENTS, - ImmutableMap.of( - "o", new Segment.Builder("o").build() - ) - ); -} \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/client/LDConfigTest.java b/src/test/java/com/launchdarkly/client/LDConfigTest.java deleted file mode 100644 index 3e89c7a5a..000000000 --- a/src/test/java/com/launchdarkly/client/LDConfigTest.java +++ /dev/null @@ -1,218 +0,0 @@ -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 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(); - 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 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.getProxy()); - } - - @SuppressWarnings("deprecation") - @Test - public void testDeprecatedHttpOnlyProxyPortConfiguredHasPortAndDefaultHost() { - LDConfig config = new LDConfig.Builder().proxyPort(1234).build(); - assertEquals(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("localhost", 1234)), config.httpConfig.getProxy()); - } - - @SuppressWarnings("deprecation") - @Test - 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.getProxy()); - } - - @SuppressWarnings("deprecation") - @Test - public void testDeprecatedHttpProxyAuth() { - LDConfig config = new LDConfig.Builder() - .proxyHost("localhost2") - .proxyPort(4444) - .proxyUsername("user") - .proxyPassword("pass") - .build(); - assertNotNull(config.httpConfig.getProxy()); - assertNotNull(config.httpConfig.getProxyAuthentication()); - assertEquals("Basic dXNlcjpwYXNz", config.httpConfig.getProxyAuthentication().provideAuthorization(null)); - } - - @SuppressWarnings("deprecation") - @Test - public void testDeprecatedHttpProxyAuthPartialConfig() { - LDConfig config = new LDConfig.Builder() - .proxyHost("localhost2") - .proxyPort(4444) - .proxyUsername("proxyUser") - .build(); - assertNotNull(config.httpConfig.getProxy()); - assertNull(config.httpConfig.getProxyAuthentication()); - - config = new LDConfig.Builder() - .proxyHost("localhost2") - .proxyPort(4444) - .proxyPassword("proxyPassword") - .build(); - assertNotNull(config.httpConfig.getProxy()); - assertNull(config.httpConfig.getProxyAuthentication()); - } - - @SuppressWarnings("deprecation") - @Test - 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 testDeprecatedHttpWrapperNameOnly() { - LDConfig config = new LDConfig.Builder() - .wrapperName("Scala") - .build(); - assertEquals("Scala", config.httpConfig.getWrapperIdentifier()); - } - - @SuppressWarnings("deprecation") - @Test - public void testDeprecatedHttpWrapperWithVersion() { - LDConfig config = new LDConfig.Builder() - .wrapperName("Scala") - .wrapperVersion("0.1.0") - .build(); - 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 deleted file mode 100644 index fd66966f6..000000000 --- a/src/test/java/com/launchdarkly/client/LDUserTest.java +++ /dev/null @@ -1,511 +0,0 @@ -package com.launchdarkly.client; - -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.ImmutableSet; -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.reflect.TypeToken; -import com.launchdarkly.client.value.LDValue; - -import org.junit.Test; - -import java.lang.reflect.Type; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -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; -import static com.launchdarkly.client.TestUtil.jint; -import static com.launchdarkly.client.TestUtil.js; -import static com.launchdarkly.client.TestUtil.makeEventsConfig; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; - -@SuppressWarnings("javadoc") -public class LDUserTest { - private static final Gson defaultGson = new Gson(); - - @Test - public void simpleConstructorSetsAttributes() { - LDUser user = new LDUser("key"); - assertEquals(LDValue.of("key"), user.getKey()); - assertEquals("key", user.getKeyAsString()); - assertEquals(LDValue.ofNull(), user.getSecondary()); - assertEquals(LDValue.ofNull(), user.getIp()); - assertEquals(LDValue.ofNull(), user.getFirstName()); - assertEquals(LDValue.ofNull(), user.getLastName()); - assertEquals(LDValue.ofNull(), user.getEmail()); - assertEquals(LDValue.ofNull(), user.getName()); - assertEquals(LDValue.ofNull(), user.getAvatar()); - assertEquals(LDValue.ofNull(), user.getAnonymous()); - assertEquals(LDValue.ofNull(), user.getCountry()); - assertEquals(LDValue.ofNull(), user.getCustom("x")); - } - - @Test - public void canCopyUserWithBuilder() { - LDUser user = new LDUser.Builder("key") - .secondary("secondary") - .ip("127.0.0.1") - .firstName("Bob") - .lastName("Loblaw") - .email("bob@example.com") - .name("Bob Loblaw") - .avatar("image") - .anonymous(false) - .country("US") - .custom("org", "LaunchDarkly") - .build(); - - assert(user.equals(new LDUser.Builder(user).build())); - } - - @Test - public void canSetKey() { - LDUser user = new LDUser.Builder("k").build(); - assertEquals("k", user.getKeyAsString()); - } - - @Test - public void canSetSecondary() { - LDUser user = new LDUser.Builder("key").secondary("s").build(); - assertEquals("s", user.getSecondary().stringValue()); - } - - @Test - public void canSetPrivateSecondary() { - LDUser user = new LDUser.Builder("key").privateSecondary("s").build(); - assertEquals("s", user.getSecondary().stringValue()); - assertEquals(ImmutableSet.of("secondary"), user.privateAttributeNames); - } - - @Test - public void canSetIp() { - LDUser user = new LDUser.Builder("key").ip("i").build(); - assertEquals("i", user.getIp().stringValue()); - } - - @Test - public void canSetPrivateIp() { - LDUser user = new LDUser.Builder("key").privateIp("i").build(); - assertEquals("i", user.getIp().stringValue()); - assertEquals(ImmutableSet.of("ip"), user.privateAttributeNames); - } - - @Test - public void canSetEmail() { - LDUser user = new LDUser.Builder("key").email("e").build(); - assertEquals("e", user.getEmail().stringValue()); - } - - @Test - public void canSetPrivateEmail() { - LDUser user = new LDUser.Builder("key").privateEmail("e").build(); - assertEquals("e", user.getEmail().stringValue()); - assertEquals(ImmutableSet.of("email"), user.privateAttributeNames); - } - - @Test - public void canSetName() { - LDUser user = new LDUser.Builder("key").name("n").build(); - assertEquals("n", user.getName().stringValue()); - } - - @Test - public void canSetPrivateName() { - LDUser user = new LDUser.Builder("key").privateName("n").build(); - assertEquals("n", user.getName().stringValue()); - assertEquals(ImmutableSet.of("name"), user.privateAttributeNames); - } - - @Test - public void canSetAvatar() { - LDUser user = new LDUser.Builder("key").avatar("a").build(); - assertEquals("a", user.getAvatar().stringValue()); - } - - @Test - public void canSetPrivateAvatar() { - LDUser user = new LDUser.Builder("key").privateAvatar("a").build(); - assertEquals("a", user.getAvatar().stringValue()); - assertEquals(ImmutableSet.of("avatar"), user.privateAttributeNames); - } - - @Test - public void canSetFirstName() { - LDUser user = new LDUser.Builder("key").firstName("f").build(); - assertEquals("f", user.getFirstName().stringValue()); - } - - @Test - public void canSetPrivateFirstName() { - LDUser user = new LDUser.Builder("key").privateFirstName("f").build(); - assertEquals("f", user.getFirstName().stringValue()); - assertEquals(ImmutableSet.of("firstName"), user.privateAttributeNames); - } - - @Test - public void canSetLastName() { - LDUser user = new LDUser.Builder("key").lastName("l").build(); - assertEquals("l", user.getLastName().stringValue()); - } - - @Test - public void canSetPrivateLastName() { - LDUser user = new LDUser.Builder("key").privateLastName("l").build(); - assertEquals("l", user.getLastName().stringValue()); - assertEquals(ImmutableSet.of("lastName"), user.privateAttributeNames); - } - - @Test - public void canSetAnonymous() { - LDUser user = new LDUser.Builder("key").anonymous(true).build(); - assertEquals(true, user.getAnonymous().booleanValue()); - } - - @SuppressWarnings("deprecation") - @Test - public void canSetCountry() { - LDUser user = new LDUser.Builder("key").country(LDCountryCode.US).build(); - assertEquals("US", user.getCountry().stringValue()); - } - - @Test - public void canSetCountryAsString() { - LDUser user = new LDUser.Builder("key").country("US").build(); - assertEquals("US", user.getCountry().stringValue()); - } - - @Test - public void canSetCountryAs3CharacterString() { - LDUser user = new LDUser.Builder("key").country("USA").build(); - assertEquals("US", user.getCountry().stringValue()); - } - - @Test - public void ambiguousCountryNameSetsCountryWithExactMatch() { - // "United States" is ambiguous: can also match "United States Minor Outlying Islands" - LDUser user = new LDUser.Builder("key").country("United States").build(); - assertEquals("US", user.getCountry().stringValue()); - } - - @Test - public void ambiguousCountryNameSetsCountryWithPartialMatch() { - // For an ambiguous match, we return the first match - LDUser user = new LDUser.Builder("key").country("United St").build(); - assertNotNull(user.getCountry()); - } - - @Test - public void partialUniqueMatchSetsCountry() { - LDUser user = new LDUser.Builder("key").country("United States Minor").build(); - assertEquals("UM", user.getCountry().stringValue()); - } - - @Test - public void invalidCountryNameDoesNotSetCountry() { - LDUser user = new LDUser.Builder("key").country("East Jibip").build(); - assertEquals(LDValue.ofNull(), user.getCountry()); - } - - @SuppressWarnings("deprecation") - @Test - public void canSetPrivateCountry() { - LDUser user = new LDUser.Builder("key").privateCountry(LDCountryCode.US).build(); - assertEquals("US", user.getCountry().stringValue()); - assertEquals(ImmutableSet.of("country"), user.privateAttributeNames); - } - - @Test - public void canSetCustomString() { - LDUser user = new LDUser.Builder("key").custom("thing", "value").build(); - assertEquals("value", user.getCustom("thing").stringValue()); - } - - @Test - public void canSetPrivateCustomString() { - LDUser user = new LDUser.Builder("key").privateCustom("thing", "value").build(); - assertEquals("value", user.getCustom("thing").stringValue()); - assertEquals(ImmutableSet.of("thing"), user.privateAttributeNames); - } - - @Test - public void canSetCustomInt() { - LDUser user = new LDUser.Builder("key").custom("thing", 1).build(); - assertEquals(1, user.getCustom("thing").intValue()); - } - - @Test - public void canSetPrivateCustomInt() { - LDUser user = new LDUser.Builder("key").privateCustom("thing", 1).build(); - assertEquals(1, user.getCustom("thing").intValue()); - assertEquals(ImmutableSet.of("thing"), user.privateAttributeNames); - } - - @Test - public void canSetCustomBoolean() { - LDUser user = new LDUser.Builder("key").custom("thing", true).build(); - assertEquals(true, user.getCustom("thing").booleanValue()); - } - - @Test - public void canSetPrivateCustomBoolean() { - LDUser user = new LDUser.Builder("key").privateCustom("thing", true).build(); - assertEquals(true, user.getCustom("thing").booleanValue()); - assertEquals(ImmutableSet.of("thing"), user.privateAttributeNames); - } - - @Test - public void canSetCustomJsonValue() { - LDValue value = LDValue.buildObject().put("1", LDValue.of("x")).build(); - LDUser user = new LDUser.Builder("key").custom("thing", value).build(); - assertEquals(value, user.getCustom("thing")); - } - - @Test - public void canSetPrivateCustomJsonValue() { - LDValue value = LDValue.buildObject().put("1", LDValue.of("x")).build(); - LDUser user = new LDUser.Builder("key").privateCustom("thing", value).build(); - assertEquals(value, user.getCustom("thing")); - assertEquals(ImmutableSet.of("thing"), user.privateAttributeNames); - } - - @SuppressWarnings("deprecation") - @Test - public void canSetDeprecatedCustomJsonValue() { - JsonObject value = new JsonObject(); - LDUser user = new LDUser.Builder("key").custom("thing", value).build(); - assertEquals(value, user.getCustom("thing").asJsonElement()); - } - - @SuppressWarnings("deprecation") - @Test - public void canSetPrivateDeprecatedCustomJsonValue() { - JsonObject value = new JsonObject(); - LDUser user = new LDUser.Builder("key").privateCustom("thing", value).build(); - assertEquals(value, user.getCustom("thing").asJsonElement()); - assertEquals(ImmutableSet.of("thing"), user.privateAttributeNames); - } - - @Test - public void testAllPropertiesInDefaultEncoding() { - for (Map.Entry e: getUserPropertiesJsonMap().entrySet()) { - JsonElement expected = TEST_GSON_INSTANCE.fromJson(e.getValue(), JsonElement.class); - JsonElement actual = TEST_GSON_INSTANCE.toJsonTree(e.getKey()); - assertEquals(expected, actual); - } - } - - @Test - public void testAllPropertiesInPrivateAttributeEncoding() { - for (Map.Entry e: getUserPropertiesJsonMap().entrySet()) { - 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\"}"); - builder.put(new LDUser.Builder("userkey").secondary("value").build(), - "{\"key\":\"userkey\",\"secondary\":\"value\"}"); - builder.put(new LDUser.Builder("userkey").ip("value").build(), - "{\"key\":\"userkey\",\"ip\":\"value\"}"); - builder.put(new LDUser.Builder("userkey").email("value").build(), - "{\"key\":\"userkey\",\"email\":\"value\"}"); - builder.put(new LDUser.Builder("userkey").name("value").build(), - "{\"key\":\"userkey\",\"name\":\"value\"}"); - builder.put(new LDUser.Builder("userkey").avatar("value").build(), - "{\"key\":\"userkey\",\"avatar\":\"value\"}"); - builder.put(new LDUser.Builder("userkey").firstName("value").build(), - "{\"key\":\"userkey\",\"firstName\":\"value\"}"); - builder.put(new LDUser.Builder("userkey").lastName("value").build(), - "{\"key\":\"userkey\",\"lastName\":\"value\"}"); - builder.put(new LDUser.Builder("userkey").anonymous(true).build(), - "{\"key\":\"userkey\",\"anonymous\":true}"); - builder.put(new LDUser.Builder("userkey").country(LDCountryCode.US).build(), - "{\"key\":\"userkey\",\"country\":\"US\"}"); - builder.put(new LDUser.Builder("userkey").custom("thing", "value").build(), - "{\"key\":\"userkey\",\"custom\":{\"thing\":\"value\"}}"); - return builder.build(); - } - - @Test - public void defaultJsonEncodingHasPrivateAttributeNames() { - LDUser user = new LDUser.Builder("userkey").privateName("x").privateEmail("y").build(); - String expected = "{\"key\":\"userkey\",\"name\":\"x\",\"email\":\"y\",\"privateAttributeNames\":[\"name\",\"email\"]}"; - assertEquals(defaultGson.fromJson(expected, JsonElement.class), defaultGson.toJsonTree(user)); - } - - @Test - public void privateAttributeEncodingRedactsAllPrivateAttributes() { - EventsConfiguration config = makeEventsConfig(true, false, null); - @SuppressWarnings("deprecation") - LDUser user = new LDUser.Builder("userkey") - .secondary("s") - .ip("i") - .email("e") - .name("n") - .avatar("a") - .firstName("f") - .lastName("l") - .anonymous(true) - .country(LDCountryCode.US) - .custom("thing", "value") - .build(); - Set redacted = ImmutableSet.of("secondary", "ip", "email", "name", "avatar", "firstName", "lastName", "country", "thing"); - - JsonObject o = gsonInstanceForEventsSerialization(config).toJsonTree(user).getAsJsonObject(); - assertEquals("userkey", o.get("key").getAsString()); - assertEquals(true, o.get("anonymous").getAsBoolean()); - for (String attr: redacted) { - assertNull(o.get(attr)); - } - assertNull(o.get("custom")); - assertEquals(redacted, getPrivateAttrs(o)); - } - - @Test - public void privateAttributeEncodingRedactsSpecificPerUserPrivateAttributes() { - LDUser user = new LDUser.Builder("userkey") - .email("e") - .privateName("n") - .custom("bar", 43) - .privateCustom("foo", 42) - .build(); - - JsonObject o = gsonInstanceForEventsSerialization(defaultEventsConfig()).toJsonTree(user).getAsJsonObject(); - assertEquals("e", o.get("email").getAsString()); - assertNull(o.get("name")); - assertEquals(43, o.get("custom").getAsJsonObject().get("bar").getAsInt()); - assertNull(o.get("custom").getAsJsonObject().get("foo")); - assertEquals(ImmutableSet.of("name", "foo"), getPrivateAttrs(o)); - } - - @Test - public void privateAttributeEncodingRedactsSpecificGlobalPrivateAttributes() { - EventsConfiguration config = makeEventsConfig(false, false, ImmutableSet.of("name", "foo")); - LDUser user = new LDUser.Builder("userkey") - .email("e") - .name("n") - .custom("bar", 43) - .custom("foo", 42) - .build(); - - JsonObject o = gsonInstanceForEventsSerialization(config).toJsonTree(user).getAsJsonObject(); - assertEquals("e", o.get("email").getAsString()); - assertNull(o.get("name")); - assertEquals(43, o.get("custom").getAsJsonObject().get("bar").getAsInt()); - assertNull(o.get("custom").getAsJsonObject().get("foo")); - assertEquals(ImmutableSet.of("name", "foo"), getPrivateAttrs(o)); - } - - @Test - public void privateAttributeEncodingWorksForMinimalUser() { - EventsConfiguration config = makeEventsConfig(true, false, null); - LDUser user = new LDUser("userkey"); - - JsonObject o = gsonInstanceForEventsSerialization(config).toJsonTree(user).getAsJsonObject(); - JsonObject expected = new JsonObject(); - expected.addProperty("key", "userkey"); - assertEquals(expected, o); - } - - @Test - public void getValueGetsBuiltInAttribute() { - LDUser user = new LDUser.Builder("key") - .name("Jane") - .build(); - assertEquals(LDValue.of("Jane"), user.getValueForEvaluation("name")); - } - - @Test - public void getValueGetsCustomAttribute() { - LDUser user = new LDUser.Builder("key") - .custom("height", 5) - .build(); - assertEquals(LDValue.of(5), user.getValueForEvaluation("height")); - } - - @Test - public void getValueGetsBuiltInAttributeEvenIfCustomAttrHasSameName() { - LDUser user = new LDUser.Builder("key") - .name("Jane") - .custom("name", "Joan") - .build(); - assertEquals(LDValue.of("Jane"), user.getValueForEvaluation("name")); - } - - @Test - public void getValueReturnsNullForCustomAttrIfThereAreNoCustomAttrs() { - LDUser user = new LDUser.Builder("key") - .name("Jane") - .build(); - assertEquals(LDValue.ofNull(), user.getValueForEvaluation("height")); - } - - @Test - public void getValueReturnsNullForCustomAttrIfThereAreCustomAttrsButNotThisOne() { - LDUser user = new LDUser.Builder("key") - .name("Jane") - .custom("eyes", "brown") - .build(); - assertEquals(LDValue.ofNull(), user.getValueForEvaluation("height")); - } - - @Test - public void canAddCustomAttrWithListOfStrings() { - LDUser user = new LDUser.Builder("key") - .customString("foo", ImmutableList.of("a", "b")) - .build(); - JsonElement expectedAttr = makeCustomAttrWithListOfValues("foo", js("a"), js("b")); - JsonObject jo = TEST_GSON_INSTANCE.toJsonTree(user).getAsJsonObject(); - assertEquals(expectedAttr, jo.get("custom")); - } - - @Test - public void canAddCustomAttrWithListOfNumbers() { - LDUser user = new LDUser.Builder("key") - .customNumber("foo", ImmutableList.of(new Integer(1), new Double(2))) - .build(); - JsonElement expectedAttr = makeCustomAttrWithListOfValues("foo", jint(1), jdouble(2)); - JsonObject jo = TEST_GSON_INSTANCE.toJsonTree(user).getAsJsonObject(); - assertEquals(expectedAttr, jo.get("custom")); - } - - @Test - public void canAddCustomAttrWithListOfMixedValues() { - LDUser user = new LDUser.Builder("key") - .customValues("foo", ImmutableList.of(js("a"), jint(1), jbool(true))) - .build(); - JsonElement expectedAttr = makeCustomAttrWithListOfValues("foo", js("a"), jint(1), jbool(true)); - JsonObject jo = TEST_GSON_INSTANCE.toJsonTree(user).getAsJsonObject(); - assertEquals(expectedAttr, jo.get("custom")); - } - - private JsonElement makeCustomAttrWithListOfValues(String name, JsonElement... values) { - JsonObject ret = new JsonObject(); - JsonArray a = new JsonArray(); - for (JsonElement v: values) { - a.add(v); - } - ret.add(name, a); - return ret; - } - - private Set getPrivateAttrs(JsonObject o) { - Type type = new TypeToken>(){}.getType(); - return new HashSet(defaultGson.>fromJson(o.get("privateAttrs"), type)); - } -} diff --git a/src/test/java/com/launchdarkly/client/OperatorParameterizedTest.java b/src/test/java/com/launchdarkly/client/OperatorParameterizedTest.java deleted file mode 100644 index 95f9cec02..000000000 --- a/src/test/java/com/launchdarkly/client/OperatorParameterizedTest.java +++ /dev/null @@ -1,132 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.value.LDValue; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; - -import java.util.Arrays; - -import static org.junit.Assert.assertEquals; - -@SuppressWarnings("javadoc") -@RunWith(Parameterized.class) -public class OperatorParameterizedTest { - private static LDValue dateStr1 = LDValue.of("2017-12-06T00:00:00.000-07:00"); - private static LDValue dateStr2 = LDValue.of("2017-12-06T00:01:01.000-07:00"); - private static LDValue dateMs1 = LDValue.of(10000000); - private static LDValue dateMs2 = LDValue.of(10000001); - private static LDValue invalidDate = LDValue.of("hey what's this?"); - private static LDValue invalidVer = LDValue.of("xbad%ver"); - - private final Operator op; - private final LDValue aValue; - private final LDValue bValue; - private final boolean shouldBe; - - public OperatorParameterizedTest(Operator op, LDValue aValue, LDValue bValue, boolean shouldBe) { - this.op = op; - this.aValue = aValue; - this.bValue = bValue; - this.shouldBe = shouldBe; - } - - @Parameterized.Parameters(name = "{1} {0} {2} should be {3}") - public static Iterable data() { - return Arrays.asList(new Object[][] { - // numeric comparisons - { Operator.in, LDValue.of(99), LDValue.of(99), true }, - { Operator.in, LDValue.of(99.0001), LDValue.of(99.0001), true }, - { Operator.in, LDValue.of(99), LDValue.of(99.0001), false }, - { Operator.in, LDValue.of(99.0001), LDValue.of(99), false }, - { Operator.lessThan, LDValue.of(99), LDValue.of(99.0001), true }, - { Operator.lessThan, LDValue.of(99.0001), LDValue.of(99), false }, - { Operator.lessThan, LDValue.of(99), LDValue.of(99), false }, - { Operator.lessThanOrEqual, LDValue.of(99), LDValue.of(99.0001), true }, - { Operator.lessThanOrEqual, LDValue.of(99.0001), LDValue.of(99), false }, - { Operator.lessThanOrEqual, LDValue.of(99), LDValue.of(99), true }, - { Operator.greaterThan, LDValue.of(99.0001), LDValue.of(99), true }, - { Operator.greaterThan, LDValue.of(99), LDValue.of(99.0001), false }, - { Operator.greaterThan, LDValue.of(99), LDValue.of(99), false }, - { Operator.greaterThanOrEqual, LDValue.of(99.0001), LDValue.of(99), true }, - { Operator.greaterThanOrEqual, LDValue.of(99), LDValue.of(99.0001), false }, - { Operator.greaterThanOrEqual, LDValue.of(99), LDValue.of(99), true }, - - // string comparisons - { Operator.in, LDValue.of("x"), LDValue.of("x"), true }, - { Operator.in, LDValue.of("x"), LDValue.of("xyz"), false }, - { Operator.startsWith, LDValue.of("xyz"), LDValue.of("x"), true }, - { Operator.startsWith, LDValue.of("x"), LDValue.of("xyz"), false }, - { Operator.endsWith, LDValue.of("xyz"), LDValue.of("z"), true }, - { Operator.endsWith, LDValue.of("z"), LDValue.of("xyz"), false }, - { Operator.contains, LDValue.of("xyz"), LDValue.of("y"), true }, - { Operator.contains, LDValue.of("y"), LDValue.of("xyz"), false }, - - // mixed strings and numbers - { Operator.in, LDValue.of("99"), LDValue.of(99), false }, - { Operator.in, LDValue.of(99), LDValue.of("99"), false }, - { Operator.contains, LDValue.of("99"), LDValue.of(99), false }, - { Operator.startsWith, LDValue.of("99"), LDValue.of(99), false }, - { Operator.endsWith, LDValue.of("99"), LDValue.of(99), false }, - { Operator.lessThanOrEqual, LDValue.of("99"), LDValue.of(99), false }, - { Operator.lessThanOrEqual, LDValue.of(99), LDValue.of("99"), false }, - { Operator.greaterThanOrEqual, LDValue.of("99"), LDValue.of(99), false }, - { Operator.greaterThanOrEqual, LDValue.of(99), LDValue.of("99"), false }, - - // regex - { Operator.matches, LDValue.of("hello world"), LDValue.of("hello.*rld"), true }, - { Operator.matches, LDValue.of("hello world"), LDValue.of("hello.*orl"), true }, - { Operator.matches, LDValue.of("hello world"), LDValue.of("l+"), true }, - { Operator.matches, LDValue.of("hello world"), LDValue.of("(world|planet)"), true }, - { Operator.matches, LDValue.of("hello world"), LDValue.of("aloha"), false }, - - // dates - { Operator.before, dateStr1, dateStr2, true }, - { Operator.before, dateMs1, dateMs2, true }, - { Operator.before, dateStr2, dateStr1, false }, - { Operator.before, dateMs2, dateMs1, false }, - { Operator.before, dateStr1, dateStr1, false }, - { Operator.before, dateMs1, dateMs1, false }, - { Operator.before, dateStr1, invalidDate, false }, - { Operator.after, dateStr1, dateStr2, false }, - { Operator.after, dateMs1, dateMs2, false }, - { Operator.after, dateStr2, dateStr1, true }, - { Operator.after, dateMs2, dateMs1, true }, - { Operator.after, dateStr1, dateStr1, false }, - { Operator.after, dateMs1, dateMs1, false }, - { Operator.after, dateStr1, invalidDate, false }, - - // semver - { Operator.semVerEqual, LDValue.of("2.0.1"), LDValue.of("2.0.1"), true }, - { Operator.semVerEqual, LDValue.of("2.0"), LDValue.of("2.0.0"), true }, - { Operator.semVerEqual, LDValue.of("2"), LDValue.of("2.0.0"), true }, - { Operator.semVerEqual, LDValue.of("2-rc1"), LDValue.of("2.0.0-rc1"), true }, - { Operator.semVerEqual, LDValue.of("2+build2"), LDValue.of("2.0.0+build2"), true }, - { Operator.semVerLessThan, LDValue.of("2.0.0"), LDValue.of("2.0.1"), true }, - { Operator.semVerLessThan, LDValue.of("2.0"), LDValue.of("2.0.1"), true }, - { Operator.semVerLessThan, LDValue.of("2.0.1"), LDValue.of("2.0.0"), false }, - { Operator.semVerLessThan, LDValue.of("2.0.1"), LDValue.of("2.0"), false }, - { Operator.semVerLessThan, LDValue.of("2.0.0-rc"), LDValue.of("2.0.0"), true }, - { Operator.semVerLessThan, LDValue.of("2.0.0-rc"), LDValue.of("2.0.0-rc.beta"), true }, - { Operator.semVerGreaterThan, LDValue.of("2.0.1"), LDValue.of("2.0.0"), true }, - { Operator.semVerGreaterThan, LDValue.of("2.0.1"), LDValue.of("2.0"), true }, - { Operator.semVerGreaterThan, LDValue.of("2.0.0"), LDValue.of("2.0.1"), false }, - { Operator.semVerGreaterThan, LDValue.of("2.0"), LDValue.of("2.0.1"), false }, - { Operator.semVerGreaterThan, LDValue.of("2.0.0-rc.1"), LDValue.of("2.0.0-rc.0"), true }, - { Operator.semVerLessThan, LDValue.of("2.0.1"), invalidVer, false }, - { Operator.semVerGreaterThan, LDValue.of("2.0.1"), invalidVer, false }, - { Operator.semVerEqual, LDValue.ofNull(), LDValue.of("2.0.0"), false }, - { Operator.semVerEqual, LDValue.of(1), LDValue.of("2.0.0"), false }, - { Operator.semVerEqual, LDValue.of(true), LDValue.of("2.0.0"), false }, - { Operator.semVerEqual, LDValue.of("2.0.0"), LDValue.ofNull(), false }, - { Operator.semVerEqual, LDValue.of("2.0.0"), LDValue.of(1), false }, - { Operator.semVerEqual, LDValue.of("2.0.0"), LDValue.of(true), false } - }); - } - - @Test - public void parameterizedTestComparison() { - assertEquals(shouldBe, op.apply(aValue, bValue)); - } -} diff --git a/src/test/java/com/launchdarkly/client/OperatorTest.java b/src/test/java/com/launchdarkly/client/OperatorTest.java deleted file mode 100644 index 4b55d4c0b..000000000 --- a/src/test/java/com/launchdarkly/client/OperatorTest.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.value.LDValue; - -import org.junit.Test; - -import java.util.regex.PatternSyntaxException; - -import static org.junit.Assert.assertFalse; - -// Any special-case tests that can't be handled by OperatorParameterizedTest. -@SuppressWarnings("javadoc") -public class OperatorTest { - // This is probably not desired behavior, but it is the current behavior - @Test(expected = PatternSyntaxException.class) - public void testInvalidRegexThrowsException() { - assertFalse(Operator.matches.apply(LDValue.of("hello world"), LDValue.of("***not a regex"))); - } -} diff --git a/src/test/java/com/launchdarkly/client/RuleBuilder.java b/src/test/java/com/launchdarkly/client/RuleBuilder.java deleted file mode 100644 index c9dd19933..000000000 --- a/src/test/java/com/launchdarkly/client/RuleBuilder.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.launchdarkly.client; - -import com.google.common.collect.ImmutableList; -import com.launchdarkly.client.VariationOrRollout.Rollout; - -import java.util.ArrayList; -import java.util.List; - -public class RuleBuilder { - private String id; - private List clauses = new ArrayList<>(); - private Integer variation; - private Rollout rollout; - private boolean trackEvents; - - public Rule build() { - return new Rule(id, clauses, variation, rollout, trackEvents); - } - - public RuleBuilder id(String id) { - this.id = id; - return this; - } - - public RuleBuilder clauses(Clause... clauses) { - this.clauses = ImmutableList.copyOf(clauses); - return this; - } - - public RuleBuilder variation(Integer variation) { - this.variation = variation; - return this; - } - - public RuleBuilder rollout(Rollout rollout) { - this.rollout = rollout; - return this; - } - - public RuleBuilder trackEvents(boolean trackEvents) { - this.trackEvents = trackEvents; - return this; - } -} diff --git a/src/test/java/com/launchdarkly/client/SegmentTest.java b/src/test/java/com/launchdarkly/client/SegmentTest.java deleted file mode 100644 index ccd9d5225..000000000 --- a/src/test/java/com/launchdarkly/client/SegmentTest.java +++ /dev/null @@ -1,142 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.value.LDValue; - -import org.junit.Test; - -import java.util.Arrays; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -@SuppressWarnings("javadoc") -public class SegmentTest { - - private int maxWeight = 100000; - - @Test - public void explicitIncludeUser() { - Segment s = new Segment.Builder("test") - .included(Arrays.asList("foo")) - .salt("abcdef") - .version(1) - .build(); - LDUser u = new LDUser.Builder("foo").build(); - - assertTrue(s.matchesUser(u)); - } - - @Test - public void explicitExcludeUser() { - Segment s = new Segment.Builder("test") - .excluded(Arrays.asList("foo")) - .salt("abcdef") - .version(1) - .build(); - LDUser u = new LDUser.Builder("foo").build(); - - assertFalse(s.matchesUser(u)); - } - - @Test - public void explicitIncludeHasPrecedence() { - Segment s = new Segment.Builder("test") - .included(Arrays.asList("foo")) - .excluded(Arrays.asList("foo")) - .salt("abcdef") - .version(1) - .build(); - LDUser u = new LDUser.Builder("foo").build(); - - assertTrue(s.matchesUser(u)); - } - - @Test - public void matchingRuleWithFullRollout() { - Clause clause = new Clause( - "email", - Operator.in, - Arrays.asList(LDValue.of("test@example.com")), - false); - SegmentRule rule = new SegmentRule( - Arrays.asList(clause), - maxWeight, - null); - Segment s = new Segment.Builder("test") - .salt("abcdef") - .rules(Arrays.asList(rule)) - .build(); - LDUser u = new LDUser.Builder("foo").email("test@example.com").build(); - - assertTrue(s.matchesUser(u)); - } - - @Test - public void matchingRuleWithZeroRollout() { - Clause clause = new Clause( - "email", - Operator.in, - Arrays.asList(LDValue.of("test@example.com")), - false); - SegmentRule rule = new SegmentRule(Arrays.asList(clause), - 0, - null); - Segment s = new Segment.Builder("test") - .salt("abcdef") - .rules(Arrays.asList(rule)) - .build(); - LDUser u = new LDUser.Builder("foo").email("test@example.com").build(); - - assertFalse(s.matchesUser(u)); - } - - @Test - public void matchingRuleWithMultipleClauses() { - Clause clause1 = new Clause( - "email", - Operator.in, - Arrays.asList(LDValue.of("test@example.com")), - false); - Clause clause2 = new Clause( - "name", - Operator.in, - Arrays.asList(LDValue.of("bob")), - false); - SegmentRule rule = new SegmentRule( - Arrays.asList(clause1, clause2), - null, - null); - Segment s = new Segment.Builder("test") - .salt("abcdef") - .rules(Arrays.asList(rule)) - .build(); - LDUser u = new LDUser.Builder("foo").email("test@example.com").name("bob").build(); - - assertTrue(s.matchesUser(u)); - } - - @Test - public void nonMatchingRuleWithMultipleClauses() { - Clause clause1 = new Clause( - "email", - Operator.in, - Arrays.asList(LDValue.of("test@example.com")), - false); - Clause clause2 = new Clause( - "name", - Operator.in, - Arrays.asList(LDValue.of("bill")), - false); - SegmentRule rule = new SegmentRule( - Arrays.asList(clause1, clause2), - null, - null); - Segment s = new Segment.Builder("test") - .salt("abcdef") - .rules(Arrays.asList(rule)) - .build(); - LDUser u = new LDUser.Builder("foo").email("test@example.com").name("bob").build(); - - assertFalse(s.matchesUser(u)); - } -} \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java deleted file mode 100644 index 98663931d..000000000 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ /dev/null @@ -1,317 +0,0 @@ -package com.launchdarkly.client; - -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; -import com.launchdarkly.client.integrations.EventProcessorBuilder; -import com.launchdarkly.client.value.LDValue; - -import org.hamcrest.Description; -import org.hamcrest.Matcher; -import org.hamcrest.TypeSafeDiagnosingMatcher; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.Future; - -import static org.hamcrest.Matchers.equalTo; - -@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() { - return store; - } - }; - } - - public static FeatureStore initedFeatureStore() { - FeatureStore store = new InMemoryFeatureStore(); - store.init(Collections., Map>emptyMap()); - return store; - } - - public static EventProcessorFactory specificEventProcessor(final EventProcessor ep) { - return new EventProcessorFactory() { - public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { - return ep; - } - }; - } - - public static UpdateProcessorFactory specificUpdateProcessor(final UpdateProcessor up) { - return new UpdateProcessorFactory() { - public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { - return up; - } - }; - } - - public static UpdateProcessorFactory updateProcessorWithData(final Map, Map> data) { - return new UpdateProcessorFactory() { - public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, final FeatureStore featureStore) { - return new UpdateProcessor() { - public Future start() { - featureStore.init(data); - return Futures.immediateFuture(null); - } - - public boolean initialized() { - return true; - } - - public void close() throws IOException { - } - }; - } - }; - } - - public static FeatureStore featureStoreThatThrowsException(final RuntimeException e) { - return new FeatureStore() { - @Override - public void close() throws IOException { } - - @Override - public T get(VersionedDataKind kind, String key) { - throw e; - } - - @Override - public Map all(VersionedDataKind kind) { - throw e; - } - - @Override - public void init(Map, Map> allData) { - throw e; - } - - @Override - public void delete(VersionedDataKind kind, String key, int version) { - throw e; - } - - @Override - public void upsert(VersionedDataKind kind, T item) { - throw e; - } - - @Override - public boolean initialized() { - return true; - } - }; - } - - public static UpdateProcessor failedUpdateProcessor() { - return new UpdateProcessor() { - @Override - public Future start() { - return SettableFuture.create(); - } - - @Override - public boolean initialized() { - return false; - } - - @Override - public void close() throws IOException { - } - }; - } - - public static class TestEventProcessor implements EventProcessor { - List events = new ArrayList<>(); - - @Override - public void close() throws IOException {} - - @Override - public void sendEvent(Event e) { - events.add(e); - } - - @Override - public void flush() {} - } - - public static JsonPrimitive js(String s) { - return new JsonPrimitive(s); - } - - public static JsonPrimitive jint(int n) { - return new JsonPrimitive(n); - } - - public static JsonPrimitive jdouble(double d) { - return new JsonPrimitive(d); - } - - public static JsonPrimitive jbool(boolean b) { - return new JsonPrimitive(b); - } - - public static VariationOrRollout fallthroughVariation(int variation) { - return new VariationOrRollout(variation, null); - } - - public static FeatureFlag booleanFlagWithClauses(String key, Clause... clauses) { - Rule rule = new Rule(null, Arrays.asList(clauses), 1, null); - return new FeatureFlagBuilder(key) - .on(true) - .rules(Arrays.asList(rule)) - .fallthrough(fallthroughVariation(0)) - .offVariation(0) - .variations(LDValue.of(false), LDValue.of(true)) - .build(); - } - - public static FeatureFlag flagWithValue(String key, LDValue value) { - return new FeatureFlagBuilder(key) - .on(false) - .offVariation(0) - .variations(value) - .build(); - } - - public static Clause makeClauseToMatchUser(LDUser user) { - return new Clause("key", Operator.in, Arrays.asList(user.getKey()), false); - } - - public static Clause makeClauseToNotMatchUser(LDUser user) { - return new Clause("key", Operator.in, Arrays.asList(LDValue.of("not-" + user.getKeyAsString())), false); - } - - public static class DataBuilder { - private Map, Map> data = new HashMap<>(); - - @SuppressWarnings("unchecked") - public DataBuilder add(VersionedDataKind kind, VersionedData... items) { - Map itemsMap = (Map) data.get(kind); - if (itemsMap == null) { - itemsMap = new HashMap<>(); - data.put(kind, itemsMap); - } - for (VersionedData item: items) { - itemsMap.put(item.getKey(), item); - } - return this; - } - - public Map, Map> build() { - return data; - } - - // Silly casting helper due to difference in generic signatures between FeatureStore and FeatureStoreCore - @SuppressWarnings({ "unchecked", "rawtypes" }) - public Map, Map> buildUnchecked() { - Map uncheckedMap = data; - return (Map, Map>)uncheckedMap; - } - } - - public static EvaluationDetail simpleEvaluation(int variation, LDValue value) { - return EvaluationDetail.fromValue(value, variation, EvaluationReason.fallthrough()); - } - - public static Matcher hasJsonProperty(final String name, JsonElement value) { - return hasJsonProperty(name, equalTo(value)); - } - - @SuppressWarnings("deprecation") - public static Matcher hasJsonProperty(final String name, LDValue value) { - return hasJsonProperty(name, equalTo(value.asUnsafeJsonElement())); - } - - public static Matcher hasJsonProperty(final String name, String value) { - return hasJsonProperty(name, new JsonPrimitive(value)); - } - - public static Matcher hasJsonProperty(final String name, int value) { - return hasJsonProperty(name, new JsonPrimitive(value)); - } - - public static Matcher hasJsonProperty(final String name, double value) { - return hasJsonProperty(name, new JsonPrimitive(value)); - } - - public static Matcher hasJsonProperty(final String name, boolean value) { - return hasJsonProperty(name, new JsonPrimitive(value)); - } - - public static Matcher hasJsonProperty(final String name, final Matcher matcher) { - return new TypeSafeDiagnosingMatcher() { - @Override - public void describeTo(Description description) { - description.appendText(name + ": "); - matcher.describeTo(description); - } - - @Override - protected boolean matchesSafely(JsonElement item, Description mismatchDescription) { - JsonElement value = item.getAsJsonObject().get(name); - if (!matcher.matches(value)) { - matcher.describeMismatch(value, mismatchDescription); - return false; - } - return true; - } - }; - } - - public static Matcher isJsonArray(final Matcher> matcher) { - return new TypeSafeDiagnosingMatcher() { - @Override - public void describeTo(Description description) { - description.appendText("array: "); - matcher.describeTo(description); - } - - @Override - protected boolean matchesSafely(JsonElement item, Description mismatchDescription) { - JsonArray value = item.getAsJsonArray(); - if (!matcher.matches(value)) { - matcher.describeMismatch(value, mismatchDescription); - return false; - } - return true; - } - }; - } - - static EventsConfiguration makeEventsConfig(boolean allAttributesPrivate, boolean inlineUsersInEvents, - Set privateAttrNames) { - return new EventsConfiguration( - allAttributesPrivate, - 0, null, 0, - inlineUsersInEvents, - privateAttrNames, - 0, 0, 0, EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS); - } - - static EventsConfiguration defaultEventsConfig() { - return makeEventsConfig(false, false, null); - } -} diff --git a/src/test/java/com/launchdarkly/client/UtilTest.java b/src/test/java/com/launchdarkly/client/UtilTest.java deleted file mode 100644 index 84744dc9c..000000000 --- a/src/test/java/com/launchdarkly/client/UtilTest.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.value.LDValue; - -import org.joda.time.DateTime; -import org.joda.time.DateTimeZone; -import org.junit.Assert; -import org.junit.Test; - -import static com.launchdarkly.client.Util.configureHttpClientBuilder; -import static com.launchdarkly.client.Util.shutdownHttpClient; -import static org.junit.Assert.assertEquals; - -import okhttp3.OkHttpClient; - -@SuppressWarnings("javadoc") -public class UtilTest { - @Test - public void testDateTimeConversionWithTimeZone() { - String validRFC3339String = "2016-04-16T17:09:12.759-07:00"; - String expected = "2016-04-17T00:09:12.759Z"; - - DateTime actual = Util.jsonPrimitiveToDateTime(LDValue.of(validRFC3339String)); - Assert.assertEquals(expected, actual.toString()); - } - - @Test - public void testDateTimeConversionWithUtc() { - String validRFC3339String = "1970-01-01T00:00:01.001Z"; - - DateTime actual = Util.jsonPrimitiveToDateTime(LDValue.of(validRFC3339String)); - Assert.assertEquals(validRFC3339String, actual.toString()); - } - - @Test - public void testDateTimeConversionWithNoTimeZone() { - String validRFC3339String = "2016-04-16T17:09:12.759"; - String expected = "2016-04-16T17:09:12.759Z"; - - DateTime actual = Util.jsonPrimitiveToDateTime(LDValue.of(validRFC3339String)); - Assert.assertEquals(expected, actual.toString()); - } - - @Test - public void testDateTimeConversionTimestampWithNoMillis() { - String validRFC3339String = "2016-04-16T17:09:12"; - String expected = "2016-04-16T17:09:12.000Z"; - - DateTime actual = Util.jsonPrimitiveToDateTime(LDValue.of(validRFC3339String)); - Assert.assertEquals(expected, actual.toString()); - } - - @Test - public void testDateTimeConversionAsUnixMillis() { - long unixMillis = 1000; - String expected = "1970-01-01T00:00:01.000Z"; - DateTime actual = Util.jsonPrimitiveToDateTime(LDValue.of(unixMillis)); - Assert.assertEquals(expected, actual.withZone(DateTimeZone.UTC).toString()); - } - - @Test - public void testDateTimeConversionCompare() { - long aMillis = 1001; - String bStamp = "1970-01-01T00:00:01.001Z"; - DateTime a = Util.jsonPrimitiveToDateTime(LDValue.of(aMillis)); - DateTime b = Util.jsonPrimitiveToDateTime(LDValue.of(bStamp)); - Assert.assertTrue(a.getMillis() == b.getMillis()); - } - - @Test - public void testDateTimeConversionAsUnixMillisBeforeEpoch() { - long unixMillis = -1000; - DateTime actual = Util.jsonPrimitiveToDateTime(LDValue.of(unixMillis)); - Assert.assertEquals(unixMillis, actual.getMillis()); - } - - @Test - public void testDateTimeConversionInvalidString() { - String invalidTimestamp = "May 3, 1980"; - DateTime actual = Util.jsonPrimitiveToDateTime(LDValue.of(invalidTimestamp)); - Assert.assertNull(actual); - } - - @Test - 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(); - try { - assertEquals(3000, httpClient.connectTimeoutMillis()); - } finally { - shutdownHttpClient(httpClient); - } - } - - @Test - 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(); - try { - assertEquals(3000, httpClient.readTimeoutMillis()); - } finally { - shutdownHttpClient(httpClient); - } - } -} diff --git a/src/test/java/com/launchdarkly/client/integrations/ClientWithFileDataSourceTest.java b/src/test/java/com/launchdarkly/client/integrations/ClientWithFileDataSourceTest.java deleted file mode 100644 index f7c365b41..000000000 --- a/src/test/java/com/launchdarkly/client/integrations/ClientWithFileDataSourceTest.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.launchdarkly.client.integrations; - -import com.google.gson.JsonPrimitive; -import com.launchdarkly.client.LDClient; -import com.launchdarkly.client.LDConfig; -import com.launchdarkly.client.LDUser; - -import org.junit.Test; - -import static com.launchdarkly.client.integrations.FileDataSourceTestData.FLAG_VALUE_1; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.FLAG_VALUE_1_KEY; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.FULL_FLAG_1_KEY; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.FULL_FLAG_1_VALUE; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.resourceFilePath; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; - -@SuppressWarnings("javadoc") -public class ClientWithFileDataSourceTest { - private static final LDUser user = new LDUser.Builder("userkey").build(); - - private LDClient makeClient() throws Exception { - FileDataSourceBuilder fdsb = FileData.dataSource() - .filePaths(resourceFilePath("all-properties.json")); - LDConfig config = new LDConfig.Builder() - .dataSource(fdsb) - .sendEvents(false) - .build(); - return new LDClient("sdkKey", config); - } - - @Test - public void fullFlagDefinitionEvaluatesAsExpected() throws Exception { - try (LDClient client = makeClient()) { - assertThat(client.jsonVariation(FULL_FLAG_1_KEY, user, new JsonPrimitive("default")), - equalTo(FULL_FLAG_1_VALUE)); - } - } - - @Test - public void simplifiedFlagEvaluatesAsExpected() throws Exception { - try (LDClient client = makeClient()) { - assertThat(client.jsonVariation(FLAG_VALUE_1_KEY, user, new JsonPrimitive("default")), - equalTo(FLAG_VALUE_1)); - } - } -} diff --git a/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreTestBase.java b/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreTestBase.java deleted file mode 100644 index 569efb3d3..000000000 --- a/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreTestBase.java +++ /dev/null @@ -1,329 +0,0 @@ -package com.launchdarkly.client.integrations; - -import com.launchdarkly.client.DataStoreTestTypes.TestItem; -import com.launchdarkly.client.TestUtil.DataBuilder; -import com.launchdarkly.client.VersionedData; -import com.launchdarkly.client.VersionedDataKind; -import com.launchdarkly.client.utils.FeatureStoreCore; - -import org.junit.After; -import org.junit.Assume; -import org.junit.Before; -import org.junit.Test; - -import java.util.Map; - -import static com.launchdarkly.client.DataStoreTestTypes.OTHER_TEST_ITEMS; -import static com.launchdarkly.client.DataStoreTestTypes.TEST_ITEMS; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; -import static org.junit.Assume.assumeTrue; - -/** - * Similar to FeatureStoreTestBase, but exercises only the underlying database implementation of a persistent - * data store. The caching behavior, which is entirely implemented by CachingStoreWrapper, is covered by - * CachingStoreWrapperTest. - */ -@SuppressWarnings("javadoc") -public abstract class PersistentDataStoreTestBase { - protected T store; - - protected TestItem item1 = new TestItem("first", "key1", 10); - - protected TestItem item2 = new TestItem("second", "key2", 10); - - protected TestItem otherItem1 = new TestItem("other-first", "key1", 11); - - /** - * Test subclasses must override this method to create an instance of the feature store class - * with default properties. - */ - protected abstract T makeStore(); - - /** - * Test subclasses should implement this if the feature store class supports a key prefix option - * for keeping data sets distinct within the same database. - */ - protected abstract T makeStoreWithPrefix(String prefix); - - /** - * Test classes should override this to clear all data from the underlying database. - */ - protected abstract void clearAllData(); - - /** - * Test classes should override this (and return true) if it is possible to instrument the feature - * store to execute the specified Runnable during an upsert operation, for concurrent modification tests. - */ - protected boolean setUpdateHook(T storeUnderTest, Runnable hook) { - return false; - } - - @Before - public void setup() { - store = makeStore(); - } - - @After - public void teardown() throws Exception { - store.close(); - } - - @Test - public void storeNotInitializedBeforeInit() { - clearAllData(); - assertFalse(store.initializedInternal()); - } - - @Test - public void storeInitializedAfterInit() { - store.initInternal(new DataBuilder().buildUnchecked()); - assertTrue(store.initializedInternal()); - } - - @Test - public void initCompletelyReplacesPreviousData() { - clearAllData(); - - Map, Map> allData = - new DataBuilder().add(TEST_ITEMS, item1, item2).add(OTHER_TEST_ITEMS, otherItem1).buildUnchecked(); - store.initInternal(allData); - - TestItem item2v2 = item2.withVersion(item2.version + 1); - allData = new DataBuilder().add(TEST_ITEMS, item2v2).add(OTHER_TEST_ITEMS).buildUnchecked(); - store.initInternal(allData); - - assertNull(store.getInternal(TEST_ITEMS, item1.key)); - assertEquals(item2v2, store.getInternal(TEST_ITEMS, item2.key)); - assertNull(store.getInternal(OTHER_TEST_ITEMS, otherItem1.key)); - } - - @Test - public void oneInstanceCanDetectIfAnotherInstanceHasInitializedTheStore() { - clearAllData(); - T store2 = makeStore(); - - assertFalse(store.initializedInternal()); - - store2.initInternal(new DataBuilder().add(TEST_ITEMS, item1).buildUnchecked()); - - assertTrue(store.initializedInternal()); - } - - @Test - public void oneInstanceCanDetectIfAnotherInstanceHasInitializedTheStoreEvenIfEmpty() { - clearAllData(); - T store2 = makeStore(); - - assertFalse(store.initializedInternal()); - - store2.initInternal(new DataBuilder().buildUnchecked()); - - assertTrue(store.initializedInternal()); - } - - @Test - public void getExistingItem() { - store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).buildUnchecked()); - assertEquals(item1, store.getInternal(TEST_ITEMS, item1.key)); - } - - @Test - public void getNonexistingItem() { - store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).buildUnchecked()); - assertNull(store.getInternal(TEST_ITEMS, "biz")); - } - - @Test - public void getAll() { - store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).add(OTHER_TEST_ITEMS, otherItem1).buildUnchecked()); - Map items = store.getAllInternal(TEST_ITEMS); - assertEquals(2, items.size()); - assertEquals(item1, items.get(item1.key)); - assertEquals(item2, items.get(item2.key)); - } - - @Test - public void getAllWithDeletedItem() { - store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).buildUnchecked()); - TestItem deletedItem = item1.withVersion(item1.version + 1).withDeleted(true); - store.upsertInternal(TEST_ITEMS, deletedItem); - Map items = store.getAllInternal(TEST_ITEMS); - assertEquals(2, items.size()); - assertEquals(item2, items.get(item2.key)); - assertEquals(deletedItem, items.get(item1.key)); - } - - @Test - public void upsertWithNewerVersion() { - store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).buildUnchecked()); - TestItem newVer = item1.withVersion(item1.version + 1).withName("modified"); - store.upsertInternal(TEST_ITEMS, newVer); - assertEquals(newVer, store.getInternal(TEST_ITEMS, item1.key)); - } - - @Test - public void upsertWithOlderVersion() { - store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).buildUnchecked()); - TestItem oldVer = item1.withVersion(item1.version - 1).withName("modified"); - store.upsertInternal(TEST_ITEMS, oldVer); - assertEquals(item1, store.getInternal(TEST_ITEMS, oldVer.key)); - } - - @Test - public void upsertNewItem() { - store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).buildUnchecked()); - TestItem newItem = new TestItem("new-name", "new-key", 99); - store.upsertInternal(TEST_ITEMS, newItem); - assertEquals(newItem, store.getInternal(TEST_ITEMS, newItem.key)); - } - - @Test - public void deleteWithNewerVersion() { - store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).buildUnchecked()); - TestItem deletedItem = item1.withVersion(item1.version + 1).withDeleted(true); - store.upsertInternal(TEST_ITEMS, deletedItem); - assertEquals(deletedItem, store.getInternal(TEST_ITEMS, item1.key)); - } - - @Test - public void deleteWithOlderVersion() { - store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).buildUnchecked()); - TestItem deletedItem = item1.withVersion(item1.version - 1).withDeleted(true); - store.upsertInternal(TEST_ITEMS, deletedItem); - assertEquals(item1, store.getInternal(TEST_ITEMS, item1.key)); - } - - @Test - public void deleteUnknownItem() { - store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).buildUnchecked()); - TestItem deletedItem = new TestItem(null, "deleted-key", 11, true); - store.upsertInternal(TEST_ITEMS, deletedItem); - assertEquals(deletedItem, store.getInternal(TEST_ITEMS, deletedItem.key)); - } - - @Test - public void upsertOlderVersionAfterDelete() { - store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).buildUnchecked()); - TestItem deletedItem = item1.withVersion(item1.version + 1).withDeleted(true); - store.upsertInternal(TEST_ITEMS, deletedItem); - store.upsertInternal(TEST_ITEMS, item1); - assertEquals(deletedItem, store.getInternal(TEST_ITEMS, item1.key)); - } - - // The following two tests verify that the update version checking logic works correctly when - // another client instance is modifying the same data. They will run only if the test class - // supports setUpdateHook(). - - @Test - public void handlesUpsertRaceConditionAgainstExternalClientWithLowerVersion() throws Exception { - final T store2 = makeStore(); - - int startVersion = 1; - final int store2VersionStart = 2; - final int store2VersionEnd = 4; - int store1VersionEnd = 10; - - final TestItem startItem = new TestItem("me", "foo", startVersion); - - Runnable concurrentModifier = new Runnable() { - int versionCounter = store2VersionStart; - public void run() { - if (versionCounter <= store2VersionEnd) { - store2.upsertInternal(TEST_ITEMS, startItem.withVersion(versionCounter)); - versionCounter++; - } - } - }; - - try { - assumeTrue(setUpdateHook(store, concurrentModifier)); - - store.initInternal(new DataBuilder().add(TEST_ITEMS, startItem).buildUnchecked()); - - TestItem store1End = startItem.withVersion(store1VersionEnd); - store.upsertInternal(TEST_ITEMS, store1End); - - VersionedData result = store.getInternal(TEST_ITEMS, startItem.key); - assertEquals(store1VersionEnd, result.getVersion()); - } finally { - store2.close(); - } - } - - @Test - public void handlesUpsertRaceConditionAgainstExternalClientWithHigherVersion() throws Exception { - final T store2 = makeStore(); - - int startVersion = 1; - final int store2Version = 3; - int store1VersionEnd = 2; - - final TestItem startItem = new TestItem("me", "foo", startVersion); - - Runnable concurrentModifier = new Runnable() { - public void run() { - store2.upsertInternal(TEST_ITEMS, startItem.withVersion(store2Version)); - } - }; - - try { - assumeTrue(setUpdateHook(store, concurrentModifier)); - - store.initInternal(new DataBuilder().add(TEST_ITEMS, startItem).buildUnchecked()); - - TestItem store1End = startItem.withVersion(store1VersionEnd); - store.upsertInternal(TEST_ITEMS, store1End); - - VersionedData result = store.getInternal(TEST_ITEMS, startItem.key); - assertEquals(store2Version, result.getVersion()); - } finally { - store2.close(); - } - } - - @Test - public void storesWithDifferentPrefixAreIndependent() throws Exception { - T store1 = makeStoreWithPrefix("aaa"); - Assume.assumeNotNull(store1); - T store2 = makeStoreWithPrefix("bbb"); - clearAllData(); - - try { - assertFalse(store1.initializedInternal()); - assertFalse(store2.initializedInternal()); - - TestItem item1a = new TestItem("a1", "flag-a", 1); - TestItem item1b = new TestItem("b", "flag-b", 1); - TestItem item2a = new TestItem("a2", "flag-a", 2); - TestItem item2c = new TestItem("c", "flag-c", 2); - - store1.initInternal(new DataBuilder().add(TEST_ITEMS, item1a, item1b).buildUnchecked()); - assertTrue(store1.initializedInternal()); - assertFalse(store2.initializedInternal()); - - store2.initInternal(new DataBuilder().add(TEST_ITEMS, item2a, item2c).buildUnchecked()); - assertTrue(store1.initializedInternal()); - assertTrue(store2.initializedInternal()); - - Map items1 = store1.getAllInternal(TEST_ITEMS); - Map items2 = store2.getAllInternal(TEST_ITEMS); - assertEquals(2, items1.size()); - assertEquals(2, items2.size()); - assertEquals(item1a, items1.get(item1a.key)); - assertEquals(item1b, items1.get(item1b.key)); - assertEquals(item2a, items2.get(item2a.key)); - assertEquals(item2c, items2.get(item2c.key)); - - assertEquals(item1a, store1.getInternal(TEST_ITEMS, item1a.key)); - assertEquals(item1b, store1.getInternal(TEST_ITEMS, item1b.key)); - assertEquals(item2a, store2.getInternal(TEST_ITEMS, item2a.key)); - assertEquals(item2c, store2.getInternal(TEST_ITEMS, item2c.key)); - } finally { - store1.close(); - store2.close(); - } - } -} diff --git a/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreBuilderTest.java b/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreBuilderTest.java deleted file mode 100644 index 7d14ccdb8..000000000 --- a/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreBuilderTest.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.launchdarkly.client.integrations; - -import org.junit.Test; - -import java.net.URI; -import java.net.URISyntaxException; -import java.util.concurrent.TimeUnit; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -import redis.clients.jedis.JedisPoolConfig; -import redis.clients.jedis.Protocol; - -@SuppressWarnings("javadoc") -public class RedisDataStoreBuilderTest { - @Test - public void testDefaultValues() { - RedisDataStoreBuilder conf = Redis.dataStore(); - assertEquals(RedisDataStoreBuilder.DEFAULT_URI, conf.uri); - assertNull(conf.database); - assertNull(conf.password); - assertFalse(conf.tls); - assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); - assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); - assertEquals(RedisDataStoreBuilder.DEFAULT_PREFIX, conf.prefix); - assertNull(conf.poolConfig); - } - - @Test - public void testUriConfigured() { - URI uri = URI.create("redis://other:9999"); - RedisDataStoreBuilder conf = Redis.dataStore().uri(uri); - assertEquals(uri, conf.uri); - } - - @Test - public void testDatabaseConfigured() { - RedisDataStoreBuilder conf = Redis.dataStore().database(3); - assertEquals(new Integer(3), conf.database); - } - - @Test - public void testPasswordConfigured() { - RedisDataStoreBuilder conf = Redis.dataStore().password("secret"); - assertEquals("secret", conf.password); - } - - @Test - public void testTlsConfigured() { - RedisDataStoreBuilder conf = Redis.dataStore().tls(true); - assertTrue(conf.tls); - } - - @Test - public void testPrefixConfigured() throws URISyntaxException { - RedisDataStoreBuilder conf = Redis.dataStore().prefix("prefix"); - assertEquals("prefix", conf.prefix); - } - - @Test - public void testConnectTimeoutConfigured() throws URISyntaxException { - RedisDataStoreBuilder conf = Redis.dataStore().connectTimeout(1, TimeUnit.SECONDS); - assertEquals(1000, conf.connectTimeout); - } - - @Test - public void testSocketTimeoutConfigured() throws URISyntaxException { - RedisDataStoreBuilder conf = Redis.dataStore().socketTimeout(1, TimeUnit.SECONDS); - assertEquals(1000, conf.socketTimeout); - } - - @Test - public void testPoolConfigConfigured() throws URISyntaxException { - JedisPoolConfig poolConfig = new JedisPoolConfig(); - RedisDataStoreBuilder conf = Redis.dataStore().poolConfig(poolConfig); - assertEquals(poolConfig, conf.poolConfig); - } -} diff --git a/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreImplTest.java b/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreImplTest.java deleted file mode 100644 index f217b3347..000000000 --- a/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreImplTest.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.launchdarkly.client.integrations; - -import com.launchdarkly.client.integrations.RedisDataStoreImpl.UpdateListener; - -import org.junit.BeforeClass; - -import java.net.URI; - -import static org.junit.Assume.assumeTrue; - -import redis.clients.jedis.Jedis; - -@SuppressWarnings("javadoc") -public class RedisDataStoreImplTest extends PersistentDataStoreTestBase { - - private static final URI REDIS_URI = URI.create("redis://localhost:6379"); - - @BeforeClass - public static void maybeSkipDatabaseTests() { - String skipParam = System.getenv("LD_SKIP_DATABASE_TESTS"); - assumeTrue(skipParam == null || skipParam.equals("")); - } - - @Override - protected RedisDataStoreImpl makeStore() { - return (RedisDataStoreImpl)Redis.dataStore().uri(REDIS_URI).createPersistentDataStore(); - } - - @Override - protected RedisDataStoreImpl makeStoreWithPrefix(String prefix) { - return (RedisDataStoreImpl)Redis.dataStore().uri(REDIS_URI).prefix(prefix).createPersistentDataStore(); - } - - @Override - protected void clearAllData() { - try (Jedis client = new Jedis("localhost")) { - client.flushDB(); - } - } - - @Override - protected boolean setUpdateHook(RedisDataStoreImpl storeUnderTest, final Runnable hook) { - storeUnderTest.setUpdateListener(new UpdateListener() { - @Override - public void aboutToUpdate(String baseKey, String itemKey) { - hook.run(); - } - }); - return true; - } -} diff --git a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java deleted file mode 100644 index 306501bd9..000000000 --- a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java +++ /dev/null @@ -1,599 +0,0 @@ -package com.launchdarkly.client.utils; - -import com.google.common.collect.ImmutableMap; -import com.launchdarkly.client.FeatureStoreCacheConfig; -import com.launchdarkly.client.VersionedData; -import com.launchdarkly.client.VersionedDataKind; -import com.launchdarkly.client.integrations.CacheMonitor; - -import org.junit.Assert; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; -import org.junit.runners.Parameterized.Parameters; - -import java.io.IOException; -import java.util.Arrays; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.Map; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.nullValue; -import static org.junit.Assert.fail; -import static org.junit.Assume.assumeThat; - -@SuppressWarnings({ "javadoc", "deprecation" }) -@RunWith(Parameterized.class) -public class CachingStoreWrapperTest { - - private final RuntimeException FAKE_ERROR = new RuntimeException("fake error"); - - private final CachingMode cachingMode; - private final MockCore core; - private final CachingStoreWrapper wrapper; - - static enum CachingMode { - UNCACHED, - CACHED_WITH_FINITE_TTL, - CACHED_INDEFINITELY; - - FeatureStoreCacheConfig toCacheConfig() { - switch (this) { - case CACHED_WITH_FINITE_TTL: - return FeatureStoreCacheConfig.enabled().ttlSeconds(30); - case CACHED_INDEFINITELY: - return FeatureStoreCacheConfig.enabled().ttlSeconds(-1); - default: - return FeatureStoreCacheConfig.disabled(); - } - } - - boolean isCached() { - return this != UNCACHED; - } - }; - - @Parameters(name="cached={0}") - public static Iterable data() { - return Arrays.asList(CachingMode.values()); - } - - public CachingStoreWrapperTest(CachingMode cachingMode) { - this.cachingMode = cachingMode; - this.core = new MockCore(); - this.wrapper = new CachingStoreWrapper(core, cachingMode.toCacheConfig(), null); - } - - @Test - public void get() { - MockItem itemv1 = new MockItem("flag", 1, false); - MockItem itemv2 = new MockItem("flag", 2, false); - - core.forceSet(THINGS, itemv1); - assertThat(wrapper.get(THINGS, itemv1.key), equalTo(itemv1)); - - core.forceSet(THINGS, itemv2); - MockItem result = wrapper.get(THINGS, itemv1.key); - assertThat(result, equalTo(cachingMode.isCached() ? itemv1 : itemv2)); // if cached, we will not see the new underlying value yet - } - - @Test - public void getDeletedItem() { - MockItem itemv1 = new MockItem("flag", 1, true); - MockItem itemv2 = new MockItem("flag", 2, false); - - core.forceSet(THINGS, itemv1); - assertThat(wrapper.get(THINGS, itemv1.key), nullValue()); // item is filtered out because deleted is true - - core.forceSet(THINGS, itemv2); - MockItem result = wrapper.get(THINGS, itemv1.key); - assertThat(result, cachingMode.isCached() ? nullValue(MockItem.class) : equalTo(itemv2)); // if cached, we will not see the new underlying value yet - } - - @Test - public void getMissingItem() { - MockItem item = new MockItem("flag", 1, false); - - assertThat(wrapper.get(THINGS, item.getKey()), nullValue()); - - core.forceSet(THINGS, item); - MockItem result = wrapper.get(THINGS, item.key); - assertThat(result, cachingMode.isCached() ? nullValue(MockItem.class) : equalTo(item)); // the cache can retain a null result - } - - @Test - public void cachedGetUsesValuesFromInit() { - assumeThat(cachingMode.isCached(), is(true)); - - MockItem item1 = new MockItem("flag1", 1, false); - MockItem item2 = new MockItem("flag2", 1, false); - Map, Map> allData = makeData(item1, item2); - wrapper.init(allData); - - core.forceRemove(THINGS, item1.key); - - assertThat(wrapper.get(THINGS, item1.key), equalTo(item1)); - } - - @Test - public void getAll() { - MockItem item1 = new MockItem("flag1", 1, false); - MockItem item2 = new MockItem("flag2", 1, false); - - core.forceSet(THINGS, item1); - core.forceSet(THINGS, item2); - Map items = wrapper.all(THINGS); - Map expected = ImmutableMap.of(item1.key, item1, item2.key, item2); - assertThat(items, equalTo(expected)); - - core.forceRemove(THINGS, item2.key); - items = wrapper.all(THINGS); - if (cachingMode.isCached()) { - assertThat(items, equalTo(expected)); - } else { - Map expected1 = ImmutableMap.of(item1.key, item1); - assertThat(items, equalTo(expected1)); - } - } - - @Test - public void getAllRemovesDeletedItems() { - MockItem item1 = new MockItem("flag1", 1, false); - MockItem item2 = new MockItem("flag2", 1, true); - - core.forceSet(THINGS, item1); - core.forceSet(THINGS, item2); - Map items = wrapper.all(THINGS); - Map expected = ImmutableMap.of(item1.key, item1); - assertThat(items, equalTo(expected)); - } - - @Test - public void cachedAllUsesValuesFromInit() { - assumeThat(cachingMode.isCached(), is(true)); - - MockItem item1 = new MockItem("flag1", 1, false); - MockItem item2 = new MockItem("flag2", 1, false); - Map, Map> allData = makeData(item1, item2); - wrapper.init(allData); - - core.forceRemove(THINGS, item2.key); - - Map items = wrapper.all(THINGS); - Map expected = ImmutableMap.of(item1.key, item1, item2.key, item2); - assertThat(items, equalTo(expected)); - } - - @Test - public void cachedStoreWithFiniteTtlDoesNotUpdateCacheIfCoreInitFails() { - assumeThat(cachingMode, is(CachingMode.CACHED_WITH_FINITE_TTL)); - - MockItem item = new MockItem("flag", 1, false); - - core.fakeError = FAKE_ERROR; - try { - wrapper.init(makeData(item)); - Assert.fail("expected exception"); - } catch(RuntimeException e) { - assertThat(e, is(FAKE_ERROR)); - } - - core.fakeError = null; - assertThat(wrapper.all(THINGS).size(), equalTo(0)); - } - - @Test - public void cachedStoreWithInfiniteTtlUpdatesCacheEvenIfCoreInitFails() { - assumeThat(cachingMode, is(CachingMode.CACHED_INDEFINITELY)); - - MockItem item = new MockItem("flag", 1, false); - - core.fakeError = FAKE_ERROR; - try { - wrapper.init(makeData(item)); - Assert.fail("expected exception"); - } catch(RuntimeException e) { - assertThat(e, is(FAKE_ERROR)); - } - - core.fakeError = null; - Map expected = ImmutableMap.of(item.key, item); - assertThat(wrapper.all(THINGS), equalTo(expected)); - } - - @Test - public void upsertSuccessful() { - MockItem itemv1 = new MockItem("flag", 1, false); - MockItem itemv2 = new MockItem("flag", 2, false); - - wrapper.upsert(THINGS, itemv1); - assertThat((MockItem)core.data.get(THINGS).get(itemv1.key), equalTo(itemv1)); - - wrapper.upsert(THINGS, itemv2); - assertThat((MockItem)core.data.get(THINGS).get(itemv1.key), equalTo(itemv2)); - - // if we have a cache, verify that the new item is now cached by writing a different value - // to the underlying data - Get should still return the cached item - if (cachingMode.isCached()) { - MockItem item1v3 = new MockItem("flag", 3, false); - core.forceSet(THINGS, item1v3); - } - - assertThat(wrapper.get(THINGS, itemv1.key), equalTo(itemv2)); - } - - @Test - public void cachedUpsertUnsuccessful() { - assumeThat(cachingMode.isCached(), is(true)); - - // This is for an upsert where the data in the store has a higher version. In an uncached - // store, this is just a no-op as far as the wrapper is concerned so there's nothing to - // test here. In a cached store, we need to verify that the cache has been refreshed - // using the data that was found in the store. - MockItem itemv1 = new MockItem("flag", 1, false); - MockItem itemv2 = new MockItem("flag", 2, false); - - wrapper.upsert(THINGS, itemv2); - assertThat((MockItem)core.data.get(THINGS).get(itemv2.key), equalTo(itemv2)); - - wrapper.upsert(THINGS, itemv1); - assertThat((MockItem)core.data.get(THINGS).get(itemv1.key), equalTo(itemv2)); // value in store remains the same - - MockItem itemv3 = new MockItem("flag", 3, false); - core.forceSet(THINGS, itemv3); // bypasses cache so we can verify that itemv2 is in the cache - - assertThat(wrapper.get(THINGS, itemv1.key), equalTo(itemv2)); - } - - @Test - public void cachedStoreWithFiniteTtlDoesNotUpdateCacheIfCoreUpdateFails() { - assumeThat(cachingMode, is(CachingMode.CACHED_WITH_FINITE_TTL)); - - MockItem itemv1 = new MockItem("flag", 1, false); - MockItem itemv2 = new MockItem("flag", 2, false); - - wrapper.init(makeData(itemv1)); - - core.fakeError = FAKE_ERROR; - try { - wrapper.upsert(THINGS, itemv2); - Assert.fail("expected exception"); - } catch(RuntimeException e) { - assertThat(e, is(FAKE_ERROR)); - } - - core.fakeError = null; - assertThat(wrapper.get(THINGS, itemv1.key), equalTo(itemv1)); // cache still has old item, same as underlying store - } - - @Test - public void cachedStoreWithInfiniteTtlUpdatesCacheEvenIfCoreUpdateFails() { - assumeThat(cachingMode, is(CachingMode.CACHED_INDEFINITELY)); - - MockItem itemv1 = new MockItem("flag", 1, false); - MockItem itemv2 = new MockItem("flag", 2, false); - - wrapper.init(makeData(itemv1)); - - core.fakeError = FAKE_ERROR; - try { - wrapper.upsert(THINGS, itemv2); - Assert.fail("expected exception"); - } catch(RuntimeException e) { - assertThat(e, is(FAKE_ERROR)); - } - - core.fakeError = null; - assertThat(wrapper.get(THINGS, itemv1.key), equalTo(itemv2)); // underlying store has old item but cache has new item - } - - @Test - public void cachedStoreWithFiniteTtlRemovesCachedAllDataIfOneItemIsUpdated() { - assumeThat(cachingMode, is(CachingMode.CACHED_WITH_FINITE_TTL)); - - MockItem item1v1 = new MockItem("item1", 1, false); - MockItem item1v2 = new MockItem("item1", 2, false); - MockItem item2v1 = new MockItem("item2", 1, false); - MockItem item2v2 = new MockItem("item2", 2, false); - - wrapper.init(makeData(item1v1, item2v1)); - wrapper.all(THINGS); // now the All data is cached - - // do an upsert for item1 - this should drop the previous all() data from the cache - wrapper.upsert(THINGS, item1v2); - - // modify item2 directly in the underlying data - core.forceSet(THINGS, item2v2); - - // now, all() should reread the underlying data so we see both changes - Map expected = ImmutableMap.of(item1v1.key, item1v2, item2v1.key, item2v2); - assertThat(wrapper.all(THINGS), equalTo(expected)); - } - - @Test - public void cachedStoreWithInfiniteTtlUpdatesCachedAllDataIfOneItemIsUpdated() { - assumeThat(cachingMode, is(CachingMode.CACHED_INDEFINITELY)); - - MockItem item1v1 = new MockItem("item1", 1, false); - MockItem item1v2 = new MockItem("item1", 2, false); - MockItem item2v1 = new MockItem("item2", 1, false); - MockItem item2v2 = new MockItem("item2", 2, false); - - wrapper.init(makeData(item1v1, item2v1)); - wrapper.all(THINGS); // now the All data is cached - - // do an upsert for item1 - this should update the underlying data *and* the cached all() data - wrapper.upsert(THINGS, item1v2); - - // modify item2 directly in the underlying data - core.forceSet(THINGS, item2v2); - - // now, all() should *not* reread the underlying data - we should only see the change to item1 - Map expected = ImmutableMap.of(item1v1.key, item1v2, item2v1.key, item2v1); - assertThat(wrapper.all(THINGS), equalTo(expected)); - } - - @Test - public void delete() { - MockItem itemv1 = new MockItem("flag", 1, false); - MockItem itemv2 = new MockItem("flag", 2, true); - MockItem itemv3 = new MockItem("flag", 3, false); - - core.forceSet(THINGS, itemv1); - MockItem item = wrapper.get(THINGS, itemv1.key); - assertThat(item, equalTo(itemv1)); - - wrapper.delete(THINGS, itemv1.key, 2); - assertThat((MockItem)core.data.get(THINGS).get(itemv1.key), equalTo(itemv2)); - - // make a change that bypasses the cache - core.forceSet(THINGS, itemv3); - - MockItem result = wrapper.get(THINGS, itemv1.key); - assertThat(result, cachingMode.isCached() ? nullValue(MockItem.class) : equalTo(itemv3)); - } - - @Test - public void initializedCallsInternalMethodOnlyIfNotAlreadyInited() { - assumeThat(cachingMode.isCached(), is(false)); - - assertThat(wrapper.initialized(), is(false)); - assertThat(core.initedQueryCount, equalTo(1)); - - core.inited = true; - assertThat(wrapper.initialized(), is(true)); - assertThat(core.initedQueryCount, equalTo(2)); - - core.inited = false; - assertThat(wrapper.initialized(), is(true)); - assertThat(core.initedQueryCount, equalTo(2)); - } - - @Test - public void initializedDoesNotCallInternalMethodAfterInitHasBeenCalled() { - assumeThat(cachingMode.isCached(), is(false)); - - assertThat(wrapper.initialized(), is(false)); - assertThat(core.initedQueryCount, equalTo(1)); - - wrapper.init(makeData()); - - assertThat(wrapper.initialized(), is(true)); - assertThat(core.initedQueryCount, equalTo(1)); - } - - @Test - public void initializedCanCacheFalseResult() throws Exception { - assumeThat(cachingMode.isCached(), is(true)); - - // We need to create a different object for this test so we can set a short cache TTL - try (CachingStoreWrapper wrapper1 = new CachingStoreWrapper(core, FeatureStoreCacheConfig.enabled().ttlMillis(500), null)) { - assertThat(wrapper1.initialized(), is(false)); - assertThat(core.initedQueryCount, equalTo(1)); - - core.inited = true; - assertThat(core.initedQueryCount, equalTo(1)); - - Thread.sleep(600); - - assertThat(wrapper1.initialized(), is(true)); - assertThat(core.initedQueryCount, equalTo(2)); - - // From this point on it should remain true and the method should not be called - assertThat(wrapper1.initialized(), is(true)); - assertThat(core.initedQueryCount, equalTo(2)); - } - } - - @Test - public void canGetCacheStats() throws Exception { - assumeThat(cachingMode, is(CachingMode.CACHED_WITH_FINITE_TTL)); - - CacheMonitor cacheMonitor = new CacheMonitor(); - - try (CachingStoreWrapper w = new CachingStoreWrapper(core, FeatureStoreCacheConfig.enabled().ttlSeconds(30), cacheMonitor)) { - CacheMonitor.CacheStats stats = cacheMonitor.getCacheStats(); - - assertThat(stats, equalTo(new CacheMonitor.CacheStats(0, 0, 0, 0, 0, 0))); - - // Cause a cache miss - w.get(THINGS, "key1"); - stats = cacheMonitor.getCacheStats(); - assertThat(stats.getHitCount(), equalTo(0L)); - assertThat(stats.getMissCount(), equalTo(1L)); - assertThat(stats.getLoadSuccessCount(), equalTo(1L)); // even though it's a miss, it's a "success" because there was no exception - assertThat(stats.getLoadExceptionCount(), equalTo(0L)); - - // Cause a cache hit - core.forceSet(THINGS, new MockItem("key2", 1, false)); - w.get(THINGS, "key2"); // this one is a cache miss, but causes the item to be loaded and cached - w.get(THINGS, "key2"); // now it's a cache hit - stats = cacheMonitor.getCacheStats(); - assertThat(stats.getHitCount(), equalTo(1L)); - assertThat(stats.getMissCount(), equalTo(2L)); - assertThat(stats.getLoadSuccessCount(), equalTo(2L)); - assertThat(stats.getLoadExceptionCount(), equalTo(0L)); - - // Cause a load exception - core.fakeError = new RuntimeException("sorry"); - try { - w.get(THINGS, "key3"); // cache miss -> tries to load the item -> gets an exception - fail("expected exception"); - } catch (RuntimeException e) { - assertThat(e.getCause(), is((Throwable)core.fakeError)); - } - stats = cacheMonitor.getCacheStats(); - assertThat(stats.getHitCount(), equalTo(1L)); - assertThat(stats.getMissCount(), equalTo(3L)); - assertThat(stats.getLoadSuccessCount(), equalTo(2L)); - assertThat(stats.getLoadExceptionCount(), equalTo(1L)); - } - } - - private Map, Map> makeData(MockItem... items) { - Map innerMap = new HashMap<>(); - for (MockItem item: items) { - innerMap.put(item.getKey(), item); - } - Map, Map> outerMap = new HashMap<>(); - outerMap.put(THINGS, innerMap); - return outerMap; - } - - static class MockCore implements FeatureStoreCore { - Map, Map> data = new HashMap<>(); - boolean inited; - int initedQueryCount; - RuntimeException fakeError; - - @Override - public void close() throws IOException { - } - - @Override - public VersionedData getInternal(VersionedDataKind kind, String key) { - maybeThrow(); - if (data.containsKey(kind)) { - return data.get(kind).get(key); - } - return null; - } - - @Override - public Map getAllInternal(VersionedDataKind kind) { - maybeThrow(); - return data.get(kind); - } - - @Override - public void initInternal(Map, Map> allData) { - maybeThrow(); - data.clear(); - for (Map.Entry, Map> entry: allData.entrySet()) { - data.put(entry.getKey(), new LinkedHashMap(entry.getValue())); - } - inited = true; - } - - @Override - public VersionedData upsertInternal(VersionedDataKind kind, VersionedData item) { - maybeThrow(); - if (!data.containsKey(kind)) { - data.put(kind, new HashMap()); - } - Map items = data.get(kind); - VersionedData oldItem = items.get(item.getKey()); - if (oldItem != null && oldItem.getVersion() >= item.getVersion()) { - return oldItem; - } - items.put(item.getKey(), item); - return item; - } - - @Override - public boolean initializedInternal() { - maybeThrow(); - initedQueryCount++; - return inited; - } - - public void forceSet(VersionedDataKind kind, VersionedData item) { - if (!data.containsKey(kind)) { - data.put(kind, new HashMap()); - } - Map items = data.get(kind); - items.put(item.getKey(), item); - } - - public void forceRemove(VersionedDataKind kind, String key) { - if (data.containsKey(kind)) { - data.get(kind).remove(key); - } - } - - private void maybeThrow() { - if (fakeError != null) { - throw fakeError; - } - } - } - - static class MockItem implements VersionedData { - private final String key; - private final int version; - private final boolean deleted; - - public MockItem(String key, int version, boolean deleted) { - this.key = key; - this.version = version; - this.deleted = deleted; - } - - public String getKey() { - return key; - } - - public int getVersion() { - return version; - } - - public boolean isDeleted() { - return deleted; - } - - @Override - public String toString() { - return "[" + key + ", " + version + ", " + deleted + "]"; - } - - @Override - public boolean equals(Object other) { - if (other instanceof MockItem) { - MockItem o = (MockItem)other; - return key.equals(o.key) && version == o.version && deleted == o.deleted; - } - return false; - } - } - - static VersionedDataKind THINGS = new VersionedDataKind() { - public String getNamespace() { - return "things"; - } - - public Class getItemClass() { - return MockItem.class; - } - - public String getStreamApiPath() { - return "/things/"; - } - - public MockItem makeDeletedItem(String key, int version) { - return new MockItem(key, version, true); - } - }; -} diff --git a/src/test/java/com/launchdarkly/client/value/LDValueTest.java b/src/test/java/com/launchdarkly/client/value/LDValueTest.java deleted file mode 100644 index e4397a676..000000000 --- a/src/test/java/com/launchdarkly/client/value/LDValueTest.java +++ /dev/null @@ -1,529 +0,0 @@ -package com.launchdarkly.client.value; - -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; -import com.google.gson.JsonPrimitive; - -import org.junit.Test; - -import java.util.ArrayList; -import java.util.List; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -@SuppressWarnings({"deprecation", "javadoc"}) -public class LDValueTest { - private static final Gson gson = new Gson(); - - private static final int someInt = 3; - private static final long someLong = 3; - private static final float someFloat = 3.25f; - private static final double someDouble = 3.25d; - private static final String someString = "hi"; - - private static final LDValue aTrueBoolValue = LDValue.of(true); - private static final LDValue anIntValue = LDValue.of(someInt); - private static final LDValue aLongValue = LDValue.of(someLong); - private static final LDValue aFloatValue = LDValue.of(someFloat); - private static final LDValue aDoubleValue = LDValue.of(someDouble); - private static final LDValue aStringValue = LDValue.of(someString); - private static final LDValue aNumericLookingStringValue = LDValue.of("3"); - private static final LDValue anArrayValue = LDValue.buildArray().add(LDValue.of(3)).build(); - private static final LDValue anObjectValue = LDValue.buildObject().put("1", LDValue.of("x")).build(); - - private static final LDValue aTrueBoolValueFromJsonElement = LDValue.fromJsonElement(new JsonPrimitive(true)); - private static final LDValue anIntValueFromJsonElement = LDValue.fromJsonElement(new JsonPrimitive(someInt)); - private static final LDValue aLongValueFromJsonElement = LDValue.fromJsonElement(new JsonPrimitive(someLong)); - private static final LDValue aFloatValueFromJsonElement = LDValue.fromJsonElement(new JsonPrimitive(someFloat)); - private static final LDValue aDoubleValueFromJsonElement = LDValue.fromJsonElement(new JsonPrimitive(someDouble)); - private static final LDValue aStringValueFromJsonElement = LDValue.fromJsonElement(new JsonPrimitive(someString)); - private static final LDValue anArrayValueFromJsonElement = LDValue.fromJsonElement(anArrayValue.asJsonElement()); - private static final LDValue anObjectValueFromJsonElement = LDValue.fromJsonElement(anObjectValue.asJsonElement()); - - @Test - public void defaultValueJsonElementsAreReused() { - assertSame(LDValue.ofNull(), LDValue.ofNull()); - assertSame(LDValue.of(true).asJsonElement(), LDValue.of(true).asJsonElement()); - assertSame(LDValue.of(false).asJsonElement(), LDValue.of(false).asJsonElement()); - assertSame(LDValue.of((int)0).asJsonElement(), LDValue.of((int)0).asJsonElement()); - assertSame(LDValue.of((long)0).asJsonElement(), LDValue.of((long)0).asJsonElement()); - assertSame(LDValue.of((float)0).asJsonElement(), LDValue.of((float)0).asJsonElement()); - assertSame(LDValue.of((double)0).asJsonElement(), LDValue.of((double)0).asJsonElement()); - assertSame(LDValue.of("").asJsonElement(), LDValue.of("").asJsonElement()); - } - - @Test - public void canGetValueAsBoolean() { - assertEquals(LDValueType.BOOLEAN, aTrueBoolValue.getType()); - assertTrue(aTrueBoolValue.booleanValue()); - assertEquals(LDValueType.BOOLEAN, aTrueBoolValueFromJsonElement.getType()); - assertTrue(aTrueBoolValueFromJsonElement.booleanValue()); - } - - @Test - public void nonBooleanValueAsBooleanIsFalse() { - LDValue[] values = new LDValue[] { - LDValue.ofNull(), - aStringValue, - aStringValueFromJsonElement, - anIntValue, - anIntValueFromJsonElement, - aLongValue, - aLongValueFromJsonElement, - aFloatValue, - aFloatValueFromJsonElement, - aDoubleValue, - aDoubleValueFromJsonElement, - anArrayValue, - anArrayValueFromJsonElement, - anObjectValue, - anObjectValueFromJsonElement - }; - for (LDValue value: values) { - assertNotEquals(value.toString(), LDValueType.BOOLEAN, value.getType()); - assertFalse(value.toString(), value.booleanValue()); - } - } - - @Test - public void canGetValueAsString() { - assertEquals(LDValueType.STRING, aStringValue.getType()); - assertEquals(someString, aStringValue.stringValue()); - assertEquals(LDValueType.STRING, aStringValueFromJsonElement.getType()); - assertEquals(someString, aStringValueFromJsonElement.stringValue()); - } - - @Test - public void nonStringValueAsStringIsNull() { - LDValue[] values = new LDValue[] { - LDValue.ofNull(), - aTrueBoolValue, - aTrueBoolValueFromJsonElement, - anIntValue, - anIntValueFromJsonElement, - aLongValue, - aLongValueFromJsonElement, - aFloatValue, - aFloatValueFromJsonElement, - aDoubleValue, - aDoubleValueFromJsonElement, - anArrayValue, - anArrayValueFromJsonElement, - anObjectValue, - anObjectValueFromJsonElement - }; - for (LDValue value: values) { - assertNotEquals(value.toString(), LDValueType.STRING, value.getType()); - assertNull(value.toString(), value.stringValue()); - } - } - - @Test - public void nullStringConstructorGivesNullInstance() { - assertEquals(LDValue.ofNull(), LDValue.of((String)null)); - } - - @Test - public void canGetIntegerValueOfAnyNumericType() { - LDValue[] values = new LDValue[] { - LDValue.of(3), - LDValue.of(3L), - LDValue.of(3.0f), - LDValue.of(3.25f), - LDValue.of(3.75f), - LDValue.of(3.0d), - LDValue.of(3.25d), - LDValue.of(3.75d) - }; - for (LDValue value: values) { - assertEquals(value.toString(), LDValueType.NUMBER, value.getType()); - assertEquals(value.toString(), 3, value.intValue()); - assertEquals(value.toString(), 3L, value.longValue()); - } - } - - @Test - public void canGetFloatValueOfAnyNumericType() { - LDValue[] values = new LDValue[] { - LDValue.of(3), - LDValue.of(3L), - LDValue.of(3.0f), - LDValue.of(3.0d), - }; - for (LDValue value: values) { - assertEquals(value.toString(), LDValueType.NUMBER, value.getType()); - assertEquals(value.toString(), 3.0f, value.floatValue(), 0); - } - } - - @Test - public void canGetDoubleValueOfAnyNumericType() { - LDValue[] values = new LDValue[] { - LDValue.of(3), - LDValue.of(3L), - LDValue.of(3.0f), - LDValue.of(3.0d), - }; - for (LDValue value: values) { - assertEquals(value.toString(), LDValueType.NUMBER, value.getType()); - assertEquals(value.toString(), 3.0d, value.doubleValue(), 0); - } - } - - @Test - public void nonNumericValueAsNumberIsZero() { - LDValue[] values = new LDValue[] { - LDValue.ofNull(), - aTrueBoolValue, - aTrueBoolValueFromJsonElement, - aStringValue, - aStringValueFromJsonElement, - aNumericLookingStringValue, - anArrayValue, - anArrayValueFromJsonElement, - anObjectValue, - anObjectValueFromJsonElement - }; - for (LDValue value: values) { - assertNotEquals(value.toString(), LDValueType.NUMBER, value.getType()); - assertEquals(value.toString(), 0, value.intValue()); - assertEquals(value.toString(), 0f, value.floatValue(), 0); - assertEquals(value.toString(), 0d, value.doubleValue(), 0); - } - } - - @Test - public void canGetSizeOfArrayOrObject() { - assertEquals(1, anArrayValue.size()); - assertEquals(1, anArrayValueFromJsonElement.size()); - } - - @Test - public void arrayCanGetItemByIndex() { - assertEquals(LDValueType.ARRAY, anArrayValue.getType()); - assertEquals(LDValueType.ARRAY, anArrayValueFromJsonElement.getType()); - assertEquals(LDValue.of(3), anArrayValue.get(0)); - assertEquals(LDValue.of(3), anArrayValueFromJsonElement.get(0)); - assertEquals(LDValue.ofNull(), anArrayValue.get(-1)); - assertEquals(LDValue.ofNull(), anArrayValue.get(1)); - assertEquals(LDValue.ofNull(), anArrayValueFromJsonElement.get(-1)); - assertEquals(LDValue.ofNull(), anArrayValueFromJsonElement.get(1)); - } - - @Test - public void arrayCanBeEnumerated() { - LDValue a = LDValue.of("a"); - LDValue b = LDValue.of("b"); - List values = new ArrayList<>(); - for (LDValue v: LDValue.buildArray().add(a).add(b).build().values()) { - values.add(v); - } - assertEquals(ImmutableList.of(a, b), values); - } - - @Test - public void nonArrayValuesBehaveLikeEmptyArray() { - LDValue[] values = new LDValue[] { - LDValue.ofNull(), - aTrueBoolValue, - aTrueBoolValueFromJsonElement, - anIntValue, - aLongValue, - aFloatValue, - aDoubleValue, - aStringValue, - aNumericLookingStringValue, - }; - for (LDValue value: values) { - assertEquals(value.toString(), 0, value.size()); - assertEquals(value.toString(), LDValue.of(null), value.get(-1)); - assertEquals(value.toString(), LDValue.of(null), value.get(0)); - for (@SuppressWarnings("unused") LDValue v: value.values()) { - fail(value.toString()); - } - } - } - - @Test - public void canGetSizeOfObject() { - assertEquals(1, anObjectValue.size()); - assertEquals(1, anObjectValueFromJsonElement.size()); - } - - @Test - public void objectCanGetValueByName() { - assertEquals(LDValueType.OBJECT, anObjectValue.getType()); - assertEquals(LDValueType.OBJECT, anObjectValueFromJsonElement.getType()); - assertEquals(LDValue.of("x"), anObjectValue.get("1")); - assertEquals(LDValue.of("x"), anObjectValueFromJsonElement.get("1")); - assertEquals(LDValue.ofNull(), anObjectValue.get(null)); - assertEquals(LDValue.ofNull(), anObjectValueFromJsonElement.get(null)); - assertEquals(LDValue.ofNull(), anObjectValue.get("2")); - assertEquals(LDValue.ofNull(), anObjectValueFromJsonElement.get("2")); - } - - @Test - public void objectKeysCanBeEnumerated() { - List keys = new ArrayList<>(); - for (String key: LDValue.buildObject().put("1", LDValue.of("x")).put("2", LDValue.of("y")).build().keys()) { - keys.add(key); - } - keys.sort(null); - assertEquals(ImmutableList.of("1", "2"), keys); - } - - @Test - public void objectValuesCanBeEnumerated() { - List values = new ArrayList<>(); - for (LDValue value: LDValue.buildObject().put("1", LDValue.of("x")).put("2", LDValue.of("y")).build().values()) { - values.add(value.stringValue()); - } - values.sort(null); - assertEquals(ImmutableList.of("x", "y"), values); - } - - @Test - public void nonObjectValuesBehaveLikeEmptyObject() { - LDValue[] values = new LDValue[] { - LDValue.ofNull(), - aTrueBoolValue, - aTrueBoolValueFromJsonElement, - anIntValue, - aLongValue, - aFloatValue, - aDoubleValue, - aStringValue, - aNumericLookingStringValue, - }; - for (LDValue value: values) { - assertEquals(value.toString(), LDValue.of(null), value.get(null)); - assertEquals(value.toString(), LDValue.of(null), value.get("1")); - for (@SuppressWarnings("unused") String key: value.keys()) { - fail(value.toString()); - } - } - } - - @Test - public void testEqualsAndHashCodeForPrimitives() - { - assertValueAndHashEqual(LDValue.ofNull(), LDValue.ofNull()); - assertValueAndHashEqual(LDValue.of(true), LDValue.of(true)); - assertValueAndHashNotEqual(LDValue.of(true), LDValue.of(false)); - assertValueAndHashEqual(LDValue.of(1), LDValue.of(1)); - assertValueAndHashEqual(LDValue.of(1), LDValue.of(1.0f)); - assertValueAndHashNotEqual(LDValue.of(1), LDValue.of(2)); - assertValueAndHashEqual(LDValue.of("a"), LDValue.of("a")); - assertValueAndHashNotEqual(LDValue.of("a"), LDValue.of("b")); - assertNotEquals(LDValue.of(false), LDValue.of(0)); - } - - private void assertValueAndHashEqual(LDValue a, LDValue b) - { - assertEquals(a, b); - assertEquals(a.hashCode(), b.hashCode()); - } - - private void assertValueAndHashNotEqual(LDValue a, LDValue b) - { - assertNotEquals(a, b); - assertNotEquals(a.hashCode(), b.hashCode()); - } - - @Test - public void samePrimitivesWithOrWithoutJsonElementAreEqual() { - assertValueAndHashEqual(aTrueBoolValue, aTrueBoolValueFromJsonElement); - assertValueAndHashEqual(anIntValue, anIntValueFromJsonElement); - assertValueAndHashEqual(aLongValue, aLongValueFromJsonElement); - assertValueAndHashEqual(aFloatValue, aFloatValueFromJsonElement); - assertValueAndHashEqual(aStringValue, aStringValueFromJsonElement); - assertValueAndHashEqual(anArrayValue, anArrayValueFromJsonElement); - assertValueAndHashEqual(anObjectValue, anObjectValueFromJsonElement); - } - - @Test - public void equalsUsesDeepEqualityForArrays() - { - LDValue a0 = LDValue.buildArray().add("a") - .add(LDValue.buildArray().add("b").add("c").build()) - .build(); - JsonArray ja1 = new JsonArray(); - ja1.add(new JsonPrimitive("a")); - JsonArray ja1a = new JsonArray(); - ja1a.add(new JsonPrimitive("b")); - ja1a.add(new JsonPrimitive("c")); - ja1.add(ja1a); - LDValue a1 = LDValue.fromJsonElement(ja1); - assertValueAndHashEqual(a0, a1); - - LDValue a2 = LDValue.buildArray().add("a").build(); - assertValueAndHashNotEqual(a0, a2); - - LDValue a3 = LDValue.buildArray().add("a").add("b").add("c").build(); - assertValueAndHashNotEqual(a0, a3); - - LDValue a4 = LDValue.buildArray().add("a") - .add(LDValue.buildArray().add("b").add("x").build()) - .build(); - assertValueAndHashNotEqual(a0, a4); - } - - @Test - public void equalsUsesDeepEqualityForObjects() - { - LDValue o0 = LDValue.buildObject() - .put("a", "b") - .put("c", LDValue.buildObject().put("d", "e").build()) - .build(); - JsonObject jo1 = new JsonObject(); - jo1.add("a", new JsonPrimitive("b")); - JsonObject jo1a = new JsonObject(); - jo1a.add("d", new JsonPrimitive("e")); - jo1.add("c", jo1a); - LDValue o1 = LDValue.fromJsonElement(jo1); - assertValueAndHashEqual(o0, o1); - - LDValue o2 = LDValue.buildObject() - .put("a", "b") - .build(); - assertValueAndHashNotEqual(o0, o2); - - LDValue o3 = LDValue.buildObject() - .put("a", "b") - .put("c", LDValue.buildObject().put("d", "e").build()) - .put("f", "g") - .build(); - assertValueAndHashNotEqual(o0, o3); - - LDValue o4 = LDValue.buildObject() - .put("a", "b") - .put("c", LDValue.buildObject().put("d", "f").build()) - .build(); - assertValueAndHashNotEqual(o0, o4); - } - - @Test - public void canUseLongTypeForNumberGreaterThanMaxInt() { - long n = (long)Integer.MAX_VALUE + 1; - assertEquals(n, LDValue.of(n).longValue()); - assertEquals(n, LDValue.Convert.Long.toType(LDValue.of(n)).longValue()); - assertEquals(n, LDValue.Convert.Long.fromType(n).longValue()); - } - - @Test - public void canUseDoubleTypeForNumberGreaterThanMaxFloat() { - double n = (double)Float.MAX_VALUE + 1; - assertEquals(n, LDValue.of(n).doubleValue(), 0); - assertEquals(n, LDValue.Convert.Double.toType(LDValue.of(n)).doubleValue(), 0); - assertEquals(n, LDValue.Convert.Double.fromType(n).doubleValue(), 0); - } - - @Test - public void testToJsonString() { - assertEquals("null", LDValue.ofNull().toJsonString()); - assertEquals("true", aTrueBoolValue.toJsonString()); - assertEquals("true", aTrueBoolValueFromJsonElement.toJsonString()); - assertEquals("false", LDValue.of(false).toJsonString()); - assertEquals(String.valueOf(someInt), anIntValue.toJsonString()); - assertEquals(String.valueOf(someInt), anIntValueFromJsonElement.toJsonString()); - assertEquals(String.valueOf(someLong), aLongValue.toJsonString()); - assertEquals(String.valueOf(someLong), aLongValueFromJsonElement.toJsonString()); - assertEquals(String.valueOf(someFloat), aFloatValue.toJsonString()); - assertEquals(String.valueOf(someFloat), aFloatValueFromJsonElement.toJsonString()); - assertEquals(String.valueOf(someDouble), aDoubleValue.toJsonString()); - assertEquals(String.valueOf(someDouble), aDoubleValueFromJsonElement.toJsonString()); - assertEquals("\"hi\"", aStringValue.toJsonString()); - assertEquals("\"hi\"", aStringValueFromJsonElement.toJsonString()); - assertEquals("[3]", anArrayValue.toJsonString()); - assertEquals("[3.0]", anArrayValueFromJsonElement.toJsonString()); - assertEquals("{\"1\":\"x\"}", anObjectValue.toJsonString()); - assertEquals("{\"1\":\"x\"}", anObjectValueFromJsonElement.toJsonString()); - } - - @Test - public void testDefaultGsonSerialization() { - LDValue[] values = new LDValue[] { - LDValue.ofNull(), - aTrueBoolValue, - aTrueBoolValueFromJsonElement, - anIntValue, - anIntValueFromJsonElement, - aLongValue, - aLongValueFromJsonElement, - aFloatValue, - aFloatValueFromJsonElement, - aDoubleValue, - aDoubleValueFromJsonElement, - aStringValue, - aStringValueFromJsonElement, - anArrayValue, - anArrayValueFromJsonElement, - anObjectValue, - anObjectValueFromJsonElement - }; - for (LDValue value: values) { - assertEquals(value.toString(), value.toJsonString(), gson.toJson(value)); - assertEquals(value.toString(), value, LDValue.normalize(gson.fromJson(value.toJsonString(), LDValue.class))); - } - } - - @Test - public void valueToJsonElement() { - assertNull(LDValue.ofNull().asJsonElement()); - assertEquals(new JsonPrimitive(true), aTrueBoolValue.asJsonElement()); - assertEquals(new JsonPrimitive(someInt), anIntValue.asJsonElement()); - assertEquals(new JsonPrimitive(someLong), aLongValue.asJsonElement()); - assertEquals(new JsonPrimitive(someFloat), aFloatValue.asJsonElement()); - assertEquals(new JsonPrimitive(someDouble), aDoubleValue.asJsonElement()); - assertEquals(new JsonPrimitive(someString), aStringValue.asJsonElement()); - } - - @Test - public void testTypeConversions() { - testTypeConversion(LDValue.Convert.Boolean, new Boolean[] { true, false }, LDValue.of(true), LDValue.of(false)); - testTypeConversion(LDValue.Convert.Integer, new Integer[] { 1, 2 }, LDValue.of(1), LDValue.of(2)); - testTypeConversion(LDValue.Convert.Long, new Long[] { 1L, 2L }, LDValue.of(1L), LDValue.of(2L)); - testTypeConversion(LDValue.Convert.Float, new Float[] { 1.5f, 2.5f }, LDValue.of(1.5f), LDValue.of(2.5f)); - testTypeConversion(LDValue.Convert.Double, new Double[] { 1.5d, 2.5d }, LDValue.of(1.5d), LDValue.of(2.5d)); - testTypeConversion(LDValue.Convert.String, new String[] { "a", "b" }, LDValue.of("a"), LDValue.of("b")); - } - - private void testTypeConversion(LDValue.Converter converter, T[] values, LDValue... ldValues) { - ArrayBuilder ab = LDValue.buildArray(); - for (LDValue v: ldValues) { - ab.add(v); - } - LDValue arrayValue = ab.build(); - assertEquals(arrayValue, converter.arrayOf(values)); - ImmutableList.Builder lb = ImmutableList.builder(); - for (T v: values) { - lb.add(v); - } - ImmutableList list = lb.build(); - assertEquals(arrayValue, converter.arrayFrom(list)); - assertEquals(list, ImmutableList.copyOf(arrayValue.valuesAs(converter))); - - ObjectBuilder ob = LDValue.buildObject(); - int i = 0; - for (LDValue v: ldValues) { - ob.put(String.valueOf(++i), v); - } - LDValue objectValue = ob.build(); - ImmutableMap.Builder mb = ImmutableMap.builder(); - i = 0; - for (T v: values) { - mb.put(String.valueOf(++i), v); - } - ImmutableMap map = mb.build(); - assertEquals(objectValue, converter.objectFrom(map)); - } -} diff --git a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java new file mode 100644 index 000000000..731427f9e --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java @@ -0,0 +1,236 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel.Clause; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Operator; +import com.launchdarkly.sdk.server.DataModel.Rule; +import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.DataModel.SegmentRule; +import com.launchdarkly.sdk.server.DataModel.Target; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; + +import org.junit.Test; + +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +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.assertTrue; + +@SuppressWarnings("javadoc") +public class DataModelSerializationTest { + @Test + public void flagIsDeserializedWithAllProperties() { + String json0 = flagWithAllPropertiesJson().toJsonString(); + FeatureFlag flag0 = (FeatureFlag)FEATURES.deserialize(json0).getItem(); + assertFlagHasAllProperties(flag0); + + String json1 = FEATURES.serialize(new ItemDescriptor(flag0.getVersion(), flag0)); + FeatureFlag flag1 = (FeatureFlag)FEATURES.deserialize(json1).getItem(); + assertFlagHasAllProperties(flag1); + } + + @Test + public void flagIsDeserializedWithMinimalProperties() { + String json = LDValue.buildObject().put("key", "flag-key").put("version", 99).build().toJsonString(); + FeatureFlag flag = (FeatureFlag)FEATURES.deserialize(json).getItem(); + assertEquals("flag-key", flag.getKey()); + assertEquals(99, flag.getVersion()); + assertFalse(flag.isOn()); + assertNull(flag.getSalt()); + assertNotNull(flag.getTargets()); + assertEquals(0, flag.getTargets().size()); + assertNotNull(flag.getRules()); + assertEquals(0, flag.getRules().size()); + assertNull(flag.getFallthrough()); + assertNull(flag.getOffVariation()); + assertNotNull(flag.getVariations()); + assertEquals(0, flag.getVariations().size()); + assertFalse(flag.isClientSide()); + assertFalse(flag.isTrackEvents()); + assertFalse(flag.isTrackEventsFallthrough()); + assertNull(flag.getDebugEventsUntilDate()); + } + + @Test + public void segmentIsDeserializedWithAllProperties() { + String json0 = segmentWithAllPropertiesJson().toJsonString(); + Segment segment0 = (Segment)SEGMENTS.deserialize(json0).getItem(); + assertSegmentHasAllProperties(segment0); + + String json1 = SEGMENTS.serialize(new ItemDescriptor(segment0.getVersion(), segment0)); + Segment segment1 = (Segment)SEGMENTS.deserialize(json1).getItem(); + assertSegmentHasAllProperties(segment1); + } + + @Test + public void segmentIsDeserializedWithMinimalProperties() { + String json = LDValue.buildObject().put("key", "segment-key").put("version", 99).build().toJsonString(); + Segment segment = (Segment)SEGMENTS.deserialize(json).getItem(); + assertEquals("segment-key", segment.getKey()); + assertEquals(99, segment.getVersion()); + assertNotNull(segment.getIncluded()); + assertEquals(0, segment.getIncluded().size()); + assertNotNull(segment.getExcluded()); + assertEquals(0, segment.getExcluded().size()); + assertNotNull(segment.getRules()); + assertEquals(0, segment.getRules().size()); + } + + private LDValue flagWithAllPropertiesJson() { + return LDValue.buildObject() + .put("key", "flag-key") + .put("version", 99) + .put("on", true) + .put("prerequisites", LDValue.buildArray() + .build()) + .put("salt", "123") + .put("targets", LDValue.buildArray() + .add(LDValue.buildObject() + .put("variation", 1) + .put("values", LDValue.buildArray().add("key1").add("key2").build()) + .build()) + .build()) + .put("rules", LDValue.buildArray() + .add(LDValue.buildObject() + .put("id", "id0") + .put("trackEvents", true) + .put("variation", 2) + .put("clauses", LDValue.buildArray() + .add(LDValue.buildObject() + .put("attribute", "name") + .put("op", "in") + .put("values", LDValue.buildArray().add("Lucy").build()) + .put("negate", true) + .build()) + .build()) + .build()) + .add(LDValue.buildObject() + .put("id", "id1") + .put("rollout", LDValue.buildObject() + .put("variations", LDValue.buildArray() + .add(LDValue.buildObject() + .put("variation", 2) + .put("weight", 100000) + .build()) + .build()) + .put("bucketBy", "email") + .build()) + .build()) + .build()) + .put("fallthrough", LDValue.buildObject() + .put("variation", 1) + .build()) + .put("offVariation", 2) + .put("variations", LDValue.buildArray().add("a").add("b").add("c").build()) + .put("clientSide", true) + .put("trackEvents", true) + .put("trackEventsFallthrough", true) + .put("debugEventsUntilDate", 1000) + .build(); + } + + private void assertFlagHasAllProperties(FeatureFlag flag) { + assertEquals("flag-key", flag.getKey()); + assertEquals(99, flag.getVersion()); + assertTrue(flag.isOn()); + assertEquals("123", flag.getSalt()); + + assertNotNull(flag.getTargets()); + assertEquals(1, flag.getTargets().size()); + Target t0 = flag.getTargets().get(0); + assertEquals(1, t0.getVariation()); + assertEquals(ImmutableSet.of("key1", "key2"), t0.getValues()); + + assertNotNull(flag.getRules()); + assertEquals(2, flag.getRules().size()); + Rule r0 = flag.getRules().get(0); + assertEquals("id0", r0.getId()); + assertTrue(r0.isTrackEvents()); + assertEquals(new Integer(2), r0.getVariation()); + assertNull(r0.getRollout()); + + assertNotNull(r0.getClauses()); + Clause c0 = r0.getClauses().get(0); + assertEquals(UserAttribute.NAME, c0.getAttribute()); + assertEquals(Operator.in, c0.getOp()); + assertEquals(ImmutableList.of(LDValue.of("Lucy")), c0.getValues()); + assertTrue(c0.isNegate()); + + Rule r1 = flag.getRules().get(1); + assertEquals("id1", r1.getId()); + assertFalse(r1.isTrackEvents()); + assertNull(r1.getVariation()); + assertNotNull(r1.getRollout()); + assertNotNull(r1.getRollout().getVariations()); + assertEquals(1, r1.getRollout().getVariations().size()); + assertEquals(2, r1.getRollout().getVariations().get(0).getVariation()); + assertEquals(100000, r1.getRollout().getVariations().get(0).getWeight()); + assertEquals(UserAttribute.EMAIL, r1.getRollout().getBucketBy()); + + assertNotNull(flag.getFallthrough()); + assertEquals(new Integer(1), flag.getFallthrough().getVariation()); + assertNull(flag.getFallthrough().getRollout()); + assertEquals(new Integer(2), flag.getOffVariation()); + assertEquals(ImmutableList.of(LDValue.of("a"), LDValue.of("b"), LDValue.of("c")), flag.getVariations()); + assertTrue(flag.isClientSide()); + assertTrue(flag.isTrackEvents()); + assertTrue(flag.isTrackEventsFallthrough()); + assertEquals(new Long(1000), flag.getDebugEventsUntilDate()); + } + + private LDValue segmentWithAllPropertiesJson() { + return LDValue.buildObject() + .put("key", "segment-key") + .put("version", 99) + .put("included", LDValue.buildArray().add("key1").add("key2").build()) + .put("excluded", LDValue.buildArray().add("key3").add("key4").build()) + .put("salt", "123") + .put("rules", LDValue.buildArray() + .add(LDValue.buildObject() + .put("weight", 50000) + .put("bucketBy", "email") + .put("clauses", LDValue.buildArray() + .add(LDValue.buildObject() + .put("attribute", "name") + .put("op", "in") + .put("values", LDValue.buildArray().add("Lucy").build()) + .put("negate", true) + .build()) + .build()) + .build()) + .add(LDValue.buildObject() + .build()) + .build()) + .build(); + } + + private void assertSegmentHasAllProperties(Segment segment) { + assertEquals("segment-key", segment.getKey()); + assertEquals(99, segment.getVersion()); + assertEquals("123", segment.getSalt()); + assertEquals(ImmutableSet.of("key1", "key2"), segment.getIncluded()); + assertEquals(ImmutableSet.of("key3", "key4"), segment.getExcluded()); + + assertNotNull(segment.getRules()); + assertEquals(2, segment.getRules().size()); + SegmentRule r0 = segment.getRules().get(0); + assertEquals(new Integer(50000), r0.getWeight()); + assertNotNull(r0.getClauses()); + assertEquals(1, r0.getClauses().size()); + Clause c0 = r0.getClauses().get(0); + assertEquals(UserAttribute.NAME, c0.getAttribute()); + assertEquals(Operator.in, c0.getOp()); + assertEquals(ImmutableList.of(LDValue.of("Lucy")), c0.getValues()); + assertTrue(c0.isNegate()); + SegmentRule r1 = segment.getRules().get(1); + assertNull(r1.getWeight()); + assertNull(r1.getBucketBy()); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreTestBase.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestBase.java new file mode 100644 index 000000000..38fef5927 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestBase.java @@ -0,0 +1,168 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; +import com.launchdarkly.sdk.server.DataStoreTestTypes.TestItem; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.Map; + +import static com.launchdarkly.sdk.server.DataStoreTestTypes.OTHER_TEST_ITEMS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.TEST_ITEMS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toItemsMap; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Basic tests for FeatureStore implementations. For database implementations, use the more + * comprehensive FeatureStoreDatabaseTestBase. + */ +@SuppressWarnings("javadoc") +public abstract class DataStoreTestBase { + + protected DataStore store; + + protected TestItem item1 = new TestItem("key1", "first", 10); + + protected TestItem item2 = new TestItem("key2", "second", 10); + + protected TestItem otherItem1 = new TestItem("key1", "other-first", 11); + + /** + * Test subclasses must override this method to create an instance of the feature store class. + * @return + */ + protected abstract DataStore makeStore(); + + @Before + public void setup() { + store = makeStore(); + } + + @After + public void teardown() throws Exception { + store.close(); + } + + @Test + public void storeNotInitializedBeforeInit() { + assertFalse(store.isInitialized()); + } + + @Test + public void storeInitializedAfterInit() { + store.init(new DataBuilder().build()); + assertTrue(store.isInitialized()); + } + + @Test + public void initCompletelyReplacesPreviousData() { + FullDataSet allData = + new DataBuilder().add(TEST_ITEMS, item1, item2).add(OTHER_TEST_ITEMS, otherItem1).build(); + store.init(allData); + + TestItem item2v2 = item2.withVersion(item2.version + 1); + allData = new DataBuilder().add(TEST_ITEMS, item2v2).add(OTHER_TEST_ITEMS).build(); + store.init(allData); + + assertNull(store.get(TEST_ITEMS, item1.key)); + assertEquals(item2v2.toItemDescriptor(), store.get(TEST_ITEMS, item2.key)); + assertNull(store.get(OTHER_TEST_ITEMS, otherItem1.key)); + } + + @Test + public void getExistingItem() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + assertEquals(item1.toItemDescriptor(), store.get(TEST_ITEMS, item1.key)); + } + + @Test + public void getNonexistingItem() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + assertNull(store.get(TEST_ITEMS, "biz")); + } + + @Test + public void getAll() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).add(OTHER_TEST_ITEMS, otherItem1).build()); + Map items = toItemsMap(store.getAll(TEST_ITEMS)); + assertEquals(2, items.size()); + assertEquals(item1.toItemDescriptor(), items.get(item1.key)); + assertEquals(item2.toItemDescriptor(), items.get(item2.key)); + } + + @Test + public void getAllWithDeletedItem() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + ItemDescriptor deletedItem = ItemDescriptor.deletedItem(item1.getVersion() + 1); + store.upsert(TEST_ITEMS, item1.key, deletedItem); + Map items = toItemsMap(store.getAll(TEST_ITEMS)); + assertEquals(2, items.size()); + assertEquals(deletedItem, items.get(item1.key)); + assertEquals(item2.toItemDescriptor(), items.get(item2.key)); + } + + @Test + public void upsertWithNewerVersion() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + TestItem newVer = item1.withVersion(item1.version + 1); + store.upsert(TEST_ITEMS, item1.key, newVer.toItemDescriptor()); + assertEquals(newVer.toItemDescriptor(), store.get(TEST_ITEMS, item1.key)); + } + + @Test + public void upsertWithOlderVersion() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + TestItem oldVer = item1.withVersion(item1.version - 1); + store.upsert(TEST_ITEMS, item1.key, oldVer.toItemDescriptor()); + assertEquals(item1.toItemDescriptor(), store.get(TEST_ITEMS, item1.key)); + } + + @Test + public void upsertNewItem() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + TestItem newItem = new TestItem("new-name", "new-key", 99); + store.upsert(TEST_ITEMS, newItem.key, newItem.toItemDescriptor()); + assertEquals(newItem.toItemDescriptor(), store.get(TEST_ITEMS, newItem.key)); + } + + @Test + public void deleteWithNewerVersion() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + ItemDescriptor deletedItem = ItemDescriptor.deletedItem(item1.version + 1); + store.upsert(TEST_ITEMS, item1.key, deletedItem); + assertEquals(deletedItem, store.get(TEST_ITEMS, item1.key)); + } + + @Test + public void deleteWithOlderVersion() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + ItemDescriptor deletedItem = ItemDescriptor.deletedItem(item1.version - 1); + store.upsert(TEST_ITEMS, item1.key, deletedItem); + assertEquals(item1.toItemDescriptor(), store.get(TEST_ITEMS, item1.key)); + } + + @Test + public void deleteUnknownItem() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + ItemDescriptor deletedItem = ItemDescriptor.deletedItem(item1.version - 1); + store.upsert(TEST_ITEMS, "biz", deletedItem); + assertEquals(deletedItem, store.get(TEST_ITEMS, "biz")); + } + + @Test + public void upsertOlderVersionAfterDelete() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + ItemDescriptor deletedItem = ItemDescriptor.deletedItem(item1.version + 1); + store.upsert(TEST_ITEMS, item1.key, deletedItem); + store.upsert(TEST_ITEMS, item1.key, item1.toItemDescriptor()); + assertEquals(deletedItem, store.get(TEST_ITEMS, item1.key)); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java new file mode 100644 index 000000000..e69eeb77d --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java @@ -0,0 +1,181 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Maps; +import com.launchdarkly.sdk.server.DataModel.VersionedData; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; + +import java.util.AbstractMap; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import static com.google.common.collect.Iterables.transform; +import static com.launchdarkly.sdk.server.TestUtil.TEST_GSON_INSTANCE; + +@SuppressWarnings("javadoc") +public class DataStoreTestTypes { + public static Map> toDataMap(FullDataSet data) { + return ImmutableMap.copyOf(transform(data.getData(), e -> new AbstractMap.SimpleEntry<>(e.getKey(), toItemsMap(e.getValue())))); + } + + public static Map toItemsMap(KeyedItems data) { + return ImmutableMap.copyOf(data.getItems()); + } + + public static SerializedItemDescriptor toSerialized(DataKind kind, ItemDescriptor item) { + boolean isDeleted = item.getItem() == null; + return new SerializedItemDescriptor(item.getVersion(), isDeleted, kind.serialize(item)); + } + + public static class TestItem implements VersionedData { + public final String key; + public final String name; + public final int version; + public final boolean deleted; + + public TestItem(String key, String name, int version, boolean deleted) { + this.key = key; + this.name = name; + this.version = version; + this.deleted = deleted; + } + + public TestItem(String key, String name, int version) { + this(key, name, version, false); + } + + public TestItem(String key, int version) { + this(key, "", version); + } + + @Override + public String getKey() { + return key; + } + + @Override + public int getVersion() { + return version; + } + + public boolean isDeleted() { + return deleted; + } + + public TestItem withName(String newName) { + return new TestItem(key, newName, version); + } + + public TestItem withVersion(int newVersion) { + return new TestItem(key, name, newVersion); + } + + public ItemDescriptor toItemDescriptor() { + return new ItemDescriptor(version, this); + } + + public SerializedItemDescriptor toSerializedItemDescriptor() { + return toSerialized(TEST_ITEMS, toItemDescriptor()); + } + + @Override + public boolean equals(Object other) { + if (other instanceof TestItem) { + TestItem o = (TestItem)other; + return Objects.equals(name, o.name) && + Objects.equals(key, o.key) && + version == o.version; + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(name, key, version); + } + + @Override + public String toString() { + return "TestItem(" + name + "," + key + "," + version + ")"; + } + } + + public static final DataKind TEST_ITEMS = new DataKind("test-items", + DataStoreTestTypes::serializeTestItem, + DataStoreTestTypes::deserializeTestItem); + + public static final DataKind OTHER_TEST_ITEMS = new DataKind("other-test-items", + DataStoreTestTypes::serializeTestItem, + DataStoreTestTypes::deserializeTestItem); + + private static String serializeTestItem(ItemDescriptor item) { + if (item.getItem() == null) { + return "DELETED:" + item.getVersion(); + } + return TEST_GSON_INSTANCE.toJson(item.getItem()); + } + + private static ItemDescriptor deserializeTestItem(String s) { + if (s.startsWith("DELETED:")) { + return ItemDescriptor.deletedItem(Integer.parseInt(s.substring(8))); + } + TestItem ti = TEST_GSON_INSTANCE.fromJson(s, TestItem.class); + return new ItemDescriptor(ti.version, ti); + } + + public static class DataBuilder { + private Map> data = new HashMap<>(); + + public DataBuilder add(DataKind kind, TestItem... items) { + return addAny(kind, items); + } + + // This is defined separately because test code that's outside of this package can't see DataModel.VersionedData + public DataBuilder addAny(DataKind kind, VersionedData... items) { + Map itemsMap = data.get(kind); + if (itemsMap == null) { + itemsMap = new HashMap<>(); + data.put(kind, itemsMap); + } + for (VersionedData item: items) { + itemsMap.put(item.getKey(), new ItemDescriptor(item.getVersion(), item)); + } + return this; + } + + public DataBuilder remove(DataKind kind, String key) { + if (data.get(kind) != null) { + data.get(kind).remove(key); + } + return this; + } + + public FullDataSet build() { + return new FullDataSet<>( + ImmutableMap.copyOf( + Maps.transformValues(data, itemsMap -> + new KeyedItems<>(ImmutableList.copyOf(itemsMap.entrySet())) + )).entrySet() + ); + } + + public FullDataSet buildSerialized() { + return new FullDataSet<>( + ImmutableMap.copyOf( + Maps.transformEntries(data, (kind, itemsMap) -> + new KeyedItems<>( + ImmutableMap.copyOf( + Maps.transformValues(itemsMap, item -> DataStoreTestTypes.toSerialized(kind, item)) + ).entrySet() + ) + ) + ).entrySet()); + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java new file mode 100644 index 000000000..4f041624c --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java @@ -0,0 +1,373 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel.Operator; +import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; +import com.launchdarkly.sdk.server.TestUtil.FlagChangeEventSink; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; + +import org.easymock.Capture; +import org.easymock.EasyMock; +import org.easymock.EasyMockSupport; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static com.google.common.collect.Iterables.transform; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toDataMap; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite; +import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; +import static com.launchdarkly.sdk.server.TestComponents.inMemoryDataStore; +import static org.easymock.EasyMock.replay; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +@SuppressWarnings("javadoc") +public class DataStoreUpdatesImplTest extends EasyMockSupport { + // Note that these tests must use the actual data model types for flags and segments, rather than the + // TestItem type from DataStoreTestTypes, because the dependency behavior is based on the real data model. + + @Test + public void doesNotTryToSendEventsIfThereIsNoEventPublisher() { + DataStore store = inMemoryDataStore(); + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, null); + storeUpdates.upsert(DataModel.FEATURES, "key", new ItemDescriptor(1, flagBuilder("key").build())); + // the test is just that this doesn't cause an exception + } + + @Test + public void sendsEventsOnInitForNewlyAddedFlags() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build()) + .addAny(SEGMENTS, + segmentBuilder("segment1").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + builder.addAny(FEATURES, flagBuilder("flag2").version(1).build()) + .addAny(SEGMENTS, segmentBuilder("segment2").version(1).build()); + // the new segment triggers no events since nothing is using it + + storeUpdates.init(builder.build()); + + eventSink.expectEvents("flag2"); + } + } + + @Test + public void sendsEventOnUpdateForNewlyAddedFlag() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build()) + .addAny(SEGMENTS, + segmentBuilder("segment1").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + storeUpdates.upsert(FEATURES, "flag2", new ItemDescriptor(1, flagBuilder("flag2").version(1).build())); + + eventSink.expectEvents("flag2"); + } + } + + @Test + public void sendsEventsOnInitForUpdatedFlags() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build(), + flagBuilder("flag2").version(1).build()) + .addAny(SEGMENTS, + segmentBuilder("segment1").version(1).build(), + segmentBuilder("segment2").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + builder.addAny(FEATURES, flagBuilder("flag2").version(2).build()) // modified flag + .addAny(SEGMENTS, segmentBuilder("segment2").version(2).build()); // modified segment, but it's irrelevant + storeUpdates.init(builder.build()); + + eventSink.expectEvents("flag2"); + } + } + + @Test + public void sendsEventOnUpdateForUpdatedFlag() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build(), + flagBuilder("flag2").version(1).build()) + .addAny(SEGMENTS, + segmentBuilder("segment1").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + storeUpdates.upsert(FEATURES, "flag2", new ItemDescriptor(2, flagBuilder("flag2").version(2).build())); + + eventSink.expectEvents("flag2"); + } + } + + @Test + public void sendsEventsOnInitForDeletedFlags() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build(), + flagBuilder("flag2").version(1).build()) + .addAny(SEGMENTS, + segmentBuilder("segment1").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + builder.remove(FEATURES, "flag2"); + builder.remove(SEGMENTS, "segment1"); // deleted segment isn't being used so it's irrelevant + // note that the full data set for init() will never include deleted item placeholders + + storeUpdates.init(builder.build()); + + eventSink.expectEvents("flag2"); + } + } + + @Test + public void sendsEventOnUpdateForDeletedFlag() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build(), + flagBuilder("flag2").version(1).build()) + .addAny(SEGMENTS, + segmentBuilder("segment1").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + storeUpdates.upsert(FEATURES, "flag2", ItemDescriptor.deletedItem(2)); + + eventSink.expectEvents("flag2"); + } + } + + @Test + public void sendsEventsOnInitForFlagsWhosePrerequisitesChanged() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build(), + flagBuilder("flag2").version(1).prerequisites(prerequisite("flag1", 0)).build(), + flagBuilder("flag3").version(1).build(), + flagBuilder("flag4").version(1).prerequisites(prerequisite("flag1", 0)).build(), + flagBuilder("flag5").version(1).prerequisites(prerequisite("flag4", 0)).build(), + flagBuilder("flag6").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + builder.addAny(FEATURES, flagBuilder("flag1").version(2).build()); + storeUpdates.init(builder.build()); + + eventSink.expectEvents("flag1", "flag2", "flag4", "flag5"); + } + } + + @Test + public void sendsEventsOnUpdateForFlagsWhosePrerequisitesChanged() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build(), + flagBuilder("flag2").version(1).prerequisites(prerequisite("flag1", 0)).build(), + flagBuilder("flag3").version(1).build(), + flagBuilder("flag4").version(1).prerequisites(prerequisite("flag1", 0)).build(), + flagBuilder("flag5").version(1).prerequisites(prerequisite("flag4", 0)).build(), + flagBuilder("flag6").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + storeUpdates.upsert(FEATURES, "flag1", new ItemDescriptor(2, flagBuilder("flag1").version(2).build())); + + eventSink.expectEvents("flag1", "flag2", "flag4", "flag5"); + } + } + + @Test + public void sendsEventsOnInitForFlagsWhoseSegmentsChanged() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build(), + flagBuilder("flag2").version(1).rules( + ruleBuilder().clauses( + ModelBuilders.clause(null, Operator.segmentMatch, LDValue.of("segment1")) + ).build() + ).build(), + flagBuilder("flag3").version(1).build(), + flagBuilder("flag4").version(1).prerequisites(prerequisite("flag2", 0)).build()) + .addAny(SEGMENTS, + segmentBuilder("segment1").version(1).build(), + segmentBuilder("segment2").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + storeUpdates.upsert(SEGMENTS, "segment1", new ItemDescriptor(2, segmentBuilder("segment1").version(2).build())); + + eventSink.expectEvents("flag2", "flag4"); + } + } + + @Test + public void sendsEventsOnUpdateForFlagsWhoseSegmentsChanged() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build(), + flagBuilder("flag2").version(1).rules( + ruleBuilder().clauses( + ModelBuilders.clause(null, Operator.segmentMatch, LDValue.of("segment1")) + ).build() + ).build(), + flagBuilder("flag3").version(1).build(), + flagBuilder("flag4").version(1).prerequisites(prerequisite("flag2", 0)).build()) + .addAny(SEGMENTS, + segmentBuilder("segment1").version(1).build(), + segmentBuilder("segment2").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + builder.addAny(SEGMENTS, segmentBuilder("segment1").version(2).build()); + storeUpdates.init(builder.build()); + + eventSink.expectEvents("flag2", "flag4"); + } + } + + @Test + public void dataSetIsPassedToDataStoreInCorrectOrder() throws Exception { + // This verifies that the client is using DataStoreClientWrapper and that it is applying the + // correct ordering for flag prerequisites, etc. This should work regardless of what kind of + // DataSource we're using. + + Capture> captureData = Capture.newInstance(); + DataStore store = createStrictMock(DataStore.class); + store.init(EasyMock.capture(captureData)); + replay(store); + + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, null); + storeUpdates.init(DEPENDENCY_ORDERING_TEST_DATA); + + Map> dataMap = toDataMap(captureData.getValue()); + assertEquals(2, dataMap.size()); + Map> inputDataMap = toDataMap(DEPENDENCY_ORDERING_TEST_DATA); + + // Segments should always come first + assertEquals(SEGMENTS, Iterables.get(dataMap.keySet(), 0)); + assertEquals(inputDataMap.get(SEGMENTS).size(), Iterables.get(dataMap.values(), 0).size()); + + // Features should be ordered so that a flag always appears after its prerequisites, if any + assertEquals(FEATURES, Iterables.get(dataMap.keySet(), 1)); + Map map1 = Iterables.get(dataMap.values(), 1); + List list1 = ImmutableList.copyOf(transform(map1.values(), d -> (DataModel.FeatureFlag)d.getItem())); + assertEquals(inputDataMap.get(FEATURES).size(), map1.size()); + for (int itemIndex = 0; itemIndex < list1.size(); itemIndex++) { + DataModel.FeatureFlag item = list1.get(itemIndex); + for (DataModel.Prerequisite prereq: item.getPrerequisites()) { + DataModel.FeatureFlag depFlag = (DataModel.FeatureFlag)map1.get(prereq.getKey()).getItem(); + int depIndex = list1.indexOf(depFlag); + if (depIndex > itemIndex) { + fail(String.format("%s depends on %s, but %s was listed first; keys in order are [%s]", + item.getKey(), prereq.getKey(), item.getKey(), + Joiner.on(", ").join(map1.keySet()))); + } + } + } + } + + private static FullDataSet DEPENDENCY_ORDERING_TEST_DATA = + new DataBuilder() + .addAny(FEATURES, + flagBuilder("a") + .prerequisites(prerequisite("b", 0), prerequisite("c", 0)).build(), + flagBuilder("b") + .prerequisites(prerequisite("c", 0), prerequisite("e", 0)).build(), + flagBuilder("c").build(), + flagBuilder("d").build(), + flagBuilder("e").build(), + flagBuilder("f").build()) + .addAny(SEGMENTS, + segmentBuilder("o").build()) + .build(); +} diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java similarity index 77% rename from src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java rename to src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java index 3ec9aab0b..5ed29e9e0 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java @@ -1,11 +1,14 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.collect.ImmutableSet; import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.launchdarkly.client.integrations.EventProcessorBuilder; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; +import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; import org.hamcrest.Matcher; import org.hamcrest.Matchers; @@ -13,16 +16,19 @@ import java.net.URI; import java.text.SimpleDateFormat; +import java.time.Duration; import java.util.Date; import java.util.UUID; import java.util.concurrent.TimeUnit; -import static com.launchdarkly.client.Components.sendEvents; -import static com.launchdarkly.client.TestHttpUtil.httpsServerWithSelfSignedCert; -import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; -import static com.launchdarkly.client.TestUtil.hasJsonProperty; -import static com.launchdarkly.client.TestUtil.isJsonArray; -import static com.launchdarkly.client.TestUtil.simpleEvaluation; +import static com.launchdarkly.sdk.server.Components.sendEvents; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.TestComponents.clientContext; +import static com.launchdarkly.sdk.server.TestHttpUtil.httpsServerWithSelfSignedCert; +import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; +import static com.launchdarkly.sdk.server.TestUtil.hasJsonProperty; +import static com.launchdarkly.sdk.server.TestUtil.isJsonArray; +import static com.launchdarkly.sdk.server.TestUtil.simpleEvaluation; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.contains; @@ -45,10 +51,9 @@ public class DefaultEventProcessorTest { private static final String SDK_KEY = "SDK_KEY"; private static final LDUser user = new LDUser.Builder("userkey").name("Red").build(); private static final Gson gson = new Gson(); - private static final JsonElement userJson = - gson.fromJson("{\"key\":\"userkey\",\"name\":\"Red\"}", JsonElement.class); - private static final JsonElement filteredUserJson = - gson.fromJson("{\"key\":\"userkey\",\"privateAttrs\":[\"name\"]}", JsonElement.class); + private static final LDValue userJson = LDValue.buildObject().put("key", "userkey").put("name", "Red").build(); + private static final LDValue filteredUserJson = LDValue.buildObject().put("key", "userkey") + .put("privateAttrs", LDValue.buildArray().add("name").build()).build(); private static final SimpleDateFormat httpDateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz"); private static final LDConfig baseLDConfig = new LDConfig.Builder().diagnosticOptOut(true).build(); private static final LDConfig diagLDConfig = new LDConfig.Builder().diagnosticOptOut(false).build(); @@ -65,29 +70,29 @@ private DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec) { } private DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec, LDConfig config) { - return (DefaultEventProcessor)ec.createEventProcessor(SDK_KEY, config); + return (DefaultEventProcessor)ec.createEventProcessor(clientContext(SDK_KEY, config)); } private DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec, DiagnosticAccumulator diagnosticAccumulator) { - return (DefaultEventProcessor)((EventProcessorFactoryWithDiagnostics)ec).createEventProcessor(SDK_KEY, - diagLDConfig, diagnosticAccumulator); + return (DefaultEventProcessor)ec.createEventProcessor( + clientContext(SDK_KEY, diagLDConfig, diagnosticAccumulator)); } @Test public void builderHasDefaultConfiguration() throws Exception { EventProcessorFactory epf = Components.sendEvents(); - try (DefaultEventProcessor ep = (DefaultEventProcessor)epf.createEventProcessor(SDK_KEY, LDConfig.DEFAULT)) { + try (DefaultEventProcessor ep = (DefaultEventProcessor)epf.createEventProcessor(clientContext(SDK_KEY, LDConfig.DEFAULT))) { EventsConfiguration ec = ep.dispatcher.eventsConfig; assertThat(ec.allAttributesPrivate, is(false)); assertThat(ec.capacity, equalTo(EventProcessorBuilder.DEFAULT_CAPACITY)); - assertThat(ec.diagnosticRecordingIntervalSeconds, equalTo(EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS)); + assertThat(ec.diagnosticRecordingInterval, equalTo(EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL)); assertThat(ec.eventsUri, equalTo(LDConfig.DEFAULT_EVENTS_URI)); - assertThat(ec.flushIntervalSeconds, equalTo(EventProcessorBuilder.DEFAULT_FLUSH_INTERVAL_SECONDS)); + assertThat(ec.flushInterval, equalTo(EventProcessorBuilder.DEFAULT_FLUSH_INTERVAL)); assertThat(ec.inlineUsersInEvents, is(false)); - assertThat(ec.privateAttrNames, equalTo(ImmutableSet.of())); + assertThat(ec.privateAttributes, equalTo(ImmutableSet.of())); assertThat(ec.samplingInterval, equalTo(0)); assertThat(ec.userKeysCapacity, equalTo(EventProcessorBuilder.DEFAULT_USER_KEYS_CAPACITY)); - assertThat(ec.userKeysFlushIntervalSeconds, equalTo(EventProcessorBuilder.DEFAULT_USER_KEYS_FLUSH_INTERVAL_SECONDS)); + assertThat(ec.userKeysFlushInterval, equalTo(EventProcessorBuilder.DEFAULT_USER_KEYS_FLUSH_INTERVAL)); } } @@ -98,96 +103,34 @@ public void builderCanSpecifyConfiguration() throws Exception { .allAttributesPrivate(true) .baseURI(uri) .capacity(3333) - .diagnosticRecordingIntervalSeconds(480) - .flushIntervalSeconds(99) - .privateAttributeNames("cats", "dogs") + .diagnosticRecordingInterval(Duration.ofSeconds(480)) + .flushInterval(Duration.ofSeconds(99)) + .privateAttributeNames("name", "dogs") .userKeysCapacity(555) - .userKeysFlushIntervalSeconds(101); - try (DefaultEventProcessor ep = (DefaultEventProcessor)epf.createEventProcessor(SDK_KEY, LDConfig.DEFAULT)) { + .userKeysFlushInterval(Duration.ofSeconds(101)); + try (DefaultEventProcessor ep = (DefaultEventProcessor)epf.createEventProcessor(clientContext(SDK_KEY, LDConfig.DEFAULT))) { EventsConfiguration ec = ep.dispatcher.eventsConfig; assertThat(ec.allAttributesPrivate, is(true)); assertThat(ec.capacity, equalTo(3333)); - assertThat(ec.diagnosticRecordingIntervalSeconds, equalTo(480)); + assertThat(ec.diagnosticRecordingInterval, equalTo(Duration.ofSeconds(480))); assertThat(ec.eventsUri, equalTo(uri)); - assertThat(ec.flushIntervalSeconds, equalTo(99)); + assertThat(ec.flushInterval, equalTo(Duration.ofSeconds(99))); assertThat(ec.inlineUsersInEvents, is(false)); // will test this separately below - assertThat(ec.privateAttrNames, equalTo(ImmutableSet.of("cats", "dogs"))); + assertThat(ec.privateAttributes, equalTo(ImmutableSet.of(UserAttribute.NAME, UserAttribute.forName("dogs")))); assertThat(ec.samplingInterval, equalTo(0)); // can only set this with the deprecated config API assertThat(ec.userKeysCapacity, equalTo(555)); - assertThat(ec.userKeysFlushIntervalSeconds, equalTo(101)); + assertThat(ec.userKeysFlushInterval, equalTo(Duration.ofSeconds(101))); } // Test inlineUsersInEvents separately to make sure it and the other boolean property (allAttributesPrivate) // are really independently settable, since there's no way to distinguish between two true values EventProcessorFactory epf1 = Components.sendEvents().inlineUsersInEvents(true); - try (DefaultEventProcessor ep = (DefaultEventProcessor)epf1.createEventProcessor(SDK_KEY, LDConfig.DEFAULT)) { + try (DefaultEventProcessor ep = (DefaultEventProcessor)epf1.createEventProcessor(clientContext(SDK_KEY, LDConfig.DEFAULT))) { EventsConfiguration ec = ep.dispatcher.eventsConfig; assertThat(ec.allAttributesPrivate, is(false)); assertThat(ec.inlineUsersInEvents, is(true)); } } - @Test - @SuppressWarnings("deprecation") - public void deprecatedConfigurationIsUsedWhenBuilderIsNotUsed() throws Exception { - URI uri = URI.create("http://fake"); - LDConfig config = new LDConfig.Builder() - .allAttributesPrivate(true) - .capacity(3333) - .eventsURI(uri) - .flushInterval(99) - .privateAttributeNames("cats", "dogs") - .samplingInterval(7) - .userKeysCapacity(555) - .userKeysFlushInterval(101) - .build(); - EventProcessorFactory epf = Components.defaultEventProcessor(); - try (DefaultEventProcessor ep = (DefaultEventProcessor)epf.createEventProcessor(SDK_KEY, config)) { - EventsConfiguration ec = ep.dispatcher.eventsConfig; - assertThat(ec.allAttributesPrivate, is(true)); - assertThat(ec.capacity, equalTo(3333)); - assertThat(ec.diagnosticRecordingIntervalSeconds, equalTo(EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS)); - // can't set diagnosticRecordingIntervalSeconds with deprecated API, must use builder - assertThat(ec.eventsUri, equalTo(uri)); - assertThat(ec.flushIntervalSeconds, equalTo(99)); - assertThat(ec.inlineUsersInEvents, is(false)); // will test this separately below - assertThat(ec.privateAttrNames, equalTo(ImmutableSet.of("cats", "dogs"))); - assertThat(ec.samplingInterval, equalTo(7)); - assertThat(ec.userKeysCapacity, equalTo(555)); - assertThat(ec.userKeysFlushIntervalSeconds, equalTo(101)); - } - // Test inlineUsersInEvents separately to make sure it and the other boolean property (allAttributesPrivate) - // are really independently settable, since there's no way to distinguish between two true values - LDConfig config1 = new LDConfig.Builder().inlineUsersInEvents(true).build(); - try (DefaultEventProcessor ep = (DefaultEventProcessor)epf.createEventProcessor(SDK_KEY, config1)) { - EventsConfiguration ec = ep.dispatcher.eventsConfig; - assertThat(ec.allAttributesPrivate, is(false)); - assertThat(ec.inlineUsersInEvents, is(true)); - } - } - - @Test - @SuppressWarnings("deprecation") - public void deprecatedConfigurationHasSameDefaultsAsBuilder() throws Exception { - EventProcessorFactory epf0 = Components.sendEvents(); - EventProcessorFactory epf1 = Components.defaultEventProcessor(); - try (DefaultEventProcessor ep0 = (DefaultEventProcessor)epf0.createEventProcessor(SDK_KEY, LDConfig.DEFAULT)) { - try (DefaultEventProcessor ep1 = (DefaultEventProcessor)epf1.createEventProcessor(SDK_KEY, LDConfig.DEFAULT)) { - EventsConfiguration ec0 = ep0.dispatcher.eventsConfig; - EventsConfiguration ec1 = ep1.dispatcher.eventsConfig; - assertThat(ec1.allAttributesPrivate, equalTo(ec0.allAttributesPrivate)); - assertThat(ec1.capacity, equalTo(ec0.capacity)); - assertThat(ec1.diagnosticRecordingIntervalSeconds, equalTo(ec1.diagnosticRecordingIntervalSeconds)); - assertThat(ec1.eventsUri, equalTo(ec0.eventsUri)); - assertThat(ec1.flushIntervalSeconds, equalTo(ec1.flushIntervalSeconds)); - assertThat(ec1.inlineUsersInEvents, equalTo(ec1.inlineUsersInEvents)); - assertThat(ec1.privateAttrNames, equalTo(ec1.privateAttrNames)); - assertThat(ec1.samplingInterval, equalTo(ec1.samplingInterval)); - assertThat(ec1.userKeysCapacity, equalTo(ec1.userKeysCapacity)); - assertThat(ec1.userKeysFlushIntervalSeconds, equalTo(ec1.userKeysFlushIntervalSeconds)); - } - } - } - @Test public void identifyEventIsQueued() throws Exception { Event e = EventFactory.DEFAULT.newIdentifyEvent(user); @@ -221,7 +164,7 @@ public void userIsFilteredInIdentifyEvent() throws Exception { @SuppressWarnings("unchecked") @Test public void individualFeatureEventIsQueuedWithIndexEvent() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -241,7 +184,7 @@ public void individualFeatureEventIsQueuedWithIndexEvent() throws Exception { @SuppressWarnings("unchecked") @Test public void userIsFilteredInIndexEvent() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -261,7 +204,7 @@ public void userIsFilteredInIndexEvent() throws Exception { @SuppressWarnings("unchecked") @Test public void featureEventCanContainInlineUser() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -280,7 +223,7 @@ public void featureEventCanContainInlineUser() throws Exception { @SuppressWarnings("unchecked") @Test public void userIsFilteredInFeatureEvent() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -300,10 +243,10 @@ public void userIsFilteredInFeatureEvent() throws Exception { @SuppressWarnings("unchecked") @Test public void featureEventCanContainReason() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); EvaluationReason reason = EvaluationReason.ruleMatch(1, null); Event.FeatureRequest fe = EventFactory.DEFAULT_WITH_REASONS.newFeatureRequestEvent(flag, user, - EvaluationDetail.fromValue(LDValue.of("value"), 1, reason), LDValue.ofNull()); + new Evaluator.EvalResult(LDValue.of("value"), 1, reason), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { @@ -321,7 +264,7 @@ public void featureEventCanContainReason() throws Exception { @SuppressWarnings("unchecked") @Test public void indexEventIsStillGeneratedIfInlineUsersIsTrueButFeatureEventIsNotTracked() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(false).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(false).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), null); @@ -341,7 +284,7 @@ public void indexEventIsStillGeneratedIfInlineUsersIsTrueButFeatureEventIsNotTra @Test public void eventKindIsDebugIfFlagIsTemporarilyInDebugMode() throws Exception { long futureTime = System.currentTimeMillis() + 1000000; - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(futureTime).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(futureTime).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -362,7 +305,7 @@ public void eventKindIsDebugIfFlagIsTemporarilyInDebugMode() throws Exception { @Test public void eventCanBeBothTrackedAndDebugged() throws Exception { long futureTime = System.currentTimeMillis() + 1000000; - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true) + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true) .debugEventsUntilDate(futureTime).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -390,7 +333,7 @@ public void debugModeExpiresBasedOnClientTimeIfClientTimeIsLaterThanServerTime() MockResponse resp2 = eventsSuccessResponse(); long debugUntil = serverTime + 1000; - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -409,7 +352,7 @@ public void debugModeExpiresBasedOnClientTimeIfClientTimeIsLaterThanServerTime() assertThat(getEventsFromLastRequest(server), contains( isIndexEvent(fe, userJson), - isSummaryEvent(fe.creationDate, fe.creationDate) + isSummaryEvent(fe.getCreationDate(), fe.getCreationDate()) )); } } @@ -423,7 +366,7 @@ public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() MockResponse resp2 = eventsSuccessResponse(); long debugUntil = serverTime - 1000; - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -443,7 +386,7 @@ public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() // Should get a summary event only, not a full feature event assertThat(getEventsFromLastRequest(server), contains( isIndexEvent(fe, userJson), - isSummaryEvent(fe.creationDate, fe.creationDate) + isSummaryEvent(fe.getCreationDate(), fe.getCreationDate()) )); } } @@ -451,8 +394,8 @@ public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() @SuppressWarnings("unchecked") @Test public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Exception { - FeatureFlag flag1 = new FeatureFlagBuilder("flagkey1").version(11).trackEvents(true).build(); - FeatureFlag flag2 = new FeatureFlagBuilder("flagkey2").version(22).trackEvents(true).build(); + DataModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag2 = flagBuilder("flagkey2").version(22).trackEvents(true).build(); LDValue value = LDValue.of("value"); Event.FeatureRequest fe1 = EventFactory.DEFAULT.newFeatureRequestEvent(flag1, user, simpleEvaluation(1, value), LDValue.ofNull()); @@ -469,7 +412,7 @@ public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Except isIndexEvent(fe1, userJson), isFeatureEvent(fe1, flag1, false, null), isFeatureEvent(fe2, flag2, false, null), - isSummaryEvent(fe1.creationDate, fe2.creationDate) + isSummaryEvent(fe1.getCreationDate(), fe2.getCreationDate()) )); } } @@ -478,7 +421,7 @@ public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Except @Test public void identifyEventMakesIndexEventUnnecessary() throws Exception { Event ie = EventFactory.DEFAULT.newIdentifyEvent(user); - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), null); @@ -500,8 +443,8 @@ public void identifyEventMakesIndexEventUnnecessary() throws Exception { @SuppressWarnings("unchecked") @Test public void nonTrackedEventsAreSummarized() throws Exception { - FeatureFlag flag1 = new FeatureFlagBuilder("flagkey1").version(11).build(); - FeatureFlag flag2 = new FeatureFlagBuilder("flagkey2").version(22).build(); + DataModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).build(); + DataModel.FeatureFlag flag2 = flagBuilder("flagkey2").version(22).build(); LDValue value1 = LDValue.of("value1"); LDValue value2 = LDValue.of("value2"); LDValue default1 = LDValue.of("default1"); @@ -526,7 +469,7 @@ public void nonTrackedEventsAreSummarized() throws Exception { assertThat(getEventsFromLastRequest(server), contains( isIndexEvent(fe1a, userJson), allOf( - isSummaryEvent(fe1a.creationDate, fe2.creationDate), + isSummaryEvent(fe1a.getCreationDate(), fe2.getCreationDate()), hasSummaryFlag(flag1.getKey(), default1, Matchers.containsInAnyOrder( isSummaryEventCounter(flag1, 1, value1, 2), @@ -676,8 +619,8 @@ public void periodicDiagnosticEventHasStatisticsBody() throws Exception { @Test public void periodicDiagnosticEventGetsEventsInLastBatchAndDeduplicatedUsers() throws Exception { - FeatureFlag flag1 = new FeatureFlagBuilder("flagkey1").version(11).trackEvents(true).build(); - FeatureFlag flag2 = new FeatureFlagBuilder("flagkey2").version(22).trackEvents(true).build(); + DataModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag2 = flagBuilder("flagkey2").version(22).trackEvents(true).build(); LDValue value = LDValue.of("value"); Event.FeatureRequest fe1 = EventFactory.DEFAULT.newFeatureRequestEvent(flag1, user, simpleEvaluation(1, value), LDValue.ofNull()); @@ -947,72 +890,66 @@ private MockResponse addDateHeader(MockResponse response, long timestamp) { return response.addHeader("Date", httpDateFormat.format(new Date(timestamp))); } - private JsonArray getEventsFromLastRequest(MockWebServer server) throws Exception { + private Iterable getEventsFromLastRequest(MockWebServer server) throws Exception { RecordedRequest req = server.takeRequest(0, TimeUnit.MILLISECONDS); assertNotNull(req); - return gson.fromJson(req.getBody().readUtf8(), JsonElement.class).getAsJsonArray(); + return gson.fromJson(req.getBody().readUtf8(), LDValue.class).values(); } - private Matcher isIdentifyEvent(Event sourceEvent, JsonElement user) { + private Matcher isIdentifyEvent(Event sourceEvent, LDValue user) { return allOf( hasJsonProperty("kind", "identify"), - hasJsonProperty("creationDate", (double)sourceEvent.creationDate), + hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), hasJsonProperty("user", user) ); } - private Matcher isIndexEvent(Event sourceEvent, JsonElement user) { + private Matcher isIndexEvent(Event sourceEvent, LDValue user) { return allOf( hasJsonProperty("kind", "index"), - hasJsonProperty("creationDate", (double)sourceEvent.creationDate), + hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), hasJsonProperty("user", user) ); } - private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, FeatureFlag flag, boolean debug, JsonElement inlineUser) { + private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, DataModel.FeatureFlag flag, boolean debug, LDValue inlineUser) { return isFeatureEvent(sourceEvent, flag, debug, inlineUser, null); } @SuppressWarnings("unchecked") - private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, FeatureFlag flag, boolean debug, JsonElement inlineUser, + private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, DataModel.FeatureFlag flag, boolean debug, LDValue inlineUser, EvaluationReason reason) { return allOf( hasJsonProperty("kind", debug ? "debug" : "feature"), - hasJsonProperty("creationDate", (double)sourceEvent.creationDate), + hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), hasJsonProperty("key", flag.getKey()), hasJsonProperty("version", (double)flag.getVersion()), - hasJsonProperty("variation", sourceEvent.variation), - hasJsonProperty("value", sourceEvent.value), - (inlineUser != null) ? hasJsonProperty("userKey", nullValue(JsonElement.class)) : - hasJsonProperty("userKey", sourceEvent.user.getKeyAsString()), - (inlineUser != null) ? hasJsonProperty("user", inlineUser) : - hasJsonProperty("user", nullValue(JsonElement.class)), - (reason == null) ? hasJsonProperty("reason", nullValue(JsonElement.class)) : - hasJsonProperty("reason", gson.toJsonTree(reason)) + hasJsonProperty("variation", sourceEvent.getVariation()), + hasJsonProperty("value", sourceEvent.getValue()), + hasJsonProperty("userKey", inlineUser == null ? LDValue.of(sourceEvent.getUser().getKey()) : LDValue.ofNull()), + hasJsonProperty("user", inlineUser == null ? LDValue.ofNull() : inlineUser), + hasJsonProperty("reason", reason == null ? LDValue.ofNull() : LDValue.parse(gson.toJson(reason))) ); } @SuppressWarnings("unchecked") - private Matcher isCustomEvent(Event.Custom sourceEvent, JsonElement inlineUser) { + private Matcher isCustomEvent(Event.Custom sourceEvent, LDValue inlineUser) { return allOf( hasJsonProperty("kind", "custom"), - hasJsonProperty("creationDate", (double)sourceEvent.creationDate), + hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), hasJsonProperty("key", "eventkey"), - (inlineUser != null) ? hasJsonProperty("userKey", nullValue(JsonElement.class)) : - hasJsonProperty("userKey", sourceEvent.user.getKeyAsString()), - (inlineUser != null) ? hasJsonProperty("user", inlineUser) : - hasJsonProperty("user", nullValue(JsonElement.class)), - hasJsonProperty("data", sourceEvent.data), - (sourceEvent.metricValue == null) ? hasJsonProperty("metricValue", nullValue(JsonElement.class)) : - hasJsonProperty("metricValue", sourceEvent.metricValue.doubleValue()) + hasJsonProperty("userKey", inlineUser == null ? LDValue.of(sourceEvent.getUser().getKey()) : LDValue.ofNull()), + hasJsonProperty("user", inlineUser == null ? LDValue.ofNull() : inlineUser), + hasJsonProperty("data", sourceEvent.getData()), + hasJsonProperty("metricValue", sourceEvent.getMetricValue() == null ? LDValue.ofNull() : LDValue.of(sourceEvent.getMetricValue())) ); } - private Matcher isSummaryEvent() { + private Matcher isSummaryEvent() { return hasJsonProperty("kind", "summary"); } - private Matcher isSummaryEvent(long startDate, long endDate) { + private Matcher isSummaryEvent(long startDate, long endDate) { return allOf( hasJsonProperty("kind", "summary"), hasJsonProperty("startDate", (double)startDate), @@ -1020,7 +957,7 @@ private Matcher isSummaryEvent(long startDate, long endDate) { ); } - private Matcher hasSummaryFlag(String key, LDValue defaultVal, Matcher> counters) { + private Matcher hasSummaryFlag(String key, LDValue defaultVal, Matcher> counters) { return hasJsonProperty("features", hasJsonProperty(key, allOf( hasJsonProperty("default", defaultVal), @@ -1028,7 +965,7 @@ private Matcher hasSummaryFlag(String key, LDValue defaultVal, Matc ))); } - private Matcher isSummaryEventCounter(FeatureFlag flag, Integer variation, LDValue value, int count) { + private Matcher isSummaryEventCounter(DataModel.FeatureFlag flag, Integer variation, LDValue value, int count) { return allOf( hasJsonProperty("variation", variation), hasJsonProperty("version", (double)flag.getVersion()), diff --git a/src/test/java/com/launchdarkly/client/DiagnosticAccumulatorTest.java b/src/test/java/com/launchdarkly/sdk/server/DiagnosticAccumulatorTest.java similarity index 91% rename from src/test/java/com/launchdarkly/client/DiagnosticAccumulatorTest.java rename to src/test/java/com/launchdarkly/sdk/server/DiagnosticAccumulatorTest.java index 1b19a24b7..06f9baa81 100644 --- a/src/test/java/com/launchdarkly/client/DiagnosticAccumulatorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DiagnosticAccumulatorTest.java @@ -1,4 +1,8 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.DiagnosticAccumulator; +import com.launchdarkly.sdk.server.DiagnosticEvent; +import com.launchdarkly.sdk.server.DiagnosticId; import org.junit.Test; @@ -6,8 +10,8 @@ import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertSame; +@SuppressWarnings("javadoc") public class DiagnosticAccumulatorTest { - @Test public void createsDiagnosticStatisticsEvent() { DiagnosticId diagnosticId = new DiagnosticId("SDK_KEY"); diff --git a/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java similarity index 70% rename from src/test/java/com/launchdarkly/client/DiagnosticEventTest.java rename to src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java index 81f847bf0..4da6cc4e5 100644 --- a/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java @@ -1,15 +1,19 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonObject; -import com.launchdarkly.client.integrations.Redis; -import com.launchdarkly.client.value.LDValue; -import com.launchdarkly.client.value.ObjectBuilder; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.ObjectBuilder; +import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription; import org.junit.Test; import java.net.URI; +import java.time.Duration; import java.util.Collections; import java.util.List; import java.util.UUID; @@ -89,7 +93,7 @@ public void testDefaultDiagnosticConfiguration() { @Test public void testCustomDiagnosticConfigurationGeneralProperties() { LDConfig ldConfig = new LDConfig.Builder() - .startWaitMillis(10_000) + .startWait(Duration.ofSeconds(10)) .build(); LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); @@ -107,7 +111,7 @@ public void testCustomDiagnosticConfigurationForStreaming() { Components.streamingDataSource() .baseURI(URI.create("https://1.1.1.1")) .pollingBaseURI(URI.create("https://1.1.1.1")) - .initialReconnectDelayMillis(2_000) + .initialReconnectDelay(Duration.ofSeconds(2)) ) .build(); @@ -127,7 +131,7 @@ public void testCustomDiagnosticConfigurationForPolling() { .dataSource( Components.pollingDataSource() .baseURI(URI.create("https://1.1.1.1")) - .pollIntervalMillis(60_000) + .pollInterval(Duration.ofSeconds(60)) ) .build(); @@ -148,11 +152,11 @@ public void testCustomDiagnosticConfigurationForEvents() { Components.sendEvents() .allAttributesPrivate(true) .capacity(20_000) - .diagnosticRecordingIntervalSeconds(1_800) - .flushIntervalSeconds(10) + .diagnosticRecordingInterval(Duration.ofSeconds(1_800)) + .flushInterval(Duration.ofSeconds(10)) .inlineUsersInEvents(true) .userKeysCapacity(2_000) - .userKeysFlushIntervalSeconds(600) + .userKeysFlushInterval(Duration.ofSeconds(600)) ) .build(); @@ -174,12 +178,12 @@ public void testCustomDiagnosticConfigurationForEvents() { public void testCustomDiagnosticConfigurationForDaemonMode() { LDConfig ldConfig = new LDConfig.Builder() .dataSource(Components.externalUpdatesOnly()) - .dataStore(Components.persistentDataStore(Redis.dataStore())) + .dataStore(new DataStoreFactoryWithComponentName()) .build(); LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); LDValue expected = expectedDefaultPropertiesWithoutStreaming() - .put("dataStoreType", "Redis") + .put("dataStoreType", "my-test-store") .put("usingRelayDaemon", true) .build(); @@ -199,14 +203,14 @@ public void testCustomDiagnosticConfigurationForOffline() { assertEquals(expected, diagnosticJson); } - + @Test public void testCustomDiagnosticConfigurationHttpProperties() { LDConfig ldConfig = new LDConfig.Builder() .http( Components.httpConfiguration() - .connectTimeoutMillis(5_000) - .socketTimeoutMillis(20_000) + .connectTimeout(Duration.ofSeconds(5)) + .socketTimeout(Duration.ofSeconds(20)) .proxyHostAndPort("localhost", 1234) .proxyAuth(Components.httpBasicAuthentication("username", "password")) ) @@ -223,81 +227,15 @@ public void testCustomDiagnosticConfigurationHttpProperties() { assertEquals(expected, diagnosticJson); } - @SuppressWarnings("deprecation") - @Test - public void testCustomDiagnosticConfigurationDeprecatedPropertiesForStreaming() { - LDConfig ldConfig = new LDConfig.Builder() - .baseURI(URI.create("https://1.1.1.1")) - .streamURI(URI.create("https://1.1.1.1")) - .reconnectTimeMs(2_000) - .build(); - - LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); - LDValue expected = expectedDefaultPropertiesWithoutStreaming() - .put("customBaseURI", true) - .put("customStreamURI", true) - .put("reconnectTimeMillis", 2_000) - .build(); - - assertEquals(expected, diagnosticJson); - } - - @SuppressWarnings("deprecation") - @Test - public void testCustomDiagnosticConfigurationDeprecatedPropertiesForPolling() { - LDConfig ldConfig = new LDConfig.Builder() - .baseURI(URI.create("https://1.1.1.1")) - .pollingIntervalMillis(60_000) - .stream(false) - .build(); - - LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); - LDValue expected = expectedDefaultPropertiesWithoutStreaming() - .put("customBaseURI", true) - .put("pollingIntervalMillis", 60_000) - .put("streamingDisabled", true) - .build(); - - assertEquals(expected, diagnosticJson); + private static class DataStoreFactoryWithComponentName implements DataStoreFactory, DiagnosticDescription { + @Override + public LDValue describeConfiguration(LDConfig config) { + return LDValue.of("my-test-store"); + } + + @Override + public DataStore createDataStore(ClientContext context) { + return null; + } } - - @SuppressWarnings("deprecation") - @Test - public void testCustomDiagnosticConfigurationDeprecatedPropertyForDaemonMode() { - LDConfig ldConfig = new LDConfig.Builder() - .featureStoreFactory(new RedisFeatureStoreBuilder()) - .useLdd(true) - .build(); - - LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); - LDValue expected = expectedDefaultPropertiesWithoutStreaming() - .put("dataStoreType", "Redis") - .put("usingRelayDaemon", true) - .build(); - - 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/DiagnosticIdTest.java b/src/test/java/com/launchdarkly/sdk/server/DiagnosticIdTest.java similarity index 92% rename from src/test/java/com/launchdarkly/client/DiagnosticIdTest.java rename to src/test/java/com/launchdarkly/sdk/server/DiagnosticIdTest.java index 7c2402e42..a81bd95eb 100644 --- a/src/test/java/com/launchdarkly/client/DiagnosticIdTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DiagnosticIdTest.java @@ -1,7 +1,8 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.gson.Gson; import com.google.gson.JsonObject; +import com.launchdarkly.sdk.server.DiagnosticId; import org.junit.Test; @@ -10,8 +11,8 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +@SuppressWarnings("javadoc") public class DiagnosticIdTest { - private static final Gson gson = new Gson(); @Test diff --git a/src/test/java/com/launchdarkly/client/DiagnosticSdkTest.java b/src/test/java/com/launchdarkly/sdk/server/DiagnosticSdkTest.java similarity index 92% rename from src/test/java/com/launchdarkly/client/DiagnosticSdkTest.java rename to src/test/java/com/launchdarkly/sdk/server/DiagnosticSdkTest.java index f0c01184f..01271c472 100644 --- a/src/test/java/com/launchdarkly/client/DiagnosticSdkTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DiagnosticSdkTest.java @@ -1,8 +1,11 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.gson.Gson; import com.google.gson.JsonObject; -import com.launchdarkly.client.DiagnosticEvent.Init.DiagnosticSdk; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.DiagnosticEvent.Init.DiagnosticSdk; + import org.junit.Test; import static org.junit.Assert.assertEquals; diff --git a/src/test/java/com/launchdarkly/client/VariationOrRolloutTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java similarity index 64% rename from src/test/java/com/launchdarkly/client/VariationOrRolloutTest.java rename to src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java index e56543f35..7cafb3705 100644 --- a/src/test/java/com/launchdarkly/client/VariationOrRolloutTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java @@ -1,6 +1,10 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.VariationOrRollout.WeightedVariation; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel.Rollout; +import com.launchdarkly.sdk.server.DataModel.VariationOrRollout; +import com.launchdarkly.sdk.server.DataModel.WeightedVariation; import org.hamcrest.Matchers; import org.junit.Test; @@ -13,7 +17,7 @@ import static org.junit.Assert.assertEquals; @SuppressWarnings("javadoc") -public class VariationOrRolloutTest { +public class EvaluatorBucketingTest { @Test public void variationIndexIsReturnedForBucket() { LDUser user = new LDUser.Builder("userkey").build(); @@ -22,7 +26,7 @@ public void variationIndexIsReturnedForBucket() { // First verify that with our test inputs, the bucket value will be greater than zero and less than 100000, // so we can construct a rollout whose second bucket just barely contains that value - int bucketValue = (int)(VariationOrRollout.bucketUser(user, flagKey, "key", salt) * 100000); + int bucketValue = (int)(EvaluatorBucketing.bucketUser(user, flagKey, UserAttribute.KEY, salt) * 100000); assertThat(bucketValue, greaterThanOrEqualTo(1)); assertThat(bucketValue, Matchers.lessThan(100000)); @@ -31,9 +35,9 @@ public void variationIndexIsReturnedForBucket() { new WeightedVariation(badVariationA, bucketValue), // end of bucket range is not inclusive, so it will *not* match the target value new WeightedVariation(matchedVariation, 1), // size of this bucket is 1, so it only matches that specific value new WeightedVariation(badVariationB, 100000 - (bucketValue + 1))); - VariationOrRollout vr = new VariationOrRollout(null, new VariationOrRollout.Rollout(variations, null)); + VariationOrRollout vr = new VariationOrRollout(null, new Rollout(variations, null)); - Integer resultVariation = vr.variationIndexForUser(user, flagKey, salt); + Integer resultVariation = EvaluatorBucketing.variationIndexForUser(vr, user, flagKey, salt); assertEquals(Integer.valueOf(matchedVariation), resultVariation); } @@ -44,12 +48,12 @@ public void lastBucketIsUsedIfBucketValueEqualsTotalWeight() { String salt = "salt"; // We'll construct a list of variations that stops right at the target bucket value - int bucketValue = (int)(VariationOrRollout.bucketUser(user, flagKey, "key", salt) * 100000); + int bucketValue = (int)(EvaluatorBucketing.bucketUser(user, flagKey, UserAttribute.KEY, salt) * 100000); List variations = Arrays.asList(new WeightedVariation(0, bucketValue)); - VariationOrRollout vr = new VariationOrRollout(null, new VariationOrRollout.Rollout(variations, null)); + VariationOrRollout vr = new VariationOrRollout(null, new Rollout(variations, null)); - Integer resultVariation = vr.variationIndexForUser(user, flagKey, salt); + Integer resultVariation = EvaluatorBucketing.variationIndexForUser(vr, user, flagKey, salt); assertEquals(Integer.valueOf(0), resultVariation); } @@ -59,8 +63,8 @@ public void canBucketByIntAttributeSameAsString() { .custom("stringattr", "33333") .custom("intattr", 33333) .build(); - float resultForString = VariationOrRollout.bucketUser(user, "key", "stringattr", "salt"); - float resultForInt = VariationOrRollout.bucketUser(user, "key", "intattr", "salt"); + float resultForString = EvaluatorBucketing.bucketUser(user, "key", UserAttribute.forName("stringattr"), "salt"); + float resultForInt = EvaluatorBucketing.bucketUser(user, "key", UserAttribute.forName("intattr"), "salt"); assertEquals(resultForString, resultForInt, Float.MIN_VALUE); } @@ -69,7 +73,7 @@ public void cannotBucketByFloatAttribute() { LDUser user = new LDUser.Builder("key") .custom("floatattr", 33.5f) .build(); - float result = VariationOrRollout.bucketUser(user, "key", "floatattr", "salt"); + float result = EvaluatorBucketing.bucketUser(user, "key", UserAttribute.forName("floatattr"), "salt"); assertEquals(0f, result, Float.MIN_VALUE); } @@ -78,7 +82,7 @@ public void cannotBucketByBooleanAttribute() { LDUser user = new LDUser.Builder("key") .custom("boolattr", true) .build(); - float result = VariationOrRollout.bucketUser(user, "key", "boolattr", "salt"); + float result = EvaluatorBucketing.bucketUser(user, "key", UserAttribute.forName("boolattr"), "salt"); assertEquals(0f, result, Float.MIN_VALUE); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorClauseTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorClauseTest.java new file mode 100644 index 000000000..38b7e8454 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorClauseTest.java @@ -0,0 +1,135 @@ +package com.launchdarkly.sdk.server; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; + +import org.junit.Test; + +import static com.launchdarkly.sdk.EvaluationDetail.fromValue; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_EVALUATOR; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.evaluatorBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.booleanFlagWithClauses; +import static com.launchdarkly.sdk.server.ModelBuilders.clause; +import static com.launchdarkly.sdk.server.ModelBuilders.fallthroughVariation; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@SuppressWarnings("javadoc") +public class EvaluatorClauseTest { + @Test + public void clauseCanMatchBuiltInAttribute() throws Exception { + DataModel.Clause clause = clause(UserAttribute.NAME, DataModel.Operator.in, LDValue.of("Bob")); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + LDUser user = new LDUser.Builder("key").name("Bob").build(); + + assertEquals(LDValue.of(true), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); + } + + @Test + public void clauseCanMatchCustomAttribute() throws Exception { + DataModel.Clause clause = clause(UserAttribute.forName("legs"), DataModel.Operator.in, LDValue.of(4)); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + LDUser user = new LDUser.Builder("key").custom("legs", 4).build(); + + assertEquals(LDValue.of(true), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); + } + + @Test + public void clauseReturnsFalseForMissingAttribute() throws Exception { + DataModel.Clause clause = clause(UserAttribute.forName("legs"), DataModel.Operator.in, LDValue.of(4)); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + LDUser user = new LDUser.Builder("key").name("Bob").build(); + + assertEquals(LDValue.of(false), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); + } + + @Test + public void clauseCanBeNegated() throws Exception { + DataModel.Clause clause = clause(UserAttribute.NAME, DataModel.Operator.in, true, LDValue.of("Bob")); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + LDUser user = new LDUser.Builder("key").name("Bob").build(); + + assertEquals(LDValue.of(false), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); + } + + @Test + public void clauseWithUnsupportedOperatorStringIsUnmarshalledWithNullOperator() throws Exception { + // This just verifies that GSON will give us a null in this case instead of throwing an exception, + // so we fail as gracefully as possible if a new operator type has been added in the application + // and the SDK hasn't been upgraded yet. + String badClauseJson = "{\"attribute\":\"name\",\"operator\":\"doesSomethingUnsupported\",\"values\":[\"x\"]}"; + Gson gson = new Gson(); + DataModel.Clause clause = gson.fromJson(badClauseJson, DataModel.Clause.class); + assertNotNull(clause); + + JsonElement json = gson.toJsonTree(clause); + String expectedJson = "{\"attribute\":\"name\",\"values\":[\"x\"],\"negate\":false}"; + assertEquals(gson.fromJson(expectedJson, JsonElement.class), json); + } + + @Test + public void clauseWithNullOperatorDoesNotMatch() throws Exception { + DataModel.Clause badClause = clause(UserAttribute.NAME, null, LDValue.of("Bob")); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", badClause); + LDUser user = new LDUser.Builder("key").name("Bob").build(); + + assertEquals(LDValue.of(false), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); + } + + @Test + public void clauseWithNullOperatorDoesNotStopSubsequentRuleFromMatching() throws Exception { + DataModel.Clause badClause = clause(UserAttribute.NAME, null, LDValue.of("Bob")); + DataModel.Rule badRule = ruleBuilder().id("rule1").clauses(badClause).variation(1).build(); + DataModel.Clause goodClause = clause(UserAttribute.NAME, DataModel.Operator.in, LDValue.of("Bob")); + DataModel.Rule goodRule = ruleBuilder().id("rule2").clauses(goodClause).variation(1).build(); + DataModel.FeatureFlag f = flagBuilder("feature") + .on(true) + .rules(badRule, goodRule) + .fallthrough(fallthroughVariation(0)) + .offVariation(0) + .variations(LDValue.of(false), LDValue.of(true)) + .build(); + LDUser user = new LDUser.Builder("key").name("Bob").build(); + + EvaluationDetail details = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails(); + assertEquals(fromValue(LDValue.of(true), 1, EvaluationReason.ruleMatch(1, "rule2")), details); + } + + @Test + public void testSegmentMatchClauseRetrievesSegmentFromStore() throws Exception { + DataModel.Segment segment = segmentBuilder("segkey") + .included("foo") + .version(1) + .build(); + Evaluator e = evaluatorBuilder().withStoredSegments(segment).build(); + + DataModel.FeatureFlag flag = segmentMatchBooleanFlag("segkey"); + LDUser user = new LDUser.Builder("foo").build(); + + Evaluator.EvalResult result = e.evaluate(flag, user, EventFactory.DEFAULT); + assertEquals(LDValue.of(true), result.getDetails().getValue()); + } + + @Test + public void testSegmentMatchClauseFallsThroughIfSegmentNotFound() throws Exception { + DataModel.FeatureFlag flag = segmentMatchBooleanFlag("segkey"); + LDUser user = new LDUser.Builder("foo").build(); + + Evaluator e = evaluatorBuilder().withNonexistentSegment("segkey").build(); + Evaluator.EvalResult result = e.evaluate(flag, user, EventFactory.DEFAULT); + assertEquals(LDValue.of(false), result.getDetails().getValue()); + } + + private DataModel.FeatureFlag segmentMatchBooleanFlag(String segmentKey) { + DataModel.Clause clause = clause(null, DataModel.Operator.segmentMatch, LDValue.of(segmentKey)); + return booleanFlagWithClauses("flag", clause); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsParameterizedTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsParameterizedTest.java new file mode 100644 index 000000000..240f70c27 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsParameterizedTest.java @@ -0,0 +1,134 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.EvaluatorOperators; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.util.Arrays; + +import static org.junit.Assert.assertEquals; + +@SuppressWarnings("javadoc") +@RunWith(Parameterized.class) +public class EvaluatorOperatorsParameterizedTest { + private static LDValue dateStr1 = LDValue.of("2017-12-06T00:00:00.000-07:00"); + private static LDValue dateStr2 = LDValue.of("2017-12-06T00:01:01.000-07:00"); + private static LDValue dateStrUtc1 = LDValue.of("2017-12-06T00:00:00.000Z"); + private static LDValue dateStrUtc2 = LDValue.of("2017-12-06T00:01:01.000Z"); + private static LDValue dateMs1 = LDValue.of(10000000); + private static LDValue dateMs2 = LDValue.of(10000001); + private static LDValue invalidDate = LDValue.of("hey what's this?"); + private static LDValue invalidVer = LDValue.of("xbad%ver"); + + private final DataModel.Operator op; + private final LDValue aValue; + private final LDValue bValue; + private final boolean shouldBe; + + public EvaluatorOperatorsParameterizedTest(DataModel.Operator op, LDValue aValue, LDValue bValue, boolean shouldBe) { + this.op = op; + this.aValue = aValue; + this.bValue = bValue; + this.shouldBe = shouldBe; + } + + @Parameterized.Parameters(name = "{1} {0} {2} should be {3}") + public static Iterable data() { + return Arrays.asList(new Object[][] { + // numeric comparisons + { DataModel.Operator.in, LDValue.of(99), LDValue.of(99), true }, + { DataModel.Operator.in, LDValue.of(99.0001), LDValue.of(99.0001), true }, + { DataModel.Operator.in, LDValue.of(99), LDValue.of(99.0001), false }, + { DataModel.Operator.in, LDValue.of(99.0001), LDValue.of(99), false }, + { DataModel.Operator.lessThan, LDValue.of(99), LDValue.of(99.0001), true }, + { DataModel.Operator.lessThan, LDValue.of(99.0001), LDValue.of(99), false }, + { DataModel.Operator.lessThan, LDValue.of(99), LDValue.of(99), false }, + { DataModel.Operator.lessThanOrEqual, LDValue.of(99), LDValue.of(99.0001), true }, + { DataModel.Operator.lessThanOrEqual, LDValue.of(99.0001), LDValue.of(99), false }, + { DataModel.Operator.lessThanOrEqual, LDValue.of(99), LDValue.of(99), true }, + { DataModel.Operator.greaterThan, LDValue.of(99.0001), LDValue.of(99), true }, + { DataModel.Operator.greaterThan, LDValue.of(99), LDValue.of(99.0001), false }, + { DataModel.Operator.greaterThan, LDValue.of(99), LDValue.of(99), false }, + { DataModel.Operator.greaterThanOrEqual, LDValue.of(99.0001), LDValue.of(99), true }, + { DataModel.Operator.greaterThanOrEqual, LDValue.of(99), LDValue.of(99.0001), false }, + { DataModel.Operator.greaterThanOrEqual, LDValue.of(99), LDValue.of(99), true }, + + // string comparisons + { DataModel.Operator.in, LDValue.of("x"), LDValue.of("x"), true }, + { DataModel.Operator.in, LDValue.of("x"), LDValue.of("xyz"), false }, + { DataModel.Operator.startsWith, LDValue.of("xyz"), LDValue.of("x"), true }, + { DataModel.Operator.startsWith, LDValue.of("x"), LDValue.of("xyz"), false }, + { DataModel.Operator.endsWith, LDValue.of("xyz"), LDValue.of("z"), true }, + { DataModel.Operator.endsWith, LDValue.of("z"), LDValue.of("xyz"), false }, + { DataModel.Operator.contains, LDValue.of("xyz"), LDValue.of("y"), true }, + { DataModel.Operator.contains, LDValue.of("y"), LDValue.of("xyz"), false }, + + // mixed strings and numbers + { DataModel.Operator.in, LDValue.of("99"), LDValue.of(99), false }, + { DataModel.Operator.in, LDValue.of(99), LDValue.of("99"), false }, + { DataModel.Operator.contains, LDValue.of("99"), LDValue.of(99), false }, + { DataModel.Operator.startsWith, LDValue.of("99"), LDValue.of(99), false }, + { DataModel.Operator.endsWith, LDValue.of("99"), LDValue.of(99), false }, + { DataModel.Operator.lessThanOrEqual, LDValue.of("99"), LDValue.of(99), false }, + { DataModel.Operator.lessThanOrEqual, LDValue.of(99), LDValue.of("99"), false }, + { DataModel.Operator.greaterThanOrEqual, LDValue.of("99"), LDValue.of(99), false }, + { DataModel.Operator.greaterThanOrEqual, LDValue.of(99), LDValue.of("99"), false }, + + // regex + { DataModel.Operator.matches, LDValue.of("hello world"), LDValue.of("hello.*rld"), true }, + { DataModel.Operator.matches, LDValue.of("hello world"), LDValue.of("hello.*orl"), true }, + { DataModel.Operator.matches, LDValue.of("hello world"), LDValue.of("l+"), true }, + { DataModel.Operator.matches, LDValue.of("hello world"), LDValue.of("(world|planet)"), true }, + { DataModel.Operator.matches, LDValue.of("hello world"), LDValue.of("aloha"), false }, + + // dates + { DataModel.Operator.before, dateStr1, dateStr2, true }, + { DataModel.Operator.before, dateStrUtc1, dateStrUtc2, true }, + { DataModel.Operator.before, dateMs1, dateMs2, true }, + { DataModel.Operator.before, dateStr2, dateStr1, false }, + { DataModel.Operator.before, dateStrUtc2, dateStrUtc1, false }, + { DataModel.Operator.before, dateMs2, dateMs1, false }, + { DataModel.Operator.before, dateStr1, dateStr1, false }, + { DataModel.Operator.before, dateMs1, dateMs1, false }, + { DataModel.Operator.before, dateStr1, invalidDate, false }, + { DataModel.Operator.after, dateStr1, dateStr2, false }, + { DataModel.Operator.after, dateStrUtc1, dateStrUtc2, false }, + { DataModel.Operator.after, dateMs1, dateMs2, false }, + { DataModel.Operator.after, dateStr2, dateStr1, true }, + { DataModel.Operator.after, dateStrUtc2, dateStrUtc1, true }, + { DataModel.Operator.after, dateMs2, dateMs1, true }, + { DataModel.Operator.after, dateStr1, dateStr1, false }, + { DataModel.Operator.after, dateMs1, dateMs1, false }, + { DataModel.Operator.after, dateStr1, invalidDate, false }, + + // semver + { DataModel.Operator.semVerEqual, LDValue.of("2.0.1"), LDValue.of("2.0.1"), true }, + { DataModel.Operator.semVerEqual, LDValue.of("2.0"), LDValue.of("2.0.0"), true }, + { DataModel.Operator.semVerEqual, LDValue.of("2"), LDValue.of("2.0.0"), true }, + { DataModel.Operator.semVerEqual, LDValue.of("2-rc1"), LDValue.of("2.0.0-rc1"), true }, + { DataModel.Operator.semVerEqual, LDValue.of("2+build2"), LDValue.of("2.0.0+build2"), true }, + { DataModel.Operator.semVerLessThan, LDValue.of("2.0.0"), LDValue.of("2.0.1"), true }, + { DataModel.Operator.semVerLessThan, LDValue.of("2.0"), LDValue.of("2.0.1"), true }, + { DataModel.Operator.semVerLessThan, LDValue.of("2.0.1"), LDValue.of("2.0.0"), false }, + { DataModel.Operator.semVerLessThan, LDValue.of("2.0.1"), LDValue.of("2.0"), false }, + { DataModel.Operator.semVerLessThan, LDValue.of("2.0.0-rc"), LDValue.of("2.0.0"), true }, + { DataModel.Operator.semVerLessThan, LDValue.of("2.0.0-rc"), LDValue.of("2.0.0-rc.beta"), true }, + { DataModel.Operator.semVerGreaterThan, LDValue.of("2.0.1"), LDValue.of("2.0.0"), true }, + { DataModel.Operator.semVerGreaterThan, LDValue.of("2.0.1"), LDValue.of("2.0"), true }, + { DataModel.Operator.semVerGreaterThan, LDValue.of("2.0.0"), LDValue.of("2.0.1"), false }, + { DataModel.Operator.semVerGreaterThan, LDValue.of("2.0"), LDValue.of("2.0.1"), false }, + { DataModel.Operator.semVerGreaterThan, LDValue.of("2.0.0-rc.1"), LDValue.of("2.0.0-rc.0"), true }, + { DataModel.Operator.semVerLessThan, LDValue.of("2.0.1"), invalidVer, false }, + { DataModel.Operator.semVerGreaterThan, LDValue.of("2.0.1"), invalidVer, false } + }); + } + + @Test + public void parameterizedTestComparison() { + assertEquals(shouldBe, EvaluatorOperators.apply(op, aValue, bValue)); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsTest.java new file mode 100644 index 000000000..5a94e7b74 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsTest.java @@ -0,0 +1,21 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.EvaluatorOperators; + +import org.junit.Test; + +import java.util.regex.PatternSyntaxException; + +import static org.junit.Assert.assertFalse; + +// Any special-case tests that can't be handled by EvaluatorOperatorsParameterizedTest. +@SuppressWarnings("javadoc") +public class EvaluatorOperatorsTest { + // This is probably not desired behavior, but it is the current behavior + @Test(expected = PatternSyntaxException.class) + public void testInvalidRegexThrowsException() { + assertFalse(EvaluatorOperators.apply(DataModel.Operator.matches, LDValue.of("hello world"), LDValue.of("***not a regex"))); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorRuleTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorRuleTest.java new file mode 100644 index 000000000..b15a21594 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorRuleTest.java @@ -0,0 +1,101 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; + +import org.junit.Test; + +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_EVALUATOR; +import static com.launchdarkly.sdk.server.ModelBuilders.clause; +import static com.launchdarkly.sdk.server.ModelBuilders.emptyRollout; +import static com.launchdarkly.sdk.server.ModelBuilders.fallthroughVariation; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.emptyIterable; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; + +@SuppressWarnings("javadoc") +public class EvaluatorRuleTest { + @Test + public void ruleMatchReasonInstanceIsReusedForSameRule() { + DataModel.Clause clause0 = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("wrongkey")); + DataModel.Clause clause1 = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Rule rule0 = ruleBuilder().id("ruleid0").clauses(clause0).variation(2).build(); + DataModel.Rule rule1 = ruleBuilder().id("ruleid1").clauses(clause1).variation(2).build(); + DataModel.FeatureFlag f = featureFlagWithRules("feature", rule0, rule1); + LDUser user = new LDUser.Builder("userkey").build(); + LDUser otherUser = new LDUser.Builder("wrongkey").build(); + + Evaluator.EvalResult sameResult0 = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + Evaluator.EvalResult sameResult1 = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + Evaluator.EvalResult otherResult = BASE_EVALUATOR.evaluate(f, otherUser, EventFactory.DEFAULT); + + assertEquals(EvaluationReason.ruleMatch(1, "ruleid1"), sameResult0.getDetails().getReason()); + assertSame(sameResult0.getDetails().getReason(), sameResult1.getDetails().getReason()); + + assertEquals(EvaluationReason.ruleMatch(0, "ruleid0"), otherResult.getDetails().getReason()); + } + + @Test + public void ruleWithTooHighVariationReturnsMalformedFlagError() { + DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).variation(999).build(); + DataModel.FeatureFlag f = featureFlagWithRules("feature", rule); + LDUser user = new LDUser.Builder("userkey").build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void ruleWithNegativeVariationReturnsMalformedFlagError() { + DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).variation(-1).build(); + DataModel.FeatureFlag f = featureFlagWithRules("feature", rule); + LDUser user = new LDUser.Builder("userkey").build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void ruleWithNoVariationOrRolloutReturnsMalformedFlagError() { + DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).build(); + DataModel.FeatureFlag f = featureFlagWithRules("feature", rule); + LDUser user = new LDUser.Builder("userkey").build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void ruleWithRolloutWithEmptyVariationsListReturnsMalformedFlagError() { + DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).rollout(emptyRollout()).build(); + DataModel.FeatureFlag f = featureFlagWithRules("feature", rule); + LDUser user = new LDUser.Builder("userkey").build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + private DataModel.FeatureFlag featureFlagWithRules(String flagKey, DataModel.Rule... rules) { + return flagBuilder(flagKey) + .on(true) + .rules(rules) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorSegmentMatchTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorSegmentMatchTest.java new file mode 100644 index 000000000..90b02a9bc --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorSegmentMatchTest.java @@ -0,0 +1,119 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; + +import org.junit.Test; + +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.evaluatorBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.booleanFlagWithClauses; +import static com.launchdarkly.sdk.server.ModelBuilders.clause; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentRuleBuilder; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +@SuppressWarnings("javadoc") +public class EvaluatorSegmentMatchTest { + + private int maxWeight = 100000; + + @Test + public void explicitIncludeUser() { + DataModel.Segment s = segmentBuilder("test") + .included("foo") + .salt("abcdef") + .version(1) + .build(); + LDUser u = new LDUser.Builder("foo").build(); + + assertTrue(segmentMatchesUser(s, u)); + } + + @Test + public void explicitExcludeUser() { + DataModel.Segment s = segmentBuilder("test") + .excluded("foo") + .salt("abcdef") + .version(1) + .build(); + LDUser u = new LDUser.Builder("foo").build(); + + assertFalse(segmentMatchesUser(s, u)); + } + + @Test + public void explicitIncludeHasPrecedence() { + DataModel.Segment s = segmentBuilder("test") + .included("foo") + .excluded("foo") + .salt("abcdef") + .version(1) + .build(); + LDUser u = new LDUser.Builder("foo").build(); + + assertTrue(segmentMatchesUser(s, u)); + } + + @Test + public void matchingRuleWithFullRollout() { + DataModel.Clause clause = clause(UserAttribute.EMAIL, DataModel.Operator.in, LDValue.of("test@example.com")); + DataModel.SegmentRule rule = segmentRuleBuilder().clauses(clause).weight(maxWeight).build(); + DataModel.Segment s = segmentBuilder("test") + .salt("abcdef") + .rules(rule) + .build(); + LDUser u = new LDUser.Builder("foo").email("test@example.com").build(); + + assertTrue(segmentMatchesUser(s, u)); + } + + @Test + public void matchingRuleWithZeroRollout() { + DataModel.Clause clause = clause(UserAttribute.EMAIL, DataModel.Operator.in, LDValue.of("test@example.com")); + DataModel.SegmentRule rule = segmentRuleBuilder().clauses(clause).weight(0).build(); + DataModel.Segment s = segmentBuilder("test") + .salt("abcdef") + .rules(rule) + .build(); + LDUser u = new LDUser.Builder("foo").email("test@example.com").build(); + + assertFalse(segmentMatchesUser(s, u)); + } + + @Test + public void matchingRuleWithMultipleClauses() { + DataModel.Clause clause1 = clause(UserAttribute.EMAIL, DataModel.Operator.in, LDValue.of("test@example.com")); + DataModel.Clause clause2 = clause(UserAttribute.NAME, DataModel.Operator.in, LDValue.of("bob")); + DataModel.SegmentRule rule = segmentRuleBuilder().clauses(clause1, clause2).build(); + DataModel.Segment s = segmentBuilder("test") + .salt("abcdef") + .rules(rule) + .build(); + LDUser u = new LDUser.Builder("foo").email("test@example.com").name("bob").build(); + + assertTrue(segmentMatchesUser(s, u)); + } + + @Test + public void nonMatchingRuleWithMultipleClauses() { + DataModel.Clause clause1 = clause(UserAttribute.EMAIL, DataModel.Operator.in, LDValue.of("test@example.com")); + DataModel.Clause clause2 = clause(UserAttribute.NAME, DataModel.Operator.in, LDValue.of("bill")); + DataModel.SegmentRule rule = segmentRuleBuilder().clauses(clause1, clause2).build(); + DataModel.Segment s = segmentBuilder("test") + .salt("abcdef") + .rules(rule) + .build(); + LDUser u = new LDUser.Builder("foo").email("test@example.com").name("bob").build(); + + assertFalse(segmentMatchesUser(s, u)); + } + + private static boolean segmentMatchesUser(DataModel.Segment segment, LDUser user) { + DataModel.Clause clause = clause(null, DataModel.Operator.segmentMatch, LDValue.of(segment.getKey())); + DataModel.FeatureFlag flag = booleanFlagWithClauses("flag", clause); + Evaluator e = evaluatorBuilder().withStoredSegments(segment).build(); + return e.evaluate(flag, user, EventFactory.DEFAULT).getValue().booleanValue(); + } +} \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java new file mode 100644 index 000000000..ffdf26ed8 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java @@ -0,0 +1,365 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.Iterables; +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.interfaces.Event; + +import org.junit.Test; + +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; +import static com.launchdarkly.sdk.EvaluationDetail.fromValue; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_EVALUATOR; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.evaluatorBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.clause; +import static com.launchdarkly.sdk.server.ModelBuilders.fallthroughVariation; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite; +import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.target; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.emptyIterable; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; + +@SuppressWarnings("javadoc") +public class EvaluatorTest { + + private static LDUser BASE_USER = new LDUser.Builder("x").build(); + + @Test + public void flagReturnsOffVariationIfFlagIsOff() throws Exception { + DataModel.FeatureFlag f = flagBuilder("feature") + .on(false) + .offVariation(1) + .fallthrough(fallthroughVariation(0)) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(fromValue(LDValue.of("off"), 1, EvaluationReason.off()), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() throws Exception { + DataModel.FeatureFlag f = flagBuilder("feature") + .on(false) + .fallthrough(fallthroughVariation(0)) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(fromValue(LDValue.ofNull(), NO_VARIATION, EvaluationReason.off()), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsErrorIfFlagIsOffAndOffVariationIsTooHigh() throws Exception { + DataModel.FeatureFlag f = flagBuilder("feature") + .on(false) + .offVariation(999) + .fallthrough(fallthroughVariation(0)) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsErrorIfFlagIsOffAndOffVariationIsNegative() throws Exception { + DataModel.FeatureFlag f = flagBuilder("feature") + .on(false) + .offVariation(-1) + .fallthrough(fallthroughVariation(0)) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsFallthroughIfFlagIsOnAndThereAreNoRules() throws Exception { + DataModel.FeatureFlag f = flagBuilder("feature") + .on(true) + .offVariation(1) + .fallthrough(fallthroughVariation(0)) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(fromValue(LDValue.of("fall"), 0, EvaluationReason.fallthrough()), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsErrorIfFallthroughHasTooHighVariation() throws Exception { + DataModel.FeatureFlag f = flagBuilder("feature") + .on(true) + .offVariation(1) + .fallthrough(fallthroughVariation(999)) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsErrorIfFallthroughHasNegativeVariation() throws Exception { + DataModel.FeatureFlag f = flagBuilder("feature") + .on(true) + .offVariation(1) + .fallthrough(fallthroughVariation(-1)) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsErrorIfFallthroughHasNeitherVariationNorRollout() throws Exception { + DataModel.FeatureFlag f = flagBuilder("feature") + .on(true) + .offVariation(1) + .fallthrough(new DataModel.VariationOrRollout(null, null)) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsErrorIfFallthroughHasEmptyRolloutVariationList() throws Exception { + DataModel.FeatureFlag f = flagBuilder("feature") + .on(true) + .offVariation(1) + .fallthrough(new DataModel.VariationOrRollout(null, ModelBuilders.emptyRollout())) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { + DataModel.FeatureFlag f0 = flagBuilder("feature0") + .on(true) + .prerequisites(prerequisite("feature1", 1)) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator e = evaluatorBuilder().withNonexistentFlag("feature1").build(); + Evaluator.EvalResult result = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + + EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); + assertEquals(fromValue(LDValue.of("off"), 1, expectedReason), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsOffVariationAndEventIfPrerequisiteIsOff() throws Exception { + DataModel.FeatureFlag f0 = flagBuilder("feature0") + .on(true) + .prerequisites(prerequisite("feature1", 1)) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .version(1) + .build(); + DataModel.FeatureFlag f1 = flagBuilder("feature1") + .on(false) + .offVariation(1) + // note that even though it returns the desired variation, it is still off and therefore not a match + .fallthrough(fallthroughVariation(0)) + .variations(LDValue.of("nogo"), LDValue.of("go")) + .version(2) + .build(); + Evaluator e = evaluatorBuilder().withStoredFlags(f1).build(); + Evaluator.EvalResult result = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + + EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); + assertEquals(fromValue(LDValue.of("off"), 1, expectedReason), result.getDetails()); + + assertEquals(1, Iterables.size(result.getPrerequisiteEvents())); + Event.FeatureRequest event = Iterables.get(result.getPrerequisiteEvents(), 0); + assertEquals(f1.getKey(), event.getKey()); + assertEquals(LDValue.of("go"), event.getValue()); + assertEquals(f1.getVersion(), event.getVersion()); + assertEquals(f0.getKey(), event.getPrereqOf()); + } + + @Test + public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Exception { + DataModel.FeatureFlag f0 = flagBuilder("feature0") + .on(true) + .prerequisites(prerequisite("feature1", 1)) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .version(1) + .build(); + DataModel.FeatureFlag f1 = flagBuilder("feature1") + .on(true) + .fallthrough(fallthroughVariation(0)) + .variations(LDValue.of("nogo"), LDValue.of("go")) + .version(2) + .build(); + Evaluator e = evaluatorBuilder().withStoredFlags(f1).build(); + Evaluator.EvalResult result = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + + EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); + assertEquals(fromValue(LDValue.of("off"), 1, expectedReason), result.getDetails()); + + assertEquals(1, Iterables.size(result.getPrerequisiteEvents())); + Event.FeatureRequest event = Iterables.get(result.getPrerequisiteEvents(), 0); + assertEquals(f1.getKey(), event.getKey()); + assertEquals(LDValue.of("nogo"), event.getValue()); + assertEquals(f1.getVersion(), event.getVersion()); + assertEquals(f0.getKey(), event.getPrereqOf()); + } + + @Test + public void prerequisiteFailedReasonInstanceIsReusedForSamePrerequisite() throws Exception { + DataModel.FeatureFlag f0 = flagBuilder("feature0") + .on(true) + .prerequisites(prerequisite("feature1", 1)) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator e = evaluatorBuilder().withNonexistentFlag("feature1").build(); + Evaluator.EvalResult result0 = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + Evaluator.EvalResult result1 = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + + EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); + assertEquals(expectedReason, result0.getDetails().getReason()); + assertSame(result0.getDetails().getReason(), result1.getDetails().getReason()); + } + + @Test + public void flagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAreNoRules() throws Exception { + DataModel.FeatureFlag f0 = flagBuilder("feature0") + .on(true) + .prerequisites(prerequisite("feature1", 1)) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .version(1) + .build(); + DataModel.FeatureFlag f1 = flagBuilder("feature1") + .on(true) + .fallthrough(fallthroughVariation(1)) + .variations(LDValue.of("nogo"), LDValue.of("go")) + .version(2) + .build(); + Evaluator e = evaluatorBuilder().withStoredFlags(f1).build(); + Evaluator.EvalResult result = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + + assertEquals(fromValue(LDValue.of("fall"), 0, EvaluationReason.fallthrough()), result.getDetails()); + + assertEquals(1, Iterables.size(result.getPrerequisiteEvents())); + Event.FeatureRequest event = Iterables.get(result.getPrerequisiteEvents(), 0); + assertEquals(f1.getKey(), event.getKey()); + assertEquals(LDValue.of("go"), event.getValue()); + assertEquals(f1.getVersion(), event.getVersion()); + assertEquals(f0.getKey(), event.getPrereqOf()); + } + + @Test + public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exception { + DataModel.FeatureFlag f0 = flagBuilder("feature0") + .on(true) + .prerequisites(prerequisite("feature1", 1)) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .version(1) + .build(); + DataModel.FeatureFlag f1 = flagBuilder("feature1") + .on(true) + .prerequisites(prerequisite("feature2", 1)) + .fallthrough(fallthroughVariation(1)) + .variations(LDValue.of("nogo"), LDValue.of("go")) + .version(2) + .build(); + DataModel.FeatureFlag f2 = flagBuilder("feature2") + .on(true) + .fallthrough(fallthroughVariation(1)) + .variations(LDValue.of("nogo"), LDValue.of("go")) + .version(3) + .build(); + Evaluator e = evaluatorBuilder().withStoredFlags(f1, f2).build(); + Evaluator.EvalResult result = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + + assertEquals(fromValue(LDValue.of("fall"), 0, EvaluationReason.fallthrough()), result.getDetails()); + assertEquals(2, Iterables.size(result.getPrerequisiteEvents())); + + Event.FeatureRequest event0 = Iterables.get(result.getPrerequisiteEvents(), 0); + assertEquals(f2.getKey(), event0.getKey()); + assertEquals(LDValue.of("go"), event0.getValue()); + assertEquals(f2.getVersion(), event0.getVersion()); + assertEquals(f1.getKey(), event0.getPrereqOf()); + + Event.FeatureRequest event1 = Iterables.get(result.getPrerequisiteEvents(), 1); + assertEquals(f1.getKey(), event1.getKey()); + assertEquals(LDValue.of("go"), event1.getValue()); + assertEquals(f1.getVersion(), event1.getVersion()); + assertEquals(f0.getKey(), event1.getPrereqOf()); + } + + @Test + public void flagMatchesUserFromTargets() throws Exception { + DataModel.FeatureFlag f = flagBuilder("feature") + .on(true) + .targets(target(2, "whoever", "userkey")) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + LDUser user = new LDUser.Builder("userkey").build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + + assertEquals(fromValue(LDValue.of("on"), 2, EvaluationReason.targetMatch()), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagMatchesUserFromRules() { + DataModel.Clause clause0 = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("wrongkey")); + DataModel.Clause clause1 = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Rule rule0 = ruleBuilder().id("ruleid0").clauses(clause0).variation(2).build(); + DataModel.Rule rule1 = ruleBuilder().id("ruleid1").clauses(clause1).variation(2).build(); + DataModel.FeatureFlag f = featureFlagWithRules("feature", rule0, rule1); + LDUser user = new LDUser.Builder("userkey").build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + + assertEquals(fromValue(LDValue.of("on"), 2, EvaluationReason.ruleMatch(1, "ruleid1")), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + private DataModel.FeatureFlag featureFlagWithRules(String flagKey, DataModel.Rule... rules) { + return flagBuilder(flagKey) + .on(true) + .rules(rules) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java new file mode 100644 index 000000000..dcb9372f4 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java @@ -0,0 +1,105 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.Evaluator; + +@SuppressWarnings("javadoc") +public abstract class EvaluatorTestUtil { + public static Evaluator BASE_EVALUATOR = evaluatorBuilder().build(); + + public static EvaluatorBuilder evaluatorBuilder() { + return new EvaluatorBuilder(); + } + + public static class EvaluatorBuilder { + private Evaluator.Getters getters; + + EvaluatorBuilder() { + getters = new Evaluator.Getters() { + public DataModel.FeatureFlag getFlag(String key) { + throw new IllegalStateException("Evaluator unexpectedly tried to query flag: " + key); + } + + public DataModel.Segment getSegment(String key) { + throw new IllegalStateException("Evaluator unexpectedly tried to query segment: " + key); + } + }; + } + + public Evaluator build() { + return new Evaluator(getters); + } + + public EvaluatorBuilder withStoredFlags(final DataModel.FeatureFlag... flags) { + final Evaluator.Getters baseGetters = getters; + getters = new Evaluator.Getters() { + public DataModel.FeatureFlag getFlag(String key) { + for (DataModel.FeatureFlag f: flags) { + if (f.getKey().equals(key)) { + return f; + } + } + return baseGetters.getFlag(key); + } + + public DataModel.Segment getSegment(String key) { + return baseGetters.getSegment(key); + } + }; + return this; + } + + public EvaluatorBuilder withNonexistentFlag(final String nonexistentFlagKey) { + final Evaluator.Getters baseGetters = getters; + getters = new Evaluator.Getters() { + public DataModel.FeatureFlag getFlag(String key) { + if (key.equals(nonexistentFlagKey)) { + return null; + } + return baseGetters.getFlag(key); + } + + public DataModel.Segment getSegment(String key) { + return baseGetters.getSegment(key); + } + }; + return this; + } + + public EvaluatorBuilder withStoredSegments(final DataModel.Segment... segments) { + final Evaluator.Getters baseGetters = getters; + getters = new Evaluator.Getters() { + public DataModel.FeatureFlag getFlag(String key) { + return baseGetters.getFlag(key); + } + + public DataModel.Segment getSegment(String key) { + for (DataModel.Segment s: segments) { + if (s.getKey().equals(key)) { + return s; + } + } + return baseGetters.getSegment(key); + } + }; + return this; + } + + public EvaluatorBuilder withNonexistentSegment(final String nonexistentSegmentKey) { + final Evaluator.Getters baseGetters = getters; + getters = new Evaluator.Getters() { + public DataModel.FeatureFlag getFlag(String key) { + return baseGetters.getFlag(key); + } + + public DataModel.Segment getSegment(String key) { + if (key.equals(nonexistentSegmentKey)) { + return null; + } + return baseGetters.getSegment(key); + } + }; + return this; + } + } +} diff --git a/src/test/java/com/launchdarkly/client/EventOutputTest.java b/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java similarity index 85% rename from src/test/java/com/launchdarkly/client/EventOutputTest.java rename to src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java index 232258e51..c254855e2 100644 --- a/src/test/java/com/launchdarkly/client/EventOutputTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java @@ -1,11 +1,15 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.collect.ImmutableSet; import com.google.gson.Gson; -import com.launchdarkly.client.Event.FeatureRequest; -import com.launchdarkly.client.EventSummarizer.EventSummary; -import com.launchdarkly.client.value.LDValue; -import com.launchdarkly.client.value.ObjectBuilder; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.ObjectBuilder; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.EventSummarizer.EventSummary; +import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.Event.FeatureRequest; import org.junit.Test; @@ -13,6 +17,10 @@ import java.io.StringWriter; import java.util.Set; +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.TestComponents.defaultEventsConfig; +import static com.launchdarkly.sdk.server.TestComponents.makeEventsConfig; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; @@ -54,30 +62,30 @@ public class EventOutputTest { @Test public void allUserAttributesAreSerialized() throws Exception { testInlineUserSerialization(userBuilderWithAllAttributes.build(), userJsonWithAllAttributes, - TestUtil.defaultEventsConfig()); + defaultEventsConfig()); } @Test public void unsetUserAttributesAreNotSerialized() throws Exception { LDUser user = new LDUser("userkey"); LDValue userJson = parseValue("{\"key\":\"userkey\"}"); - testInlineUserSerialization(user, userJson, TestUtil.defaultEventsConfig()); + testInlineUserSerialization(user, userJson, defaultEventsConfig()); } @Test public void userKeyIsSetInsteadOfUserWhenNotInlined() throws Exception { LDUser user = new LDUser.Builder("userkey").name("me").build(); LDValue userJson = parseValue("{\"key\":\"userkey\",\"name\":\"me\"}"); - EventOutputFormatter f = new EventOutputFormatter(TestUtil.defaultEventsConfig()); + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); Event.FeatureRequest featureEvent = EventFactory.DEFAULT.newFeatureRequestEvent( - new FeatureFlagBuilder("flag").build(), + flagBuilder("flag").build(), user, - new EvaluationDetail(EvaluationReason.off(), null, LDValue.ofNull()), + new Evaluator.EvalResult(LDValue.ofNull(), NO_VARIATION, EvaluationReason.off()), LDValue.ofNull()); LDValue outputEvent = getSingleOutputEvent(f, featureEvent); assertEquals(LDValue.ofNull(), outputEvent.get("user")); - assertEquals(user.getKey(), outputEvent.get("userKey")); + assertEquals(LDValue.of(user.getKey()), outputEvent.get("userKey")); Event.Identify identifyEvent = EventFactory.DEFAULT.newIdentifyEvent(user); outputEvent = getSingleOutputEvent(f, identifyEvent); @@ -87,7 +95,7 @@ public void userKeyIsSetInsteadOfUserWhenNotInlined() throws Exception { Event.Custom customEvent = EventFactory.DEFAULT.newCustomEvent("custom", user, LDValue.ofNull(), null); outputEvent = getSingleOutputEvent(f, customEvent); assertEquals(LDValue.ofNull(), outputEvent.get("user")); - assertEquals(user.getKey(), outputEvent.get("userKey")); + assertEquals(LDValue.of(user.getKey()), outputEvent.get("userKey")); Event.Index indexEvent = new Event.Index(0, user); outputEvent = getSingleOutputEvent(f, indexEvent); @@ -98,7 +106,7 @@ public void userKeyIsSetInsteadOfUserWhenNotInlined() throws Exception { @Test public void allAttributesPrivateMakesAttributesPrivate() throws Exception { LDUser user = userBuilderWithAllAttributes.build(); - EventsConfiguration config = TestUtil.makeEventsConfig(true, false, null); + EventsConfiguration config = makeEventsConfig(true, false, null); testPrivateAttributes(config, user, attributesThatCanBePrivate); } @@ -106,7 +114,7 @@ public void allAttributesPrivateMakesAttributesPrivate() throws Exception { public void globalPrivateAttributeNamesMakeAttributesPrivate() throws Exception { LDUser user = userBuilderWithAllAttributes.build(); for (String attrName: attributesThatCanBePrivate) { - EventsConfiguration config = TestUtil.makeEventsConfig(false, false, ImmutableSet.of(attrName)); + EventsConfiguration config = makeEventsConfig(false, false, ImmutableSet.of(UserAttribute.forName(attrName))); testPrivateAttributes(config, user, attrName); } } @@ -114,7 +122,7 @@ public void globalPrivateAttributeNamesMakeAttributesPrivate() throws Exception @Test public void perUserPrivateAttributesMakeAttributePrivate() throws Exception { LDUser baseUser = userBuilderWithAllAttributes.build(); - EventsConfiguration config = TestUtil.defaultEventsConfig(); + EventsConfiguration config = defaultEventsConfig(); testPrivateAttributes(config, new LDUser.Builder(baseUser).privateAvatar("x").build(), "avatar"); testPrivateAttributes(config, new LDUser.Builder(baseUser).privateCountry("US").build(), "country"); @@ -162,12 +170,12 @@ private void testPrivateAttributes(EventsConfiguration config, LDUser user, Stri public void featureEventIsSerialized() throws Exception { EventFactory factory = eventFactoryWithTimestamp(100000, false); EventFactory factoryWithReason = eventFactoryWithTimestamp(100000, true); - FeatureFlag flag = new FeatureFlagBuilder("flag").version(11).build(); + DataModel.FeatureFlag flag = flagBuilder("flag").version(11).build(); LDUser user = new LDUser.Builder("userkey").name("me").build(); - EventOutputFormatter f = new EventOutputFormatter(TestUtil.defaultEventsConfig()); + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); FeatureRequest feWithVariation = factory.newFeatureRequestEvent(flag, user, - new EvaluationDetail(EvaluationReason.off(), 1, LDValue.of("flagvalue")), + new Evaluator.EvalResult(LDValue.of("flagvalue"), 1, EvaluationReason.off()), LDValue.of("defaultvalue")); LDValue feJson1 = parseValue("{" + "\"kind\":\"feature\"," + @@ -182,7 +190,7 @@ public void featureEventIsSerialized() throws Exception { assertEquals(feJson1, getSingleOutputEvent(f, feWithVariation)); FeatureRequest feWithoutVariationOrDefault = factory.newFeatureRequestEvent(flag, user, - new EvaluationDetail(EvaluationReason.off(), null, LDValue.of("flagvalue")), + new Evaluator.EvalResult(LDValue.of("flagvalue"), NO_VARIATION, EvaluationReason.off()), LDValue.ofNull()); LDValue feJson2 = parseValue("{" + "\"kind\":\"feature\"," + @@ -195,7 +203,7 @@ public void featureEventIsSerialized() throws Exception { assertEquals(feJson2, getSingleOutputEvent(f, feWithoutVariationOrDefault)); FeatureRequest feWithReason = factoryWithReason.newFeatureRequestEvent(flag, user, - new EvaluationDetail(EvaluationReason.ruleMatch(1, "id"), 1, LDValue.of("flagvalue")), + new Evaluator.EvalResult(LDValue.of("flagvalue"), 1, EvaluationReason.ruleMatch(1, "id")), LDValue.of("defaultvalue")); LDValue feJson3 = parseValue("{" + "\"kind\":\"feature\"," + @@ -241,7 +249,7 @@ public void featureEventIsSerialized() throws Exception { public void identifyEventIsSerialized() throws IOException { EventFactory factory = eventFactoryWithTimestamp(100000, false); LDUser user = new LDUser.Builder("userkey").name("me").build(); - EventOutputFormatter f = new EventOutputFormatter(TestUtil.defaultEventsConfig()); + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); Event.Identify ie = factory.newIdentifyEvent(user); LDValue ieJson = parseValue("{" + @@ -257,7 +265,7 @@ public void identifyEventIsSerialized() throws IOException { public void customEventIsSerialized() throws IOException { EventFactory factory = eventFactoryWithTimestamp(100000, false); LDUser user = new LDUser.Builder("userkey").name("me").build(); - EventOutputFormatter f = new EventOutputFormatter(TestUtil.defaultEventsConfig()); + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); Event.Custom ceWithoutData = factory.newCustomEvent("customkey", user, LDValue.ofNull(), null); LDValue ceJson1 = parseValue("{" + @@ -316,14 +324,14 @@ public void summaryEventIsSerialized() throws Exception { summary.incrementCounter(new String("first"), 1, 12, LDValue.of("value1a"), LDValue.of("default1")); summary.incrementCounter(new String("second"), 2, 21, LDValue.of("value2b"), LDValue.of("default2")); - summary.incrementCounter(new String("second"), null, 21, LDValue.of("default2"), LDValue.of("default2")); // flag exists (has version), but eval failed (no variation) + summary.incrementCounter(new String("second"), -1, 21, LDValue.of("default2"), LDValue.of("default2")); // flag exists (has version), but eval failed (no variation) - summary.incrementCounter(new String("third"), null, null, LDValue.of("default3"), LDValue.of("default3")); // flag doesn't exist (no version) + summary.incrementCounter(new String("third"), -1, -1, LDValue.of("default3"), LDValue.of("default3")); // flag doesn't exist (no version) summary.noteTimestamp(1000); summary.noteTimestamp(1002); - EventOutputFormatter f = new EventOutputFormatter(TestUtil.defaultEventsConfig()); + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); StringWriter w = new StringWriter(); int count = f.writeOutputEvents(new Event[0], summary, w); assertEquals(1, count); @@ -382,13 +390,13 @@ private LDValue getSingleOutputEvent(EventOutputFormatter f, Event event) throws } private void testInlineUserSerialization(LDUser user, LDValue expectedJsonValue, EventsConfiguration baseConfig) throws IOException { - EventsConfiguration config = TestUtil.makeEventsConfig(baseConfig.allAttributesPrivate, true, baseConfig.privateAttrNames); + EventsConfiguration config = makeEventsConfig(baseConfig.allAttributesPrivate, true, baseConfig.privateAttributes); EventOutputFormatter f = new EventOutputFormatter(config); Event.FeatureRequest featureEvent = EventFactory.DEFAULT.newFeatureRequestEvent( - new FeatureFlagBuilder("flag").build(), + flagBuilder("flag").build(), user, - new EvaluationDetail(EvaluationReason.off(), null, LDValue.ofNull()), + new Evaluator.EvalResult(LDValue.ofNull(), NO_VARIATION, EvaluationReason.off()), LDValue.ofNull()); LDValue outputEvent = getSingleOutputEvent(f, featureEvent); assertEquals(LDValue.ofNull(), outputEvent.get("userKey")); diff --git a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java b/src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java similarity index 83% rename from src/test/java/com/launchdarkly/client/EventSummarizerTest.java rename to src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java index c0e6f0aed..c8294de9d 100644 --- a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java @@ -1,18 +1,25 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.EventFactory; +import com.launchdarkly.sdk.server.EventSummarizer; +import com.launchdarkly.sdk.server.interfaces.Event; import org.junit.Test; import java.util.HashMap; import java.util.Map; -import static com.launchdarkly.client.TestUtil.js; -import static com.launchdarkly.client.TestUtil.simpleEvaluation; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.TestUtil.simpleEvaluation; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertEquals; +@SuppressWarnings("javadoc") public class EventSummarizerTest { private static final LDUser user = new LDUser.Builder("key").build(); @@ -50,7 +57,7 @@ public void summarizeEventDoesNothingForCustomEvent() { @Test public void summarizeEventSetsStartAndEndDates() { EventSummarizer es = new EventSummarizer(); - FeatureFlag flag = new FeatureFlagBuilder("key").build(); + DataModel.FeatureFlag flag = flagBuilder("key").build(); eventTimestamp = 2000; Event event1 = eventFactory.newFeatureRequestEvent(flag, user, null, null); eventTimestamp = 1000; @@ -69,8 +76,8 @@ public void summarizeEventSetsStartAndEndDates() { @Test public void summarizeEventIncrementsCounters() { EventSummarizer es = new EventSummarizer(); - FeatureFlag flag1 = new FeatureFlagBuilder("key1").version(11).build(); - FeatureFlag flag2 = new FeatureFlagBuilder("key2").version(22).build(); + DataModel.FeatureFlag flag1 = flagBuilder("key1").version(11).build(); + DataModel.FeatureFlag flag2 = flagBuilder("key2").version(22).build(); String unknownFlagKey = "badkey"; Event event1 = eventFactory.newFeatureRequestEvent(flag1, user, simpleEvaluation(1, LDValue.of("value1")), LDValue.of("default1")); @@ -95,7 +102,7 @@ public void summarizeEventIncrementsCounters() { new EventSummarizer.CounterValue(1, LDValue.of("value2"), LDValue.of("default1"))); expected.put(new EventSummarizer.CounterKey(flag2.getKey(), 1, flag2.getVersion()), new EventSummarizer.CounterValue(1, LDValue.of("value99"), LDValue.of("default2"))); - expected.put(new EventSummarizer.CounterKey(unknownFlagKey, null, null), + expected.put(new EventSummarizer.CounterKey(unknownFlagKey, -1, -1), new EventSummarizer.CounterValue(1, LDValue.of("default3"), LDValue.of("default3"))); assertThat(data.counters, equalTo(expected)); } diff --git a/src/test/java/com/launchdarkly/sdk/server/EventUserSerializationTest.java b/src/test/java/com/launchdarkly/sdk/server/EventUserSerializationTest.java new file mode 100644 index 000000000..3fcb78e39 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/EventUserSerializationTest.java @@ -0,0 +1,148 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.reflect.TypeToken; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.UserAttribute; + +import org.junit.Test; + +import java.lang.reflect.Type; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static com.launchdarkly.sdk.server.JsonHelpers.gsonInstanceForEventsSerialization; +import static com.launchdarkly.sdk.server.TestComponents.defaultEventsConfig; +import static com.launchdarkly.sdk.server.TestComponents.makeEventsConfig; +import static com.launchdarkly.sdk.server.TestUtil.TEST_GSON_INSTANCE; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +@SuppressWarnings("javadoc") +public class EventUserSerializationTest { + + @Test + public void testAllPropertiesInPrivateAttributeEncoding() { + for (Map.Entry e: getUserPropertiesJsonMap().entrySet()) { + JsonElement expected = TEST_GSON_INSTANCE.fromJson(e.getValue(), JsonElement.class); + JsonElement actual = TEST_GSON_INSTANCE.toJsonTree(e.getKey()); + assertEquals(expected, actual); + } + } + + private Map getUserPropertiesJsonMap() { + ImmutableMap.Builder builder = ImmutableMap.builder(); + builder.put(new LDUser.Builder("userkey").build(), "{\"key\":\"userkey\"}"); + builder.put(new LDUser.Builder("userkey").secondary("value").build(), + "{\"key\":\"userkey\",\"secondary\":\"value\"}"); + builder.put(new LDUser.Builder("userkey").ip("value").build(), + "{\"key\":\"userkey\",\"ip\":\"value\"}"); + builder.put(new LDUser.Builder("userkey").email("value").build(), + "{\"key\":\"userkey\",\"email\":\"value\"}"); + builder.put(new LDUser.Builder("userkey").name("value").build(), + "{\"key\":\"userkey\",\"name\":\"value\"}"); + builder.put(new LDUser.Builder("userkey").avatar("value").build(), + "{\"key\":\"userkey\",\"avatar\":\"value\"}"); + builder.put(new LDUser.Builder("userkey").firstName("value").build(), + "{\"key\":\"userkey\",\"firstName\":\"value\"}"); + builder.put(new LDUser.Builder("userkey").lastName("value").build(), + "{\"key\":\"userkey\",\"lastName\":\"value\"}"); + builder.put(new LDUser.Builder("userkey").anonymous(true).build(), + "{\"key\":\"userkey\",\"anonymous\":true}"); + builder.put(new LDUser.Builder("userkey").country("value").build(), + "{\"key\":\"userkey\",\"country\":\"value\"}"); + builder.put(new LDUser.Builder("userkey").custom("thing", "value").build(), + "{\"key\":\"userkey\",\"custom\":{\"thing\":\"value\"}}"); + return builder.build(); + } + + @Test + public void defaultJsonEncodingHasPrivateAttributeNames() { + LDUser user = new LDUser.Builder("userkey").privateName("x").build(); + String expected = "{\"key\":\"userkey\",\"name\":\"x\",\"privateAttributeNames\":[\"name\"]}"; + assertEquals(TEST_GSON_INSTANCE.fromJson(expected, JsonElement.class), TEST_GSON_INSTANCE.toJsonTree(user)); + } + + @Test + public void privateAttributeEncodingRedactsAllPrivateAttributes() { + EventsConfiguration config = makeEventsConfig(true, false, null); + LDUser user = new LDUser.Builder("userkey") + .secondary("s") + .ip("i") + .email("e") + .name("n") + .avatar("a") + .firstName("f") + .lastName("l") + .anonymous(true) + .country("USA") + .custom("thing", "value") + .build(); + Set redacted = ImmutableSet.of("secondary", "ip", "email", "name", "avatar", "firstName", "lastName", "country", "thing"); + + JsonObject o = gsonInstanceForEventsSerialization(config).toJsonTree(user).getAsJsonObject(); + assertEquals("userkey", o.get("key").getAsString()); + assertEquals(true, o.get("anonymous").getAsBoolean()); + for (String attr: redacted) { + assertNull(o.get(attr)); + } + assertNull(o.get("custom")); + assertEquals(redacted, getPrivateAttrs(o)); + } + + @Test + public void privateAttributeEncodingRedactsSpecificPerUserPrivateAttributes() { + LDUser user = new LDUser.Builder("userkey") + .email("e") + .privateName("n") + .custom("bar", 43) + .privateCustom("foo", 42) + .build(); + + JsonObject o = gsonInstanceForEventsSerialization(defaultEventsConfig()).toJsonTree(user).getAsJsonObject(); + assertEquals("e", o.get("email").getAsString()); + assertNull(o.get("name")); + assertEquals(43, o.get("custom").getAsJsonObject().get("bar").getAsInt()); + assertNull(o.get("custom").getAsJsonObject().get("foo")); + assertEquals(ImmutableSet.of("name", "foo"), getPrivateAttrs(o)); + } + + @Test + public void privateAttributeEncodingRedactsSpecificGlobalPrivateAttributes() { + EventsConfiguration config = makeEventsConfig(false, false, + ImmutableSet.of(UserAttribute.NAME, UserAttribute.forName("foo"))); + LDUser user = new LDUser.Builder("userkey") + .email("e") + .name("n") + .custom("bar", 43) + .custom("foo", 42) + .build(); + + JsonObject o = gsonInstanceForEventsSerialization(config).toJsonTree(user).getAsJsonObject(); + assertEquals("e", o.get("email").getAsString()); + assertNull(o.get("name")); + assertEquals(43, o.get("custom").getAsJsonObject().get("bar").getAsInt()); + assertNull(o.get("custom").getAsJsonObject().get("foo")); + assertEquals(ImmutableSet.of("name", "foo"), getPrivateAttrs(o)); + } + + @Test + public void privateAttributeEncodingWorksForMinimalUser() { + EventsConfiguration config = makeEventsConfig(true, false, null); + LDUser user = new LDUser("userkey"); + + JsonObject o = gsonInstanceForEventsSerialization(config).toJsonTree(user).getAsJsonObject(); + JsonObject expected = new JsonObject(); + expected.addProperty("key", "userkey"); + assertEquals(expected, o); + } + + private Set getPrivateAttrs(JsonObject o) { + Type type = new TypeToken>(){}.getType(); + return TEST_GSON_INSTANCE.>fromJson(o.get("privateAttrs"), type); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java new file mode 100644 index 000000000..d442141b6 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java @@ -0,0 +1,141 @@ +package com.launchdarkly.sdk.server; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableMap; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.json.JsonSerialization; +import com.launchdarkly.sdk.json.LDJackson; +import com.launchdarkly.sdk.json.SerializationException; + +import org.junit.Test; + +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; +import static com.launchdarkly.sdk.EvaluationReason.ErrorKind.MALFORMED_FLAG; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +@SuppressWarnings("javadoc") +public class FeatureFlagsStateTest { + @Test + public void canGetFlagValue() { + Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.of("value"), 1, EvaluationReason.off()); + DataModel.FeatureFlag flag = flagBuilder("key").build(); + FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); + + assertEquals(LDValue.of("value"), state.getFlagValue("key")); + } + + @Test + public void unknownFlagReturnsNullValue() { + FeatureFlagsState state = new FeatureFlagsState.Builder().build(); + + assertNull(state.getFlagValue("key")); + } + + @Test + public void canGetFlagReason() { + Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.of("value1"), 1, EvaluationReason.off()); + DataModel.FeatureFlag flag = flagBuilder("key").build(); + FeatureFlagsState state = new FeatureFlagsState.Builder(FlagsStateOption.WITH_REASONS) + .addFlag(flag, eval).build(); + + assertEquals(EvaluationReason.off(), state.getFlagReason("key")); + } + + @Test + public void unknownFlagReturnsNullReason() { + FeatureFlagsState state = new FeatureFlagsState.Builder().build(); + + assertNull(state.getFlagReason("key")); + } + + @Test + public void reasonIsNullIfReasonsWereNotRecorded() { + Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.of("value1"), 1, EvaluationReason.off()); + DataModel.FeatureFlag flag = flagBuilder("key").build(); + FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); + + assertNull(state.getFlagReason("key")); + } + + @Test + public void flagCanHaveNullValue() { + Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.ofNull(), 1, null); + DataModel.FeatureFlag flag = flagBuilder("key").build(); + FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); + + assertEquals(LDValue.ofNull(), state.getFlagValue("key")); + } + + @Test + public void canConvertToValuesMap() { + Evaluator.EvalResult eval1 = new Evaluator.EvalResult(LDValue.of("value1"), 0, EvaluationReason.off()); + DataModel.FeatureFlag flag1 = flagBuilder("key1").build(); + Evaluator.EvalResult eval2 = new Evaluator.EvalResult(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); + DataModel.FeatureFlag flag2 = flagBuilder("key2").build(); + FeatureFlagsState state = new FeatureFlagsState.Builder() + .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); + + ImmutableMap expected = ImmutableMap.of("key1", LDValue.of("value1"), "key2", LDValue.of("value2")); + assertEquals(expected, state.toValuesMap()); + } + + @Test + public void canConvertToJson() { + String actualJsonString = JsonSerialization.serialize(makeInstanceForSerialization()); + assertEquals(LDValue.parse(makeExpectedJsonSerialization()), LDValue.parse(actualJsonString)); + } + + @Test + public void canConvertFromJson() throws SerializationException { + FeatureFlagsState state = JsonSerialization.deserialize(makeExpectedJsonSerialization(), FeatureFlagsState.class); + assertEquals(makeInstanceForSerialization(), state); + } + + private static FeatureFlagsState makeInstanceForSerialization() { + Evaluator.EvalResult eval1 = new Evaluator.EvalResult(LDValue.of("value1"), 0, EvaluationReason.off()); + DataModel.FeatureFlag flag1 = flagBuilder("key1").version(100).trackEvents(false).build(); + Evaluator.EvalResult eval2 = new Evaluator.EvalResult(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); + DataModel.FeatureFlag flag2 = flagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); + Evaluator.EvalResult eval3 = new Evaluator.EvalResult(LDValue.of("default"), NO_VARIATION, EvaluationReason.error(MALFORMED_FLAG)); + DataModel.FeatureFlag flag3 = flagBuilder("key3").version(300).build(); + return new FeatureFlagsState.Builder(FlagsStateOption.WITH_REASONS) + .addFlag(flag1, eval1).addFlag(flag2, eval2).addFlag(flag3, eval3).build(); + } + + private static String makeExpectedJsonSerialization() { + return "{\"key1\":\"value1\",\"key2\":\"value2\",\"key3\":\"default\"," + + "\"$flagsState\":{" + + "\"key1\":{" + + "\"variation\":0,\"version\":100,\"reason\":{\"kind\":\"OFF\"}" + // note, "trackEvents: false" is omitted + "},\"key2\":{" + + "\"variation\":1,\"version\":200,\"reason\":{\"kind\":\"FALLTHROUGH\"},\"trackEvents\":true,\"debugEventsUntilDate\":1000" + + "},\"key3\":{" + + "\"version\":300,\"reason\":{\"kind\":\"ERROR\",\"errorKind\":\"MALFORMED_FLAG\"}" + + "}" + + "}," + + "\"$valid\":true" + + "}"; + } + + @Test + public void canSerializeAndDeserializeWithJackson() throws Exception { + // FeatureFlagsState, being a JsonSerializable, should get the same custom serialization/deserialization + // support that is provided by java-sdk-common for Gson and Jackson. Our Gson interoperability just relies + // on the same Gson annotations that we use internally, but the Jackson adapter will only work if the + // java-server-sdk and java-sdk-common packages are configured together correctly. So we'll test that here. + // If it fails, the symptom will be something like Jackson complaining that it doesn't know how to + // instantiate the FeatureFlagsState class. + + ObjectMapper jacksonMapper = new ObjectMapper(); + jacksonMapper.registerModule(LDJackson.module()); + + String actualJsonString = jacksonMapper.writeValueAsString(makeInstanceForSerialization()); + assertEquals(LDValue.parse(makeExpectedJsonSerialization()), LDValue.parse(actualJsonString)); + + FeatureFlagsState state = jacksonMapper.readValue(makeExpectedJsonSerialization(), FeatureFlagsState.class); + assertEquals(makeInstanceForSerialization(), state); + } +} diff --git a/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java b/src/test/java/com/launchdarkly/sdk/server/FeatureRequestorTest.java similarity index 87% rename from src/test/java/com/launchdarkly/client/FeatureRequestorTest.java rename to src/test/java/com/launchdarkly/sdk/server/FeatureRequestorTest.java index dacb9b0a2..99a37daad 100644 --- a/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/FeatureRequestorTest.java @@ -1,4 +1,11 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.DefaultFeatureRequestor; +import com.launchdarkly.sdk.server.FeatureRequestor; +import com.launchdarkly.sdk.server.HttpErrorException; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; import org.junit.Assert; import org.junit.Test; @@ -8,9 +15,9 @@ import javax.net.ssl.SSLHandshakeException; -import static com.launchdarkly.client.TestHttpUtil.httpsServerWithSelfSignedCert; -import static com.launchdarkly.client.TestHttpUtil.jsonResponse; -import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; +import static com.launchdarkly.sdk.server.TestHttpUtil.httpsServerWithSelfSignedCert; +import static com.launchdarkly.sdk.server.TestHttpUtil.jsonResponse; +import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; @@ -72,8 +79,8 @@ public void requestFlag() throws Exception { try (MockWebServer server = makeStartedServer(resp)) { try (DefaultFeatureRequestor r = makeRequestor(server)) { - FeatureFlag flag = r.getFlag(flag1Key); - + DataModel.FeatureFlag flag = r.getFlag(flag1Key); + RecordedRequest req = server.takeRequest(); assertEquals("/sdk/latest-flags/" + flag1Key, req.getPath()); verifyHeaders(req); @@ -89,8 +96,8 @@ public void requestSegment() throws Exception { try (MockWebServer server = makeStartedServer(resp)) { try (DefaultFeatureRequestor r = makeRequestor(server)) { - Segment segment = r.getSegment(segment1Key); - + DataModel.Segment segment = r.getSegment(segment1Key); + RecordedRequest req = server.takeRequest(); assertEquals("/sdk/latest-segments/" + segment1Key, req.getPath()); verifyHeaders(req); @@ -140,15 +147,15 @@ public void requestsAreCached() throws Exception { try (MockWebServer server = makeStartedServer(cacheableResp)) { try (DefaultFeatureRequestor r = makeRequestor(server)) { - FeatureFlag flag1a = r.getFlag(flag1Key); - + DataModel.FeatureFlag flag1a = r.getFlag(flag1Key); + RecordedRequest req1 = server.takeRequest(); assertEquals("/sdk/latest-flags/" + flag1Key, req1.getPath()); verifyHeaders(req1); verifyFlag(flag1a, flag1Key); - FeatureFlag flag1b = r.getFlag(flag1Key); + DataModel.FeatureFlag flag1b = r.getFlag(flag1Key); verifyFlag(flag1b, flag1Key); assertNull(server.takeRequest(0, TimeUnit.SECONDS)); // there was no second request, due to the cache hit } @@ -183,7 +190,7 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { .build(); try (DefaultFeatureRequestor r = makeRequestor(serverWithCert.server, config)) { - FeatureFlag flag = r.getFlag(flag1Key); + DataModel.FeatureFlag flag = r.getFlag(flag1Key); verifyFlag(flag, flag1Key); } } @@ -199,7 +206,7 @@ public void httpClientCanUseProxyConfig() throws Exception { .build(); try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, config.httpConfig, fakeBaseUri, true)) { - FeatureFlag flag = r.getFlag(flag1Key); + DataModel.FeatureFlag flag = r.getFlag(flag1Key); verifyFlag(flag, flag1Key); assertEquals(1, server.getRequestCount()); @@ -212,12 +219,12 @@ private void verifyHeaders(RecordedRequest req) { assertEquals("JavaClient/" + LDClient.CLIENT_VERSION, req.getHeader("User-Agent")); } - private void verifyFlag(FeatureFlag flag, String key) { + private void verifyFlag(DataModel.FeatureFlag flag, String key) { assertNotNull(flag); assertEquals(key, flag.getKey()); } - private void verifySegment(Segment segment, String key) { + private void verifySegment(DataModel.Segment segment, String key) { assertNotNull(segment); assertEquals(key, segment.getKey()); } diff --git a/src/test/java/com/launchdarkly/client/FlagModelDeserializationTest.java b/src/test/java/com/launchdarkly/sdk/server/FlagModelDeserializationTest.java similarity index 80% rename from src/test/java/com/launchdarkly/client/FlagModelDeserializationTest.java rename to src/test/java/com/launchdarkly/sdk/server/FlagModelDeserializationTest.java index 90b2d9c50..83d0dea83 100644 --- a/src/test/java/com/launchdarkly/client/FlagModelDeserializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/FlagModelDeserializationTest.java @@ -1,6 +1,8 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.gson.Gson; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.server.DataModel; import org.junit.Test; @@ -14,7 +16,7 @@ public class FlagModelDeserializationTest { @Test public void precomputedReasonsAreAddedToPrerequisites() { String flagJson = "{\"key\":\"flagkey\",\"prerequisites\":[{\"key\":\"prereq0\"},{\"key\":\"prereq1\"}]}"; - FeatureFlag flag = gson.fromJson(flagJson, FeatureFlag.class); + DataModel.FeatureFlag flag = gson.fromJson(flagJson, DataModel.FeatureFlag.class); assertNotNull(flag.getPrerequisites()); assertEquals(2, flag.getPrerequisites().size()); assertEquals(EvaluationReason.prerequisiteFailed("prereq0"), flag.getPrerequisites().get(0).getPrerequisiteFailedReason()); @@ -24,7 +26,7 @@ public void precomputedReasonsAreAddedToPrerequisites() { @Test public void precomputedReasonsAreAddedToRules() { String flagJson = "{\"key\":\"flagkey\",\"rules\":[{\"id\":\"ruleid0\"},{\"id\":\"ruleid1\"}]}"; - FeatureFlag flag = gson.fromJson(flagJson, FeatureFlag.class); + DataModel.FeatureFlag flag = gson.fromJson(flagJson, DataModel.FeatureFlag.class); assertNotNull(flag.getRules()); assertEquals(2, flag.getRules().size()); assertEquals(EvaluationReason.ruleMatch(0, "ruleid0"), flag.getRules().get(0).getRuleMatchReason()); diff --git a/src/test/java/com/launchdarkly/sdk/server/InMemoryDataStoreTest.java b/src/test/java/com/launchdarkly/sdk/server/InMemoryDataStoreTest.java new file mode 100644 index 000000000..9e2b0d1ff --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/InMemoryDataStoreTest.java @@ -0,0 +1,13 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.InMemoryDataStore; +import com.launchdarkly.sdk.server.interfaces.DataStore; + +@SuppressWarnings("javadoc") +public class InMemoryDataStoreTest extends DataStoreTestBase { + + @Override + protected DataStore makeStore() { + return new InMemoryDataStore(); + } +} diff --git a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java similarity index 85% rename from src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java rename to src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java index 355090516..d75975bd9 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java @@ -1,17 +1,22 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.gson.Gson; import com.google.gson.JsonObject; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; import org.junit.Test; -import static com.launchdarkly.client.Components.noEvents; -import static com.launchdarkly.client.TestHttpUtil.basePollingConfig; -import static com.launchdarkly.client.TestHttpUtil.baseStreamingConfig; -import static com.launchdarkly.client.TestHttpUtil.httpsServerWithSelfSignedCert; -import static com.launchdarkly.client.TestHttpUtil.jsonResponse; -import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; +import static com.launchdarkly.sdk.server.Components.noEvents; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.TestHttpUtil.basePollingConfig; +import static com.launchdarkly.sdk.server.TestHttpUtil.baseStreamingConfig; +import static com.launchdarkly.sdk.server.TestHttpUtil.httpsServerWithSelfSignedCert; +import static com.launchdarkly.sdk.server.TestHttpUtil.jsonResponse; +import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -23,7 +28,7 @@ public class LDClientEndToEndTest { private static final Gson gson = new Gson(); private static final String sdkKey = "sdk-key"; private static final String flagKey = "flag1"; - private static final FeatureFlag flag = new FeatureFlagBuilder(flagKey) + private static final DataModel.FeatureFlag flag = flagBuilder(flagKey) .offVariation(0).variations(LDValue.of(true)) .build(); private static final LDUser user = new LDUser("user-key"); diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java similarity index 64% rename from src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java rename to src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java index 98403deb7..ed745ad25 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java @@ -1,25 +1,36 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.collect.ImmutableMap; import com.google.gson.Gson; import com.google.gson.JsonElement; -import com.google.gson.JsonPrimitive; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import org.junit.Test; -import java.util.Arrays; +import java.time.Duration; import java.util.Map; -import static com.launchdarkly.client.TestUtil.booleanFlagWithClauses; -import static com.launchdarkly.client.TestUtil.failedUpdateProcessor; -import static com.launchdarkly.client.TestUtil.fallthroughVariation; -import static com.launchdarkly.client.TestUtil.featureStoreThatThrowsException; -import static com.launchdarkly.client.TestUtil.flagWithValue; -import static com.launchdarkly.client.TestUtil.specificFeatureStore; -import static com.launchdarkly.client.TestUtil.specificUpdateProcessor; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; -import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; +import static com.google.common.collect.Iterables.getFirst; +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.ModelBuilders.booleanFlagWithClauses; +import static com.launchdarkly.sdk.server.ModelBuilders.clause; +import static com.launchdarkly.sdk.server.ModelBuilders.fallthroughVariation; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; +import static com.launchdarkly.sdk.server.TestComponents.dataStoreThatThrowsException; +import static com.launchdarkly.sdk.server.TestComponents.failedDataSource; +import static com.launchdarkly.sdk.server.TestComponents.initedDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificDataSource; +import static com.launchdarkly.sdk.server.TestComponents.specificDataStore; +import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; +import static com.launchdarkly.sdk.server.TestUtil.upsertSegment; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; @@ -31,10 +42,10 @@ public class LDClientEvaluationTest { private static final LDUser userWithNullKey = new LDUser.Builder((String)null).build(); private static final Gson gson = new Gson(); - private FeatureStore featureStore = TestUtil.initedFeatureStore(); + private DataStore dataStore = initedDataStore(); private LDConfig config = new LDConfig.Builder() - .dataStore(specificFeatureStore(featureStore)) + .dataStore(specificDataStore(dataStore)) .events(Components.noEvents()) .dataSource(Components.externalUpdatesOnly()) .build(); @@ -42,7 +53,7 @@ public class LDClientEvaluationTest { @Test public void boolVariationReturnsFlagValue() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of(true))); assertTrue(client.boolVariation("key", user, false)); } @@ -54,31 +65,31 @@ public void boolVariationReturnsDefaultValueForUnknownFlag() throws Exception { @Test public void boolVariationReturnsDefaultValueForWrongType() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("wrong"))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of("wrong"))); assertFalse(client.boolVariation("key", user, false)); } @Test public void intVariationReturnsFlagValue() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(2))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of(2))); assertEquals(new Integer(2), client.intVariation("key", user, 1)); } @Test public void intVariationReturnsFlagValueEvenIfEncodedAsDouble() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(2.0))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of(2.0))); assertEquals(new Integer(2), client.intVariation("key", user, 1)); } @Test public void intVariationFromDoubleRoundsTowardZero() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("flag1", LDValue.of(2.25))); - featureStore.upsert(FEATURES, flagWithValue("flag2", LDValue.of(2.75))); - featureStore.upsert(FEATURES, flagWithValue("flag3", LDValue.of(-2.25))); - featureStore.upsert(FEATURES, flagWithValue("flag4", LDValue.of(-2.75))); + upsertFlag(dataStore, flagWithValue("flag1", LDValue.of(2.25))); + upsertFlag(dataStore, flagWithValue("flag2", LDValue.of(2.75))); + upsertFlag(dataStore, flagWithValue("flag3", LDValue.of(-2.25))); + upsertFlag(dataStore, flagWithValue("flag4", LDValue.of(-2.75))); assertEquals(new Integer(2), client.intVariation("flag1", user, 1)); assertEquals(new Integer(2), client.intVariation("flag2", user, 1)); @@ -93,21 +104,21 @@ public void intVariationReturnsDefaultValueForUnknownFlag() throws Exception { @Test public void intVariationReturnsDefaultValueForWrongType() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("wrong"))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of("wrong"))); assertEquals(new Integer(1), client.intVariation("key", user, 1)); } @Test public void doubleVariationReturnsFlagValue() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(2.5d))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of(2.5d))); assertEquals(new Double(2.5d), client.doubleVariation("key", user, 1.0d)); } @Test public void doubleVariationReturnsFlagValueEvenIfEncodedAsInt() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(2))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of(2))); assertEquals(new Double(2.0d), client.doubleVariation("key", user, 1.0d)); } @@ -119,50 +130,46 @@ public void doubleVariationReturnsDefaultValueForUnknownFlag() throws Exception @Test public void doubleVariationReturnsDefaultValueForWrongType() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("wrong"))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of("wrong"))); assertEquals(new Double(1.0d), client.doubleVariation("key", user, 1.0d)); } @Test public void stringVariationReturnsFlagValue() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("b"))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of("b"))); assertEquals("b", client.stringVariation("key", user, "a")); } + @Test + public void stringVariationWithNullDefaultReturnsFlagValue() throws Exception { + upsertFlag(dataStore, flagWithValue("key", LDValue.of("b"))); + + assertEquals("b", client.stringVariation("key", user, null)); + } + @Test public void stringVariationReturnsDefaultValueForUnknownFlag() throws Exception { assertEquals("a", client.stringVariation("key", user, "a")); } + @Test + public void stringVariationWithNullDefaultReturnsDefaultValueForUnknownFlag() throws Exception { + assertNull(client.stringVariation("key", user, null)); + } + @Test public void stringVariationReturnsDefaultValueForWrongType() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of(true))); assertEquals("a", client.stringVariation("key", user, "a")); } - @SuppressWarnings("deprecation") - @Test - public void deprecatedJsonVariationReturnsFlagValue() throws Exception { - LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); - featureStore.upsert(FEATURES, flagWithValue("key", data)); - - assertEquals(data.asJsonElement(), client.jsonVariation("key", user, new JsonPrimitive(42))); - } - - @SuppressWarnings("deprecation") - @Test - public void deprecatedJsonVariationReturnsDefaultValueForUnknownFlag() throws Exception { - JsonElement defaultVal = new JsonPrimitive(42); - assertEquals(defaultVal, client.jsonVariation("key", user, defaultVal)); - } - @Test public void jsonValueVariationReturnsFlagValue() throws Exception { LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); - featureStore.upsert(FEATURES, flagWithValue("key", data)); + upsertFlag(dataStore, flagWithValue("key", data)); assertEquals(data, client.jsonValueVariation("key", user, LDValue.of(42))); } @@ -176,22 +183,22 @@ public void jsonValueVariationReturnsDefaultValueForUnknownFlag() throws Excepti @Test public void canMatchUserBySegment() throws Exception { // This is similar to one of the tests in FeatureFlagTest, but more end-to-end - Segment segment = new Segment.Builder("segment1") + DataModel.Segment segment = segmentBuilder("segment1") .version(1) - .included(Arrays.asList(user.getKeyAsString())) + .included(user.getKey()) .build(); - featureStore.upsert(SEGMENTS, segment); + upsertSegment(dataStore, segment); - Clause clause = new Clause("", Operator.segmentMatch, Arrays.asList(LDValue.of("segment1")), false); - FeatureFlag feature = booleanFlagWithClauses("feature", clause); - featureStore.upsert(FEATURES, feature); + DataModel.Clause clause = clause(null, DataModel.Operator.segmentMatch, LDValue.of("segment1")); + DataModel.FeatureFlag feature = booleanFlagWithClauses("feature", clause); + upsertFlag(dataStore, feature); assertTrue(client.boolVariation("feature", user, false)); } @Test public void canGetDetailsForSuccessfulEvaluation() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of(true))); EvaluationDetail expectedResult = EvaluationDetail.fromValue(true, 0, EvaluationReason.off()); @@ -200,19 +207,19 @@ public void canGetDetailsForSuccessfulEvaluation() throws Exception { @Test public void variationReturnsDefaultIfFlagEvaluatesToNull() { - FeatureFlag flag = new FeatureFlagBuilder("key").on(false).offVariation(null).build(); - featureStore.upsert(FEATURES, flag); + DataModel.FeatureFlag flag = flagBuilder("key").on(false).offVariation(null).build(); + upsertFlag(dataStore, flag); assertEquals("default", client.stringVariation("key", user, "default")); } @Test public void variationDetailReturnsDefaultIfFlagEvaluatesToNull() { - FeatureFlag flag = new FeatureFlagBuilder("key").on(false).offVariation(null).build(); - featureStore.upsert(FEATURES, flag); + DataModel.FeatureFlag flag = flagBuilder("key").on(false).offVariation(null).build(); + upsertFlag(dataStore, flag); EvaluationDetail expected = EvaluationDetail.fromValue("default", - null, EvaluationReason.off()); + NO_VARIATION, EvaluationReason.off()); EvaluationDetail actual = client.stringVariationDetail("key", user, "default"); assertEquals(expected, actual); assertTrue(actual.isDefaultValue()); @@ -220,15 +227,15 @@ public void variationDetailReturnsDefaultIfFlagEvaluatesToNull() { @Test public void appropriateErrorIfClientNotInitialized() throws Exception { - FeatureStore badFeatureStore = new InMemoryFeatureStore(); + DataStore badDataStore = new InMemoryDataStore(); LDConfig badConfig = new LDConfig.Builder() - .dataStore(specificFeatureStore(badFeatureStore)) + .dataStore(specificDataStore(badDataStore)) .events(Components.noEvents()) - .dataSource(specificUpdateProcessor(failedUpdateProcessor())) - .startWaitMillis(0) + .dataSource(specificDataSource(failedDataSource())) + .startWait(Duration.ZERO) .build(); try (LDClientInterface badClient = new LDClient("SDK_KEY", badConfig)) { - EvaluationDetail expectedResult = EvaluationDetail.fromValue(false, null, + EvaluationDetail expectedResult = EvaluationDetail.fromValue(false, NO_VARIATION, EvaluationReason.error(EvaluationReason.ErrorKind.CLIENT_NOT_READY)); assertEquals(expectedResult, badClient.boolVariationDetail("key", user, false)); } @@ -236,25 +243,25 @@ public void appropriateErrorIfClientNotInitialized() throws Exception { @Test public void appropriateErrorIfFlagDoesNotExist() throws Exception { - EvaluationDetail expectedResult = EvaluationDetail.fromValue("default", null, + EvaluationDetail expectedResult = EvaluationDetail.fromValue("default", NO_VARIATION, EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND)); assertEquals(expectedResult, client.stringVariationDetail("key", user, "default")); } @Test public void appropriateErrorIfUserNotSpecified() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of(true))); - EvaluationDetail expectedResult = EvaluationDetail.fromValue("default", null, + EvaluationDetail expectedResult = EvaluationDetail.fromValue("default", NO_VARIATION, EvaluationReason.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED)); assertEquals(expectedResult, client.stringVariationDetail("key", null, "default")); } @Test public void appropriateErrorIfValueWrongType() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of(true))); - EvaluationDetail expectedResult = EvaluationDetail.fromValue(3, null, + EvaluationDetail expectedResult = EvaluationDetail.fromValue(3, NO_VARIATION, EvaluationReason.error(EvaluationReason.ErrorKind.WRONG_TYPE)); assertEquals(expectedResult, client.intVariationDetail("key", user, 3)); } @@ -262,55 +269,29 @@ public void appropriateErrorIfValueWrongType() throws Exception { @Test public void appropriateErrorForUnexpectedException() throws Exception { RuntimeException exception = new RuntimeException("sorry"); - FeatureStore badFeatureStore = featureStoreThatThrowsException(exception); + DataStore badDataStore = dataStoreThatThrowsException(exception); LDConfig badConfig = new LDConfig.Builder() - .dataStore(specificFeatureStore(badFeatureStore)) + .dataStore(specificDataStore(badDataStore)) .events(Components.noEvents()) .dataSource(Components.externalUpdatesOnly()) .build(); try (LDClientInterface badClient = new LDClient("SDK_KEY", badConfig)) { - EvaluationDetail expectedResult = EvaluationDetail.fromValue(false, null, + EvaluationDetail expectedResult = EvaluationDetail.fromValue(false, NO_VARIATION, EvaluationReason.exception(exception)); assertEquals(expectedResult, badClient.boolVariationDetail("key", user, false)); } } - @SuppressWarnings("deprecation") - @Test - public void allFlagsReturnsFlagValues() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key1", LDValue.of("value1"))); - featureStore.upsert(FEATURES, flagWithValue("key2", LDValue.of("value2"))); - - Map result = client.allFlags(user); - assertEquals(ImmutableMap.of("key1", new JsonPrimitive("value1"), "key2", new JsonPrimitive("value2")), result); - } - - @SuppressWarnings("deprecation") - @Test - public void allFlagsReturnsNullForNullUser() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("value"))); - - assertNull(client.allFlags(null)); - } - - @SuppressWarnings("deprecation") - @Test - public void allFlagsReturnsNullForNullUserKey() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("value"))); - - assertNull(client.allFlags(userWithNullKey)); - } - @Test public void allFlagsStateReturnsState() throws Exception { - FeatureFlag flag1 = new FeatureFlagBuilder("key1") + DataModel.FeatureFlag flag1 = flagBuilder("key1") .version(100) .trackEvents(false) .on(false) .offVariation(0) .variations(LDValue.of("value1")) .build(); - FeatureFlag flag2 = new FeatureFlagBuilder("key2") + DataModel.FeatureFlag flag2 = flagBuilder("key2") .version(200) .trackEvents(true) .debugEventsUntilDate(1000L) @@ -318,8 +299,8 @@ public void allFlagsStateReturnsState() throws Exception { .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("off"), LDValue.of("value2")) .build(); - featureStore.upsert(FEATURES, flag1); - featureStore.upsert(FEATURES, flag2); + upsertFlag(dataStore, flag1); + upsertFlag(dataStore, flag2); FeatureFlagsState state = client.allFlagsState(user); assertTrue(state.isValid()); @@ -340,34 +321,34 @@ public void allFlagsStateReturnsState() throws Exception { @Test public void allFlagsStateCanFilterForOnlyClientSideFlags() { - FeatureFlag flag1 = new FeatureFlagBuilder("server-side-1").build(); - FeatureFlag flag2 = new FeatureFlagBuilder("server-side-2").build(); - FeatureFlag flag3 = new FeatureFlagBuilder("client-side-1").clientSide(true) + DataModel.FeatureFlag flag1 = flagBuilder("server-side-1").build(); + DataModel.FeatureFlag flag2 = flagBuilder("server-side-2").build(); + DataModel.FeatureFlag flag3 = flagBuilder("client-side-1").clientSide(true) .variations(LDValue.of("value1")).offVariation(0).build(); - FeatureFlag flag4 = new FeatureFlagBuilder("client-side-2").clientSide(true) + DataModel.FeatureFlag flag4 = flagBuilder("client-side-2").clientSide(true) .variations(LDValue.of("value2")).offVariation(0).build(); - featureStore.upsert(FEATURES, flag1); - featureStore.upsert(FEATURES, flag2); - featureStore.upsert(FEATURES, flag3); - featureStore.upsert(FEATURES, flag4); + upsertFlag(dataStore, flag1); + upsertFlag(dataStore, flag2); + upsertFlag(dataStore, flag3); + upsertFlag(dataStore, flag4); FeatureFlagsState state = client.allFlagsState(user, FlagsStateOption.CLIENT_SIDE_ONLY); assertTrue(state.isValid()); - Map allValues = state.toValuesMap(); - assertEquals(ImmutableMap.of("client-side-1", new JsonPrimitive("value1"), "client-side-2", new JsonPrimitive("value2")), allValues); + Map allValues = state.toValuesMap(); + assertEquals(ImmutableMap.of("client-side-1", LDValue.of("value1"), "client-side-2", LDValue.of("value2")), allValues); } @Test public void allFlagsStateReturnsStateWithReasons() { - FeatureFlag flag1 = new FeatureFlagBuilder("key1") + DataModel.FeatureFlag flag1 = flagBuilder("key1") .version(100) .trackEvents(false) .on(false) .offVariation(0) .variations(LDValue.of("value1")) .build(); - FeatureFlag flag2 = new FeatureFlagBuilder("key2") + DataModel.FeatureFlag flag2 = flagBuilder("key2") .version(200) .trackEvents(true) .debugEventsUntilDate(1000L) @@ -375,8 +356,8 @@ public void allFlagsStateReturnsStateWithReasons() { .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("off"), LDValue.of("value2")) .build(); - featureStore.upsert(FEATURES, flag1); - featureStore.upsert(FEATURES, flag2); + upsertFlag(dataStore, flag1); + upsertFlag(dataStore, flag2); FeatureFlagsState state = client.allFlagsState(user, FlagsStateOption.WITH_REASONS); assertTrue(state.isValid()); @@ -398,21 +379,21 @@ public void allFlagsStateReturnsStateWithReasons() { @Test public void allFlagsStateCanOmitDetailsForUntrackedFlags() { long futureTime = System.currentTimeMillis() + 1000000; - FeatureFlag flag1 = new FeatureFlagBuilder("key1") + DataModel.FeatureFlag flag1 = flagBuilder("key1") .version(100) .trackEvents(false) .on(false) .offVariation(0) .variations(LDValue.of("value1")) .build(); - FeatureFlag flag2 = new FeatureFlagBuilder("key2") + DataModel.FeatureFlag flag2 = flagBuilder("key2") .version(200) .trackEvents(true) .on(true) .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("off"), LDValue.of("value2")) .build(); - FeatureFlag flag3 = new FeatureFlagBuilder("key3") + DataModel.FeatureFlag flag3 = flagBuilder("key3") .version(300) .trackEvents(false) .debugEventsUntilDate(futureTime) // event tracking is turned on temporarily even though trackEvents is false @@ -420,9 +401,9 @@ public void allFlagsStateCanOmitDetailsForUntrackedFlags() { .offVariation(0) .variations(LDValue.of("value3")) .build(); - featureStore.upsert(FEATURES, flag1); - featureStore.upsert(FEATURES, flag2); - featureStore.upsert(FEATURES, flag3); + upsertFlag(dataStore, flag1); + upsertFlag(dataStore, flag2); + upsertFlag(dataStore, flag3); FeatureFlagsState state = client.allFlagsState(user, FlagsStateOption.WITH_REASONS, FlagsStateOption.DETAILS_ONLY_FOR_TRACKED_FLAGS); assertTrue(state.isValid()); @@ -443,9 +424,25 @@ public void allFlagsStateCanOmitDetailsForUntrackedFlags() { assertEquals(expected, gson.toJsonTree(state)); } + @Test + public void allFlagsStateFiltersOutDeletedFlags() throws Exception { + DataModel.FeatureFlag flag1 = flagBuilder("key1").version(1).build(); + DataModel.FeatureFlag flag2 = flagBuilder("key2").version(1).build(); + upsertFlag(dataStore, flag1); + upsertFlag(dataStore, flag2); + dataStore.upsert(FEATURES, flag2.getKey(), ItemDescriptor.deletedItem(flag2.getVersion() + 1)); + + FeatureFlagsState state = client.allFlagsState(user); + assertTrue(state.isValid()); + + Map valuesMap = state.toValuesMap(); + assertEquals(1, valuesMap.size()); + assertEquals(flag1.getKey(), getFirst(valuesMap.keySet(), null)); + } + @Test public void allFlagsStateReturnsEmptyStateForNullUser() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("value"))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of("value"))); FeatureFlagsState state = client.allFlagsState(null); assertFalse(state.isValid()); @@ -454,7 +451,7 @@ public void allFlagsStateReturnsEmptyStateForNullUser() throws Exception { @Test public void allFlagsStateReturnsEmptyStateForNullUserKey() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("value"))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of("value"))); FeatureFlagsState state = client.allFlagsState(userWithNullKey); assertFalse(state.isValid()); diff --git a/src/test/java/com/launchdarkly/client/LDClientEventTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java similarity index 61% rename from src/test/java/com/launchdarkly/client/LDClientEventTest.java rename to src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java index d15453fab..c0cd0c61b 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java @@ -1,21 +1,25 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.google.gson.JsonElement; -import com.google.gson.JsonPrimitive; -import com.launchdarkly.client.EvaluationReason.ErrorKind; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.EvaluationReason.ErrorKind; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.Event; import org.junit.Test; -import java.util.Arrays; - -import static com.launchdarkly.client.TestUtil.fallthroughVariation; -import static com.launchdarkly.client.TestUtil.flagWithValue; -import static com.launchdarkly.client.TestUtil.makeClauseToMatchUser; -import static com.launchdarkly.client.TestUtil.makeClauseToNotMatchUser; -import static com.launchdarkly.client.TestUtil.specificEventProcessor; -import static com.launchdarkly.client.TestUtil.specificFeatureStore; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; +import static com.launchdarkly.sdk.server.ModelBuilders.clauseMatchingUser; +import static com.launchdarkly.sdk.server.ModelBuilders.clauseNotMatchingUser; +import static com.launchdarkly.sdk.server.ModelBuilders.fallthroughVariation; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; +import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite; +import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; +import static com.launchdarkly.sdk.server.TestComponents.initedDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificEventProcessor; +import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; @@ -26,10 +30,10 @@ public class LDClientEventTest { private static final LDUser user = new LDUser("userkey"); private static final LDUser userWithNullKey = new LDUser.Builder((String)null).build(); - private FeatureStore featureStore = TestUtil.initedFeatureStore(); - private TestUtil.TestEventProcessor eventSink = new TestUtil.TestEventProcessor(); + private DataStore dataStore = initedDataStore(); + private TestComponents.TestEventProcessor eventSink = new TestComponents.TestEventProcessor(); private LDConfig config = new LDConfig.Builder() - .dataStore(specificFeatureStore(featureStore)) + .dataStore(specificDataStore(dataStore)) .events(specificEventProcessor(eventSink)) .dataSource(Components.externalUpdatesOnly()) .build(); @@ -43,7 +47,7 @@ public void identifySendsEvent() throws Exception { Event e = eventSink.events.get(0); assertEquals(Event.Identify.class, e.getClass()); Event.Identify ie = (Event.Identify)e; - assertEquals(user.getKey(), ie.user.getKey()); + assertEquals(user.getKey(), ie.getUser().getKey()); } @Test @@ -66,9 +70,9 @@ public void trackSendsEventWithoutData() throws Exception { Event e = eventSink.events.get(0); assertEquals(Event.Custom.class, e.getClass()); Event.Custom ce = (Event.Custom)e; - assertEquals(user.getKey(), ce.user.getKey()); - assertEquals("eventkey", ce.key); - assertEquals(LDValue.ofNull(), ce.data); + assertEquals(user.getKey(), ce.getUser().getKey()); + assertEquals("eventkey", ce.getKey()); + assertEquals(LDValue.ofNull(), ce.getData()); } @Test @@ -80,9 +84,9 @@ public void trackSendsEventWithData() throws Exception { Event e = eventSink.events.get(0); assertEquals(Event.Custom.class, e.getClass()); Event.Custom ce = (Event.Custom)e; - assertEquals(user.getKey(), ce.user.getKey()); - assertEquals("eventkey", ce.key); - assertEquals(data, ce.data); + assertEquals(user.getKey(), ce.getUser().getKey()); + assertEquals("eventkey", ce.getKey()); + assertEquals(data, ce.getData()); } @Test @@ -95,42 +99,10 @@ public void trackSendsEventWithDataAndMetricValue() throws Exception { Event e = eventSink.events.get(0); assertEquals(Event.Custom.class, e.getClass()); Event.Custom ce = (Event.Custom)e; - assertEquals(user.getKey(), ce.user.getKey()); - assertEquals("eventkey", ce.key); - assertEquals(data, ce.data); - assertEquals(new Double(metricValue), ce.metricValue); - } - - @SuppressWarnings("deprecation") - @Test - public void deprecatedTrackSendsEventWithData() throws Exception { - JsonElement data = new JsonPrimitive("stuff"); - client.track("eventkey", user, data); - - assertEquals(1, eventSink.events.size()); - Event e = eventSink.events.get(0); - assertEquals(Event.Custom.class, e.getClass()); - Event.Custom ce = (Event.Custom)e; - assertEquals(user.getKey(), ce.user.getKey()); - assertEquals("eventkey", ce.key); - assertEquals(data, ce.data.asJsonElement()); - } - - @SuppressWarnings("deprecation") - @Test - public void deprecatedTrackSendsEventWithDataAndMetricValue() throws Exception { - JsonElement data = new JsonPrimitive("stuff"); - double metricValue = 1.5; - client.track("eventkey", user, data, metricValue); - - assertEquals(1, eventSink.events.size()); - Event e = eventSink.events.get(0); - assertEquals(Event.Custom.class, e.getClass()); - Event.Custom ce = (Event.Custom)e; - assertEquals(user.getKey(), ce.user.getKey()); - assertEquals("eventkey", ce.key); - assertEquals(data, ce.data.asJsonElement()); - assertEquals(new Double(metricValue), ce.metricValue); + assertEquals(user.getKey(), ce.getUser().getKey()); + assertEquals("eventkey", ce.getKey()); + assertEquals(data, ce.getData()); + assertEquals(new Double(metricValue), ce.getMetricValue()); } @Test @@ -147,8 +119,8 @@ public void trackWithUserWithNoKeyDoesNotSendEvent() { @Test public void boolVariationSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", LDValue.of(true)); - featureStore.upsert(FEATURES, flag); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(true)); + upsertFlag(dataStore, flag); client.boolVariation("key", user, false); assertEquals(1, eventSink.events.size()); @@ -164,8 +136,8 @@ public void boolVariationSendsEventForUnknownFlag() throws Exception { @Test public void boolVariationDetailSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", LDValue.of(true)); - featureStore.upsert(FEATURES, flag); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(true)); + upsertFlag(dataStore, flag); client.boolVariationDetail("key", user, false); assertEquals(1, eventSink.events.size()); @@ -182,8 +154,8 @@ public void boolVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void intVariationSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", LDValue.of(2)); - featureStore.upsert(FEATURES, flag); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2)); + upsertFlag(dataStore, flag); client.intVariation("key", user, 1); assertEquals(1, eventSink.events.size()); @@ -199,8 +171,8 @@ public void intVariationSendsEventForUnknownFlag() throws Exception { @Test public void intVariationDetailSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", LDValue.of(2)); - featureStore.upsert(FEATURES, flag); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2)); + upsertFlag(dataStore, flag); client.intVariationDetail("key", user, 1); assertEquals(1, eventSink.events.size()); @@ -217,8 +189,8 @@ public void intVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void doubleVariationSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", LDValue.of(2.5d)); - featureStore.upsert(FEATURES, flag); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2.5d)); + upsertFlag(dataStore, flag); client.doubleVariation("key", user, 1.0d); assertEquals(1, eventSink.events.size()); @@ -234,8 +206,8 @@ public void doubleVariationSendsEventForUnknownFlag() throws Exception { @Test public void doubleVariationDetailSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", LDValue.of(2.5d)); - featureStore.upsert(FEATURES, flag); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2.5d)); + upsertFlag(dataStore, flag); client.doubleVariationDetail("key", user, 1.0d); assertEquals(1, eventSink.events.size()); @@ -252,8 +224,8 @@ public void doubleVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void stringVariationSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", LDValue.of("b")); - featureStore.upsert(FEATURES, flag); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of("b")); + upsertFlag(dataStore, flag); client.stringVariation("key", user, "a"); assertEquals(1, eventSink.events.size()); @@ -269,8 +241,8 @@ public void stringVariationSendsEventForUnknownFlag() throws Exception { @Test public void stringVariationDetailSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", LDValue.of("b")); - featureStore.upsert(FEATURES, flag); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of("b")); + upsertFlag(dataStore, flag); client.stringVariationDetail("key", user, "a"); assertEquals(1, eventSink.events.size()); @@ -285,58 +257,11 @@ public void stringVariationDetailSendsEventForUnknownFlag() throws Exception { EvaluationReason.error(ErrorKind.FLAG_NOT_FOUND)); } - @SuppressWarnings("deprecation") - @Test - public void jsonVariationSendsEvent() throws Exception { - LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); - FeatureFlag flag = flagWithValue("key", data); - featureStore.upsert(FEATURES, flag); - LDValue defaultVal = LDValue.of(42); - - client.jsonVariation("key", user, new JsonPrimitive(defaultVal.intValue())); - assertEquals(1, eventSink.events.size()); - checkFeatureEvent(eventSink.events.get(0), flag, data, defaultVal, null, null); - } - - @SuppressWarnings("deprecation") - @Test - public void jsonVariationSendsEventForUnknownFlag() throws Exception { - LDValue defaultVal = LDValue.of(42); - - client.jsonVariation("key", user, new JsonPrimitive(defaultVal.intValue())); - assertEquals(1, eventSink.events.size()); - checkUnknownFeatureEvent(eventSink.events.get(0), "key", defaultVal, null, null); - } - - @SuppressWarnings("deprecation") - @Test - public void jsonVariationDetailSendsEvent() throws Exception { - LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); - FeatureFlag flag = flagWithValue("key", data); - featureStore.upsert(FEATURES, flag); - LDValue defaultVal = LDValue.of(42); - - client.jsonVariationDetail("key", user, new JsonPrimitive(defaultVal.intValue())); - assertEquals(1, eventSink.events.size()); - checkFeatureEvent(eventSink.events.get(0), flag, data, defaultVal, null, EvaluationReason.off()); - } - - @SuppressWarnings("deprecation") - @Test - public void jsonVariationDetailSendsEventForUnknownFlag() throws Exception { - LDValue defaultVal = LDValue.of(42); - - client.jsonVariationDetail("key", user, new JsonPrimitive(defaultVal.intValue())); - assertEquals(1, eventSink.events.size()); - checkUnknownFeatureEvent(eventSink.events.get(0), "key", defaultVal, null, - EvaluationReason.error(ErrorKind.FLAG_NOT_FOUND)); - } - @Test public void jsonValueVariationDetailSendsEvent() throws Exception { LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); - FeatureFlag flag = flagWithValue("key", data); - featureStore.upsert(FEATURES, flag); + DataModel.FeatureFlag flag = flagWithValue("key", data); + upsertFlag(dataStore, flag); LDValue defaultVal = LDValue.of(42); client.jsonValueVariationDetail("key", user, defaultVal); @@ -356,15 +281,15 @@ public void jsonValueVariationDetailSendsEventForUnknownFlag() throws Exception @Test public void eventTrackingAndReasonCanBeForcedForRule() throws Exception { - Clause clause = makeClauseToMatchUser(user); - Rule rule = new RuleBuilder().id("id").clauses(clause).variation(1).trackEvents(true).build(); - FeatureFlag flag = new FeatureFlagBuilder("flag") + DataModel.Clause clause = clauseMatchingUser(user); + DataModel.Rule rule = ruleBuilder().id("id").clauses(clause).variation(1).trackEvents(true).build(); + DataModel.FeatureFlag flag = flagBuilder("flag") .on(true) - .rules(Arrays.asList(rule)) + .rules(rule) .offVariation(0) .variations(LDValue.of("off"), LDValue.of("on")) .build(); - featureStore.upsert(FEATURES, flag); + upsertFlag(dataStore, flag); client.stringVariation("flag", user, "default"); @@ -373,23 +298,23 @@ public void eventTrackingAndReasonCanBeForcedForRule() throws Exception { assertEquals(1, eventSink.events.size()); Event.FeatureRequest event = (Event.FeatureRequest)eventSink.events.get(0); - assertTrue(event.trackEvents); - assertEquals(EvaluationReason.ruleMatch(0, "id"), event.reason); + assertTrue(event.isTrackEvents()); + assertEquals(EvaluationReason.ruleMatch(0, "id"), event.getReason()); } @Test public void eventTrackingAndReasonAreNotForcedIfFlagIsNotSetForMatchingRule() throws Exception { - Clause clause0 = makeClauseToNotMatchUser(user); - Clause clause1 = makeClauseToMatchUser(user); - Rule rule0 = new RuleBuilder().id("id0").clauses(clause0).variation(1).trackEvents(true).build(); - Rule rule1 = new RuleBuilder().id("id1").clauses(clause1).variation(1).trackEvents(false).build(); - FeatureFlag flag = new FeatureFlagBuilder("flag") + DataModel.Clause clause0 = clauseNotMatchingUser(user); + DataModel.Clause clause1 = clauseMatchingUser(user); + DataModel.Rule rule0 = ruleBuilder().id("id0").clauses(clause0).variation(1).trackEvents(true).build(); + DataModel.Rule rule1 = ruleBuilder().id("id1").clauses(clause1).variation(1).trackEvents(false).build(); + DataModel.FeatureFlag flag = flagBuilder("flag") .on(true) - .rules(Arrays.asList(rule0, rule1)) + .rules(rule0, rule1) .offVariation(0) .variations(LDValue.of("off"), LDValue.of("on")) .build(); - featureStore.upsert(FEATURES, flag); + upsertFlag(dataStore, flag); client.stringVariation("flag", user, "default"); @@ -397,19 +322,19 @@ public void eventTrackingAndReasonAreNotForcedIfFlagIsNotSetForMatchingRule() th assertEquals(1, eventSink.events.size()); Event.FeatureRequest event = (Event.FeatureRequest)eventSink.events.get(0); - assertFalse(event.trackEvents); - assertNull(event.reason); + assertFalse(event.isTrackEvents()); + assertNull(event.getReason()); } @Test public void eventTrackingAndReasonCanBeForcedForFallthrough() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flag") + DataModel.FeatureFlag flag = flagBuilder("flag") .on(true) - .fallthrough(new VariationOrRollout(0, null)) + .fallthrough(new DataModel.VariationOrRollout(0, null)) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .trackEventsFallthrough(true) .build(); - featureStore.upsert(FEATURES, flag); + upsertFlag(dataStore, flag); client.stringVariation("flag", user, "default"); @@ -418,65 +343,65 @@ public void eventTrackingAndReasonCanBeForcedForFallthrough() throws Exception { assertEquals(1, eventSink.events.size()); Event.FeatureRequest event = (Event.FeatureRequest)eventSink.events.get(0); - assertTrue(event.trackEvents); - assertEquals(EvaluationReason.fallthrough(), event.reason); + assertTrue(event.isTrackEvents()); + assertEquals(EvaluationReason.fallthrough(), event.getReason()); } @Test public void eventTrackingAndReasonAreNotForcedForFallthroughIfFlagIsNotSet() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flag") + DataModel.FeatureFlag flag = flagBuilder("flag") .on(true) - .fallthrough(new VariationOrRollout(0, null)) + .fallthrough(new DataModel.VariationOrRollout(0, null)) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .trackEventsFallthrough(false) .build(); - featureStore.upsert(FEATURES, flag); + upsertFlag(dataStore, flag); client.stringVariation("flag", user, "default"); assertEquals(1, eventSink.events.size()); Event.FeatureRequest event = (Event.FeatureRequest)eventSink.events.get(0); - assertFalse(event.trackEvents); - assertNull(event.reason); + assertFalse(event.isTrackEvents()); + assertNull(event.getReason()); } @Test public void eventTrackingAndReasonAreNotForcedForFallthroughIfReasonIsNotFallthrough() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flag") + DataModel.FeatureFlag flag = flagBuilder("flag") .on(false) // so the evaluation reason will be OFF, not FALLTHROUGH .offVariation(1) - .fallthrough(new VariationOrRollout(0, null)) + .fallthrough(new DataModel.VariationOrRollout(0, null)) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .trackEventsFallthrough(true) .build(); - featureStore.upsert(FEATURES, flag); + upsertFlag(dataStore, flag); client.stringVariation("flag", user, "default"); assertEquals(1, eventSink.events.size()); Event.FeatureRequest event = (Event.FeatureRequest)eventSink.events.get(0); - assertFalse(event.trackEvents); - assertNull(event.reason); + assertFalse(event.isTrackEvents()); + assertNull(event.getReason()); } @Test public void eventIsSentForExistingPrererequisiteFlag() throws Exception { - FeatureFlag f0 = new FeatureFlagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) + .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) .offVariation(1) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); - FeatureFlag f1 = new FeatureFlagBuilder("feature1") + DataModel.FeatureFlag f1 = flagBuilder("feature1") .on(true) .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("nogo"), LDValue.of("go")) .version(2) .build(); - featureStore.upsert(FEATURES, f0); - featureStore.upsert(FEATURES, f1); + upsertFlag(dataStore, f0); + upsertFlag(dataStore, f1); client.stringVariation("feature0", user, "default"); @@ -487,22 +412,22 @@ public void eventIsSentForExistingPrererequisiteFlag() throws Exception { @Test public void eventIsSentWithReasonForExistingPrererequisiteFlag() throws Exception { - FeatureFlag f0 = new FeatureFlagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) + .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) .offVariation(1) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); - FeatureFlag f1 = new FeatureFlagBuilder("feature1") + DataModel.FeatureFlag f1 = flagBuilder("feature1") .on(true) .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("nogo"), LDValue.of("go")) .version(2) .build(); - featureStore.upsert(FEATURES, f0); - featureStore.upsert(FEATURES, f1); + upsertFlag(dataStore, f0); + upsertFlag(dataStore, f1); client.stringVariationDetail("feature0", user, "default"); @@ -513,15 +438,15 @@ public void eventIsSentWithReasonForExistingPrererequisiteFlag() throws Exceptio @Test public void eventIsNotSentForUnknownPrererequisiteFlag() throws Exception { - FeatureFlag f0 = new FeatureFlagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) + .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) .offVariation(1) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); - featureStore.upsert(FEATURES, f0); + upsertFlag(dataStore, f0); client.stringVariation("feature0", user, "default"); @@ -531,15 +456,15 @@ public void eventIsNotSentForUnknownPrererequisiteFlag() throws Exception { @Test public void failureReasonIsGivenForUnknownPrererequisiteFlagIfDetailsWereRequested() throws Exception { - FeatureFlag f0 = new FeatureFlagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) + .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) .offVariation(1) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); - featureStore.upsert(FEATURES, f0); + upsertFlag(dataStore, f0); client.stringVariationDetail("feature0", user, "default"); @@ -548,33 +473,34 @@ public void failureReasonIsGivenForUnknownPrererequisiteFlagIfDetailsWereRequest EvaluationReason.prerequisiteFailed("feature1")); } - private void checkFeatureEvent(Event e, FeatureFlag flag, LDValue value, LDValue defaultVal, + private void checkFeatureEvent(Event e, DataModel.FeatureFlag flag, LDValue value, LDValue defaultVal, String prereqOf, EvaluationReason reason) { assertEquals(Event.FeatureRequest.class, e.getClass()); Event.FeatureRequest fe = (Event.FeatureRequest)e; - assertEquals(flag.getKey(), fe.key); - assertEquals(user.getKey(), fe.user.getKey()); - assertEquals(new Integer(flag.getVersion()), fe.version); - assertEquals(value, fe.value); - assertEquals(defaultVal, fe.defaultVal); - assertEquals(prereqOf, fe.prereqOf); - assertEquals(reason, fe.reason); - assertEquals(flag.isTrackEvents(), fe.trackEvents); - assertEquals(flag.getDebugEventsUntilDate(), fe.debugEventsUntilDate); + assertEquals(flag.getKey(), fe.getKey()); + assertEquals(user.getKey(), fe.getUser().getKey()); + assertEquals(flag.getVersion(), fe.getVersion()); + assertEquals(value, fe.getValue()); + assertEquals(defaultVal, fe.getDefaultVal()); + assertEquals(prereqOf, fe.getPrereqOf()); + assertEquals(reason, fe.getReason()); + assertEquals(flag.isTrackEvents(), fe.isTrackEvents()); + assertEquals(flag.getDebugEventsUntilDate() == null ? 0L : flag.getDebugEventsUntilDate().longValue(), fe.getDebugEventsUntilDate()); } private void checkUnknownFeatureEvent(Event e, String key, LDValue defaultVal, String prereqOf, EvaluationReason reason) { assertEquals(Event.FeatureRequest.class, e.getClass()); Event.FeatureRequest fe = (Event.FeatureRequest)e; - assertEquals(key, fe.key); - assertEquals(user.getKey(), fe.user.getKey()); - assertNull(fe.version); - assertEquals(defaultVal, fe.value); - assertEquals(defaultVal, fe.defaultVal); - assertEquals(prereqOf, fe.prereqOf); - assertEquals(reason, fe.reason); - assertFalse(fe.trackEvents); - assertNull(fe.debugEventsUntilDate); + assertEquals(key, fe.getKey()); + assertEquals(user.getKey(), fe.getUser().getKey()); + assertEquals(-1, fe.getVersion()); + assertEquals(-1, fe.getVariation()); + assertEquals(defaultVal, fe.getValue()); + assertEquals(defaultVal, fe.getDefaultVal()); + assertEquals(prereqOf, fe.getPrereqOf()); + assertEquals(reason, fe.getReason()); + assertFalse(fe.isTrackEvents()); + assertEquals(0L, fe.getDebugEventsUntilDate()); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java new file mode 100644 index 000000000..089b3ec12 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java @@ -0,0 +1,63 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.DataStore; + +import org.junit.Test; + +import java.io.IOException; + +import static com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; +import static com.launchdarkly.sdk.server.TestComponents.initedDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificDataStore; +import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +@SuppressWarnings("javadoc") +public class LDClientExternalUpdatesOnlyTest { + @Test + public void externalUpdatesOnlyClientHasNullDataSource() throws Exception { + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .build(); + try (LDClient client = new LDClient("SDK_KEY", config)) { + assertEquals(Components.NullDataSource.class, client.dataSource.getClass()); + } + } + + @Test + public void externalUpdatesOnlyClientHasDefaultEventProcessor() throws Exception { + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .build(); + try (LDClient client = new LDClient("SDK_KEY", config)) { + assertEquals(DefaultEventProcessor.class, client.eventProcessor.getClass()); + } + } + + @Test + public void externalUpdatesOnlyClientIsInitialized() throws Exception { + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .build(); + try (LDClient client = new LDClient("SDK_KEY", config)) { + assertTrue(client.initialized()); + } + } + + @Test + public void externalUpdatesOnlyClientGetsFlagFromDataStore() throws IOException { + DataStore testDataStore = initedDataStore(); + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .dataStore(specificDataStore(testDataStore)) + .build(); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(true)); + upsertFlag(testDataStore, flag); + try (LDClient client = new LDClient("SDK_KEY", config)) { + assertTrue(client.boolVariation("key", new LDUser("user"), false)); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java similarity index 56% rename from src/test/java/com/launchdarkly/client/LDClientOfflineTest.java rename to src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java index c725f25ef..3565554d7 100644 --- a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java @@ -1,19 +1,23 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.collect.ImmutableMap; -import com.google.gson.JsonElement; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.FeatureFlagsState; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDClientInterface; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.interfaces.DataStore; import org.junit.Test; import java.io.IOException; -import java.util.Map; -import static com.launchdarkly.client.TestUtil.flagWithValue; -import static com.launchdarkly.client.TestUtil.initedFeatureStore; -import static com.launchdarkly.client.TestUtil.jbool; -import static com.launchdarkly.client.TestUtil.specificFeatureStore; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; +import static com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; +import static com.launchdarkly.sdk.server.TestComponents.initedDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificDataStore; +import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -22,12 +26,12 @@ public class LDClientOfflineTest { private static final LDUser user = new LDUser("user"); @Test - public void offlineClientHasNullUpdateProcessor() throws IOException { + public void offlineClientHasNullDataSource() throws IOException { LDConfig config = new LDConfig.Builder() .offline(true) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { - assertEquals(Components.NullUpdateProcessor.class, client.updateProcessor.getClass()); + assertEquals(Components.NullDataSource.class, client.dataSource.getClass()); } } @@ -62,31 +66,17 @@ public void offlineClientReturnsDefaultValue() throws IOException { } @Test - public void offlineClientGetsAllFlagsFromFeatureStore() throws IOException { - FeatureStore testFeatureStore = initedFeatureStore(); + public void offlineClientGetsFlagsStateFromDataStore() throws IOException { + DataStore testDataStore = initedDataStore(); LDConfig config = new LDConfig.Builder() .offline(true) - .dataStore(specificFeatureStore(testFeatureStore)) + .dataStore(specificDataStore(testDataStore)) .build(); - testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); - try (LDClient client = new LDClient("SDK_KEY", config)) { - Map allFlags = client.allFlags(user); - assertEquals(ImmutableMap.of("key", jbool(true)), allFlags); - } - } - - @Test - public void offlineClientGetsFlagsStateFromFeatureStore() throws IOException { - FeatureStore testFeatureStore = initedFeatureStore(); - LDConfig config = new LDConfig.Builder() - .offline(true) - .dataStore(specificFeatureStore(testFeatureStore)) - .build(); - testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); + upsertFlag(testDataStore, flagWithValue("key", LDValue.of(true))); try (LDClient client = new LDClient("SDK_KEY", config)) { FeatureFlagsState state = client.allFlagsState(user); assertTrue(state.isValid()); - assertEquals(ImmutableMap.of("key", jbool(true)), state.toValuesMap()); + assertEquals(ImmutableMap.of("key", LDValue.of(true)), state.toValuesMap()); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java new file mode 100644 index 000000000..d8440d7a2 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java @@ -0,0 +1,484 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; +import com.launchdarkly.sdk.server.TestComponents.DataSourceFactoryThatExposesUpdater; +import com.launchdarkly.sdk.server.TestUtil.FlagChangeEventSink; +import com.launchdarkly.sdk.server.TestUtil.FlagValueChangeEventSink; +import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.EventProcessor; +import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagValueChangeEvent; + +import org.easymock.Capture; +import org.easymock.EasyMock; +import org.easymock.EasyMockSupport; +import org.junit.Before; +import org.junit.Test; + +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; +import static com.launchdarkly.sdk.server.TestComponents.failedDataSource; +import static com.launchdarkly.sdk.server.TestComponents.initedDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificDataSource; +import static com.launchdarkly.sdk.server.TestComponents.specificDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificEventProcessor; +import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; +import static org.easymock.EasyMock.anyObject; +import static org.easymock.EasyMock.capture; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.expectLastCall; +import static org.easymock.EasyMock.isA; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +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; +import static org.junit.Assert.fail; + +import junit.framework.AssertionFailedError; + +/** + * See also LDClientEvaluationTest, etc. This file contains mostly tests for the startup logic. + */ +@SuppressWarnings("javadoc") +public class LDClientTest extends EasyMockSupport { + private final static String SDK_KEY = "SDK_KEY"; + + private DataSource dataSource; + private EventProcessor eventProcessor; + private Future initFuture; + private LDClientInterface client; + + @SuppressWarnings("unchecked") + @Before + public void before() { + dataSource = createStrictMock(DataSource.class); + eventProcessor = createStrictMock(EventProcessor.class); + initFuture = createStrictMock(Future.class); + } + + @Test + public void constructorThrowsExceptionForNullSdkKey() throws Exception { + try (LDClient client = new LDClient(null)) { + fail("expected exception"); + } catch (NullPointerException e) { + assertEquals("sdkKey must not be null", e.getMessage()); + } + } + + @Test + public void constructorWithConfigThrowsExceptionForNullSdkKey() throws Exception { + try (LDClient client = new LDClient(null, new LDConfig.Builder().build())) { + fail("expected exception"); + } catch (NullPointerException e) { + assertEquals("sdkKey must not be null", e.getMessage()); + } + } + + @Test + public void constructorThrowsExceptionForNullConfig() throws Exception { + try (LDClient client = new LDClient(SDK_KEY, null)) { + fail("expected exception"); + } catch (NullPointerException e) { + assertEquals("config must not be null", e.getMessage()); + } + } + + @Test + public void clientHasDefaultEventProcessorWithDefaultConfig() throws Exception { + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .build(); + try (LDClient client = new LDClient("SDK_KEY", config)) { + assertEquals(DefaultEventProcessor.class, client.eventProcessor.getClass()); + } + } + + @Test + public void clientHasDefaultEventProcessorWithSendEvents() throws Exception { + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .events(Components.sendEvents()) + .build(); + try (LDClient client = new LDClient("SDK_KEY", config)) { + assertEquals(DefaultEventProcessor.class, client.eventProcessor.getClass()); + } + } + + @Test + public void clientHasNullEventProcessorWithNoEvents() throws Exception { + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .events(Components.noEvents()) + .build(); + try (LDClient client = new LDClient("SDK_KEY", config)) { + assertEquals(Components.NullEventProcessor.class, client.eventProcessor.getClass()); + } + } + + @Test + public void streamingClientHasStreamProcessor() throws Exception { + LDConfig config = new LDConfig.Builder() + .dataSource(Components.streamingDataSource().baseURI(URI.create("http://fake"))) + .startWait(Duration.ZERO) + .build(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertEquals(StreamProcessor.class, client.dataSource.getClass()); + } + } + + @Test + public void pollingClientHasPollingProcessor() throws IOException { + LDConfig config = new LDConfig.Builder() + .dataSource(Components.pollingDataSource().baseURI(URI.create("http://fake"))) + .startWait(Duration.ZERO) + .build(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertEquals(PollingProcessor.class, client.dataSource.getClass()); + } + } + + @Test + public void sameDiagnosticAccumulatorPassedToFactoriesWhenSupported() throws IOException { + DataSourceFactory mockDataSourceFactory = createStrictMock(DataSourceFactory.class); + + LDConfig config = new LDConfig.Builder() + .dataSource(mockDataSourceFactory) + .events(Components.sendEvents().baseURI(URI.create("fake-host"))) // event processor will try to send a diagnostic event here + .startWait(Duration.ZERO) + .build(); + + Capture capturedDataSourceContext = Capture.newInstance(); + expect(mockDataSourceFactory.createDataSource(capture(capturedDataSourceContext), + isA(DataStoreUpdates.class))).andReturn(failedDataSource()); + + replayAll(); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + verifyAll(); + DiagnosticAccumulator acc = ((DefaultEventProcessor)client.eventProcessor).dispatcher.diagnosticAccumulator; + assertNotNull(acc); + assertSame(acc, ClientContextImpl.getDiagnosticAccumulator(capturedDataSourceContext.getValue())); + } + } + + @Test + public void nullDiagnosticAccumulatorPassedToFactoriesWhenOptedOut() throws IOException { + DataSourceFactory mockDataSourceFactory = createStrictMock(DataSourceFactory.class); + + LDConfig config = new LDConfig.Builder() + .dataSource(mockDataSourceFactory) + .diagnosticOptOut(true) + .startWait(Duration.ZERO) + .build(); + + Capture capturedDataSourceContext = Capture.newInstance(); + expect(mockDataSourceFactory.createDataSource(capture(capturedDataSourceContext), + isA(DataStoreUpdates.class))).andReturn(failedDataSource()); + + replayAll(); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + verifyAll(); + assertNull(((DefaultEventProcessor)client.eventProcessor).dispatcher.diagnosticAccumulator); + assertNull(ClientContextImpl.getDiagnosticAccumulator(capturedDataSourceContext.getValue())); + } + } + + @Test + public void nullDiagnosticAccumulatorPassedToUpdateFactoryWhenEventProcessorDoesNotSupportDiagnostics() throws IOException { + EventProcessor mockEventProcessor = createStrictMock(EventProcessor.class); + mockEventProcessor.close(); + EasyMock.expectLastCall().anyTimes(); + EventProcessorFactory mockEventProcessorFactory = createStrictMock(EventProcessorFactory.class); + DataSourceFactory mockDataSourceFactory = createStrictMock(DataSourceFactory.class); + + LDConfig config = new LDConfig.Builder() + .events(mockEventProcessorFactory) + .dataSource(mockDataSourceFactory) + .startWait(Duration.ZERO) + .build(); + + Capture capturedEventContext = Capture.newInstance(); + Capture capturedDataSourceContext = Capture.newInstance(); + expect(mockEventProcessorFactory.createEventProcessor(capture(capturedEventContext))).andReturn(mockEventProcessor); + expect(mockDataSourceFactory.createDataSource(capture(capturedDataSourceContext), + isA(DataStoreUpdates.class))).andReturn(failedDataSource()); + + replayAll(); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + verifyAll(); + assertNull(ClientContextImpl.getDiagnosticAccumulator(capturedEventContext.getValue())); + assertNull(ClientContextImpl.getDiagnosticAccumulator(capturedDataSourceContext.getValue())); + } + } + + @Test + public void noWaitForDataSourceIfWaitMillisIsZero() throws Exception { + LDConfig.Builder config = new LDConfig.Builder() + .startWait(Duration.ZERO); + + expect(dataSource.start()).andReturn(initFuture); + expect(dataSource.isInitialized()).andReturn(false); + replayAll(); + + client = createMockClient(config); + assertFalse(client.initialized()); + + verifyAll(); + } + + @Test + public void willWaitForDataSourceIfWaitMillisIsNonZero() throws Exception { + LDConfig.Builder config = new LDConfig.Builder() + .startWait(Duration.ofMillis(10)); + + expect(dataSource.start()).andReturn(initFuture); + expect(initFuture.get(10L, TimeUnit.MILLISECONDS)).andReturn(null); + expect(dataSource.isInitialized()).andReturn(false).anyTimes(); + replayAll(); + + client = createMockClient(config); + assertFalse(client.initialized()); + + verifyAll(); + } + + @Test + public void dataSourceCanTimeOut() throws Exception { + LDConfig.Builder config = new LDConfig.Builder() + .startWait(Duration.ofMillis(10)); + + expect(dataSource.start()).andReturn(initFuture); + expect(initFuture.get(10L, TimeUnit.MILLISECONDS)).andThrow(new TimeoutException()); + expect(dataSource.isInitialized()).andReturn(false).anyTimes(); + replayAll(); + + client = createMockClient(config); + assertFalse(client.initialized()); + + verifyAll(); + } + + @Test + public void clientCatchesRuntimeExceptionFromDataSource() throws Exception { + LDConfig.Builder config = new LDConfig.Builder() + .startWait(Duration.ofMillis(10)); + + expect(dataSource.start()).andReturn(initFuture); + expect(initFuture.get(10L, TimeUnit.MILLISECONDS)).andThrow(new RuntimeException()); + expect(dataSource.isInitialized()).andReturn(false).anyTimes(); + replayAll(); + + client = createMockClient(config); + assertFalse(client.initialized()); + + verifyAll(); + } + + @Test + public void isFlagKnownReturnsTrueForExistingFlag() throws Exception { + DataStore testDataStore = initedDataStore(); + LDConfig.Builder config = new LDConfig.Builder() + .startWait(Duration.ZERO) + .dataStore(specificDataStore(testDataStore)); + expect(dataSource.start()).andReturn(initFuture); + expect(dataSource.isInitialized()).andReturn(true).times(1); + replayAll(); + + client = createMockClient(config); + + upsertFlag(testDataStore, flagWithValue("key", LDValue.of(1))); + assertTrue(client.isFlagKnown("key")); + verifyAll(); + } + + @Test + public void isFlagKnownReturnsFalseForUnknownFlag() throws Exception { + DataStore testDataStore = initedDataStore(); + LDConfig.Builder config = new LDConfig.Builder() + .startWait(Duration.ZERO) + .dataStore(specificDataStore(testDataStore)); + expect(dataSource.start()).andReturn(initFuture); + expect(dataSource.isInitialized()).andReturn(true).times(1); + replayAll(); + + client = createMockClient(config); + + assertFalse(client.isFlagKnown("key")); + verifyAll(); + } + + @Test + public void isFlagKnownReturnsFalseIfStoreAndClientAreNotInitialized() throws Exception { + DataStore testDataStore = new InMemoryDataStore(); + LDConfig.Builder config = new LDConfig.Builder() + .startWait(Duration.ZERO) + .dataStore(specificDataStore(testDataStore)); + expect(dataSource.start()).andReturn(initFuture); + expect(dataSource.isInitialized()).andReturn(false).times(1); + replayAll(); + + client = createMockClient(config); + + upsertFlag(testDataStore, flagWithValue("key", LDValue.of(1))); + assertFalse(client.isFlagKnown("key")); + verifyAll(); + } + + @Test + public void isFlagKnownUsesStoreIfStoreIsInitializedButClientIsNot() throws Exception { + DataStore testDataStore = initedDataStore(); + LDConfig.Builder config = new LDConfig.Builder() + .startWait(Duration.ZERO) + .dataStore(specificDataStore(testDataStore)); + expect(dataSource.start()).andReturn(initFuture); + expect(dataSource.isInitialized()).andReturn(false).times(1); + replayAll(); + + client = createMockClient(config); + + upsertFlag(testDataStore, flagWithValue("key", LDValue.of(1))); + assertTrue(client.isFlagKnown("key")); + verifyAll(); + } + + @Test + public void evaluationUsesStoreIfStoreIsInitializedButClientIsNot() throws Exception { + DataStore testDataStore = initedDataStore(); + LDConfig.Builder config = new LDConfig.Builder() + .dataStore(specificDataStore(testDataStore)) + .startWait(Duration.ZERO); + expect(dataSource.start()).andReturn(initFuture); + expect(dataSource.isInitialized()).andReturn(false); + expectEventsSent(1); + replayAll(); + + client = createMockClient(config); + + upsertFlag(testDataStore, flagWithValue("key", LDValue.of(1))); + assertEquals(new Integer(1), client.intVariation("key", new LDUser("user"), 0)); + + verifyAll(); + } + + @Test + public void clientSendsFlagChangeEvents() throws Exception { + // The logic for sending change events is tested in detail in DataStoreUpdatesImplTest, but here we'll + // verify that the client is actually telling DataStoreUpdatesImpl about updates, and managing the + // listener list. + DataStore testDataStore = initedDataStore(); + DataBuilder initialData = new DataBuilder().addAny(DataModel.FEATURES, + flagBuilder("flagkey").version(1).build()); + DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(initialData.build()); + LDConfig config = new LDConfig.Builder() + .dataStore(specificDataStore(testDataStore)) + .dataSource(updatableSource) + .events(Components.noEvents()) + .build(); + + client = new LDClient(SDK_KEY, config); + + FlagChangeEventSink eventSink1 = new FlagChangeEventSink(); + FlagChangeEventSink eventSink2 = new FlagChangeEventSink(); + client.registerFlagChangeListener(eventSink1); + client.registerFlagChangeListener(eventSink2); + + eventSink1.expectNoEvents(); + eventSink2.expectNoEvents(); + + updatableSource.updateFlag(flagBuilder("flagkey").version(2).build()); + + FlagChangeEvent event1 = eventSink1.awaitEvent(); + FlagChangeEvent event2 = eventSink2.awaitEvent(); + assertThat(event1.getKey(), equalTo("flagkey")); + assertThat(event2.getKey(), equalTo("flagkey")); + eventSink1.expectNoEvents(); + eventSink2.expectNoEvents(); + + client.unregisterFlagChangeListener(eventSink1); + + updatableSource.updateFlag(flagBuilder("flagkey").version(3).build()); + + FlagChangeEvent event3 = eventSink2.awaitEvent(); + assertThat(event3.getKey(), equalTo("flagkey")); + eventSink1.expectNoEvents(); + eventSink2.expectNoEvents(); + } + + @Test + public void clientSendsFlagValueChangeEvents() throws Exception { + String flagKey = "important-flag"; + LDUser user = new LDUser("important-user"); + LDUser otherUser = new LDUser("unimportant-user"); + DataStore testDataStore = initedDataStore(); + + FeatureFlag alwaysFalseFlag = flagBuilder(flagKey).version(1).on(true).variations(false, true) + .fallthroughVariation(0).build(); + DataBuilder initialData = new DataBuilder().addAny(DataModel.FEATURES, alwaysFalseFlag); + + DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(initialData.build()); + LDConfig config = new LDConfig.Builder() + .dataStore(specificDataStore(testDataStore)) + .dataSource(updatableSource) + .events(Components.noEvents()) + .build(); + + client = new LDClient(SDK_KEY, config); + FlagValueChangeEventSink eventSink1 = new FlagValueChangeEventSink(); + FlagValueChangeEventSink eventSink2 = new FlagValueChangeEventSink(); + client.registerFlagChangeListener(Components.flagValueMonitoringListener(client, flagKey, user, eventSink1)); + client.registerFlagChangeListener(Components.flagValueMonitoringListener(client, flagKey, otherUser, eventSink2)); + + eventSink1.expectNoEvents(); + eventSink2.expectNoEvents(); + + FeatureFlag flagIsTrueForMyUserOnly = flagBuilder(flagKey).version(2).on(true).variations(false, true) + .targets(ModelBuilders.target(1, user.getKey())).fallthroughVariation(0).build(); + updatableSource.updateFlag(flagIsTrueForMyUserOnly); + + // eventSink1 receives a value change event; eventSink2 doesn't because the flag's value hasn't changed for otherUser + FlagValueChangeEvent event1 = eventSink1.awaitEvent(); + assertThat(event1.getKey(), equalTo(flagKey)); + assertThat(event1.getOldValue(), equalTo(LDValue.of(false))); + assertThat(event1.getNewValue(), equalTo(LDValue.of(true))); + eventSink1.expectNoEvents(); + + eventSink2.expectNoEvents(); + } + + private void expectEventsSent(int count) { + eventProcessor.sendEvent(anyObject(Event.class)); + if (count > 0) { + expectLastCall().times(count); + } else { + expectLastCall().andThrow(new AssertionFailedError("should not have queued an event")).anyTimes(); + } + } + + private LDClientInterface createMockClient(LDConfig.Builder config) { + config.dataSource(specificDataSource(dataSource)); + config.events(specificEventProcessor(eventProcessor)); + return new LDClient(SDK_KEY, config.build()); + } +} \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java b/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java new file mode 100644 index 000000000..b0649573c --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java @@ -0,0 +1,67 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +@SuppressWarnings("javadoc") +public class LDConfigTest { + @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 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(); + HttpConfiguration hc = config.httpConfig; + HttpConfiguration defaults = Components.httpConfiguration().createHttpConfiguration(); + assertEquals(defaults.getConnectTimeout(), hc.getConnectTimeout()); + assertNull(hc.getProxy()); + assertNull(hc.getProxyAuthentication()); + assertEquals(defaults.getSocketTimeout(), hc.getSocketTimeout()); + assertNull(hc.getSslSocketFactory()); + assertNull(hc.getTrustManager()); + assertNull(hc.getWrapperIdentifier()); + } +} \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java b/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java new file mode 100644 index 000000000..33f6ed218 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java @@ -0,0 +1,347 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@SuppressWarnings("javadoc") +public abstract class ModelBuilders { + public static FlagBuilder flagBuilder(String key) { + return new FlagBuilder(key); + } + + public static FlagBuilder flagBuilder(DataModel.FeatureFlag fromFlag) { + return new FlagBuilder(fromFlag); + } + + public static DataModel.FeatureFlag booleanFlagWithClauses(String key, DataModel.Clause... clauses) { + DataModel.Rule rule = ruleBuilder().variation(1).clauses(clauses).build(); + return flagBuilder(key) + .on(true) + .rules(rule) + .fallthrough(fallthroughVariation(0)) + .offVariation(0) + .variations(LDValue.of(false), LDValue.of(true)) + .build(); + } + + public static DataModel.FeatureFlag flagWithValue(String key, LDValue value) { + return flagBuilder(key) + .on(false) + .offVariation(0) + .variations(value) + .build(); + } + + public static DataModel.VariationOrRollout fallthroughVariation(int variation) { + return new DataModel.VariationOrRollout(variation, null); + } + + public static RuleBuilder ruleBuilder() { + return new RuleBuilder(); + } + + public static DataModel.Clause clause(UserAttribute attribute, DataModel.Operator op, boolean negate, LDValue... values) { + return new DataModel.Clause(attribute, op, Arrays.asList(values), negate); + } + + public static DataModel.Clause clause(UserAttribute attribute, DataModel.Operator op, LDValue... values) { + return clause(attribute, op, false, values); + } + + public static DataModel.Clause clauseMatchingUser(LDUser user) { + return clause(UserAttribute.KEY, DataModel.Operator.in, user.getAttribute(UserAttribute.KEY)); + } + + public static DataModel.Clause clauseNotMatchingUser(LDUser user) { + return clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("not-" + user.getKey())); + } + + public static DataModel.Target target(int variation, String... userKeys) { + return new DataModel.Target(ImmutableSet.copyOf(userKeys), variation); + } + + public static DataModel.Prerequisite prerequisite(String key, int variation) { + return new DataModel.Prerequisite(key, variation); + } + + public static DataModel.Rollout emptyRollout() { + return new DataModel.Rollout(ImmutableList.of(), null); + } + + public static SegmentBuilder segmentBuilder(String key) { + return new SegmentBuilder(key); + } + + public static SegmentRuleBuilder segmentRuleBuilder() { + return new SegmentRuleBuilder(); + } + + public static class FlagBuilder { + private String key; + private int version; + private boolean on; + private List prerequisites = new ArrayList<>(); + private String salt; + private List targets = new ArrayList<>(); + private List rules = new ArrayList<>(); + private DataModel.VariationOrRollout fallthrough; + private Integer offVariation; + private List variations = new ArrayList<>(); + private boolean clientSide; + private boolean trackEvents; + private boolean trackEventsFallthrough; + private Long debugEventsUntilDate; + private boolean deleted; + + private FlagBuilder(String key) { + this.key = key; + } + + private FlagBuilder(DataModel.FeatureFlag f) { + if (f != null) { + this.key = f.getKey(); + this.version = f.getVersion(); + this.on = f.isOn(); + this.prerequisites = f.getPrerequisites(); + this.salt = f.getSalt(); + this.targets = f.getTargets(); + this.rules = f.getRules(); + this.fallthrough = f.getFallthrough(); + this.offVariation = f.getOffVariation(); + this.variations = f.getVariations(); + this.clientSide = f.isClientSide(); + this.trackEvents = f.isTrackEvents(); + this.trackEventsFallthrough = f.isTrackEventsFallthrough(); + this.debugEventsUntilDate = f.getDebugEventsUntilDate(); + this.deleted = f.isDeleted(); + } + } + + FlagBuilder version(int version) { + this.version = version; + return this; + } + + FlagBuilder on(boolean on) { + this.on = on; + return this; + } + + FlagBuilder prerequisites(DataModel.Prerequisite... prerequisites) { + this.prerequisites = Arrays.asList(prerequisites); + return this; + } + + FlagBuilder salt(String salt) { + this.salt = salt; + return this; + } + + FlagBuilder targets(DataModel.Target... targets) { + this.targets = Arrays.asList(targets); + return this; + } + + FlagBuilder rules(DataModel.Rule... rules) { + this.rules = Arrays.asList(rules); + return this; + } + + FlagBuilder fallthroughVariation(int fallthroughVariation) { + this.fallthrough = new DataModel.VariationOrRollout(fallthroughVariation, null); + return this; + } + + FlagBuilder fallthrough(DataModel.VariationOrRollout fallthrough) { + this.fallthrough = fallthrough; + return this; + } + + FlagBuilder offVariation(Integer offVariation) { + this.offVariation = offVariation; + return this; + } + + FlagBuilder variations(LDValue... variations) { + this.variations = Arrays.asList(variations); + return this; + } + + FlagBuilder variations(boolean... variations) { + List values = new ArrayList<>(); + for (boolean v: variations) { + values.add(LDValue.of(v)); + } + this.variations = values; + return this; + } + + FlagBuilder clientSide(boolean clientSide) { + this.clientSide = clientSide; + return this; + } + + FlagBuilder trackEvents(boolean trackEvents) { + this.trackEvents = trackEvents; + return this; + } + + FlagBuilder trackEventsFallthrough(boolean trackEventsFallthrough) { + this.trackEventsFallthrough = trackEventsFallthrough; + return this; + } + + FlagBuilder debugEventsUntilDate(Long debugEventsUntilDate) { + this.debugEventsUntilDate = debugEventsUntilDate; + return this; + } + + FlagBuilder deleted(boolean deleted) { + this.deleted = deleted; + return this; + } + + DataModel.FeatureFlag build() { + FeatureFlag flag = new DataModel.FeatureFlag(key, version, on, prerequisites, salt, targets, rules, fallthrough, offVariation, variations, + clientSide, trackEvents, trackEventsFallthrough, debugEventsUntilDate, deleted); + flag.afterDeserialized(); + return flag; + } + } + + public static class RuleBuilder { + private String id; + private List clauses = new ArrayList<>(); + private Integer variation; + private DataModel.Rollout rollout; + private boolean trackEvents; + + private RuleBuilder() { + } + + public DataModel.Rule build() { + return new DataModel.Rule(id, clauses, variation, rollout, trackEvents); + } + + public RuleBuilder id(String id) { + this.id = id; + return this; + } + + public RuleBuilder clauses(DataModel.Clause... clauses) { + this.clauses = ImmutableList.copyOf(clauses); + return this; + } + + public RuleBuilder variation(Integer variation) { + this.variation = variation; + return this; + } + + public RuleBuilder rollout(DataModel.Rollout rollout) { + this.rollout = rollout; + return this; + } + + public RuleBuilder trackEvents(boolean trackEvents) { + this.trackEvents = trackEvents; + return this; + } + } + + public static class SegmentBuilder { + private String key; + private Set included = new HashSet<>(); + private Set excluded = new HashSet<>(); + private String salt = ""; + private List rules = new ArrayList<>(); + private int version = 0; + private boolean deleted; + + private SegmentBuilder(String key) { + this.key = key; + } + + private SegmentBuilder(DataModel.Segment from) { + this.key = from.getKey(); + this.included = ImmutableSet.copyOf(from.getIncluded()); + this.excluded = ImmutableSet.copyOf(from.getExcluded()); + this.salt = from.getSalt(); + this.rules = ImmutableList.copyOf(from.getRules()); + this.version = from.getVersion(); + this.deleted = from.isDeleted(); + } + + public DataModel.Segment build() { + return new DataModel.Segment(key, included, excluded, salt, rules, version, deleted); + } + + public SegmentBuilder included(String... included) { + this.included = ImmutableSet.copyOf(included); + return this; + } + + public SegmentBuilder excluded(String... excluded) { + this.excluded = ImmutableSet.copyOf(excluded); + return this; + } + + public SegmentBuilder salt(String salt) { + this.salt = salt; + return this; + } + + public SegmentBuilder rules(DataModel.SegmentRule... rules) { + this.rules = Arrays.asList(rules); + return this; + } + + public SegmentBuilder version(int version) { + this.version = version; + return this; + } + + public SegmentBuilder deleted(boolean deleted) { + this.deleted = deleted; + return this; + } + } + + public static class SegmentRuleBuilder { + private List clauses = new ArrayList<>(); + private Integer weight; + private UserAttribute bucketBy; + + private SegmentRuleBuilder() { + } + + public DataModel.SegmentRule build() { + return new DataModel.SegmentRule(clauses, weight, bucketBy); + } + + public SegmentRuleBuilder clauses(DataModel.Clause... clauses) { + this.clauses = ImmutableList.copyOf(clauses); + return this; + } + + public SegmentRuleBuilder weight(Integer weight) { + this.weight = weight; + return this; + } + + public SegmentRuleBuilder bucketBy(UserAttribute bucketBy) { + this.bucketBy = bucketBy; + return this; + } + } +} diff --git a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java similarity index 59% rename from src/test/java/com/launchdarkly/client/PollingProcessorTest.java rename to src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java index 8a9eff959..b43a2e2c5 100644 --- a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java @@ -1,16 +1,29 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.integrations.PollingDataSourceBuilder; +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.DefaultFeatureRequestor; +import com.launchdarkly.sdk.server.FeatureRequestor; +import com.launchdarkly.sdk.server.HttpErrorException; +import com.launchdarkly.sdk.server.InMemoryDataStore; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.PollingProcessor; +import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStore; import org.junit.Test; import java.io.IOException; import java.net.URI; +import java.time.Duration; import java.util.HashMap; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import static com.launchdarkly.sdk.server.TestComponents.clientContext; +import static com.launchdarkly.sdk.server.TestComponents.dataStoreUpdates; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertFalse; @@ -20,71 +33,40 @@ @SuppressWarnings("javadoc") public class PollingProcessorTest { private static final String SDK_KEY = "sdk-key"; - private static final long LENGTHY_INTERVAL = 60000; + private static final Duration LENGTHY_INTERVAL = Duration.ofSeconds(60); @Test public void builderHasDefaultConfiguration() throws Exception { - UpdateProcessorFactory f = Components.pollingDataSource(); - try (PollingProcessor pp = (PollingProcessor)f.createUpdateProcessor(SDK_KEY, LDConfig.DEFAULT, null)) { + DataSourceFactory f = Components.pollingDataSource(); + try (PollingProcessor pp = (PollingProcessor)f.createDataSource(clientContext(SDK_KEY, LDConfig.DEFAULT), null)) { assertThat(((DefaultFeatureRequestor)pp.requestor).baseUri, equalTo(LDConfig.DEFAULT_BASE_URI)); - assertThat(pp.pollIntervalMillis, equalTo(PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL_MILLIS)); + assertThat(pp.pollInterval, equalTo(PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL)); } } @Test public void builderCanSpecifyConfiguration() throws Exception { URI uri = URI.create("http://fake"); - UpdateProcessorFactory f = Components.pollingDataSource() - .baseURI(uri) - .pollIntervalMillis(LENGTHY_INTERVAL); - try (PollingProcessor pp = (PollingProcessor)f.createUpdateProcessor(SDK_KEY, LDConfig.DEFAULT, null)) { - assertThat(((DefaultFeatureRequestor)pp.requestor).baseUri, equalTo(uri)); - assertThat(pp.pollIntervalMillis, equalTo(LENGTHY_INTERVAL)); - } - } - - @Test - @SuppressWarnings("deprecation") - public void deprecatedConfigurationIsUsedWhenBuilderIsNotUsed() throws Exception { - URI uri = URI.create("http://fake"); - LDConfig config = new LDConfig.Builder() + DataSourceFactory f = Components.pollingDataSource() .baseURI(uri) - .pollingIntervalMillis(LENGTHY_INTERVAL) - .stream(false) - .build(); - UpdateProcessorFactory f = Components.defaultUpdateProcessor(); - try (PollingProcessor pp = (PollingProcessor)f.createUpdateProcessor(SDK_KEY, config, null)) { + .pollInterval(LENGTHY_INTERVAL); + try (PollingProcessor pp = (PollingProcessor)f.createDataSource(clientContext(SDK_KEY, LDConfig.DEFAULT), null)) { assertThat(((DefaultFeatureRequestor)pp.requestor).baseUri, equalTo(uri)); - assertThat(pp.pollIntervalMillis, equalTo(LENGTHY_INTERVAL)); - } - } - - @Test - @SuppressWarnings("deprecation") - public void deprecatedConfigurationHasSameDefaultsAsBuilder() throws Exception { - UpdateProcessorFactory f0 = Components.pollingDataSource(); - UpdateProcessorFactory f1 = Components.defaultUpdateProcessor(); - LDConfig config = new LDConfig.Builder().stream(false).build(); - try (PollingProcessor pp0 = (PollingProcessor)f0.createUpdateProcessor(SDK_KEY, LDConfig.DEFAULT, null)) { - try (PollingProcessor pp1 = (PollingProcessor)f1.createUpdateProcessor(SDK_KEY, config, null)) { - assertThat(((DefaultFeatureRequestor)pp1.requestor).baseUri, - equalTo(((DefaultFeatureRequestor)pp0.requestor).baseUri)); - assertThat(pp1.pollIntervalMillis, equalTo(pp0.pollIntervalMillis)); - } + assertThat(pp.pollInterval, equalTo(LENGTHY_INTERVAL)); } } @Test public void testConnectionOk() throws Exception { MockFeatureRequestor requestor = new MockFeatureRequestor(); - requestor.allData = new FeatureRequestor.AllData(new HashMap(), new HashMap()); - FeatureStore store = new InMemoryFeatureStore(); + requestor.allData = new FeatureRequestor.AllData(new HashMap<>(), new HashMap<>()); + DataStore store = new InMemoryDataStore(); - try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, store, LENGTHY_INTERVAL)) { + try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, dataStoreUpdates(store), LENGTHY_INTERVAL)) { Future initFuture = pollingProcessor.start(); initFuture.get(1000, TimeUnit.MILLISECONDS); - assertTrue(pollingProcessor.initialized()); - assertTrue(store.initialized()); + assertTrue(pollingProcessor.isInitialized()); + assertTrue(store.isInitialized()); } } @@ -92,9 +74,9 @@ public void testConnectionOk() throws Exception { public void testConnectionProblem() throws Exception { MockFeatureRequestor requestor = new MockFeatureRequestor(); requestor.ioException = new IOException("This exception is part of a test and yes you should be seeing it."); - FeatureStore store = new InMemoryFeatureStore(); + DataStore store = new InMemoryDataStore(); - try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, store, LENGTHY_INTERVAL)) { + try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, dataStoreUpdates(store), LENGTHY_INTERVAL)) { Future initFuture = pollingProcessor.start(); try { initFuture.get(200L, TimeUnit.MILLISECONDS); @@ -102,8 +84,8 @@ public void testConnectionProblem() throws Exception { } catch (TimeoutException ignored) { } assertFalse(initFuture.isDone()); - assertFalse(pollingProcessor.initialized()); - assertFalse(store.initialized()); + assertFalse(pollingProcessor.isInitialized()); + assertFalse(store.isInitialized()); } } @@ -140,7 +122,9 @@ public void http500ErrorIsRecoverable() throws Exception { private void testUnrecoverableHttpError(int status) throws Exception { MockFeatureRequestor requestor = new MockFeatureRequestor(); requestor.httpException = new HttpErrorException(status); - try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, new InMemoryFeatureStore(), LENGTHY_INTERVAL)) { + DataStore store = new InMemoryDataStore(); + + try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, dataStoreUpdates(store), LENGTHY_INTERVAL)) { long startTime = System.currentTimeMillis(); Future initFuture = pollingProcessor.start(); try { @@ -150,14 +134,16 @@ private void testUnrecoverableHttpError(int status) throws Exception { } assertTrue((System.currentTimeMillis() - startTime) < 9000); assertTrue(initFuture.isDone()); - assertFalse(pollingProcessor.initialized()); + assertFalse(pollingProcessor.isInitialized()); } } private void testRecoverableHttpError(int status) throws Exception { MockFeatureRequestor requestor = new MockFeatureRequestor(); requestor.httpException = new HttpErrorException(status); - try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, new InMemoryFeatureStore(), LENGTHY_INTERVAL)) { + DataStore store = new InMemoryDataStore(); + + try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, dataStoreUpdates(store), LENGTHY_INTERVAL)) { Future initFuture = pollingProcessor.start(); try { initFuture.get(200, TimeUnit.MILLISECONDS); @@ -165,7 +151,7 @@ private void testRecoverableHttpError(int status) throws Exception { } catch (TimeoutException ignored) { } assertFalse(initFuture.isDone()); - assertFalse(pollingProcessor.initialized()); + assertFalse(pollingProcessor.isInitialized()); } } @@ -176,11 +162,11 @@ private static class MockFeatureRequestor implements FeatureRequestor { public void close() throws IOException {} - public FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException { + public DataModel.FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException { return null; } - public Segment getSegment(String segmentKey) throws IOException, HttpErrorException { + public DataModel.Segment getSegment(String segmentKey) throws IOException, HttpErrorException { return null; } diff --git a/src/test/java/com/launchdarkly/client/SemanticVersionTest.java b/src/test/java/com/launchdarkly/sdk/server/SemanticVersionTest.java similarity index 98% rename from src/test/java/com/launchdarkly/client/SemanticVersionTest.java rename to src/test/java/com/launchdarkly/sdk/server/SemanticVersionTest.java index cfb08752e..41ceb972b 100644 --- a/src/test/java/com/launchdarkly/client/SemanticVersionTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/SemanticVersionTest.java @@ -1,12 +1,14 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; +import com.launchdarkly.sdk.server.SemanticVersion; + import org.junit.Test; +@SuppressWarnings("javadoc") public class SemanticVersionTest { - @Test public void canParseSimpleCompleteVersion() throws Exception { SemanticVersion sv = SemanticVersion.parse("2.3.4"); diff --git a/src/test/java/com/launchdarkly/client/SimpleLRUCacheTest.java b/src/test/java/com/launchdarkly/sdk/server/SimpleLRUCacheTest.java similarity index 92% rename from src/test/java/com/launchdarkly/client/SimpleLRUCacheTest.java rename to src/test/java/com/launchdarkly/sdk/server/SimpleLRUCacheTest.java index 996d51c29..69cf609e6 100644 --- a/src/test/java/com/launchdarkly/client/SimpleLRUCacheTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/SimpleLRUCacheTest.java @@ -1,10 +1,13 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.SimpleLRUCache; import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; +@SuppressWarnings("javadoc") public class SimpleLRUCacheTest { @Test public void getReturnsNullForNeverSeenValue() { diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java similarity index 63% rename from src/test/java/com/launchdarkly/client/StreamProcessorTest.java rename to src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index 8858e6a3f..ce466454f 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -1,12 +1,16 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -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; import com.launchdarkly.eventsource.MessageEvent; import com.launchdarkly.eventsource.UnsuccessfulResponseException; +import com.launchdarkly.sdk.server.TestComponents.MockEventSourceCreator; +import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import org.easymock.EasyMockSupport; import org.junit.Before; @@ -14,8 +18,10 @@ import java.io.IOException; import java.net.URI; +import java.time.Duration; import java.util.Collections; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; @@ -23,11 +29,19 @@ import javax.net.ssl.SSLHandshakeException; -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 com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.JsonHelpers.gsonInstance; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; +import static com.launchdarkly.sdk.server.TestComponents.clientContext; +import static com.launchdarkly.sdk.server.TestComponents.dataStoreThatThrowsException; +import static com.launchdarkly.sdk.server.TestComponents.dataStoreUpdates; +import static com.launchdarkly.sdk.server.TestHttpUtil.eventStreamResponse; +import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; +import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; +import static com.launchdarkly.sdk.server.TestUtil.upsertSegment; +import static com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY; import static org.easymock.EasyMock.expect; import static org.easymock.EasyMock.expectLastCall; import static org.hamcrest.MatcherAssert.assertThat; @@ -41,7 +55,6 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; -import okhttp3.Headers; import okhttp3.HttpUrl; import okhttp3.mockwebserver.MockWebServer; @@ -52,34 +65,33 @@ public class StreamProcessorTest extends EasyMockSupport { private static final URI STREAM_URI = URI.create("http://stream.test.com"); private static final String FEATURE1_KEY = "feature1"; private static final int FEATURE1_VERSION = 11; - private static final FeatureFlag FEATURE = new FeatureFlagBuilder(FEATURE1_KEY).version(FEATURE1_VERSION).build(); + private static final DataModel.FeatureFlag FEATURE = flagBuilder(FEATURE1_KEY).version(FEATURE1_VERSION).build(); private static final String SEGMENT1_KEY = "segment1"; private static final int SEGMENT1_VERSION = 22; - private static final Segment SEGMENT = new Segment.Builder(SEGMENT1_KEY).version(SEGMENT1_VERSION).build(); + private static final DataModel.Segment SEGMENT = segmentBuilder(SEGMENT1_KEY).version(SEGMENT1_VERSION).build(); private static final String STREAM_RESPONSE_WITH_EMPTY_DATA = "event: put\n" + "data: {\"data\":{\"flags\":{},\"segments\":{}}}\n\n"; - private InMemoryFeatureStore featureStore; + private InMemoryDataStore dataStore; private FeatureRequestor mockRequestor; private EventSource mockEventSource; - private EventHandler eventHandler; - private URI actualStreamUri; - private ConnectionErrorHandler errorHandler; - private Headers headers; + private MockEventSourceCreator mockEventSourceCreator; @Before public void setup() { - featureStore = new InMemoryFeatureStore(); + dataStore = new InMemoryDataStore(); mockRequestor = createStrictMock(FeatureRequestor.class); - mockEventSource = createStrictMock(EventSource.class); + mockEventSource = createMock(EventSource.class); + mockEventSourceCreator = new MockEventSourceCreator(mockEventSource); } @Test public void builderHasDefaultConfiguration() throws Exception { - UpdateProcessorFactory f = Components.streamingDataSource(); - try (StreamProcessor sp = (StreamProcessor)f.createUpdateProcessor(SDK_KEY, LDConfig.DEFAULT, null)) { - assertThat(sp.initialReconnectDelayMillis, equalTo(StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS)); + DataSourceFactory f = Components.streamingDataSource(); + try (StreamProcessor sp = (StreamProcessor)f.createDataSource(clientContext(SDK_KEY, LDConfig.DEFAULT), + dataStoreUpdates(dataStore))) { + assertThat(sp.initialReconnectDelay, equalTo(StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY)); assertThat(sp.streamUri, equalTo(LDConfig.DEFAULT_STREAM_URI)); assertThat(((DefaultFeatureRequestor)sp.requestor).baseUri, equalTo(LDConfig.DEFAULT_BASE_URI)); } @@ -89,72 +101,44 @@ public void builderHasDefaultConfiguration() throws Exception { public void builderCanSpecifyConfiguration() throws Exception { URI streamUri = URI.create("http://fake"); URI pollUri = URI.create("http://also-fake"); - UpdateProcessorFactory f = Components.streamingDataSource() + DataSourceFactory f = Components.streamingDataSource() .baseURI(streamUri) - .initialReconnectDelayMillis(5555) + .initialReconnectDelay(Duration.ofMillis(5555)) .pollingBaseURI(pollUri); - try (StreamProcessor sp = (StreamProcessor)f.createUpdateProcessor(SDK_KEY, LDConfig.DEFAULT, null)) { - assertThat(sp.initialReconnectDelayMillis, equalTo(5555L)); + try (StreamProcessor sp = (StreamProcessor)f.createDataSource(clientContext(SDK_KEY, LDConfig.DEFAULT), + dataStoreUpdates(dataStore))) { + assertThat(sp.initialReconnectDelay, equalTo(Duration.ofMillis(5555))); assertThat(sp.streamUri, equalTo(streamUri)); assertThat(((DefaultFeatureRequestor)sp.requestor).baseUri, equalTo(pollUri)); } } - @Test - @SuppressWarnings("deprecation") - public void deprecatedConfigurationIsUsedWhenBuilderIsNotUsed() throws Exception { - URI streamUri = URI.create("http://fake"); - URI pollUri = URI.create("http://also-fake"); - LDConfig config = new LDConfig.Builder() - .baseURI(pollUri) - .reconnectTimeMs(5555) - .streamURI(streamUri) - .build(); - UpdateProcessorFactory f = Components.defaultUpdateProcessor(); - try (StreamProcessor sp = (StreamProcessor)f.createUpdateProcessor(SDK_KEY, config, null)) { - assertThat(sp.initialReconnectDelayMillis, equalTo(5555L)); - assertThat(sp.streamUri, equalTo(streamUri)); - assertThat(((DefaultFeatureRequestor)sp.requestor).baseUri, equalTo(pollUri)); - } - } - - @Test - @SuppressWarnings("deprecation") - public void deprecatedConfigurationHasSameDefaultsAsBuilder() throws Exception { - UpdateProcessorFactory f0 = Components.streamingDataSource(); - UpdateProcessorFactory f1 = Components.defaultUpdateProcessor(); - try (StreamProcessor sp0 = (StreamProcessor)f0.createUpdateProcessor(SDK_KEY, LDConfig.DEFAULT, null)) { - try (StreamProcessor sp1 = (StreamProcessor)f1.createUpdateProcessor(SDK_KEY, LDConfig.DEFAULT, null)) { - assertThat(sp1.initialReconnectDelayMillis, equalTo(sp0.initialReconnectDelayMillis)); - assertThat(sp1.streamUri, equalTo(sp0.streamUri)); - assertThat(((DefaultFeatureRequestor)sp1.requestor).baseUri, - equalTo(((DefaultFeatureRequestor)sp0.requestor).baseUri)); - } - } - } - @Test public void streamUriHasCorrectEndpoint() { createStreamProcessor(STREAM_URI).start(); - assertEquals(URI.create(STREAM_URI.toString() + "/all"), actualStreamUri); + assertEquals(URI.create(STREAM_URI.toString() + "/all"), + mockEventSourceCreator.getNextReceivedParams().streamUri); } @Test public void headersHaveAuthorization() { createStreamProcessor(STREAM_URI).start(); - assertEquals(SDK_KEY, headers.get("Authorization")); + assertEquals(SDK_KEY, + mockEventSourceCreator.getNextReceivedParams().headers.get("Authorization")); } @Test public void headersHaveUserAgent() { createStreamProcessor(STREAM_URI).start(); - assertEquals("JavaClient/" + LDClient.CLIENT_VERSION, headers.get("User-Agent")); + assertEquals("JavaClient/" + LDClient.CLIENT_VERSION, + mockEventSourceCreator.getNextReceivedParams().headers.get("User-Agent")); } @Test public void headersHaveAccept() { createStreamProcessor(STREAM_URI).start(); - assertEquals("text/event-stream", headers.get("Accept")); + assertEquals("text/event-stream", + mockEventSourceCreator.getNextReceivedParams().headers.get("Accept")); } @Test @@ -163,7 +147,8 @@ public void headersHaveWrapperWhenSet() { .http(Components.httpConfiguration().wrapper("Scala", "0.1.0")) .build(); createStreamProcessor(config, STREAM_URI).start(); - assertEquals("Scala/0.1.0", headers.get("X-LaunchDarkly-Wrapper")); + assertEquals("Scala/0.1.0", + mockEventSourceCreator.getNextReceivedParams().headers.get("X-LaunchDarkly-Wrapper")); } @Test @@ -171,10 +156,12 @@ public void putCausesFeatureToBeStored() throws Exception { expectNoStreamRestart(); createStreamProcessor(STREAM_URI).start(); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + MessageEvent event = new MessageEvent("{\"data\":{\"flags\":{\"" + FEATURE1_KEY + "\":" + featureJson(FEATURE1_KEY, FEATURE1_VERSION) + "}," + "\"segments\":{}}}"); - eventHandler.onMessage("put", event); + handler.onMessage("put", event); assertFeatureInStore(FEATURE); } @@ -184,9 +171,11 @@ public void putCausesSegmentToBeStored() throws Exception { expectNoStreamRestart(); createStreamProcessor(STREAM_URI).start(); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + MessageEvent event = new MessageEvent("{\"data\":{\"flags\":{},\"segments\":{\"" + SEGMENT1_KEY + "\":" + segmentJson(SEGMENT1_KEY, SEGMENT1_VERSION) + "}}}"); - eventHandler.onMessage("put", event); + handler.onMessage("put", event); assertSegmentInStore(SEGMENT); } @@ -194,29 +183,31 @@ public void putCausesSegmentToBeStored() throws Exception { @Test public void storeNotInitializedByDefault() throws Exception { createStreamProcessor(STREAM_URI).start(); - assertFalse(featureStore.initialized()); + assertFalse(dataStore.isInitialized()); } @Test public void putCausesStoreToBeInitialized() throws Exception { createStreamProcessor(STREAM_URI).start(); - eventHandler.onMessage("put", emptyPutEvent()); - assertTrue(featureStore.initialized()); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); + assertTrue(dataStore.isInitialized()); } @Test public void processorNotInitializedByDefault() throws Exception { StreamProcessor sp = createStreamProcessor(STREAM_URI); sp.start(); - assertFalse(sp.initialized()); + assertFalse(sp.isInitialized()); } @Test public void putCausesProcessorToBeInitialized() throws Exception { StreamProcessor sp = createStreamProcessor(STREAM_URI); sp.start(); - eventHandler.onMessage("put", emptyPutEvent()); - assertTrue(sp.initialized()); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); + assertTrue(sp.isInitialized()); } @Test @@ -230,7 +221,8 @@ public void futureIsNotSetByDefault() throws Exception { public void putCausesFutureToBeSet() throws Exception { StreamProcessor sp = createStreamProcessor(STREAM_URI); Future future = sp.start(); - eventHandler.onMessage("put", emptyPutEvent()); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); assertTrue(future.isDone()); } @@ -239,12 +231,13 @@ public void patchUpdatesFeature() throws Exception { expectNoStreamRestart(); createStreamProcessor(STREAM_URI).start(); - eventHandler.onMessage("put", emptyPutEvent()); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); String path = "/flags/" + FEATURE1_KEY; MessageEvent event = new MessageEvent("{\"path\":\"" + path + "\",\"data\":" + featureJson(FEATURE1_KEY, FEATURE1_VERSION) + "}"); - eventHandler.onMessage("patch", event); + handler.onMessage("patch", event); assertFeatureInStore(FEATURE); } @@ -254,12 +247,13 @@ public void patchUpdatesSegment() throws Exception { expectNoStreamRestart(); createStreamProcessor(STREAM_URI).start(); - eventHandler.onMessage("put", emptyPutEvent()); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); String path = "/segments/" + SEGMENT1_KEY; MessageEvent event = new MessageEvent("{\"path\":\"" + path + "\",\"data\":" + segmentJson(SEGMENT1_KEY, SEGMENT1_VERSION) + "}"); - eventHandler.onMessage("patch", event); + handler.onMessage("patch", event); assertSegmentInStore(SEGMENT); } @@ -269,15 +263,16 @@ public void deleteDeletesFeature() throws Exception { expectNoStreamRestart(); createStreamProcessor(STREAM_URI).start(); - eventHandler.onMessage("put", emptyPutEvent()); - featureStore.upsert(FEATURES, FEATURE); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); + upsertFlag(dataStore, FEATURE); String path = "/flags/" + FEATURE1_KEY; MessageEvent event = new MessageEvent("{\"path\":\"" + path + "\",\"version\":" + (FEATURE1_VERSION + 1) + "}"); - eventHandler.onMessage("delete", event); + handler.onMessage("delete", event); - assertNull(featureStore.get(FEATURES, FEATURE1_KEY)); + assertEquals(ItemDescriptor.deletedItem(FEATURE1_VERSION + 1), dataStore.get(FEATURES, FEATURE1_KEY)); } @Test @@ -285,15 +280,16 @@ public void deleteDeletesSegment() throws Exception { expectNoStreamRestart(); createStreamProcessor(STREAM_URI).start(); - eventHandler.onMessage("put", emptyPutEvent()); - featureStore.upsert(SEGMENTS, SEGMENT); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); + upsertSegment(dataStore, SEGMENT); String path = "/segments/" + SEGMENT1_KEY; MessageEvent event = new MessageEvent("{\"path\":\"" + path + "\",\"version\":" + (SEGMENT1_VERSION + 1) + "}"); - eventHandler.onMessage("delete", event); + handler.onMessage("delete", event); - assertNull(featureStore.get(SEGMENTS, SEGMENT1_KEY)); + assertEquals(ItemDescriptor.deletedItem(SEGMENT1_VERSION + 1), dataStore.get(SEGMENTS, SEGMENT1_KEY)); } @Test @@ -305,7 +301,8 @@ public void indirectPutRequestsAndStoresFeature() throws Exception { try (StreamProcessor sp = createStreamProcessor(STREAM_URI)) { sp.start(); - eventHandler.onMessage("indirect/put", new MessageEvent("")); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("indirect/put", new MessageEvent("")); assertFeatureInStore(FEATURE); } @@ -317,9 +314,10 @@ public void indirectPutInitializesStore() throws Exception { setupRequestorToReturnAllDataWithFlag(FEATURE); replayAll(); - eventHandler.onMessage("indirect/put", new MessageEvent("")); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("indirect/put", new MessageEvent("")); - assertTrue(featureStore.initialized()); + assertTrue(dataStore.isInitialized()); } @Test @@ -329,9 +327,10 @@ public void indirectPutInitializesProcessor() throws Exception { setupRequestorToReturnAllDataWithFlag(FEATURE); replayAll(); - eventHandler.onMessage("indirect/put", new MessageEvent("")); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("indirect/put", new MessageEvent("")); - assertTrue(featureStore.initialized()); + assertTrue(dataStore.isInitialized()); } @Test @@ -341,7 +340,8 @@ public void indirectPutSetsFuture() throws Exception { setupRequestorToReturnAllDataWithFlag(FEATURE); replayAll(); - eventHandler.onMessage("indirect/put", new MessageEvent("")); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("indirect/put", new MessageEvent("")); assertTrue(future.isDone()); } @@ -355,8 +355,9 @@ public void indirectPatchRequestsAndUpdatesFeature() throws Exception { try (StreamProcessor sp = createStreamProcessor(STREAM_URI)) { sp.start(); - eventHandler.onMessage("put", emptyPutEvent()); - eventHandler.onMessage("indirect/patch", new MessageEvent("/flags/" + FEATURE1_KEY)); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); + handler.onMessage("indirect/patch", new MessageEvent("/flags/" + FEATURE1_KEY)); assertFeatureInStore(FEATURE); } @@ -371,8 +372,9 @@ public void indirectPatchRequestsAndUpdatesSegment() throws Exception { try (StreamProcessor sp = createStreamProcessor(STREAM_URI)) { sp.start(); - eventHandler.onMessage("put", emptyPutEvent()); - eventHandler.onMessage("indirect/patch", new MessageEvent("/segments/" + SEGMENT1_KEY)); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); + handler.onMessage("indirect/patch", new MessageEvent("/segments/" + SEGMENT1_KEY)); assertSegmentInStore(SEGMENT); } @@ -381,12 +383,14 @@ public void indirectPatchRequestsAndUpdatesSegment() throws Exception { @Test public void unknownEventTypeDoesNotThrowException() throws Exception { createStreamProcessor(STREAM_URI).start(); - eventHandler.onMessage("what", new MessageEvent("")); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("what", new MessageEvent("")); } @Test public void streamWillReconnectAfterGeneralIOException() throws Exception { createStreamProcessor(STREAM_URI).start(); + ConnectionErrorHandler errorHandler = mockEventSourceCreator.getNextReceivedParams().errorHandler; ConnectionErrorHandler.Action action = errorHandler.onConnectionError(new IOException()); assertEquals(ConnectionErrorHandler.Action.PROCEED, action); } @@ -396,7 +400,8 @@ public void streamInitDiagnosticRecordedOnOpen() throws Exception { DiagnosticAccumulator acc = new DiagnosticAccumulator(new DiagnosticId(SDK_KEY)); long startTime = System.currentTimeMillis(); createStreamProcessor(LDConfig.DEFAULT, STREAM_URI, acc).start(); - eventHandler.onMessage("put", emptyPutEvent()); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); long timeAfterOpen = System.currentTimeMillis(); DiagnosticEvent.Statistics event = acc.createEventAndReset(0, 0); assertEquals(1, event.streamInits.size()); @@ -412,6 +417,7 @@ public void streamInitDiagnosticRecordedOnErrorDuringInit() throws Exception { DiagnosticAccumulator acc = new DiagnosticAccumulator(new DiagnosticId(SDK_KEY)); long startTime = System.currentTimeMillis(); createStreamProcessor(LDConfig.DEFAULT, STREAM_URI, acc).start(); + ConnectionErrorHandler errorHandler = mockEventSourceCreator.getNextReceivedParams().errorHandler; errorHandler.onConnectionError(new IOException()); long timeAfterOpen = System.currentTimeMillis(); DiagnosticEvent.Statistics event = acc.createEventAndReset(0, 0); @@ -427,10 +433,11 @@ public void streamInitDiagnosticRecordedOnErrorDuringInit() throws Exception { public void streamInitDiagnosticNotRecordedOnErrorAfterInit() throws Exception { DiagnosticAccumulator acc = new DiagnosticAccumulator(new DiagnosticId(SDK_KEY)); createStreamProcessor(LDConfig.DEFAULT, STREAM_URI, acc).start(); - eventHandler.onMessage("put", emptyPutEvent()); + StreamProcessor.EventSourceParams params = mockEventSourceCreator.getNextReceivedParams(); + params.handler.onMessage("put", emptyPutEvent()); // Drop first stream init from stream open acc.createEventAndReset(0, 0); - errorHandler.onConnectionError(new IOException()); + params.errorHandler.onConnectionError(new IOException()); DiagnosticEvent.Statistics event = acc.createEventAndReset(0, 0); assertEquals(0, event.streamInits.size()); } @@ -467,22 +474,22 @@ public void http500ErrorIsRecoverable() throws Exception { @Test public void putEventWithInvalidJsonCausesStreamRestart() throws Exception { - verifyEventCausesStreamRestart("put", "{sorry"); + verifyEventCausesStreamRestartWithInMemoryStore("put", "{sorry"); } @Test public void putEventWithWellFormedJsonButInvalidDataCausesStreamRestart() throws Exception { - verifyEventCausesStreamRestart("put", "{\"data\":{\"flags\":3}}"); + verifyEventCausesStreamRestartWithInMemoryStore("put", "{\"data\":{\"flags\":3}}"); } @Test public void patchEventWithInvalidJsonCausesStreamRestart() throws Exception { - verifyEventCausesStreamRestart("patch", "{sorry"); + verifyEventCausesStreamRestartWithInMemoryStore("patch", "{sorry"); } @Test public void patchEventWithWellFormedJsonButInvalidDataCausesStreamRestart() throws Exception { - verifyEventCausesStreamRestart("patch", "{\"path\":\"/flags/flagkey\", \"data\":{\"rules\":3}}"); + verifyEventCausesStreamRestartWithInMemoryStore("patch", "{\"path\":\"/flags/flagkey\", \"data\":{\"rules\":3}}"); } @Test @@ -492,7 +499,7 @@ public void patchEventWithInvalidPathCausesNoStreamRestart() throws Exception { @Test public void deleteEventWithInvalidJsonCausesStreamRestart() throws Exception { - verifyEventCausesStreamRestart("delete", "{sorry"); + verifyEventCausesStreamRestartWithInMemoryStore("delete", "{sorry"); } @Test @@ -508,37 +515,98 @@ public void indirectPatchEventWithInvalidPathDoesNotCauseStreamRestart() throws @Test public void indirectPutWithFailedPollCausesStreamRestart() throws Exception { expect(mockRequestor.getAllData()).andThrow(new IOException("sorry")); - verifyEventCausesStreamRestart("indirect/put", ""); + verifyEventCausesStreamRestartWithInMemoryStore("indirect/put", ""); } @Test public void indirectPatchWithFailedPollCausesStreamRestart() throws Exception { expect(mockRequestor.getFlag("flagkey")).andThrow(new IOException("sorry")); - verifyEventCausesStreamRestart("indirect/patch", "/flags/flagkey"); + verifyEventCausesStreamRestartWithInMemoryStore("indirect/patch", "/flags/flagkey"); } + @Test + public void restartsStreamIfStoreNeedsRefresh() throws Exception { + TestComponents.DataStoreWithStatusUpdates storeWithStatus = new TestComponents.DataStoreWithStatusUpdates(dataStore); + + CompletableFuture restarted = new CompletableFuture<>(); + mockEventSource.start(); + expectLastCall(); + mockEventSource.restart(); + expectLastCall().andAnswer(() -> { + restarted.complete(null); + return null; + }); + mockEventSource.close(); + expectLastCall(); + mockRequestor.close(); + expectLastCall(); + + replayAll(); + + try (StreamProcessor sp = createStreamProcessorWithStore(storeWithStatus)) { + sp.start(); + + storeWithStatus.broadcastStatusChange(new DataStoreStatusProvider.Status(false, false)); + storeWithStatus.broadcastStatusChange(new DataStoreStatusProvider.Status(true, true)); + + restarted.get(); + } + } + + @Test + public void doesNotRestartStreamIfStoreHadOutageButDoesNotNeedRefresh() throws Exception { + TestComponents.DataStoreWithStatusUpdates storeWithStatus = new TestComponents.DataStoreWithStatusUpdates(dataStore); + + CompletableFuture restarted = new CompletableFuture<>(); + mockEventSource.start(); + expectLastCall(); + mockEventSource.restart(); + expectLastCall().andAnswer(() -> { + restarted.complete(null); + return null; + }); + mockEventSource.close(); + expectLastCall(); + mockRequestor.close(); + expectLastCall(); + + replayAll(); + + try (StreamProcessor sp = createStreamProcessorWithStore(storeWithStatus)) { + sp.start(); + + storeWithStatus.broadcastStatusChange(new DataStoreStatusProvider.Status(false, false)); + storeWithStatus.broadcastStatusChange(new DataStoreStatusProvider.Status(true, false)); + + Thread.sleep(500); + assertFalse(restarted.isDone()); + } + } + @Test public void storeFailureOnPutCausesStreamRestart() throws Exception { - FeatureStore badStore = featureStoreThatThrowsException(new RuntimeException("sorry")); + DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); expectStreamRestart(); replayAll(); try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { sp.start(); - eventHandler.onMessage("put", emptyPutEvent()); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); } verifyAll(); } @Test public void storeFailureOnPatchCausesStreamRestart() throws Exception { - FeatureStore badStore = featureStoreThatThrowsException(new RuntimeException("sorry")); + DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); expectStreamRestart(); replayAll(); try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { sp.start(); - eventHandler.onMessage("patch", + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("patch", new MessageEvent("{\"path\":\"/flags/flagkey\",\"data\":{\"key\":\"flagkey\",\"version\":1}}")); } verifyAll(); @@ -546,13 +614,14 @@ public void storeFailureOnPatchCausesStreamRestart() throws Exception { @Test public void storeFailureOnDeleteCausesStreamRestart() throws Exception { - FeatureStore badStore = featureStoreThatThrowsException(new RuntimeException("sorry")); + DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); expectStreamRestart(); replayAll(); try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { sp.start(); - eventHandler.onMessage("delete", + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("delete", new MessageEvent("{\"path\":\"/flags/flagkey\",\"version\":1}")); } verifyAll(); @@ -560,21 +629,22 @@ public void storeFailureOnDeleteCausesStreamRestart() throws Exception { @Test public void storeFailureOnIndirectPutCausesStreamRestart() throws Exception { - FeatureStore badStore = featureStoreThatThrowsException(new RuntimeException("sorry")); + DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); setupRequestorToReturnAllDataWithFlag(FEATURE); expectStreamRestart(); replayAll(); try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { sp.start(); - eventHandler.onMessage("indirect/put", new MessageEvent("")); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("indirect/put", new MessageEvent("")); } verifyAll(); } @Test public void storeFailureOnIndirectPatchCausesStreamRestart() throws Exception { - FeatureStore badStore = featureStoreThatThrowsException(new RuntimeException("sorry")); + DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); setupRequestorToReturnAllDataWithFlag(FEATURE); expectStreamRestart(); @@ -582,7 +652,8 @@ public void storeFailureOnIndirectPatchCausesStreamRestart() throws Exception { try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { sp.start(); - eventHandler.onMessage("indirect/put", new MessageEvent("")); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("indirect/put", new MessageEvent("")); } verifyAll(); } @@ -592,7 +663,7 @@ private void verifyEventCausesNoStreamRestart(String eventName, String eventData verifyEventBehavior(eventName, eventData); } - private void verifyEventCausesStreamRestart(String eventName, String eventData) throws Exception { + private void verifyEventCausesStreamRestartWithInMemoryStore(String eventName, String eventData) throws Exception { expectStreamRestart(); verifyEventBehavior(eventName, eventData); } @@ -601,7 +672,8 @@ private void verifyEventBehavior(String eventName, String eventData) throws Exce replayAll(); try (StreamProcessor sp = createStreamProcessor(LDConfig.DEFAULT, STREAM_URI, null)) { sp.start(); - eventHandler.onMessage(eventName, new MessageEvent(eventData)); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage(eventName, new MessageEvent(eventData)); } verifyAll(); } @@ -705,6 +777,7 @@ private void testUnrecoverableHttpError(int status) throws Exception { StreamProcessor sp = createStreamProcessor(STREAM_URI); Future initFuture = sp.start(); + ConnectionErrorHandler errorHandler = mockEventSourceCreator.getNextReceivedParams().errorHandler; ConnectionErrorHandler.Action action = errorHandler.onConnectionError(e); assertEquals(ConnectionErrorHandler.Action.SHUTDOWN, action); @@ -715,7 +788,7 @@ private void testUnrecoverableHttpError(int status) throws Exception { } assertTrue((System.currentTimeMillis() - startTime) < 9000); assertTrue(initFuture.isDone()); - assertFalse(sp.initialized()); + assertFalse(sp.isInitialized()); } private void testRecoverableHttpError(int status) throws Exception { @@ -724,6 +797,7 @@ private void testRecoverableHttpError(int status) throws Exception { StreamProcessor sp = createStreamProcessor(STREAM_URI); Future initFuture = sp.start(); + ConnectionErrorHandler errorHandler = mockEventSourceCreator.getNextReceivedParams().errorHandler; ConnectionErrorHandler.Action action = errorHandler.onConnectionError(e); assertEquals(ConnectionErrorHandler.Action.PROCEED, action); @@ -734,7 +808,7 @@ private void testRecoverableHttpError(int status) throws Exception { } assertTrue((System.currentTimeMillis() - startTime) >= 200); assertFalse(initFuture.isDone()); - assertFalse(sp.initialized()); + assertFalse(sp.isInitialized()); } private StreamProcessor createStreamProcessor(URI streamUri) { @@ -746,55 +820,44 @@ private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri) { } private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri, DiagnosticAccumulator diagnosticAccumulator) { - return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, featureStore, - new StubEventSourceCreator(), diagnosticAccumulator, - streamUri, config.deprecatedReconnectTimeMs); + return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, dataStoreUpdates(dataStore), + mockEventSourceCreator, diagnosticAccumulator, + streamUri, DEFAULT_INITIAL_RECONNECT_DELAY); } private StreamProcessor createStreamProcessorWithRealHttp(LDConfig config, URI streamUri) { - return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, featureStore, null, null, - streamUri, config.deprecatedReconnectTimeMs); + return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, dataStoreUpdates(dataStore), null, null, + streamUri, DEFAULT_INITIAL_RECONNECT_DELAY); } - private StreamProcessor createStreamProcessorWithStore(FeatureStore store) { - return new StreamProcessor(SDK_KEY, LDConfig.DEFAULT.httpConfig, mockRequestor, store, - new StubEventSourceCreator(), null, STREAM_URI, 0); + private StreamProcessor createStreamProcessorWithStore(DataStore store) { + return new StreamProcessor(SDK_KEY, LDConfig.DEFAULT.httpConfig, mockRequestor, dataStoreUpdates(store), + mockEventSourceCreator, null, STREAM_URI, DEFAULT_INITIAL_RECONNECT_DELAY); } private String featureJson(String key, int version) { - return "{\"key\":\"" + key + "\",\"version\":" + version + ",\"on\":true}"; + return gsonInstance().toJson(flagBuilder(key).version(version).build()); } private String segmentJson(String key, int version) { - return "{\"key\":\"" + key + "\",\"version\":" + version + ",\"includes\":[],\"excludes\":[],\"rules\":[]}"; + return gsonInstance().toJson(ModelBuilders.segmentBuilder(key).version(version).build()); } private MessageEvent emptyPutEvent() { return new MessageEvent("{\"data\":{\"flags\":{},\"segments\":{}}}"); } - private void setupRequestorToReturnAllDataWithFlag(FeatureFlag feature) throws Exception { + private void setupRequestorToReturnAllDataWithFlag(DataModel.FeatureFlag feature) throws Exception { FeatureRequestor.AllData data = new FeatureRequestor.AllData( - Collections.singletonMap(feature.getKey(), feature), Collections.emptyMap()); + Collections.singletonMap(feature.getKey(), feature), Collections.emptyMap()); expect(mockRequestor.getAllData()).andReturn(data); } - private void assertFeatureInStore(FeatureFlag feature) { - assertEquals(feature.getVersion(), featureStore.get(FEATURES, feature.getKey()).getVersion()); + private void assertFeatureInStore(DataModel.FeatureFlag feature) { + assertEquals(feature.getVersion(), dataStore.get(FEATURES, feature.getKey()).getVersion()); } - private void assertSegmentInStore(Segment segment) { - assertEquals(segment.getVersion(), featureStore.get(SEGMENTS, segment.getKey()).getVersion()); - } - - private class StubEventSourceCreator implements StreamProcessor.EventSourceCreator { - public EventSource createEventSource(EventHandler handler, URI streamUri, - long initialReconnectDelay, ConnectionErrorHandler errorHandler, Headers headers, HttpConfiguration httpConfig) { - StreamProcessorTest.this.eventHandler = handler; - StreamProcessorTest.this.actualStreamUri = streamUri; - StreamProcessorTest.this.errorHandler = errorHandler; - StreamProcessorTest.this.headers = headers; - return mockEventSource; - } + private void assertSegmentInStore(DataModel.Segment segment) { + assertEquals(segment.getVersion(), dataStore.get(SEGMENTS, segment.getKey()).getVersion()); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java new file mode 100644 index 000000000..cace1de30 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java @@ -0,0 +1,281 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.eventsource.EventSource; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; +import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.EventProcessor; +import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; + +import static com.launchdarkly.sdk.server.DataModel.FEATURES; + +@SuppressWarnings("javadoc") +public class TestComponents { + public static ClientContext clientContext(final String sdkKey, final LDConfig config) { + return new ClientContextImpl(sdkKey, config, null); + } + + public static ClientContext clientContext(final String sdkKey, final LDConfig config, DiagnosticAccumulator diagnosticAccumulator) { + return new ClientContextImpl(sdkKey, config, diagnosticAccumulator); + } + + public static DataSourceFactory dataSourceWithData(FullDataSet data) { + return (context, dataStoreUpdates) -> new DataSourceWithData(data, dataStoreUpdates); + } + + public static DataStore dataStoreThatThrowsException(final RuntimeException e) { + return new DataStoreThatThrowsException(e); + } + + public static DataStoreUpdates dataStoreUpdates(final DataStore store) { + return new DataStoreUpdatesImpl(store, null); + } + + static EventsConfiguration defaultEventsConfig() { + return makeEventsConfig(false, false, null); + } + + public static DataSource failedDataSource() { + return new DataSourceThatNeverInitializes(); + } + + public static DataStore inMemoryDataStore() { + return new InMemoryDataStore(); // this is for tests in other packages which can't see this concrete class + } + + public static DataStore initedDataStore() { + DataStore store = new InMemoryDataStore(); + store.init(new FullDataSet(null)); + return store; + } + + static EventsConfiguration makeEventsConfig(boolean allAttributesPrivate, boolean inlineUsersInEvents, + Set privateAttributes) { + return new EventsConfiguration( + allAttributesPrivate, + 0, null, EventProcessorBuilder.DEFAULT_FLUSH_INTERVAL, + inlineUsersInEvents, + privateAttributes, + 0, 0, EventProcessorBuilder.DEFAULT_USER_KEYS_FLUSH_INTERVAL, + EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL); + } + + public static DataSourceFactory specificDataSource(final DataSource up) { + return (context, dataStoreUpdates) -> up; + } + + public static DataStoreFactory specificDataStore(final DataStore store) { + return context -> store; + } + + public static EventProcessorFactory specificEventProcessor(final EventProcessor ep) { + return context -> ep; + } + + public static class TestEventProcessor implements EventProcessor { + List events = new ArrayList<>(); + + @Override + public void close() throws IOException {} + + @Override + public void sendEvent(Event e) { + events.add(e); + } + + @Override + public void flush() {} + } + + public static class DataSourceFactoryThatExposesUpdater implements DataSourceFactory { + private final FullDataSet initialData; + private DataStoreUpdates dataStoreUpdates; + + public DataSourceFactoryThatExposesUpdater(FullDataSet initialData) { + this.initialData = initialData; + } + + @Override + public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { + this.dataStoreUpdates = dataStoreUpdates; + return dataSourceWithData(initialData).createDataSource(context, dataStoreUpdates); + } + + public void updateFlag(FeatureFlag flag) { + dataStoreUpdates.upsert(FEATURES, flag.getKey(), new ItemDescriptor(flag.getVersion(), flag)); + } + } + + private static class DataSourceThatNeverInitializes implements DataSource { + public Future start() { + return new CompletableFuture<>(); + } + + public boolean isInitialized() { + return false; + } + + public void close() throws IOException { + } + }; + + private static class DataSourceWithData implements DataSource { + private final FullDataSet data; + private final DataStoreUpdates dataStoreUpdates; + + DataSourceWithData(FullDataSet data, DataStoreUpdates dataStoreUpdates) { + this.data = data; + this.dataStoreUpdates = dataStoreUpdates; + } + + public Future start() { + dataStoreUpdates.init(data); + return CompletableFuture.completedFuture(null); + } + + public boolean isInitialized() { + return true; + } + + public void close() throws IOException { + } + } + + private static class DataStoreThatThrowsException implements DataStore { + private final RuntimeException e; + + DataStoreThatThrowsException(RuntimeException e) { + this.e = e; + } + + public void close() throws IOException { } + + public ItemDescriptor get(DataKind kind, String key) { + throw e; + } + + public KeyedItems getAll(DataKind kind) { + throw e; + } + + public void init(FullDataSet allData) { + throw e; + } + + public boolean upsert(DataKind kind, String key, ItemDescriptor item) { + throw e; + } + + public boolean isInitialized() { + return true; + } + } + + public static class DataStoreWithStatusUpdates implements DataStore, DataStoreStatusProvider { + private final DataStore wrappedStore; + private final List listeners = new ArrayList<>(); + volatile Status currentStatus = new Status(true, false); + + DataStoreWithStatusUpdates(DataStore wrappedStore) { + this.wrappedStore = wrappedStore; + } + + public void broadcastStatusChange(final Status newStatus) { + currentStatus = newStatus; + final StatusListener[] ls; + synchronized (this) { + ls = listeners.toArray(new StatusListener[listeners.size()]); + } + Thread t = new Thread(() -> { + for (StatusListener l: ls) { + l.dataStoreStatusChanged(newStatus); + } + }); + t.start(); + } + + public void close() throws IOException { + wrappedStore.close(); + } + + public ItemDescriptor get(DataKind kind, String key) { + return wrappedStore.get(kind, key); + } + + public KeyedItems getAll(DataKind kind) { + return wrappedStore.getAll(kind); + } + + public void init(FullDataSet allData) { + wrappedStore.init(allData); + } + + public boolean upsert(DataKind kind, String key, ItemDescriptor item) { + return wrappedStore.upsert(kind, key, item); + } + + public boolean isInitialized() { + return wrappedStore.isInitialized(); + } + + public Status getStoreStatus() { + return currentStatus; + } + + public boolean addStatusListener(StatusListener listener) { + synchronized (this) { + listeners.add(listener); + } + return true; + } + + public void removeStatusListener(StatusListener listener) { + synchronized (this) { + listeners.remove(listener); + } + } + + public CacheStats getCacheStats() { + return null; + } + } + + public static class MockEventSourceCreator implements StreamProcessor.EventSourceCreator { + private final EventSource eventSource; + private final BlockingQueue receivedParams = new LinkedBlockingQueue<>(); + + MockEventSourceCreator(EventSource eventSource) { + this.eventSource = eventSource; + } + + public EventSource createEventSource(StreamProcessor.EventSourceParams params) { + receivedParams.add(params); + return eventSource; + } + + public StreamProcessor.EventSourceParams getNextReceivedParams() { + return receivedParams.poll(); + } + } +} diff --git a/src/test/java/com/launchdarkly/client/TestHttpUtil.java b/src/test/java/com/launchdarkly/sdk/server/TestHttpUtil.java similarity index 91% rename from src/test/java/com/launchdarkly/client/TestHttpUtil.java rename to src/test/java/com/launchdarkly/sdk/server/TestHttpUtil.java index 79fd8f30a..fa45ea59d 100644 --- a/src/test/java/com/launchdarkly/client/TestHttpUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestHttpUtil.java @@ -1,7 +1,8 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.integrations.PollingDataSourceBuilder; -import com.launchdarkly.client.integrations.StreamingDataSourceBuilder; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; +import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; import java.io.Closeable; import java.io.IOException; diff --git a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java new file mode 100644 index 000000000..c81630b5c --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java @@ -0,0 +1,176 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableSet; +import com.google.gson.Gson; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; +import com.launchdarkly.sdk.server.interfaces.FlagValueChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagValueChangeListener; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeDiagnosingMatcher; + +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.TimeUnit; + +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.fail; + +@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 void upsertFlag(DataStore store, FeatureFlag flag) { + store.upsert(FEATURES, flag.getKey(), new ItemDescriptor(flag.getVersion(), flag)); + } + + public static void upsertSegment(DataStore store, Segment segment) { + store.upsert(SEGMENTS, segment.getKey(), new ItemDescriptor(segment.getVersion(), segment)); + } + + public static class FlagChangeEventSink extends FlagChangeEventSinkBase implements FlagChangeListener { + @Override + public void onFlagChange(FlagChangeEvent event) { + events.add(event); + } + } + + public static class FlagValueChangeEventSink extends FlagChangeEventSinkBase implements FlagValueChangeListener { + @Override + public void onFlagValueChange(FlagValueChangeEvent event) { + events.add(event); + } + } + + private static class FlagChangeEventSinkBase { + protected final BlockingQueue events = new ArrayBlockingQueue<>(100); + + public T awaitEvent() { + try { + T event = events.poll(1, TimeUnit.SECONDS); + assertNotNull("expected flag change event", event); + return event; + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + public void expectEvents(String... flagKeys) { + Set expectedChangedFlagKeys = ImmutableSet.copyOf(flagKeys); + Set actualChangedFlagKeys = new HashSet<>(); + for (int i = 0; i < expectedChangedFlagKeys.size(); i++) { + try { + T e = events.poll(1, TimeUnit.SECONDS); + if (e == null) { + fail("expected change events for " + expectedChangedFlagKeys + " but got " + actualChangedFlagKeys); + } + actualChangedFlagKeys.add(e.getKey()); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + assertThat(actualChangedFlagKeys, equalTo(expectedChangedFlagKeys)); + expectNoEvents(); + } + + public void expectNoEvents() { + try { + T event = events.poll(100, TimeUnit.MILLISECONDS); + assertNull("expected no more flag change events", event); + } catch (InterruptedException e) {} + } + } + + public static Evaluator.EvalResult simpleEvaluation(int variation, LDValue value) { + return new Evaluator.EvalResult(value, variation, EvaluationReason.fallthrough()); + } + + public static Matcher hasJsonProperty(final String name, LDValue value) { + return hasJsonProperty(name, equalTo(value)); + } + + public static Matcher hasJsonProperty(final String name, String value) { + return hasJsonProperty(name, LDValue.of(value)); + } + + public static Matcher hasJsonProperty(final String name, int value) { + return hasJsonProperty(name, LDValue.of(value)); + } + + public static Matcher hasJsonProperty(final String name, double value) { + return hasJsonProperty(name, LDValue.of(value)); + } + + public static Matcher hasJsonProperty(final String name, boolean value) { + return hasJsonProperty(name, LDValue.of(value)); + } + + public static Matcher hasJsonProperty(final String name, final Matcher matcher) { + return new TypeSafeDiagnosingMatcher() { + @Override + public void describeTo(Description description) { + description.appendText(name + ": "); + matcher.describeTo(description); + } + + @Override + protected boolean matchesSafely(LDValue item, Description mismatchDescription) { + LDValue value = item.get(name); + if (!matcher.matches(value)) { + matcher.describeMismatch(value, mismatchDescription); + return false; + } + return true; + } + }; + } + + public static Matcher isJsonArray(final Matcher> matcher) { + return new TypeSafeDiagnosingMatcher() { + @Override + public void describeTo(Description description) { + description.appendText("array: "); + matcher.describeTo(description); + } + + @Override + protected boolean matchesSafely(LDValue item, Description mismatchDescription) { + if (item.getType() != LDValueType.ARRAY) { + matcher.describeMismatch(item, mismatchDescription); + return false; + } else { + Iterable values = item.values(); + if (!matcher.matches(values)) { + matcher.describeMismatch(values, mismatchDescription); + return false; + } + } + return true; + } + }; + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/UtilTest.java b/src/test/java/com/launchdarkly/sdk/server/UtilTest.java new file mode 100644 index 000000000..ef7ffed7f --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/UtilTest.java @@ -0,0 +1,40 @@ +package com.launchdarkly.sdk.server; + +import org.junit.Test; + +import java.time.Duration; + +import static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; +import static com.launchdarkly.sdk.server.Util.shutdownHttpClient; +import static org.junit.Assert.assertEquals; + +import okhttp3.OkHttpClient; + +@SuppressWarnings("javadoc") +public class UtilTest { + @Test + public void testConnectTimeout() { + LDConfig config = new LDConfig.Builder().http(Components.httpConfiguration().connectTimeout(Duration.ofSeconds(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 testSocketTimeout() { + LDConfig config = new LDConfig.Builder().http(Components.httpConfiguration().socketTimeout(Duration.ofSeconds(3))).build(); + OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); + configureHttpClientBuilder(config.httpConfig, httpBuilder); + OkHttpClient httpClient = httpBuilder.build(); + try { + assertEquals(3000, httpClient.readTimeoutMillis()); + } finally { + shutdownHttpClient(httpClient); + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/ClientWithFileDataSourceTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/ClientWithFileDataSourceTest.java new file mode 100644 index 000000000..9f0b8430b --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/ClientWithFileDataSourceTest.java @@ -0,0 +1,50 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.integrations.FileData; +import com.launchdarkly.sdk.server.integrations.FileDataSourceBuilder; + +import org.junit.Test; + +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.FLAG_VALUE_1; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.FLAG_VALUE_1_KEY; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.FULL_FLAG_1_KEY; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.FULL_FLAG_1_VALUE; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.resourceFilePath; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +@SuppressWarnings("javadoc") +public class ClientWithFileDataSourceTest { + private static final LDUser user = new LDUser.Builder("userkey").build(); + + private LDClient makeClient() throws Exception { + FileDataSourceBuilder fdsb = FileData.dataSource() + .filePaths(resourceFilePath("all-properties.json")); + LDConfig config = new LDConfig.Builder() + .dataSource(fdsb) + .events(Components.noEvents()) + .build(); + return new LDClient("sdkKey", config); + } + + @Test + public void fullFlagDefinitionEvaluatesAsExpected() throws Exception { + try (LDClient client = makeClient()) { + assertThat(client.jsonValueVariation(FULL_FLAG_1_KEY, user, LDValue.of("default")), + equalTo(FULL_FLAG_1_VALUE)); + } + } + + @Test + public void simplifiedFlagEvaluatesAsExpected() throws Exception { + try (LDClient client = makeClient()) { + assertThat(client.jsonValueVariation(FLAG_VALUE_1_KEY, user, LDValue.of("default")), + equalTo(FLAG_VALUE_1)); + } + } +} diff --git a/src/test/java/com/launchdarkly/client/integrations/DataLoaderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java similarity index 74% rename from src/test/java/com/launchdarkly/client/integrations/DataLoaderTest.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java index b78585783..d9f2969db 100644 --- a/src/test/java/com/launchdarkly/client/integrations/DataLoaderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java @@ -1,24 +1,25 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; import com.google.common.collect.ImmutableList; import com.google.gson.Gson; import com.google.gson.JsonElement; import com.google.gson.JsonObject; -import com.launchdarkly.client.VersionedData; -import com.launchdarkly.client.VersionedDataKind; -import com.launchdarkly.client.integrations.FileDataSourceImpl.DataBuilder; -import com.launchdarkly.client.integrations.FileDataSourceImpl.DataLoader; -import com.launchdarkly.client.integrations.FileDataSourceParsing.FileDataException; +import com.launchdarkly.sdk.server.integrations.FileDataSourceImpl.DataBuilder; +import com.launchdarkly.sdk.server.integrations.FileDataSourceImpl.DataLoader; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FileDataException; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import org.junit.Assert; import org.junit.Test; import java.util.Map; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; -import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.FLAG_VALUE_1_KEY; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.resourceFilePath; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toDataMap; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.FLAG_VALUE_1_KEY; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.resourceFilePath; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; @@ -59,8 +60,8 @@ public void flagValueIsConvertedToFlag() throws Exception { "\"trackEvents\":false,\"deleted\":false,\"version\":0}", JsonObject.class); ds.load(builder); - VersionedData flag = builder.build().get(FEATURES).get(FLAG_VALUE_1_KEY); - JsonObject actual = gson.toJsonTree(flag).getAsJsonObject(); + ItemDescriptor flag = toDataMap(builder.build()).get(FEATURES).get(FLAG_VALUE_1_KEY); + JsonObject actual = gson.toJsonTree(flag.getItem()).getAsJsonObject(); // Note, we're comparing one property at a time here because the version of the Java SDK we're // building against may have more properties than it did when the test was written. for (Map.Entry e: expected.entrySet()) { @@ -101,10 +102,10 @@ public void duplicateSegmentKeyThrowsException() throws Exception { } } - private void assertDataHasItemsOfKind(VersionedDataKind kind) { - Map items = builder.build().get(kind); + private void assertDataHasItemsOfKind(DataKind kind) { + Map items = toDataMap(builder.build()).get(kind); if (items == null || items.size() == 0) { - Assert.fail("expected at least one item in \"" + kind.getNamespace() + "\", received: " + builder.build()); + Assert.fail("expected at least one item in \"" + kind.getName() + "\", received: " + builder.build()); } } } diff --git a/src/test/java/com/launchdarkly/client/integrations/EventProcessorBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilderTest.java similarity index 50% rename from src/test/java/com/launchdarkly/client/integrations/EventProcessorBuilderTest.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilderTest.java index 689c210fa..7a9142d80 100644 --- a/src/test/java/com/launchdarkly/client/integrations/EventProcessorBuilderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilderTest.java @@ -1,9 +1,12 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.Components; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; import org.junit.Test; +import java.time.Duration; + import static org.junit.Assert.assertEquals; @SuppressWarnings("javadoc") @@ -11,19 +14,18 @@ public class EventProcessorBuilderTest { @Test public void testDefaultDiagnosticRecordingInterval() { EventProcessorBuilder builder = Components.sendEvents(); - assertEquals(900, builder.diagnosticRecordingIntervalSeconds); + assertEquals(Duration.ofSeconds(900), builder.diagnosticRecordingInterval); } @Test public void testDiagnosticRecordingInterval() { - EventProcessorBuilder builder = Components.sendEvents().diagnosticRecordingIntervalSeconds(120); - assertEquals(120, builder.diagnosticRecordingIntervalSeconds); + EventProcessorBuilder builder = Components.sendEvents().diagnosticRecordingInterval(Duration.ofSeconds(120)); + assertEquals(Duration.ofSeconds(120), builder.diagnosticRecordingInterval); } @Test public void testMinimumDiagnosticRecordingIntervalEnforced() { - EventProcessorBuilder builder = Components.sendEvents().diagnosticRecordingIntervalSeconds(10); - assertEquals(60, builder.diagnosticRecordingIntervalSeconds); + EventProcessorBuilder builder = Components.sendEvents().diagnosticRecordingInterval(Duration.ofSeconds(10)); + assertEquals(Duration.ofSeconds(60), builder.diagnosticRecordingInterval); } - } diff --git a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java similarity index 62% rename from src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java index 0d933e967..0861c178c 100644 --- a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java @@ -1,10 +1,8 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.FeatureStore; -import com.launchdarkly.client.InMemoryFeatureStore; -import com.launchdarkly.client.LDConfig; -import com.launchdarkly.client.UpdateProcessor; -import com.launchdarkly.client.VersionedDataKind; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataStore; import org.junit.Test; @@ -14,10 +12,17 @@ import java.nio.file.Paths; import java.util.concurrent.Future; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.ALL_FLAG_KEYS; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.ALL_SEGMENT_KEYS; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.getResourceContents; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.resourceFilePath; +import static com.google.common.collect.Iterables.size; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toItemsMap; +import static com.launchdarkly.sdk.server.TestComponents.clientContext; +import static com.launchdarkly.sdk.server.TestComponents.dataStoreUpdates; +import static com.launchdarkly.sdk.server.TestComponents.inMemoryDataStore; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.ALL_FLAG_KEYS; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.ALL_SEGMENT_KEYS; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.getResourceContents; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.resourceFilePath; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.fail; @@ -26,7 +31,7 @@ public class FileDataSourceTest { private static final Path badFilePath = Paths.get("no-such-file.json"); - private final FeatureStore store = new InMemoryFeatureStore(); + private final DataStore store = inMemoryDataStore(); private final LDConfig config = new LDConfig.Builder().build(); private final FileDataSourceBuilder factory; @@ -37,29 +42,33 @@ public FileDataSourceTest() throws Exception { private static FileDataSourceBuilder makeFactoryWithFile(Path path) { return FileData.dataSource().filePaths(path); } + + private DataSource makeDataSource(FileDataSourceBuilder builder) { + return builder.createDataSource(clientContext("", config), dataStoreUpdates(store)); + } @Test public void flagsAreNotLoadedUntilStart() throws Exception { - try (UpdateProcessor fp = factory.createUpdateProcessor("", config, store)) { - assertThat(store.initialized(), equalTo(false)); - assertThat(store.all(VersionedDataKind.FEATURES).size(), equalTo(0)); - assertThat(store.all(VersionedDataKind.SEGMENTS).size(), equalTo(0)); + try (DataSource fp = makeDataSource(factory)) { + assertThat(store.isInitialized(), equalTo(false)); + assertThat(size(store.getAll(FEATURES).getItems()), equalTo(0)); + assertThat(size(store.getAll(SEGMENTS).getItems()), equalTo(0)); } } @Test public void flagsAreLoadedOnStart() throws Exception { - try (UpdateProcessor fp = factory.createUpdateProcessor("", config, store)) { + try (DataSource fp = makeDataSource(factory)) { fp.start(); - assertThat(store.initialized(), equalTo(true)); - assertThat(store.all(VersionedDataKind.FEATURES).keySet(), equalTo(ALL_FLAG_KEYS)); - assertThat(store.all(VersionedDataKind.SEGMENTS).keySet(), equalTo(ALL_SEGMENT_KEYS)); + assertThat(store.isInitialized(), equalTo(true)); + assertThat(toItemsMap(store.getAll(FEATURES)).keySet(), equalTo(ALL_FLAG_KEYS)); + assertThat(toItemsMap(store.getAll(SEGMENTS)).keySet(), equalTo(ALL_SEGMENT_KEYS)); } } @Test public void startFutureIsCompletedAfterSuccessfulLoad() throws Exception { - try (UpdateProcessor fp = factory.createUpdateProcessor("", config, store)) { + try (DataSource fp = makeDataSource(factory)) { Future future = fp.start(); assertThat(future.isDone(), equalTo(true)); } @@ -67,16 +76,16 @@ public void startFutureIsCompletedAfterSuccessfulLoad() throws Exception { @Test public void initializedIsTrueAfterSuccessfulLoad() throws Exception { - try (UpdateProcessor fp = factory.createUpdateProcessor("", config, store)) { + try (DataSource fp = makeDataSource(factory)) { fp.start(); - assertThat(fp.initialized(), equalTo(true)); + assertThat(fp.isInitialized(), equalTo(true)); } } @Test public void startFutureIsCompletedAfterUnsuccessfulLoad() throws Exception { factory.filePaths(badFilePath); - try (UpdateProcessor fp = factory.createUpdateProcessor("", config, store)) { + try (DataSource fp = makeDataSource(factory)) { Future future = fp.start(); assertThat(future.isDone(), equalTo(true)); } @@ -85,9 +94,9 @@ public void startFutureIsCompletedAfterUnsuccessfulLoad() throws Exception { @Test public void initializedIsFalseAfterUnsuccessfulLoad() throws Exception { factory.filePaths(badFilePath); - try (UpdateProcessor fp = factory.createUpdateProcessor("", config, store)) { + try (DataSource fp = makeDataSource(factory)) { fp.start(); - assertThat(fp.initialized(), equalTo(false)); + assertThat(fp.isInitialized(), equalTo(false)); } } @@ -97,12 +106,12 @@ public void modifiedFileIsNotReloadedIfAutoUpdateIsOff() throws Exception { FileDataSourceBuilder factory1 = makeFactoryWithFile(file.toPath()); try { setFileContents(file, getResourceContents("flag-only.json")); - try (UpdateProcessor fp = factory1.createUpdateProcessor("", config, store)) { + try (DataSource fp = makeDataSource(factory1)) { fp.start(); setFileContents(file, getResourceContents("segment-only.json")); Thread.sleep(400); - assertThat(store.all(VersionedDataKind.FEATURES).size(), equalTo(1)); - assertThat(store.all(VersionedDataKind.SEGMENTS).size(), equalTo(0)); + assertThat(toItemsMap(store.getAll(FEATURES)).size(), equalTo(1)); + assertThat(toItemsMap(store.getAll(SEGMENTS)).size(), equalTo(0)); } } finally { file.delete(); @@ -119,13 +128,13 @@ public void modifiedFileIsReloadedIfAutoUpdateIsOn() throws Exception { long maxMsToWait = 10000; try { setFileContents(file, getResourceContents("flag-only.json")); // this file has 1 flag - try (UpdateProcessor fp = factory1.createUpdateProcessor("", config, store)) { + try (DataSource fp = makeDataSource(factory1)) { fp.start(); Thread.sleep(1000); setFileContents(file, getResourceContents("all-properties.json")); // this file has all the flags long deadline = System.currentTimeMillis() + maxMsToWait; while (System.currentTimeMillis() < deadline) { - if (store.all(VersionedDataKind.FEATURES).size() == ALL_FLAG_KEYS.size()) { + if (toItemsMap(store.getAll(FEATURES)).size() == ALL_FLAG_KEYS.size()) { // success return; } @@ -145,13 +154,13 @@ public void ifFilesAreBadAtStartTimeAutoUpdateCanStillLoadGoodDataLater() throws FileDataSourceBuilder factory1 = makeFactoryWithFile(file.toPath()).autoUpdate(true); long maxMsToWait = 10000; try { - try (UpdateProcessor fp = factory1.createUpdateProcessor("", config, store)) { + try (DataSource fp = makeDataSource(factory1)) { fp.start(); Thread.sleep(1000); setFileContents(file, getResourceContents("flag-only.json")); // this file has 1 flag long deadline = System.currentTimeMillis() + maxMsToWait; while (System.currentTimeMillis() < deadline) { - if (store.all(VersionedDataKind.FEATURES).size() > 0) { + if (toItemsMap(store.getAll(FEATURES)).size() > 0) { // success return; } diff --git a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTestData.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTestData.java similarity index 54% rename from src/test/java/com/launchdarkly/client/integrations/FileDataSourceTestData.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTestData.java index d222f4c77..de1cb8ae5 100644 --- a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTestData.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTestData.java @@ -1,10 +1,8 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; -import com.google.gson.Gson; -import com.google.gson.JsonElement; -import com.google.gson.JsonPrimitive; +import com.launchdarkly.sdk.LDValue; import java.net.URISyntaxException; import java.net.URL; @@ -16,26 +14,23 @@ @SuppressWarnings("javadoc") public class FileDataSourceTestData { - private static final Gson gson = new Gson(); - // These should match the data in our test files public static final String FULL_FLAG_1_KEY = "flag1"; - public static final JsonElement FULL_FLAG_1 = - gson.fromJson("{\"key\":\"flag1\",\"on\":true,\"fallthrough\":{\"variation\":2},\"variations\":[\"fall\",\"off\",\"on\"]}", - JsonElement.class); - public static final JsonElement FULL_FLAG_1_VALUE = new JsonPrimitive("on"); - public static final Map FULL_FLAGS = - ImmutableMap.of(FULL_FLAG_1_KEY, FULL_FLAG_1); + public static final LDValue FULL_FLAG_1 = + LDValue.parse("{\"key\":\"flag1\",\"on\":true,\"fallthrough\":{\"variation\":2},\"variations\":[\"fall\",\"off\",\"on\"]}"); + public static final LDValue FULL_FLAG_1_VALUE = LDValue.of("on"); + public static final Map FULL_FLAGS = + ImmutableMap.of(FULL_FLAG_1_KEY, FULL_FLAG_1); public static final String FLAG_VALUE_1_KEY = "flag2"; - public static final JsonElement FLAG_VALUE_1 = new JsonPrimitive("value2"); - public static final Map FLAG_VALUES = - ImmutableMap.of(FLAG_VALUE_1_KEY, FLAG_VALUE_1); + public static final LDValue FLAG_VALUE_1 = LDValue.of("value2"); + public static final Map FLAG_VALUES = + ImmutableMap.of(FLAG_VALUE_1_KEY, FLAG_VALUE_1); public static final String FULL_SEGMENT_1_KEY = "seg1"; - public static final JsonElement FULL_SEGMENT_1 = gson.fromJson("{\"key\":\"seg1\",\"include\":[\"user1\"]}", JsonElement.class); - public static final Map FULL_SEGMENTS = - ImmutableMap.of(FULL_SEGMENT_1_KEY, FULL_SEGMENT_1); + public static final LDValue FULL_SEGMENT_1 = LDValue.parse("{\"key\":\"seg1\",\"include\":[\"user1\"]}"); + public static final Map FULL_SEGMENTS = + ImmutableMap.of(FULL_SEGMENT_1_KEY, FULL_SEGMENT_1); public static final Set ALL_FLAG_KEYS = ImmutableSet.of(FULL_FLAG_1_KEY, FLAG_VALUE_1_KEY); public static final Set ALL_SEGMENT_KEYS = ImmutableSet.of(FULL_SEGMENT_1_KEY); diff --git a/src/test/java/com/launchdarkly/client/integrations/FlagFileParserJsonTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserJsonTest.java similarity index 57% rename from src/test/java/com/launchdarkly/client/integrations/FlagFileParserJsonTest.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserJsonTest.java index c23a66772..45fe4cdfb 100644 --- a/src/test/java/com/launchdarkly/client/integrations/FlagFileParserJsonTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserJsonTest.java @@ -1,6 +1,6 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.integrations.FileDataSourceParsing.JsonFlagFileParser; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.JsonFlagFileParser; @SuppressWarnings("javadoc") public class FlagFileParserJsonTest extends FlagFileParserTestBase { diff --git a/src/test/java/com/launchdarkly/client/integrations/FlagFileParserTestBase.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserTestBase.java similarity index 77% rename from src/test/java/com/launchdarkly/client/integrations/FlagFileParserTestBase.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserTestBase.java index fd2be268f..4aedd7359 100644 --- a/src/test/java/com/launchdarkly/client/integrations/FlagFileParserTestBase.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserTestBase.java @@ -1,8 +1,8 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.integrations.FileDataSourceParsing.FileDataException; -import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFileParser; -import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFileRep; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FileDataException; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFileParser; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFileRep; import org.junit.Test; @@ -10,10 +10,10 @@ import java.io.FileNotFoundException; import java.net.URISyntaxException; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.FLAG_VALUES; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.FULL_FLAGS; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.FULL_SEGMENTS; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.resourceFilePath; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.FLAG_VALUES; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.FULL_FLAGS; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.FULL_SEGMENTS; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.resourceFilePath; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.nullValue; diff --git a/src/test/java/com/launchdarkly/client/integrations/FlagFileParserYamlTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserYamlTest.java similarity index 57% rename from src/test/java/com/launchdarkly/client/integrations/FlagFileParserYamlTest.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserYamlTest.java index 3ad640e92..ce9100c4a 100644 --- a/src/test/java/com/launchdarkly/client/integrations/FlagFileParserYamlTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserYamlTest.java @@ -1,6 +1,6 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.integrations.FileDataSourceParsing.YamlFlagFileParser; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.YamlFlagFileParser; @SuppressWarnings("javadoc") public class FlagFileParserYamlTest extends FlagFileParserTestBase { diff --git a/src/test/java/com/launchdarkly/client/integrations/HttpConfigurationBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java similarity index 90% rename from src/test/java/com/launchdarkly/client/integrations/HttpConfigurationBuilderTest.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java index b9a254866..1bef491f7 100644 --- a/src/test/java/com/launchdarkly/client/integrations/HttpConfigurationBuilderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java @@ -1,7 +1,7 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.Components; -import com.launchdarkly.client.interfaces.HttpConfiguration; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import org.junit.Test; @@ -13,6 +13,7 @@ import java.net.UnknownHostException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.time.Duration; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.X509TrustManager; @@ -27,10 +28,10 @@ public class HttpConfigurationBuilderTest { @Test public void testDefaults() { HttpConfiguration hc = Components.httpConfiguration().createHttpConfiguration(); - assertEquals(HttpConfigurationBuilder.DEFAULT_CONNECT_TIMEOUT_MILLIS, hc.getConnectTimeoutMillis()); + assertEquals(HttpConfigurationBuilder.DEFAULT_CONNECT_TIMEOUT, hc.getConnectTimeout()); assertNull(hc.getProxy()); assertNull(hc.getProxyAuthentication()); - assertEquals(HttpConfigurationBuilder.DEFAULT_SOCKET_TIMEOUT_MILLIS, hc.getSocketTimeoutMillis()); + assertEquals(HttpConfigurationBuilder.DEFAULT_SOCKET_TIMEOUT, hc.getSocketTimeout()); assertNull(hc.getSslSocketFactory()); assertNull(hc.getTrustManager()); assertNull(hc.getWrapperIdentifier()); @@ -39,9 +40,9 @@ public void testDefaults() { @Test public void testConnectTimeout() { HttpConfiguration hc = Components.httpConfiguration() - .connectTimeoutMillis(999) + .connectTimeout(Duration.ofMillis(999)) .createHttpConfiguration(); - assertEquals(999, hc.getConnectTimeoutMillis()); + assertEquals(999, hc.getConnectTimeout().toMillis()); } @Test @@ -67,9 +68,9 @@ public void testProxyBasicAuth() { @Test public void testSocketTimeout() { HttpConfiguration hc = Components.httpConfiguration() - .socketTimeoutMillis(999) + .socketTimeout(Duration.ofMillis(999)) .createHttpConfiguration(); - assertEquals(999, hc.getSocketTimeoutMillis()); + assertEquals(999, hc.getSocketTimeout().toMillis()); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java b/src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java new file mode 100644 index 000000000..f8697dbb7 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java @@ -0,0 +1,165 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.google.common.collect.ImmutableList; +import com.launchdarkly.sdk.server.DataStoreTestTypes.TestItem; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; + +import java.io.IOException; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +@SuppressWarnings("javadoc") +public final class MockPersistentDataStore implements PersistentDataStore { + public static final class MockDatabaseInstance { + Map>> dataByPrefix = new HashMap<>(); + Map initedByPrefix = new HashMap<>(); + } + + final Map> data; + final AtomicBoolean inited; + final AtomicInteger initedCount = new AtomicInteger(0); + volatile int initedQueryCount; + volatile boolean persistOnlyAsString; + volatile boolean unavailable; + volatile RuntimeException fakeError; + volatile Runnable updateHook; + + public MockPersistentDataStore() { + this.data = new HashMap<>(); + this.inited = new AtomicBoolean(); + } + + public MockPersistentDataStore(MockDatabaseInstance sharedData, String prefix) { + synchronized (sharedData) { + if (sharedData.dataByPrefix.containsKey(prefix)) { + this.data = sharedData.dataByPrefix.get(prefix); + this.inited = sharedData.initedByPrefix.get(prefix); + } else { + this.data = new HashMap<>(); + this.inited = new AtomicBoolean(); + sharedData.dataByPrefix.put(prefix, this.data); + sharedData.initedByPrefix.put(prefix, this.inited); + } + } + } + + @Override + public void close() throws IOException { + } + + @Override + public SerializedItemDescriptor get(DataKind kind, String key) { + maybeThrow(); + if (data.containsKey(kind)) { + SerializedItemDescriptor item = data.get(kind).get(key); + if (item != null) { + if (persistOnlyAsString) { + // This simulates the kind of store implementation that can't track metadata separately + return new SerializedItemDescriptor(0, false, item.getSerializedItem()); + } else { + return item; + } + } + } + return null; + } + + @Override + public KeyedItems getAll(DataKind kind) { + maybeThrow(); + return data.containsKey(kind) ? new KeyedItems<>(ImmutableList.copyOf(data.get(kind).entrySet())) : new KeyedItems<>(null); + } + + @Override + public void init(FullDataSet allData) { + initedCount.incrementAndGet(); + maybeThrow(); + data.clear(); + for (Map.Entry> entry: allData.getData()) { + DataKind kind = entry.getKey(); + HashMap items = new LinkedHashMap<>(); + for (Map.Entry e: entry.getValue().getItems()) { + items.put(e.getKey(), storableItem(kind, e.getValue())); + } + data.put(kind, items); + } + inited.set(true); + } + + @Override + public boolean upsert(DataKind kind, String key, SerializedItemDescriptor item) { + maybeThrow(); + if (updateHook != null) { + updateHook.run(); + } + if (!data.containsKey(kind)) { + data.put(kind, new HashMap<>()); + } + Map items = data.get(kind); + SerializedItemDescriptor oldItem = items.get(key); + if (oldItem != null) { + // If persistOnlyAsString is true, simulate the kind of implementation where we can't see the + // version as a separate attribute in the database and must deserialize the item to get it. + int oldVersion = persistOnlyAsString ? + kind.deserialize(oldItem.getSerializedItem()).getVersion() : + oldItem.getVersion(); + if (oldVersion >= item.getVersion()) { + return false; + } + } + items.put(key, storableItem(kind, item)); + return true; + } + + @Override + public boolean isInitialized() { + maybeThrow(); + initedQueryCount++; + return inited.get(); + } + + @Override + public boolean isStoreAvailable() { + return !unavailable; + } + + public void forceSet(DataKind kind, TestItem item) { + forceSet(kind, item.key, item.toSerializedItemDescriptor()); + } + + public void forceSet(DataKind kind, String key, SerializedItemDescriptor item) { + if (!data.containsKey(kind)) { + data.put(kind, new HashMap<>()); + } + Map items = data.get(kind); + items.put(key, storableItem(kind, item)); + } + + public void forceRemove(DataKind kind, String key) { + if (data.containsKey(kind)) { + data.get(kind).remove(key); + } + } + + private SerializedItemDescriptor storableItem(DataKind kind, SerializedItemDescriptor item) { + if (item.isDeleted() && !persistOnlyAsString) { + // This simulates the kind of store implementation that *can* track metadata separately, so we don't + // have to persist the placeholder string for deleted items + return new SerializedItemDescriptor(item.getVersion(), true, null); + } + return item; + } + + private void maybeThrow() { + if (fakeError != null) { + throw fakeError; + } + } +} \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreGenericTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreGenericTest.java new file mode 100644 index 000000000..af4040c3a --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreGenericTest.java @@ -0,0 +1,76 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.google.common.collect.ImmutableList; + +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; + +/** + * This verifies that PersistentDataStoreTestBase behaves as expected as long as the PersistentDataStore + * implementation behaves as expected. Since there aren't any actual database integrations built into the + * SDK project, and PersistentDataStoreTestBase will be used by external projects like java-server-sdk-redis, + * we want to make sure the test logic is correct regardless of database implementation details. + * + * PersistentDataStore implementations may be able to persist the version and deleted state as metadata + * separate from the serialized item string; or they may not, in which case a little extra parsing is + * necessary. MockPersistentDataStore is able to simulate both of these scenarios, and we test both here. + */ +@SuppressWarnings("javadoc") +@RunWith(Parameterized.class) +public class PersistentDataStoreGenericTest extends PersistentDataStoreTestBase { + private final MockPersistentDataStore.MockDatabaseInstance sharedData = new MockPersistentDataStore.MockDatabaseInstance(); + private final TestMode testMode; + + static class TestMode { + final boolean persistOnlyAsString; + + TestMode(boolean persistOnlyAsString) { + this.persistOnlyAsString = persistOnlyAsString; + } + + @Override + public String toString() { + return "TestMode(" + (persistOnlyAsString ? "persistOnlyAsString" : "persistWithMetadata") + ")"; + } + } + + @Parameters(name="{0}") + public static Iterable data() { + return ImmutableList.of( + new TestMode(false), + new TestMode(true) + ); + } + + public PersistentDataStoreGenericTest(TestMode testMode) { + this.testMode = testMode; + } + + @Override + protected MockPersistentDataStore makeStore() { + return makeStoreWithPrefix(""); + } + + @Override + protected MockPersistentDataStore makeStoreWithPrefix(String prefix) { + MockPersistentDataStore store = new MockPersistentDataStore(sharedData, prefix); + store.persistOnlyAsString = testMode.persistOnlyAsString; + return store; + } + + @Override + protected void clearAllData() { + synchronized (sharedData) { + for (String prefix: sharedData.dataByPrefix.keySet()) { + sharedData.dataByPrefix.get(prefix).clear(); + } + } + } + + @Override + protected boolean setUpdateHook(MockPersistentDataStore storeUnderTest, Runnable hook) { + storeUnderTest.updateHook = hook; + return true; + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreTestBase.java b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreTestBase.java new file mode 100644 index 000000000..df805b330 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreTestBase.java @@ -0,0 +1,353 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; +import com.launchdarkly.sdk.server.DataStoreTestTypes.TestItem; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; + +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Test; + +import java.util.Map; + +import static com.launchdarkly.sdk.server.DataStoreTestTypes.OTHER_TEST_ITEMS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.TEST_ITEMS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toItemsMap; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toSerialized; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; + +/** + * Similar to FeatureStoreTestBase, but exercises only the underlying database implementation of a persistent + * data store. The caching behavior, which is entirely implemented by CachingStoreWrapper, is covered by + * CachingStoreWrapperTest. + */ +@SuppressWarnings("javadoc") +public abstract class PersistentDataStoreTestBase { + protected T store; + + protected TestItem item1 = new TestItem("key1", "first", 10); + + protected TestItem item2 = new TestItem("key2", "second", 10); + + protected TestItem otherItem1 = new TestItem("key1", "other-first", 11); + + /** + * Test subclasses must override this method to create an instance of the feature store class + * with default properties. + */ + protected abstract T makeStore(); + + /** + * Test subclasses should implement this if the feature store class supports a key prefix option + * for keeping data sets distinct within the same database. + */ + protected abstract T makeStoreWithPrefix(String prefix); + + /** + * Test classes should override this to clear all data from the underlying database. + */ + protected abstract void clearAllData(); + + /** + * Test classes should override this (and return true) if it is possible to instrument the feature + * store to execute the specified Runnable during an upsert operation, for concurrent modification tests. + */ + protected boolean setUpdateHook(T storeUnderTest, Runnable hook) { + return false; + } + + private void assertEqualsSerializedItem(TestItem item, SerializedItemDescriptor serializedItemDesc) { + // This allows for the fact that a PersistentDataStore may not be able to get the item version without + // deserializing it, so we allow the version to be zero. + assertEquals(item.toSerializedItemDescriptor().getSerializedItem(), serializedItemDesc.getSerializedItem()); + if (serializedItemDesc.getVersion() != 0) { + assertEquals(item.version, serializedItemDesc.getVersion()); + } + } + + private void assertEqualsDeletedItem(SerializedItemDescriptor expected, SerializedItemDescriptor serializedItemDesc) { + // As above, the PersistentDataStore may not have separate access to the version and deleted state; + // PersistentDataStoreWrapper compensates for this when it deserializes the item. + if (serializedItemDesc.getSerializedItem() == null) { + assertTrue(serializedItemDesc.isDeleted()); + assertEquals(expected.getVersion(), serializedItemDesc.getVersion()); + } else { + ItemDescriptor itemDesc = TEST_ITEMS.deserialize(serializedItemDesc.getSerializedItem()); + assertEquals(ItemDescriptor.deletedItem(expected.getVersion()), itemDesc); + } + } + + @Before + public void setup() { + store = makeStore(); + } + + @After + public void teardown() throws Exception { + store.close(); + } + + @Test + public void storeNotInitializedBeforeInit() { + clearAllData(); + assertFalse(store.isInitialized()); + } + + @Test + public void storeInitializedAfterInit() { + store.init(new DataBuilder().buildSerialized()); + assertTrue(store.isInitialized()); + } + + @Test + public void initCompletelyReplacesPreviousData() { + clearAllData(); + + FullDataSet allData = + new DataBuilder().add(TEST_ITEMS, item1, item2).add(OTHER_TEST_ITEMS, otherItem1).buildSerialized(); + store.init(allData); + + TestItem item2v2 = item2.withVersion(item2.version + 1); + allData = new DataBuilder().add(TEST_ITEMS, item2v2).add(OTHER_TEST_ITEMS).buildSerialized(); + store.init(allData); + + assertNull(store.get(TEST_ITEMS, item1.key)); + assertEqualsSerializedItem(item2v2, store.get(TEST_ITEMS, item2.key)); + assertNull(store.get(OTHER_TEST_ITEMS, otherItem1.key)); + } + + @Test + public void oneInstanceCanDetectIfAnotherInstanceHasInitializedTheStore() { + clearAllData(); + T store2 = makeStore(); + + assertFalse(store.isInitialized()); + + store2.init(new DataBuilder().add(TEST_ITEMS, item1).buildSerialized()); + + assertTrue(store.isInitialized()); + } + + @Test + public void oneInstanceCanDetectIfAnotherInstanceHasInitializedTheStoreEvenIfEmpty() { + clearAllData(); + T store2 = makeStore(); + + assertFalse(store.isInitialized()); + + store2.init(new DataBuilder().buildSerialized()); + + assertTrue(store.isInitialized()); + } + + @Test + public void getExistingItem() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + assertEqualsSerializedItem(item1, store.get(TEST_ITEMS, item1.key)); + } + + @Test + public void getNonexistingItem() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + assertNull(store.get(TEST_ITEMS, "biz")); + } + + @Test + public void getAll() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).add(OTHER_TEST_ITEMS, otherItem1).buildSerialized()); + Map items = toItemsMap(store.getAll(TEST_ITEMS)); + assertEquals(2, items.size()); + assertEqualsSerializedItem(item1, items.get(item1.key)); + assertEqualsSerializedItem(item2, items.get(item2.key)); + } + + @Test + public void getAllWithDeletedItem() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + SerializedItemDescriptor deletedItem = toSerialized(TEST_ITEMS, ItemDescriptor.deletedItem(item1.version + 1)); + store.upsert(TEST_ITEMS, item1.key, deletedItem); + Map items = toItemsMap(store.getAll(TEST_ITEMS)); + assertEquals(2, items.size()); + assertEqualsSerializedItem(item2, items.get(item2.key)); + assertEqualsDeletedItem(deletedItem, items.get(item1.key)); + } + + @Test + public void upsertWithNewerVersion() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + TestItem newVer = item1.withVersion(item1.version + 1).withName("modified"); + store.upsert(TEST_ITEMS, item1.key, newVer.toSerializedItemDescriptor()); + assertEqualsSerializedItem(newVer, store.get(TEST_ITEMS, item1.key)); + } + + @Test + public void upsertWithOlderVersion() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + TestItem oldVer = item1.withVersion(item1.version - 1).withName("modified"); + store.upsert(TEST_ITEMS, item1.key, oldVer.toSerializedItemDescriptor()); + assertEqualsSerializedItem(item1, store.get(TEST_ITEMS, oldVer.key)); + } + + @Test + public void upsertNewItem() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + TestItem newItem = new TestItem("new-name", "new-key", 99); + store.upsert(TEST_ITEMS, newItem.key, newItem.toSerializedItemDescriptor()); + assertEqualsSerializedItem(newItem, store.get(TEST_ITEMS, newItem.key)); + } + + @Test + public void deleteWithNewerVersion() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + SerializedItemDescriptor deletedItem = toSerialized(TEST_ITEMS, ItemDescriptor.deletedItem(item1.version + 1)); + store.upsert(TEST_ITEMS, item1.key, deletedItem); + assertEqualsDeletedItem(deletedItem, store.get(TEST_ITEMS, item1.key)); + } + + @Test + public void deleteWithOlderVersion() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + SerializedItemDescriptor deletedItem = toSerialized(TEST_ITEMS, ItemDescriptor.deletedItem(item1.version - 1)); + store.upsert(TEST_ITEMS, item1.key, deletedItem); + assertEqualsSerializedItem(item1, store.get(TEST_ITEMS, item1.key)); + } + + @Test + public void deleteUnknownItem() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + SerializedItemDescriptor deletedItem = toSerialized(TEST_ITEMS, ItemDescriptor.deletedItem(11)); + store.upsert(TEST_ITEMS, "deleted-key", deletedItem); + assertEqualsDeletedItem(deletedItem, store.get(TEST_ITEMS, "deleted-key")); + } + + @Test + public void upsertOlderVersionAfterDelete() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + SerializedItemDescriptor deletedItem = toSerialized(TEST_ITEMS, ItemDescriptor.deletedItem(item1.version + 1)); + store.upsert(TEST_ITEMS, item1.key, deletedItem); + store.upsert(TEST_ITEMS, item1.key, item1.toSerializedItemDescriptor()); + assertEqualsDeletedItem(deletedItem, store.get(TEST_ITEMS, item1.key)); + } + + // The following two tests verify that the update version checking logic works correctly when + // another client instance is modifying the same data. They will run only if the test class + // supports setUpdateHook(). + + @Test + public void handlesUpsertRaceConditionAgainstExternalClientWithLowerVersion() throws Exception { + final T store2 = makeStore(); + + int startVersion = 1; + final int store2VersionStart = 2; + final int store2VersionEnd = 4; + int store1VersionEnd = 10; + + final TestItem startItem = new TestItem("me", "foo", startVersion); + + Runnable concurrentModifier = new Runnable() { + int versionCounter = store2VersionStart; + public void run() { + if (versionCounter <= store2VersionEnd) { + store2.upsert(TEST_ITEMS, startItem.key, startItem.withVersion(versionCounter).toSerializedItemDescriptor()); + versionCounter++; + } + } + }; + + try { + assumeTrue(setUpdateHook(store, concurrentModifier)); + + store.init(new DataBuilder().add(TEST_ITEMS, startItem).buildSerialized()); + + TestItem store1End = startItem.withVersion(store1VersionEnd); + store.upsert(TEST_ITEMS, startItem.key, store1End.toSerializedItemDescriptor()); + + SerializedItemDescriptor result = store.get(TEST_ITEMS, startItem.key); + assertEqualsSerializedItem(startItem.withVersion(store1VersionEnd), result); + } finally { + store2.close(); + } + } + + @Test + public void handlesUpsertRaceConditionAgainstExternalClientWithHigherVersion() throws Exception { + final T store2 = makeStore(); + + int startVersion = 1; + final int store2Version = 3; + int store1VersionEnd = 2; + + final TestItem startItem = new TestItem("me", "foo", startVersion); + + Runnable concurrentModifier = new Runnable() { + public void run() { + store2.upsert(TEST_ITEMS, startItem.key, startItem.withVersion(store2Version).toSerializedItemDescriptor()); + } + }; + + try { + assumeTrue(setUpdateHook(store, concurrentModifier)); + + store.init(new DataBuilder().add(TEST_ITEMS, startItem).buildSerialized()); + + TestItem store1End = startItem.withVersion(store1VersionEnd); + store.upsert(TEST_ITEMS, startItem.key, store1End.toSerializedItemDescriptor()); + + SerializedItemDescriptor result = store.get(TEST_ITEMS, startItem.key); + assertEqualsSerializedItem(startItem.withVersion(store2Version), result); + } finally { + store2.close(); + } + } + + @Test + public void storesWithDifferentPrefixAreIndependent() throws Exception { + T store1 = makeStoreWithPrefix("aaa"); + Assume.assumeNotNull(store1); + T store2 = makeStoreWithPrefix("bbb"); + clearAllData(); + + try { + assertFalse(store1.isInitialized()); + assertFalse(store2.isInitialized()); + + TestItem item1a = new TestItem("a1", "flag-a", 1); + TestItem item1b = new TestItem("b", "flag-b", 1); + TestItem item2a = new TestItem("a2", "flag-a", 2); + TestItem item2c = new TestItem("c", "flag-c", 2); + + store1.init(new DataBuilder().add(TEST_ITEMS, item1a, item1b).buildSerialized()); + assertTrue(store1.isInitialized()); + assertFalse(store2.isInitialized()); + + store2.init(new DataBuilder().add(TEST_ITEMS, item2a, item2c).buildSerialized()); + assertTrue(store1.isInitialized()); + assertTrue(store2.isInitialized()); + + Map items1 = toItemsMap(store1.getAll(TEST_ITEMS)); + Map items2 = toItemsMap(store2.getAll(TEST_ITEMS)); + assertEquals(2, items1.size()); + assertEquals(2, items2.size()); + assertEqualsSerializedItem(item1a, items1.get(item1a.key)); + assertEqualsSerializedItem(item1b, items1.get(item1b.key)); + assertEqualsSerializedItem(item2a, items2.get(item2a.key)); + assertEqualsSerializedItem(item2c, items2.get(item2c.key)); + + assertEqualsSerializedItem(item1a, store1.get(TEST_ITEMS, item1a.key)); + assertEqualsSerializedItem(item1b, store1.get(TEST_ITEMS, item1b.key)); + assertEqualsSerializedItem(item2a, store2.get(TEST_ITEMS, item2a.key)); + assertEqualsSerializedItem(item2c, store2.get(TEST_ITEMS, item2c.key)); + } finally { + store1.close(); + store2.close(); + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java new file mode 100644 index 000000000..1db918cbd --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java @@ -0,0 +1,685 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; +import com.launchdarkly.sdk.server.DataStoreTestTypes.TestItem; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; + +import java.io.IOException; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +import static com.launchdarkly.sdk.server.DataStoreTestTypes.TEST_ITEMS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toDataMap; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toItemsMap; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toSerialized; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.fail; +import static org.junit.Assume.assumeThat; + +@SuppressWarnings("javadoc") +@RunWith(Parameterized.class) +public class PersistentDataStoreWrapperTest { + private static final RuntimeException FAKE_ERROR = new RuntimeException("fake error"); + + private final TestMode testMode; + private final MockPersistentDataStore core; + private final PersistentDataStoreWrapper wrapper; + + static class TestMode { + final boolean cached; + final boolean cachedIndefinitely; + final boolean persistOnlyAsString; + + TestMode(boolean cached, boolean cachedIndefinitely, boolean persistOnlyAsString) { + this.cached = cached; + this.cachedIndefinitely = cachedIndefinitely; + this.persistOnlyAsString = persistOnlyAsString; + } + + boolean isCached() { + return cached; + } + + boolean isCachedWithFiniteTtl() { + return cached && !cachedIndefinitely; + } + + boolean isCachedIndefinitely() { + return cached && cachedIndefinitely; + } + + Duration getCacheTtl() { + return cached ? (cachedIndefinitely ? Duration.ofMillis(-1) : Duration.ofSeconds(30)) : Duration.ZERO; + } + + @Override + public String toString() { + return "TestMode(" + + (cached ? (cachedIndefinitely ? "CachedIndefinitely" : "Cached") : "Uncached") + + (persistOnlyAsString ? ",persistOnlyAsString" : "") + ")"; + } + } + + @Parameters(name="cached={0}") + public static Iterable data() { + return ImmutableList.of( + new TestMode(true, false, false), + new TestMode(true, false, true), + new TestMode(true, true, false), + new TestMode(true, true, true), + new TestMode(false, false, false), + new TestMode(false, false, true) + ); + } + + public PersistentDataStoreWrapperTest(TestMode testMode) { + this.testMode = testMode; + this.core = new MockPersistentDataStore(); + this.core.persistOnlyAsString = testMode.persistOnlyAsString; + this.wrapper = new PersistentDataStoreWrapper(core, testMode.getCacheTtl(), + PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, false); + } + + @After + public void tearDown() throws IOException { + this.wrapper.close(); + } + + @Test + public void get() { + TestItem itemv1 = new TestItem("key", 1); + TestItem itemv2 = itemv1.withVersion(2); + + core.forceSet(TEST_ITEMS, itemv1); + assertThat(wrapper.get(TEST_ITEMS, itemv1.key), equalTo(itemv1.toItemDescriptor())); + + core.forceSet(TEST_ITEMS, itemv2); + + // if cached, we will not see the new underlying value yet + ItemDescriptor result = wrapper.get(TEST_ITEMS, itemv1.key); + ItemDescriptor expected = (testMode.isCached() ? itemv1 : itemv2).toItemDescriptor(); + assertThat(result, equalTo(expected)); + } + + @Test + public void getDeletedItem() { + String key = "key"; + + core.forceSet(TEST_ITEMS, key, toSerialized(TEST_ITEMS, ItemDescriptor.deletedItem(1))); + assertThat(wrapper.get(TEST_ITEMS, key), equalTo(ItemDescriptor.deletedItem(1))); + + TestItem itemv2 = new TestItem(key, 2); + core.forceSet(TEST_ITEMS, itemv2); + + // if cached, we will not see the new underlying value yet + ItemDescriptor result = wrapper.get(TEST_ITEMS, key); + ItemDescriptor expected = testMode.isCached() ? ItemDescriptor.deletedItem(1) : itemv2.toItemDescriptor(); + assertThat(result, equalTo(expected)); + } + + @Test + public void getMissingItem() { + String key = "key"; + + assertThat(wrapper.get(TEST_ITEMS, key), nullValue()); + + TestItem item = new TestItem(key, 1); + core.forceSet(TEST_ITEMS, item); + + // if cached, the cache can retain a null result + ItemDescriptor result = wrapper.get(TEST_ITEMS, item.key); + assertThat(result, testMode.isCached() ? nullValue(ItemDescriptor.class) : equalTo(item.toItemDescriptor())); + } + + @Test + public void cachedGetUsesValuesFromInit() { + assumeThat(testMode.isCached(), is(true)); + + TestItem item1 = new TestItem("key1", 1); + TestItem item2 = new TestItem("key2", 1); + wrapper.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + + core.forceRemove(TEST_ITEMS, item1.key); + + assertThat(wrapper.get(TEST_ITEMS, item1.key), equalTo(item1.toItemDescriptor())); + } + + @Test + public void getAll() { + TestItem item1 = new TestItem("key1", 1); + TestItem item2 = new TestItem("key2", 1); + + core.forceSet(TEST_ITEMS, item1); + core.forceSet(TEST_ITEMS, item2); + Map items = toItemsMap(wrapper.getAll(TEST_ITEMS)); + Map expected = ImmutableMap.of( + item1.key, item1.toItemDescriptor(), item2.key, item2.toItemDescriptor()); + assertThat(items, equalTo(expected)); + + core.forceRemove(TEST_ITEMS, item2.key); + items = toItemsMap(wrapper.getAll(TEST_ITEMS)); + if (testMode.isCached()) { + assertThat(items, equalTo(expected)); + } else { + Map expected1 = ImmutableMap.of(item1.key, item1.toItemDescriptor()); + assertThat(items, equalTo(expected1)); + } + } + + @Test + public void getAllDoesNotRemoveDeletedItems() { + String key1 = "key1", key2 = "key2"; + TestItem item1 = new TestItem(key1, 1); + + core.forceSet(TEST_ITEMS, item1); + core.forceSet(TEST_ITEMS, key2, toSerialized(TEST_ITEMS, ItemDescriptor.deletedItem(1))); + Map items = toItemsMap(wrapper.getAll(TEST_ITEMS)); + Map expected = ImmutableMap.of( + key1, item1.toItemDescriptor(), key2, ItemDescriptor.deletedItem(1)); + assertThat(items, equalTo(expected)); + } + + @Test + public void cachedAllUsesValuesFromInit() { + assumeThat(testMode.isCached(), is(true)); + + TestItem item1 = new TestItem("key1", 1); + TestItem item2 = new TestItem("key2", 1); + FullDataSet allData = new DataBuilder().add(TEST_ITEMS, item1, item2).build(); + wrapper.init(allData); + + core.forceRemove(TEST_ITEMS, item2.key); + + Map items = toItemsMap(wrapper.getAll(TEST_ITEMS)); + Map expected = toDataMap(allData).get(TEST_ITEMS); + assertThat(items, equalTo(expected)); + } + + @Test + public void cachedStoreWithFiniteTtlDoesNotUpdateCacheIfCoreInitFails() { + assumeThat(testMode.isCachedWithFiniteTtl(), is(true)); + + TestItem item = new TestItem("key", 1); + + core.fakeError = FAKE_ERROR; + try { + wrapper.init(new DataBuilder().add(TEST_ITEMS, item).build()); + fail("expected exception"); + } catch(RuntimeException e) { + assertThat(e, is(FAKE_ERROR)); + } + + core.fakeError = null; + assertThat(toItemsMap(wrapper.getAll(TEST_ITEMS)).size(), equalTo(0)); + } + + @Test + public void cachedStoreWithInfiniteTtlUpdatesCacheEvenIfCoreInitFails() { + assumeThat(testMode.isCachedIndefinitely(), is(true)); + + TestItem item = new TestItem("key", 1); + + core.fakeError = FAKE_ERROR; + try { + wrapper.init(new DataBuilder().add(TEST_ITEMS, item).build()); + fail("expected exception"); + } catch(RuntimeException e) { + assertThat(e, is(FAKE_ERROR)); + } + + core.fakeError = null; + Map expected = ImmutableMap.of(item.key, item.toItemDescriptor()); + assertThat(toItemsMap(wrapper.getAll(TEST_ITEMS)), equalTo(expected)); + } + + @Test + public void upsertSuccessful() { + TestItem itemv1 = new TestItem("key", 1); + TestItem itemv2 = itemv1.withVersion(2); + + wrapper.upsert(TEST_ITEMS, itemv1.key, itemv1.toItemDescriptor()); + assertThat(core.data.get(TEST_ITEMS).get(itemv1.key), equalTo(itemv1.toSerializedItemDescriptor())); + + wrapper.upsert(TEST_ITEMS, itemv1.key, itemv2.toItemDescriptor()); + assertThat(core.data.get(TEST_ITEMS).get(itemv1.key), equalTo(itemv2.toSerializedItemDescriptor())); + + // if we have a cache, verify that the new item is now cached by writing a different value + // to the underlying data - Get should still return the cached item + if (testMode.isCached()) { + TestItem itemv3 = itemv1.withVersion(3); + core.forceSet(TEST_ITEMS, itemv3); + } + + assertThat(wrapper.get(TEST_ITEMS, itemv1.key), equalTo(itemv2.toItemDescriptor())); + } + + @Test + public void cachedUpsertUnsuccessful() { + assumeThat(testMode.isCached(), is(true)); + + // This is for an upsert where the data in the store has a higher version. In an uncached + // store, this is just a no-op as far as the wrapper is concerned so there's nothing to + // test here. In a cached store, we need to verify that the cache has been refreshed + // using the data that was found in the store. + TestItem itemv1 = new TestItem("key", 1); + TestItem itemv2 = itemv1.withVersion(2); + + wrapper.upsert(TEST_ITEMS, itemv1.key, itemv2.toItemDescriptor()); + assertThat(core.data.get(TEST_ITEMS).get(itemv2.key), equalTo(itemv2.toSerializedItemDescriptor())); + + boolean success = wrapper.upsert(TEST_ITEMS, itemv1.key, itemv1.toItemDescriptor()); + assertThat(success, is(false)); + assertThat(core.data.get(TEST_ITEMS).get(itemv1.key), equalTo(itemv2.toSerializedItemDescriptor())); // value in store remains the same + + TestItem itemv3 = itemv1.withVersion(3); + core.forceSet(TEST_ITEMS, itemv3); // bypasses cache so we can verify that itemv2 is in the cache + + assertThat(wrapper.get(TEST_ITEMS, itemv1.key), equalTo(itemv2.toItemDescriptor())); + } + + @Test + public void cachedStoreWithFiniteTtlDoesNotUpdateCacheIfCoreUpdateFails() { + assumeThat(testMode.isCachedWithFiniteTtl(), is(true)); + + TestItem itemv1 = new TestItem("key", 1); + TestItem itemv2 = itemv1.withVersion(2); + + wrapper.init(new DataBuilder().add(TEST_ITEMS, itemv1).build()); + + core.fakeError = FAKE_ERROR; + try { + wrapper.upsert(TEST_ITEMS, itemv1.key, itemv2.toItemDescriptor()); + fail("expected exception"); + } catch(RuntimeException e) { + assertThat(e, is(FAKE_ERROR)); + } + core.fakeError = null; + + // cache still has old item, same as underlying store + assertThat(wrapper.get(TEST_ITEMS, itemv1.key), equalTo(itemv1.toItemDescriptor())); + } + + @Test + public void cachedStoreWithInfiniteTtlUpdatesCacheEvenIfCoreUpdateFails() { + assumeThat(testMode.isCachedIndefinitely(), is(true)); + + TestItem itemv1 = new TestItem("key", 1); + TestItem itemv2 = itemv1.withVersion(2); + + wrapper.init(new DataBuilder().add(TEST_ITEMS, itemv1).build()); + + core.fakeError = FAKE_ERROR; + try { + wrapper.upsert(TEST_ITEMS, itemv1.key, itemv2.toItemDescriptor()); + Assert.fail("expected exception"); + } catch(RuntimeException e) { + assertThat(e, is(FAKE_ERROR)); + } + core.fakeError = null; + + // underlying store has old item but cache has new item + assertThat(wrapper.get(TEST_ITEMS, itemv1.key), equalTo(itemv2.toItemDescriptor())); + } + + @Test + public void cachedStoreWithFiniteTtlRemovesCachedAllDataIfOneItemIsUpdated() { + assumeThat(testMode.isCachedWithFiniteTtl(), is(true)); + + TestItem item1v1 = new TestItem("key1", 1); + TestItem item1v2 = item1v1.withVersion(2); + TestItem item2v1 = new TestItem("key2", 1); + TestItem item2v2 = item2v1.withVersion(2); + + wrapper.init(new DataBuilder().add(TEST_ITEMS, item1v1, item2v1).build()); + wrapper.getAll(TEST_ITEMS); // now the All data is cached + + // do an upsert for item1 - this should drop the previous all() data from the cache + wrapper.upsert(TEST_ITEMS, item1v1.key, item1v2.toItemDescriptor()); + + // modify item2 directly in the underlying data + core.forceSet(TEST_ITEMS, item2v2); + + // now, all() should reread the underlying data so we see both changes + Map expected = ImmutableMap.of( + item1v1.key, item1v2.toItemDescriptor(), item2v1.key, item2v2.toItemDescriptor()); + assertThat(toItemsMap(wrapper.getAll(TEST_ITEMS)), equalTo(expected)); + } + + @Test + public void cachedStoreWithInfiniteTtlUpdatesCachedAllDataIfOneItemIsUpdated() { + assumeThat(testMode.isCachedIndefinitely(), is(true)); + + TestItem item1v1 = new TestItem("key1", 1); + TestItem item1v2 = item1v1.withVersion(2); + TestItem item2v1 = new TestItem("key2", 1); + TestItem item2v2 = item2v1.withVersion(2); + + wrapper.init(new DataBuilder().add(TEST_ITEMS, item1v1, item2v1).build()); + wrapper.getAll(TEST_ITEMS); // now the All data is cached + + // do an upsert for item1 - this should update the underlying data *and* the cached all() data + wrapper.upsert(TEST_ITEMS, item1v1.key, item1v2.toItemDescriptor()); + + // modify item2 directly in the underlying data + core.forceSet(TEST_ITEMS, item2v2); + + // now, all() should *not* reread the underlying data - we should only see the change to item1 + Map expected = ImmutableMap.of( + item1v1.key, item1v2.toItemDescriptor(), item2v1.key, item2v1.toItemDescriptor()); + assertThat(toItemsMap(wrapper.getAll(TEST_ITEMS)), equalTo(expected)); + } + + @Test + public void delete() { + TestItem itemv1 = new TestItem("key", 1); + + core.forceSet(TEST_ITEMS, itemv1); + assertThat(wrapper.get(TEST_ITEMS, itemv1.key), equalTo(itemv1.toItemDescriptor())); + + ItemDescriptor deletedItem = ItemDescriptor.deletedItem(2); + wrapper.upsert(TEST_ITEMS, itemv1.key, deletedItem); + + // some stores will persist a special placeholder string, others will store the metadata separately + SerializedItemDescriptor serializedDeletedItem = testMode.persistOnlyAsString ? + toSerialized(TEST_ITEMS, ItemDescriptor.deletedItem(deletedItem.getVersion())) : + new SerializedItemDescriptor(deletedItem.getVersion(), true, null); + assertThat(core.data.get(TEST_ITEMS).get(itemv1.key), equalTo(serializedDeletedItem)); + + // make a change that bypasses the cache + TestItem itemv3 = itemv1.withVersion(3); + core.forceSet(TEST_ITEMS, itemv3); + + ItemDescriptor result = wrapper.get(TEST_ITEMS, itemv1.key); + assertThat(result, equalTo(testMode.isCached() ? deletedItem : itemv3.toItemDescriptor())); + } + + @Test + public void initializedCallsInternalMethodOnlyIfNotAlreadyInited() { + assumeThat(testMode.isCached(), is(false)); + + assertThat(wrapper.isInitialized(), is(false)); + assertThat(core.initedQueryCount, equalTo(1)); + + core.inited.set(true); + assertThat(wrapper.isInitialized(), is(true)); + assertThat(core.initedQueryCount, equalTo(2)); + + core.inited.set(false); + assertThat(wrapper.isInitialized(), is(true)); + assertThat(core.initedQueryCount, equalTo(2)); + } + + @Test + public void initializedDoesNotCallInternalMethodAfterInitHasBeenCalled() { + assumeThat(testMode.isCached(), is(false)); + + assertThat(wrapper.isInitialized(), is(false)); + assertThat(core.initedQueryCount, equalTo(1)); + + wrapper.init(new DataBuilder().build()); + + assertThat(wrapper.isInitialized(), is(true)); + assertThat(core.initedQueryCount, equalTo(1)); + } + + @Test + public void initializedCanCacheFalseResult() throws Exception { + assumeThat(testMode.isCached(), is(true)); + + // We need to create a different object for this test so we can set a short cache TTL + try (PersistentDataStoreWrapper wrapper1 = new PersistentDataStoreWrapper(core, + Duration.ofMillis(500), PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, false)) { + assertThat(wrapper1.isInitialized(), is(false)); + assertThat(core.initedQueryCount, equalTo(1)); + + core.inited.set(true); + assertThat(core.initedQueryCount, equalTo(1)); + + Thread.sleep(600); + + assertThat(wrapper1.isInitialized(), is(true)); + assertThat(core.initedQueryCount, equalTo(2)); + + // From this point on it should remain true and the method should not be called + assertThat(wrapper1.isInitialized(), is(true)); + assertThat(core.initedQueryCount, equalTo(2)); + } + } + + @Test + public void canGetCacheStats() throws Exception { + assumeThat(testMode.isCachedWithFiniteTtl(), is(true)); + + try (PersistentDataStoreWrapper w = new PersistentDataStoreWrapper(core, + Duration.ofSeconds(30), PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, true)) { + DataStoreStatusProvider.CacheStats stats = w.getCacheStats(); + + assertThat(stats, equalTo(new DataStoreStatusProvider.CacheStats(0, 0, 0, 0, 0, 0))); + + // Cause a cache miss + w.get(TEST_ITEMS, "key1"); + stats = w.getCacheStats(); + assertThat(stats.getHitCount(), equalTo(0L)); + assertThat(stats.getMissCount(), equalTo(1L)); + assertThat(stats.getLoadSuccessCount(), equalTo(1L)); // even though it's a miss, it's a "success" because there was no exception + assertThat(stats.getLoadExceptionCount(), equalTo(0L)); + + // Cause a cache hit + core.forceSet(TEST_ITEMS, new TestItem("key2", 1)); + w.get(TEST_ITEMS, "key2"); // this one is a cache miss, but causes the item to be loaded and cached + w.get(TEST_ITEMS, "key2"); // now it's a cache hit + stats = w.getCacheStats(); + assertThat(stats.getHitCount(), equalTo(1L)); + assertThat(stats.getMissCount(), equalTo(2L)); + assertThat(stats.getLoadSuccessCount(), equalTo(2L)); + assertThat(stats.getLoadExceptionCount(), equalTo(0L)); + + // Cause a load exception + core.fakeError = new RuntimeException("sorry"); + try { + w.get(TEST_ITEMS, "key3"); // cache miss -> tries to load the item -> gets an exception + fail("expected exception"); + } catch (RuntimeException e) { + assertThat(e, is((Throwable)core.fakeError)); + } + stats = w.getCacheStats(); + assertThat(stats.getHitCount(), equalTo(1L)); + assertThat(stats.getMissCount(), equalTo(3L)); + assertThat(stats.getLoadSuccessCount(), equalTo(2L)); + assertThat(stats.getLoadExceptionCount(), equalTo(1L)); + } + } + + @Test + public void statusIsOkInitially() throws Exception { + DataStoreStatusProvider.Status status = wrapper.getStoreStatus(); + assertThat(status.isAvailable(), is(true)); + assertThat(status.isRefreshNeeded(), is(false)); + } + + @Test + public void statusIsUnavailableAfterError() throws Exception { + causeStoreError(core, wrapper); + + DataStoreStatusProvider.Status status = wrapper.getStoreStatus(); + assertThat(status.isAvailable(), is(false)); + assertThat(status.isRefreshNeeded(), is(false)); + } + + @Test + public void statusListenerIsNotifiedOnFailureAndRecovery() throws Exception { + final BlockingQueue statuses = new LinkedBlockingQueue<>(); + wrapper.addStatusListener(statuses::add); + + causeStoreError(core, wrapper); + + DataStoreStatusProvider.Status status1 = statuses.take(); + assertThat(status1.isAvailable(), is(false)); + assertThat(status1.isRefreshNeeded(), is(false)); + + // Trigger another error, just to show that it will *not* publish a redundant status update since it + // is already in a failed state + causeStoreError(core, wrapper); + + // Now simulate the data store becoming OK again; the poller detects this and publishes a new status + makeStoreAvailable(core); + DataStoreStatusProvider.Status status2 = statuses.take(); + assertThat(status2.isAvailable(), is(true)); + assertThat(status2.isRefreshNeeded(), is(!testMode.isCachedIndefinitely())); + } + + @Test + public void cacheIsWrittenToStoreAfterRecoveryIfTtlIsInfinite() throws Exception { + assumeThat(testMode.isCachedIndefinitely(), is(true)); + + final BlockingQueue statuses = new LinkedBlockingQueue<>(); + wrapper.addStatusListener(statuses::add); + + TestItem item1v1 = new TestItem("key1", 1); + TestItem item1v2 = item1v1.withVersion(2); + TestItem item2 = new TestItem("key2", 1); + + wrapper.init(new DataBuilder().add(TEST_ITEMS, item1v1).build()); + + // In infinite cache mode, we do *not* expect exceptions thrown by the store to be propagated; it will + // swallow the error, but also go into polling/recovery mode. Note that even though the store rejects + // this update, it'll still be cached. + causeStoreError(core, wrapper); + try { + wrapper.upsert(TEST_ITEMS, item1v1.key, item1v2.toItemDescriptor()); + fail("expected exception"); + } catch (RuntimeException e) { + assertThat(e.getMessage(), equalTo(FAKE_ERROR.getMessage())); + } + assertThat(wrapper.get(TEST_ITEMS, item1v1.key), equalTo(item1v2.toItemDescriptor())); + + DataStoreStatusProvider.Status status1 = statuses.take(); + assertThat(status1.isAvailable(), is(false)); + assertThat(status1.isRefreshNeeded(), is(false)); + + // While the store is still down, try to update it again - the update goes into the cache + try { + wrapper.upsert(TEST_ITEMS, item2.key, item2.toItemDescriptor()); + fail("expected exception"); + } catch (RuntimeException e) { + assertThat(e.getMessage(), equalTo(FAKE_ERROR.getMessage())); + } + assertThat(wrapper.get(TEST_ITEMS, item2.key), equalTo(item2.toItemDescriptor())); + + // Verify that this update did not go into the underlying data yet + assertThat(core.data.get(TEST_ITEMS).get(item2.key), nullValue()); + + // Now simulate the store coming back up + makeStoreAvailable(core); + + // Wait for the poller to notice this and publish a new status + DataStoreStatusProvider.Status status2 = statuses.take(); + assertThat(status2.isAvailable(), is(true)); + assertThat(status2.isRefreshNeeded(), is(false)); + + // Once that has happened, the cache should have been written to the store + assertThat(core.data.get(TEST_ITEMS).get(item1v1.key), equalTo(item1v2.toSerializedItemDescriptor())); + assertThat(core.data.get(TEST_ITEMS).get(item2.key), equalTo(item2.toSerializedItemDescriptor())); + } + + @Test + public void statusRemainsUnavailableIfStoreSaysItIsAvailableButInitFails() throws Exception { + assumeThat(testMode.isCachedIndefinitely(), is(true)); + + // Most of this test is identical to cacheIsWrittenToStoreAfterRecoveryIfTtlIsInfinite() except as noted below. + + final BlockingQueue statuses = new LinkedBlockingQueue<>(); + wrapper.addStatusListener(statuses::add); + + TestItem item1v1 = new TestItem("key1", 1); + TestItem item1v2 = item1v1.withVersion(2); + TestItem item2 = new TestItem("key2", 1); + + wrapper.init(new DataBuilder().add(TEST_ITEMS, item1v1).build()); + + causeStoreError(core, wrapper); + try { + wrapper.upsert(TEST_ITEMS, item1v1.key, item1v2.toItemDescriptor()); + fail("expected exception"); + } catch (RuntimeException e) { + assertThat(e.getMessage(), equalTo(FAKE_ERROR.getMessage())); + } + assertThat(wrapper.get(TEST_ITEMS, item1v1.key), equalTo(item1v2.toItemDescriptor())); + + DataStoreStatusProvider.Status status1 = statuses.take(); + assertThat(status1.isAvailable(), is(false)); + assertThat(status1.isRefreshNeeded(), is(false)); + + // While the store is still down, try to update it again - the update goes into the cache + try { + wrapper.upsert(TEST_ITEMS, item2.key, item2.toItemDescriptor()); + fail("expected exception"); + } catch (RuntimeException e) { + assertThat(e.getMessage(), equalTo(FAKE_ERROR.getMessage())); + } + assertThat(wrapper.get(TEST_ITEMS, item2.key), equalTo(item2.toItemDescriptor())); + + // Verify that this update did not go into the underlying data yet + assertThat(core.data.get(TEST_ITEMS).get(item2.key), nullValue()); + + // Here's what is unique to this test: we are telling the store to report its status as "available", + // but *not* clearing the fake exception, so when the poller tries to write the cached data with + // init() it should fail. + core.unavailable = false; + + // We can't prove that an unwanted status transition will never happen, but we can verify that it + // does not happen within two status poll intervals. + Thread.sleep(PersistentDataStoreStatusManager.POLL_INTERVAL_MS * 2); + + assertThat(statuses.isEmpty(), is(true)); + int initedCount = core.initedCount.get(); + assertThat(initedCount, greaterThan(1)); // that is, it *tried* to do at least one init + + // Now simulate the store coming back up and actually working + core.fakeError = null; + + // Wait for the poller to notice this and publish a new status + DataStoreStatusProvider.Status status2 = statuses.take(); + assertThat(status2.isAvailable(), is(true)); + assertThat(status2.isRefreshNeeded(), is(false)); + + // Once that has happened, the cache should have been written to the store + assertThat(core.data.get(TEST_ITEMS).get(item1v1.key), equalTo(item1v2.toSerializedItemDescriptor())); + assertThat(core.data.get(TEST_ITEMS).get(item2.key), equalTo(item2.toSerializedItemDescriptor())); + assertThat(core.initedCount.get(), greaterThan(initedCount)); + } + + private void causeStoreError(MockPersistentDataStore core, PersistentDataStoreWrapper w) { + core.unavailable = true; + core.fakeError = new RuntimeException(FAKE_ERROR.getMessage()); + try { + wrapper.upsert(TEST_ITEMS, "irrelevant-key", ItemDescriptor.deletedItem(1)); + fail("expected exception"); + } catch (RuntimeException e) { + assertThat(e.getMessage(), equalTo(FAKE_ERROR.getMessage())); + } + } + + private void makeStoreAvailable(MockPersistentDataStore core) { + core.fakeError = null; + core.unavailable = false; + } +}